From c34ed2d142b1dfb6fb416efa27dff8c470c1830c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sun, 3 Aug 2025 10:09:58 +0200 Subject: [PATCH] Player controller (and model) refactor (#2249) --- music_assistant/__init__.py | 4 +- music_assistant/constants.py | 16 +- music_assistant/controllers/config.py | 112 +- music_assistant/controllers/media/base.py | 5 +- .../controllers/media/playlists.py | 7 + music_assistant/controllers/media/radio.py | 7 + music_assistant/controllers/music.py | 85 +- music_assistant/controllers/player_queues.py | 132 +- music_assistant/controllers/players.py | 1285 +++++++------ music_assistant/controllers/streams.py | 12 +- music_assistant/helpers/api.py | 2 +- music_assistant/helpers/audio.py | 39 +- music_assistant/helpers/database.py | 2 +- music_assistant/helpers/json.py | 4 +- music_assistant/helpers/logging.py | 38 +- music_assistant/helpers/throttle_retry.py | 13 +- music_assistant/helpers/upnp.py | 2 +- music_assistant/helpers/util.py | 67 +- music_assistant/mass.py | 48 +- music_assistant/models/metadata_provider.py | 1 - music_assistant/models/music_provider.py | 2 - music_assistant/models/player.py | 1591 +++++++++++++++++ music_assistant/models/player_provider.py | 457 +---- music_assistant/models/plugin.py | 12 +- music_assistant/models/provider.py | 13 + .../__init__.py | 0 .../icon.svg | 0 .../_demo_music_provider/manifest.json | 9 + .../_demo_player_provider/__init__.py | 91 + .../_demo_player_provider/constants.py | 4 + .../icon.svg | 0 .../_demo_player_provider/manifest.json | 10 + .../providers/_demo_player_provider/player.py | 323 ++++ .../_demo_player_provider/provider.py | 183 ++ .../__init__.py | 0 .../icon.svg | 0 .../_demo_plugin_provider/manifest.json | 9 + .../_template_music_provider/manifest.json | 10 - .../_template_player_provider/__init__.py | 391 ---- .../_template_player_provider/manifest.json | 10 - .../_template_plugin_provider/manifest.json | 10 - music_assistant/providers/airplay/__init__.py | 19 +- .../airplay/{const.py => constants.py} | 2 +- music_assistant/providers/airplay/helpers.py | 4 +- music_assistant/providers/airplay/player.py | 390 +++- music_assistant/providers/airplay/provider.py | 506 +----- music_assistant/providers/airplay/raop.py | 96 +- music_assistant/providers/alexa/__init__.py | 279 ++- .../providers/bluesound/__init__.py | 374 +--- music_assistant/providers/bluesound/player.py | 300 ++++ .../providers/bluesound/provider.py | 105 ++ .../providers/builtin_player/__init__.py | 428 +---- .../providers/builtin_player/player.py | 326 ++++ .../providers/builtin_player/provider.py | 135 ++ .../providers/chromecast/__init__.py | 734 +------- .../providers/chromecast/constants.py | 49 + .../providers/chromecast/helpers.py | 48 +- .../providers/chromecast/player.py | 539 ++++++ .../providers/chromecast/provider.py | 148 ++ music_assistant/providers/dlna/__init__.py | 552 +----- music_assistant/providers/dlna/constants.py | 22 + music_assistant/providers/dlna/player.py | 384 ++++ music_assistant/providers/dlna/provider.py | 162 ++ .../providers/filesystem_local/__init__.py | 8 +- .../providers/fully_kiosk/__init__.py | 148 +- .../providers/fully_kiosk/player.py | 111 ++ .../providers/fully_kiosk/provider.py | 45 + music_assistant/providers/gpodder/__init__.py | 3 +- music_assistant/providers/hass/constants.py | 18 +- .../providers/hass_players/__init__.py | 599 +------ .../providers/hass_players/constants.py | 20 + .../providers/hass_players/helpers.py | 92 + .../providers/hass_players/player.py | 371 ++++ .../providers/hass_players/provider.py | 173 ++ .../providers/musicbrainz/__init__.py | 82 +- .../providers/musiccast/__init__.py | 844 +-------- .../providers/musiccast/avt_helpers.py | 2 +- .../providers/musiccast/constants.py | 4 +- .../providers/musiccast/musiccast.py | 5 +- music_assistant/providers/musiccast/player.py | 614 +++++++ .../providers/musiccast/provider.py | 248 +++ .../providers/opensubsonic/sonic_provider.py | 5 +- .../providers/player_group/__init__.py | 991 ---------- .../providers/player_group/manifest.json | 14 - .../providers/snapcast/__init__.py | 790 +------- .../providers/snapcast/constants.py | 66 + music_assistant/providers/snapcast/control.py | 13 +- music_assistant/providers/snapcast/player.py | 488 +++++ .../providers/snapcast/provider.py | 295 +++ music_assistant/providers/sonos/const.py | 13 +- music_assistant/providers/sonos/player.py | 674 +++++-- music_assistant/providers/sonos/provider.py | 403 +---- .../providers/sonos_s1/__init__.py | 466 +---- music_assistant/providers/sonos_s1/player.py | 906 +++------- .../providers/sonos_s1/provider.py | 124 ++ music_assistant/providers/spotify/__init__.py | 2 +- music_assistant/providers/spotify/helpers.py | 1 - .../providers/spotify_connect/__init__.py | 2 +- .../providers/squeezelite/__init__.py | 888 +-------- .../providers/squeezelite/constants.py | 16 + .../providers/squeezelite/player.py | 400 +++++ .../providers/squeezelite/provider.py | 134 ++ music_assistant/providers/tidal/__init__.py | 4 +- .../providers/universal_group/__init__.py | 52 + .../providers/universal_group/constants.py | 35 + .../providers/universal_group/manifest.json | 13 + .../providers/universal_group/player.py | 429 +++++ .../providers/universal_group/provider.py | 70 + .../ugp_stream.py | 12 +- music_assistant/providers/ytmusic/__init__.py | 2 +- pyproject.toml | 3 +- requirements_all.txt | 3 +- scripts/example.py | 3 - scripts/gen_requirements_all.py | 2 +- scripts/profiler.py | 2 +- 115 files changed, 10973 insertions(+), 10343 deletions(-) create mode 100644 music_assistant/models/player.py rename music_assistant/providers/{_template_music_provider => _demo_music_provider}/__init__.py (100%) rename music_assistant/providers/{_template_music_provider => _demo_music_provider}/icon.svg (100%) create mode 100644 music_assistant/providers/_demo_music_provider/manifest.json create mode 100644 music_assistant/providers/_demo_player_provider/__init__.py create mode 100644 music_assistant/providers/_demo_player_provider/constants.py rename music_assistant/providers/{_template_player_provider => _demo_player_provider}/icon.svg (100%) create mode 100644 music_assistant/providers/_demo_player_provider/manifest.json create mode 100644 music_assistant/providers/_demo_player_provider/player.py create mode 100644 music_assistant/providers/_demo_player_provider/provider.py rename music_assistant/providers/{_template_plugin_provider => _demo_plugin_provider}/__init__.py (100%) rename music_assistant/providers/{_template_plugin_provider => _demo_plugin_provider}/icon.svg (100%) create mode 100644 music_assistant/providers/_demo_plugin_provider/manifest.json delete mode 100644 music_assistant/providers/_template_music_provider/manifest.json delete mode 100644 music_assistant/providers/_template_player_provider/__init__.py delete mode 100644 music_assistant/providers/_template_player_provider/manifest.json delete mode 100644 music_assistant/providers/_template_plugin_provider/manifest.json rename music_assistant/providers/airplay/{const.py => constants.py} (97%) create mode 100644 music_assistant/providers/bluesound/player.py create mode 100644 music_assistant/providers/bluesound/provider.py create mode 100644 music_assistant/providers/builtin_player/player.py create mode 100644 music_assistant/providers/builtin_player/provider.py create mode 100644 music_assistant/providers/chromecast/constants.py create mode 100644 music_assistant/providers/chromecast/player.py create mode 100644 music_assistant/providers/chromecast/provider.py create mode 100644 music_assistant/providers/dlna/constants.py create mode 100644 music_assistant/providers/dlna/player.py create mode 100644 music_assistant/providers/dlna/provider.py create mode 100644 music_assistant/providers/fully_kiosk/player.py create mode 100644 music_assistant/providers/fully_kiosk/provider.py create mode 100644 music_assistant/providers/hass_players/constants.py create mode 100644 music_assistant/providers/hass_players/helpers.py create mode 100644 music_assistant/providers/hass_players/player.py create mode 100644 music_assistant/providers/hass_players/provider.py create mode 100644 music_assistant/providers/musiccast/player.py create mode 100644 music_assistant/providers/musiccast/provider.py delete mode 100644 music_assistant/providers/player_group/__init__.py delete mode 100644 music_assistant/providers/player_group/manifest.json create mode 100644 music_assistant/providers/snapcast/constants.py create mode 100644 music_assistant/providers/snapcast/player.py create mode 100644 music_assistant/providers/snapcast/provider.py create mode 100644 music_assistant/providers/sonos_s1/provider.py create mode 100644 music_assistant/providers/squeezelite/constants.py create mode 100644 music_assistant/providers/squeezelite/player.py create mode 100644 music_assistant/providers/squeezelite/provider.py create mode 100644 music_assistant/providers/universal_group/__init__.py create mode 100644 music_assistant/providers/universal_group/constants.py create mode 100644 music_assistant/providers/universal_group/manifest.json create mode 100644 music_assistant/providers/universal_group/player.py create mode 100644 music_assistant/providers/universal_group/provider.py rename music_assistant/providers/{player_group => universal_group}/ugp_stream.py (92%) diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index 6632acb6..4e08d0e0 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -1,3 +1,5 @@ """Music Assistant: The music library manager in python.""" -from .mass import MusicAssistant # noqa: F401 +from .mass import MusicAssistant + +__all__ = ("MusicAssistant",) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index f1fdf1e5..28bfef21 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -11,7 +11,7 @@ from music_assistant_models.config_entries import ( from music_assistant_models.enums import ConfigEntryType, ContentType, HidePlayerOption from music_assistant_models.media_items import AudioFormat -API_SCHEMA_VERSION: Final[int] = 26 +API_SCHEMA_VERSION: Final[int] = 27 MIN_SCHEMA_VERSION: Final[int] = 24 @@ -43,6 +43,7 @@ CONF_PROVIDERS: Final[str] = "providers" CONF_PLAYERS: Final[str] = "players" CONF_CORE: Final[str] = "core" CONF_PATH: Final[str] = "path" +CONF_NAME: Final[str] = "name" CONF_USERNAME: Final[str] = "username" CONF_PASSWORD: Final[str] = "password" CONF_VOLUME_NORMALIZATION: Final[str] = "volume_normalization" @@ -63,6 +64,7 @@ CONF_PUBLISH_IP: Final[str] = "publish_ip" CONF_AUTO_PLAY: Final[str] = "auto_play" CONF_CROSSFADE: Final[str] = "crossfade" CONF_GROUP_MEMBERS: Final[str] = "group_members" +CONF_DYNAMIC_GROUP_MEMBERS: Final[str] = "dynamic_members" CONF_HIDE_PLAYER_IN_UI: Final[str] = "hide_player_in_ui" CONF_EXPOSE_PLAYER_TO_HA: Final[str] = "expose_player_to_ha" CONF_SYNC_ADJUST: Final[str] = "sync_adjust" @@ -128,7 +130,7 @@ CONFIGURABLE_CORE_CONTROLLERS = ( ) VERBOSE_LOG_LEVEL: Final[int] = 5 PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz") - +SYNCGROUP_PREFIX: Final[str] = "syncgroup_" ####### REUSABLE CONFIG ENTRIES ####### @@ -714,3 +716,13 @@ CACHE_CATEGORY_OPEN_SUBSONIC: Final[int] = 12 # CACHE base keys CACHE_KEY_PLAYER_POWER: Final[str] = "player_power" + + +# extra data / extra attributes keys +ATTR_FAKE_POWER: Final[str] = "fake_power" +ATTR_FAKE_VOLUME: Final[str] = "fake_volume_level" +ATTR_FAKE_MUTE: Final[str] = "fake_volume_muted" +ATTR_ANNOUNCEMENT_IN_PROGRESS: Final[str] = "announcement_in_progress" +ATTR_PREVIOUS_VOLUME: Final[str] = "previous_volume" +ATTR_LAST_POLL: Final[str] = "last_poll" +ATTR_GROUP_MEMBERS: Final[str] = "group_members" diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 8fda17e0..e8d3a1da 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -38,7 +38,6 @@ from music_assistant.constants import ( CONF_DEPRECATED_EQ_MID, CONF_DEPRECATED_EQ_TREBLE, CONF_ONBOARD_DONE, - CONF_OUTPUT_LIMITER, CONF_PLAYER_DSP, CONF_PLAYERS, CONF_PROVIDERS, @@ -304,7 +303,7 @@ class ConfigController: if existing["type"] == "player": # cleanup entries in player manager for player in list(self.mass.players): - if player.provider != instance_id: + if player.provider.instance_id != instance_id: continue self.mass.players.remove(player.player_id, cleanup_config=True) # cleanup remaining player configs @@ -342,18 +341,15 @@ class ConfigController: @api_command("config/players/get") async def get_player_config(self, player_id: str) -> PlayerConfig: """Return (full) configuration for a single player.""" + raw_conf: dict[str, Any] if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"): if player := self.mass.players.get(player_id, False): raw_conf["default_name"] = player.display_name - raw_conf["provider"] = player.provider - prov = self.mass.get_provider(player.provider) - conf_entries = await prov.get_player_config_entries(player_id) + raw_conf["provider"] = player.provider.lookup_key + conf_entries = await player.get_config_entries() else: # handle unavailable player and/or provider - if prov := self.mass.get_provider(raw_conf["provider"]): - conf_entries = await prov.get_player_config_entries(player_id) - else: - conf_entries = () + conf_entries = [] 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"] @@ -391,6 +387,20 @@ class ConfigController: self.get(f"{CONF_PLAYERS}/{player_id}/{key}", default), ) + def get_base_player_config(self, player_id: str, provider: str) -> PlayerConfig: + """ + Return base PlayerConfig for a player. + + This is used to get the base config for a player, without any provider specific values, + for initialization purposes. + """ + if not (raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}")): + raw_conf = { + "player_id": player_id, + "provider": provider, + } + return PlayerConfig.parse([], raw_conf) + @api_command("config/players/save") async def save_player_config( self, player_id: str, values: dict[str, ConfigValueType] @@ -406,15 +416,12 @@ class ConfigController: # actually store changes (if the above did not raise) conf_key = f"{CONF_PLAYERS}/{player_id}" self.set(conf_key, config.to_raw()) - # always update player attributes to calculate e.g. player controls etc. - self.mass.players.update(config.player_id, force_update=True) # send config updated event self.mass.signal_event( EventType.PLAYER_CONFIG_UPDATED, object_id=config.player_id, data=config, ) - # return full player config (just in case) return await self.get_player_config(player_id) @@ -428,23 +435,24 @@ class ConfigController: msg = f"Player configuration for {player_id} does not exist" raise KeyError(msg) player = self.mass.players.get(player_id) - player_prov = player.provider if player else existing["provider"] - player_provider = self.mass.get_provider(player_prov) + player_provider = player.provider if player_provider and ProviderFeature.REMOVE_PLAYER in player_provider.supported_features: # provider supports removal of player (e.g. group player) await player_provider.remove_player(player_id) elif player and player_provider and player.available: # removing a player config while it is active is not allowed - # unless the provider repoirts it has the remove_player feature (e.g. group player) + # unless the provider reports it has the remove_player feature (e.g. group player) raise ActionUnavailable("Can not remove config for an active player!") # check for group memberships that need to be updated - if player and player.active_group and player_provider: + if ( + player + and player.active_group + and (group_player := self.mass.players.get(player.active_group)) + ): # try to remove from the group - group_player = self.mass.players.get(player.active_group) with suppress(UnsupportedFeaturedException, PlayerCommandFailed): - await player_provider.set_members( - player.active_group, - [x for x in group_player.group_childs if x != player.player_id], + await group_player.set_members( + player_ids_to_remove=[player_id], ) # tell the player manager to remove the player if its lingering around # set cleanup_flag to false otherwise we end up in an infinite loop @@ -532,8 +540,8 @@ class ConfigController: self, player_id: str, provider: str, - name: str, - enabled: bool, + name: str | None = None, + enabled: bool = True, values: dict[str, ConfigValueType] | None = None, ) -> None: """ @@ -804,18 +812,25 @@ class ConfigController: LOGGER.exception("Error while reading persistent storage file %s", filename) LOGGER.debug("Started with empty storage: No persistent storage file found.") - async def _migrate(self) -> None: # noqa: PLR0915 + async def _migrate(self) -> None: changed = False + # some type hints to help with the code below + instance_id: str + provider_config: dict[str, Any] + player_config: dict[str, Any] + values: dict[str, ConfigValueType] + # Older versions of MA can create corrupt entries with no domain if retrying # logic runs after a provider has been removed. Remove those corrupt entries. - for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()): + for instance_id, provider_config in {**self._data.get(CONF_PROVIDERS, {})}.items(): if "domain" not in provider_config: self._data[CONF_PROVIDERS].pop(instance_id, None) LOGGER.warning("Removed corrupt provider configuration: %s", instance_id) changed = True + # migrate manual_ips to new format - for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()): + for instance_id, provider_config in self._data.get(CONF_PROVIDERS, {}).items(): if not (values := provider_config.get("values")): continue if not (ips := values.get("ips")): @@ -823,8 +838,9 @@ class ConfigController: values["manual_discovery_ip_addresses"] = ips.split(",") del values["ips"] changed = True + # migrate sample_rates config entry - for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()): + for player_config in self._data.get(CONF_PLAYERS, {}).values(): if not (values := player_config.get("values")): continue if not (sample_rates := values.get("sample_rates")): @@ -838,24 +854,6 @@ class ConfigController: for x in sample_rates ] changed = True - # migrate DSPConfig.output_limiter - for player_id, dsp_config in list(self._data.get(CONF_PLAYER_DSP, {}).items()): - output_limiter = dsp_config.get("output_limiter") - enabled = dsp_config.get("enabled") - if output_limiter is None or enabled is None or output_limiter: - continue - - if enabled: - # The DSP is enabled, and the user disabled the output limiter in a prior version - # Migrate the output limiter option to the player config - if (players := self._data.get(f"{CONF_PLAYERS}")) and ( - player := players.get(player_id) - ): - player["values"][CONF_OUTPUT_LIMITER] = False - # Delete the old option, so this migration logic will never be called - # anymore for this player. - del dsp_config["output_limiter"] - changed = True # set 'onboard_done' flag if we have any (non default) provider configs if self._data.get(CONF_ONBOARD_DONE) is None: @@ -865,23 +863,21 @@ class ConfigController: self._data[CONF_ONBOARD_DONE] = True changed = True break - # migrate slimproto --> squeezelite - for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()): - if provider_config.get("domain") == "slimproto": - del self._data[CONF_PROVIDERS][instance_id] - new_instance_id = instance_id.replace("slimproto", "squeezelite") - provider_config["instance_id"] = new_instance_id - provider_config["domain"] = "squeezelite" - self._data[CONF_PROVIDERS][new_instance_id] = provider_config - changed = True - # migrate "hide_player" --> "hide_player_in_ui" - for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()): + # migrate player_group entries + for player_config in self._data.get(CONF_PLAYERS, {}).values(): + if not player_config.get("provider").startswith("player_group"): + continue if not (values := player_config.get("values")): continue - if values.pop("hide_player", None): - player_config["values"]["hide_player_in_ui"] = ["always"] + if (group_type := values.pop("group_type", None)) is None: + continue + # this is a legacy player group, migrate the values changed = True + if group_type == "universal": + player_config["provider"] = "universal_group" + else: + player_config["provider"] = group_type if changed: await self._async_save() @@ -942,7 +938,7 @@ class ConfigController: if config.type == ProviderType.PLAYER: # cleanup entries in player manager for player in self.mass.players.all(return_unavailable=True, return_disabled=True): - if player.provider != instance_id: + if player.provider.instance_id != instance_id: continue self.mass.players.remove(player.player_id, cleanup_config=False) return config diff --git a/music_assistant/controllers/media/base.py b/music_assistant/controllers/media/base.py index 53eaf702..17b26a06 100644 --- a/music_assistant/controllers/media/base.py +++ b/music_assistant/controllers/media/base.py @@ -7,7 +7,7 @@ import logging from abc import ABCMeta, abstractmethod from collections.abc import Iterable from contextlib import suppress -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from music_assistant_models.enums import EventType, ExternalID, MediaType, ProviderFeature from music_assistant_models.errors import MediaNotFoundError, ProviderUnavailableError @@ -78,7 +78,7 @@ SORT_KEYS = { } -class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): +class MediaControllerBase[ItemCls](metaclass=ABCMeta): """Base model for controller managing a MediaType.""" media_type: MediaType @@ -682,6 +682,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): ) -> None: """Update existing library record in the database.""" + @abstractmethod async def match_providers(self, db_item: ItemCls) -> None: """ Try to find match on all (streaming) providers for the provided (database) item. diff --git a/music_assistant/controllers/media/playlists.py b/music_assistant/controllers/media/playlists.py index da2257b5..d961625f 100644 --- a/music_assistant/controllers/media/playlists.py +++ b/music_assistant/controllers/media/playlists.py @@ -431,3 +431,10 @@ class PlaylistController(MediaControllerBase[Playlist]): # filter out unavailable tracks if x.available ] + + async def match_providers(self, db_item: Playlist) -> None: + """Try to find match on all (streaming) providers for the provided (database) item. + + This is used to link objects of different providers/qualities together. + """ + raise NotImplementedError diff --git a/music_assistant/controllers/media/radio.py b/music_assistant/controllers/media/radio.py index 163ea109..c9ae1151 100644 --- a/music_assistant/controllers/media/radio.py +++ b/music_assistant/controllers/media/radio.py @@ -117,3 +117,10 @@ class RadioController(MediaControllerBase[Radio]): """Get the list of base tracks from the controller used to calculate the dynamic radio.""" msg = "Dynamic tracks not supported for Radio MediaItem" raise NotImplementedError(msg) + + async def match_providers(self, db_item: Radio) -> None: + """Try to find match on all (streaming) providers for the provided (database) item. + + This is used to link objects of different providers/qualities together. + """ + raise NotImplementedError diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 593d83fb..3a8ee257 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -35,6 +35,7 @@ from music_assistant_models.media_items import ( MediaItemType, RecommendationFolder, SearchResults, + Track, ) from music_assistant_models.provider import SyncTask from music_assistant_models.unique_list import UniqueList @@ -58,12 +59,13 @@ from music_assistant.constants import ( PROVIDERS_WITH_SHAREABLE_URLS, ) from music_assistant.helpers.api import api_command -from music_assistant.helpers.compare import create_safe_string +from music_assistant.helpers.compare import compare_strings, compare_version, create_safe_string from music_assistant.helpers.database import DatabaseConnection from music_assistant.helpers.datetime import utc_timestamp from music_assistant.helpers.json import json_loads, serialize_to_json +from music_assistant.helpers.tags import split_artists from music_assistant.helpers.uri import parse_uri -from music_assistant.helpers.util import TaskManager +from music_assistant.helpers.util import TaskManager, parse_title_and_version from music_assistant.models.core_controller import CoreController from .media.albums import AlbumsController @@ -960,6 +962,85 @@ class MusicController(CoreController): ) await self.database.commit() + @api_command("music/track_by_name") + async def get_track_by_name( + self, + track_name: str, + artist_name: str | None = None, + album_name: str | None = None, + track_version: str | None = None, + ) -> Track | None: + """Get a track by its name, optionally with artist and album.""" + if track_version is None: + track_name, version = parse_title_and_version(track_name) + search_query = f"{artist_name} - {track_name}" if artist_name else track_name + search_result = await self.mass.music.search( + search_query=search_query, + media_types=[MediaType.TRACK], + ) + for allow_item_mapping in (False, True): + for search_track in search_result.tracks: + is_track = isinstance(search_track, Track) + if not allow_item_mapping and not is_track: + continue + if not compare_strings(track_name, search_track.name): + continue + if not compare_version(version, search_track.version): + continue + # check optional artist(s) + if artist_name and is_track: + for artist in search_track.artists: + if compare_strings(artist_name, artist.name, False): + break + else: + # no artist match found: abort + continue + # check optional album + if ( + album_name + and is_track + and not compare_strings(album_name, search_track.album.name, False) + ): + # no album match found: abort + continue + # if we reach this, we found a match + if not isinstance(search_track, Track): + # ensure we return an actual Track object + return await self.mass.music.tracks.get( + item_id=search_track.item_id, + provider_instance_id_or_domain=search_track.provider, + ) + return search_track + + # try to handle case where something is appended to the title + for splitter in ("•", "-", "|", "(", "["): + if splitter in track_name: + return await self.get_track_by_name( + track_name=track_name.split(splitter)[0].strip(), + artist_name=artist_name, + album_name=None, + track_version=track_version, + ) + # try to handle case where multiple artists are given as single string + if artist_name and (artists := split_artists(artist_name, True)) and len(artists) > 1: + for artist in artists: + return await self.get_track_by_name( + track_name=track_name, + artist_name=artist.split(splitter)[0].strip(), + album_name=None, + track_version=track_version, + ) + # allow non-exact album match as fallback + if album_name: + return await self.get_track_by_name( + track_name=track_name, + artist_name=artist_name, + album_name=None, + track_version=track_version, + ) + # no match found + return None + async def get_resume_position(self, media_item: Audiobook | PodcastEpisode) -> tuple[bool, int]: """ Get progress (resume point) details for the given audiobook or episode. diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 1c12bf7b..25552f16 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import random import time +from contextlib import suppress from types import NoneType from typing import TYPE_CHECKING, Any, TypedDict, cast @@ -26,8 +27,8 @@ from music_assistant_models.enums import ( ContentType, EventType, MediaType, + PlaybackState, PlayerFeature, - PlayerState, ProviderFeature, QueueOption, RepeatMode, @@ -57,6 +58,7 @@ from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.queue_item import QueueItem from music_assistant.constants import ( + ATTR_ANNOUNCEMENT_IN_PROGRESS, CACHE_CATEGORY_PLAYER_QUEUE_STATE, CONF_CROSSFADE, CONF_FLOW_MODE, @@ -80,7 +82,8 @@ if TYPE_CHECKING: Track, UniqueList, ) - from music_assistant_models.player import Player + + from music_assistant.models.player import Player CONF_DEFAULT_ENQUEUE_SELECT_ARTIST = "default_enqueue_select_artist" @@ -109,7 +112,7 @@ class CompareState(TypedDict): """ queue_id: str - state: PlayerState + state: PlaybackState current_item_id: str | None next_item_id: str | None current_item: QueueItem | None @@ -141,7 +144,7 @@ class PlayerQueuesController(CoreController): """Cleanup on exit.""" # stop all playback for queue in self.all(): - if queue.state in (PlayerState.PLAYING, PlayerState.PAUSED): + if queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED): await self.stop(queue.queue_id) async def get_config_entries( @@ -289,18 +292,11 @@ class PlayerQueuesController(CoreController): return self._queue_items[queue_id][offset : offset + limit] @api_command("player_queues/get_active_queue") - def get_active_queue(self, player_id: str) -> PlayerQueue: + def get_active_queue(self, player_id: str) -> PlayerQueue | None: """Return the current active/synced queue for a player.""" if player := self.mass.players.get(player_id): - # account for player that is synced (sync child) - if player.synced_to and player.synced_to != player.player_id: - return self.get_active_queue(player.synced_to) - # handle active group player - if player.active_group and player.active_group != player.player_id: - return self.get_active_queue(player.active_group) - # active_source may be filled with other queue id - return self.get(player.active_source) or self.get(player_id) - return self.get(player_id) + return self.mass.players.get_active_queue(player) + return None # Queue commands @@ -380,7 +376,7 @@ class PlayerQueuesController(CoreController): - radio_mode: Enable radio mode for the given item(s). - start_item: Optional item to start the playlist or album from. """ - # ruff: noqa: PLR0915,PLR0912 + # ruff: noqa: PLR0915 # we use a contextvar to bypass the throttler for this asyncio task/context # this makes sure that playback has priority over other requests that may be # happening in the background @@ -389,7 +385,8 @@ class PlayerQueuesController(CoreController): raise PlayerUnavailableError(f"Queue {queue_id} is not available") # always fetch the underlying player so we can raise early if its not available queue_player = self.mass.players.get(queue_id, True) - if queue_player.announcement_in_progress: + assert queue_player is not None # for type checking + if queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS): self.logger.warning("Ignore queue command: An announcement is in progress") return @@ -467,7 +464,7 @@ class PlayerQueuesController(CoreController): raise MediaNotFoundError("No playable items found") # load the items into the queue - if queue.state in (PlayerState.PLAYING, PlayerState.PAUSED): + if queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED): cur_index = queue.index_in_buffer or queue.current_index or 0 else: cur_index = queue.current_index or 0 @@ -553,7 +550,7 @@ class PlayerQueuesController(CoreController): queue_items = self._queue_items[queue_id] queue_items = queue_items.copy() - if pos_shift == 0 and queue.state == PlayerState.PLAYING: + if pos_shift == 0 and queue.state == PlaybackState.PLAYING: new_index = (queue.current_index or 0) + 1 elif pos_shift == 0: new_index = queue.current_index or 0 @@ -587,7 +584,7 @@ class PlayerQueuesController(CoreController): """Clear all items in the queue.""" queue = self._queues[queue_id] queue.radio_source = [] - if queue.state != PlayerState.IDLE and not skip_stop: + if queue.state != PlaybackState.IDLE and not skip_stop: self.mass.create_task(self.stop(queue_id)) queue.current_index = None queue.current_item = None @@ -602,12 +599,13 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the playerqueue to handle the command. """ + queue_player: Player = self.mass.players.get(queue_id, True) if (queue := self.get(queue_id)) and queue.active: - if queue.state == PlayerState.PLAYING: + if queue.state == PlaybackState.PLAYING: queue.resume_pos = queue.corrected_elapsed_time - # forward the actual command to the player provider - if player_provider := self.mass.players.get_player_provider(queue.queue_id): - await player_provider.cmd_stop(queue_id) + # forward the actual command to the player + if queue_player := self.mass.players.get(queue_id): + await queue_player.stop() @api_command("player_queues/play") async def play(self, queue_id: str) -> None: @@ -620,12 +618,11 @@ class PlayerQueuesController(CoreController): if ( (queue := self._queues.get(queue_id)) and queue.active - and queue_player.state == PlayerState.PAUSED + and queue.state == PlaybackState.PAUSED ): - # forward the actual play/unpause command to the player provider - if player_provider := self.mass.players.get_player_provider(queue.queue_id): - await player_provider.cmd_play(queue_id) - return + # forward the actual play/unpause command to the player + await queue_player.play() + return # player is not paused, perform resume instead await self.resume(queue_id) @@ -636,38 +633,39 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the playerqueue to handle the command. """ if queue := self._queues.get(queue_id): - if queue.state == PlayerState.PLAYING: + if queue.state == PlaybackState.PLAYING: queue.resume_pos = queue.corrected_elapsed_time # forward the actual command to the player controller queue_player = self.mass.players.get(queue_id) - if not (player_provider := self.mass.players.get_player_provider(queue.queue_id)): + assert queue_player is not None # for type checking + if not (self.mass.players.get_player_provider(queue_id)): return # guard if PlayerFeature.PAUSE not in queue_player.supported_features: # if player does not support pause, we need to send stop - await player_provider.cmd_stop(queue_player.player_id) + await queue_player.stop() return - await player_provider.cmd_pause(queue_player.player_id) + await queue_player.pause() async def _watch_pause() -> None: count = 0 # wait for pause - while count < 5 and queue_player.state == PlayerState.PLAYING: + while count < 5 and queue_player.playback_state == PlaybackState.PLAYING: count += 1 await asyncio.sleep(1) # wait for unpause - if queue_player.state != PlayerState.PAUSED: + if queue_player.playback_state != PlaybackState.PAUSED: return count = 0 - while count < 30 and queue_player.state == PlayerState.PAUSED: + while count < 30 and queue_player.playback_state == PlaybackState.PAUSED: count += 1 await asyncio.sleep(1) # if player is still paused when the limit is reached, send stop - if queue_player.state == PlayerState.PAUSED: - await player_provider.cmd_stop(queue_player.player_id) + if queue_player.playback_state == PlaybackState.PAUSED: + await queue_player.stop() # we auto stop a player from paused when its paused for 30 seconds - if not queue_player.announcement_in_progress: + if not queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS): self.mass.create_task(_watch_pause()) @api_command("player_queues/play_pause") @@ -676,7 +674,7 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the queue to handle the command. """ - if (queue := self._queues.get(queue_id)) and queue.state == PlayerState.PLAYING: + if (queue := self._queues.get(queue_id)) and queue.state == PlaybackState.PLAYING: await self.pause(queue_id) return await self.play(queue_id) @@ -759,7 +757,7 @@ class PlayerQueuesController(CoreController): queue = self._queues[queue_id] queue_items = self._queue_items[queue_id] resume_item = queue.current_item - if queue.state == PlayerState.PLAYING: + if queue.state == PlaybackState.PLAYING: # resume requested while already playing, # use current position as resume position resume_pos = queue.corrected_elapsed_time @@ -779,7 +777,7 @@ class PlayerQueuesController(CoreController): queue_player = self.mass.players.get(queue_id) if ( fade_in is None - and queue_player.state == PlayerState.IDLE + and queue_player.playback_state == PlaybackState.IDLE and (time.time() - queue.elapsed_time_last_updated) > 60 ): # enable fade in effect if the player is idle for a while @@ -883,7 +881,7 @@ class PlayerQueuesController(CoreController): if not (target_queue := self.get(target_queue_id)): raise PlayerUnavailableError(f"Queue {target_queue_id} is not available") if auto_play is None: - auto_play = source_queue.state == PlayerState.PLAYING + auto_play = source_queue.state == PlaybackState.PLAYING target_player = self.mass.players.get(target_queue_id) if target_player.active_group or target_player.synced_to: @@ -971,13 +969,13 @@ class PlayerQueuesController(CoreController): if (queue := self._queues.get(queue_id)) is None: # race condition return - if player.announcement_in_progress: + if player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS): # do nothing while the announcement is in progress return # determine if this queue is currently active for this player queue.active = player.active_source == queue.queue_id if not queue.active and queue_id not in self._prev_states: - queue.state = PlayerState.IDLE + queue.state = PlaybackState.IDLE # return early if the queue is not active and we have no previous state return if queue.queue_id in self._transitioning_players: @@ -988,10 +986,12 @@ class PlayerQueuesController(CoreController): # queue is active and preflight checks passed, update the queue details self._update_queue_from_player(player) - def on_player_remove(self, player_id: str) -> None: + def on_player_remove(self, player_id: str, permanent: bool) -> None: """Call when a player is removed from the registry.""" - self.mass.create_task(self.mass.cache.delete(f"queue.state.{player_id}")) - self.mass.create_task(self.mass.cache.delete(f"queue.items.{player_id}")) + if permanent: + # if the player is permanently removed, we also remove the cached queue data + self.mass.create_task(self.mass.cache.delete(f"queue.state.{player_id}")) + self.mass.create_task(self.mass.cache.delete(f"queue.items.{player_id}")) self._queues.pop(player_id, None) self._queue_items.pop(player_id, None) @@ -1208,7 +1208,7 @@ class PlayerQueuesController(CoreController): # without having to compare the entire list queue.items_last_updated = time.time() self.signal_update(queue_id, True) - if queue.state == PlayerState.PLAYING and queue.index_in_buffer is not None: + if queue.state == PlaybackState.PLAYING and queue.index_in_buffer is not None: # if the queue is playing, # ensure to (re)queue the next track because it might have changed if next_item := self.get_next_item(queue_id, queue.index_in_buffer): @@ -1734,9 +1734,11 @@ class PlayerQueuesController(CoreController): queue.available = player.available queue.items = len(self._queue_items[queue_id]) - queue.state = player.state or PlayerState.IDLE if queue.active else PlayerState.IDLE + queue.state = ( + player.playback_state or PlaybackState.IDLE if queue.active else PlaybackState.IDLE + ) # update current item/index from player report - if queue.active and queue.state in (PlayerState.PLAYING, PlayerState.PAUSED): + if queue.active and queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED): # NOTE: If the queue is not playing (yet) we will not update the current index # to ensure we keep the previously known current index if queue.flow_mode: @@ -1771,11 +1773,13 @@ class PlayerQueuesController(CoreController): # This is enough to detect any changes in the DSPDetails # (so child count changed, or any output format changed) output_formats = [] - if player.output_format: - output_formats.append(str(player.output_format)) - for child_id in player.group_childs: - if (child := self.mass.players.get(child_id)) and child.output_format: - output_formats.append(str(child.output_format)) + if output_format := player.extra_data.get("output_format"): + output_formats.append(str(output_format)) + for child_id in player.group_members: + if (child := self.mass.players.get(child_id)) and ( + output_format := child.extra_data.get("output_format") + ): + output_formats.append(str(output_format)) else: output_formats.append("unknown") @@ -1784,7 +1788,7 @@ class PlayerQueuesController(CoreController): queue_id, CompareState( queue_id=queue_id, - state=PlayerState.IDLE, + state=PlaybackState.IDLE, current_item_id=None, next_item_id=None, current_item=None, @@ -1812,7 +1816,9 @@ class PlayerQueuesController(CoreController): ), output_formats=output_formats, ) - changed_keys = get_changed_keys(prev_state, new_state, ["next_item"]) + changed_keys = get_changed_keys(prev_state, new_state) + with suppress(KeyError): + changed_keys.remove("next_item_id") # return early if nothing changed if len(changed_keys) == 0: return @@ -1851,7 +1857,7 @@ class PlayerQueuesController(CoreController): self._handle_playback_progress_report(queue, prev_state, new_state) # check if we need to clear the queue if we reached the end - if "state" in changed_keys and queue.state == PlayerState.IDLE: + if "state" in changed_keys and queue.state == PlaybackState.IDLE: self._handle_end_of_queue(queue, prev_state, new_state) # watch dynamic radio items refill if needed @@ -1922,7 +1928,7 @@ class PlayerQueuesController(CoreController): track_sec_skipped = 0 track_time = elapsed_time_queue_total + track_sec_skipped - played_time break - if player.state != PlayerState.PLAYING: + if player.playback_state != PlaybackState.PLAYING: # if the player is not playing, we can't be sure that the elapsed time is correct # so we just return the queue index and the elapsed time return queue.current_index, queue.elapsed_time @@ -1968,8 +1974,8 @@ class PlayerQueuesController(CoreController): """Check if the queue should be cleared after the current item.""" # check if queue state changed to stopped (from playing/paused to idle) if not ( - prev_state["state"] in (PlayerState.PLAYING, PlayerState.PAUSED) - and new_state["state"] == PlayerState.IDLE + prev_state["state"] in (PlaybackState.PLAYING, PlaybackState.PAUSED) + and new_state["state"] == PlaybackState.IDLE ): return # check if no more items in the queue @@ -1985,7 +1991,7 @@ class PlayerQueuesController(CoreController): async def _clear_queue_delayed(): for _ in range(5): await asyncio.sleep(1) - if queue.state != PlayerState.IDLE: + if queue.state != PlaybackState.IDLE: return if queue.next_item is not None: return @@ -2055,7 +2061,7 @@ class PlayerQueuesController(CoreController): else: fully_played = seconds_played >= duration - 10 - is_playing = is_current_item and queue.state == PlayerState.PLAYING + is_playing = is_current_item and queue.state == PlaybackState.PLAYING if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): self.logger.debug( "%s %s '%s' (%s) - Fully played: %s - Progress: %s (%s/%ss)", diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py index 8d7a6087..1473e7b8 100644 --- a/music_assistant/controllers/players.py +++ b/music_assistant/controllers/players.py @@ -1,9 +1,17 @@ """ -MusicAssistant Players Controller. +MusicAssistant PlayerController. Handles all logic to control supported players, which are provided by Player Providers. +Note that the PlayerController has a concept of a 'player' and a 'playerstate'. +The Player is the actual object that is provided by the provider, +which incorporates the actual state of the player (e.g. volume, state, etc) +and functions for controlling the player (e.g. play, pause, etc). + +The playerstate is the (final) state of the player, including any user customizations +and transformations that are applied to the player. +The playerstate is the object that is exposed to the outside world (via the API). """ from __future__ import annotations @@ -21,25 +29,32 @@ from music_assistant_models.constants import ( ) from music_assistant_models.enums import ( EventType, - HidePlayerOption, MediaType, + PlaybackState, PlayerFeature, - PlayerState, PlayerType, ProviderFeature, ProviderType, ) from music_assistant_models.errors import ( AlreadyRegisteredError, + MusicAssistantError, PlayerCommandFailed, PlayerUnavailableError, + ProviderUnavailableError, UnsupportedFeaturedException, ) from music_assistant_models.media_items import UniqueList -from music_assistant_models.player import Player, PlayerMedia from music_assistant_models.player_control import PlayerControl # noqa: TC002 from music_assistant.constants import ( + ATTR_ANNOUNCEMENT_IN_PROGRESS, + ATTR_FAKE_MUTE, + ATTR_FAKE_POWER, + ATTR_FAKE_VOLUME, + ATTR_GROUP_MEMBERS, + ATTR_LAST_POLL, + ATTR_PREVIOUS_VOLUME, CACHE_CATEGORY_PLAYERS, CACHE_KEY_PLAYER_POWER, CONF_AUTO_PLAY, @@ -47,20 +62,14 @@ from music_assistant.constants import ( CONF_ENTRY_ANNOUNCE_VOLUME_MAX, CONF_ENTRY_ANNOUNCE_VOLUME_MIN, CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, - CONF_ENTRY_PLAYER_ICON, - CONF_EXPOSE_PLAYER_TO_HA, - CONF_HIDE_PLAYER_IN_UI, - CONF_MUTE_CONTROL, - CONF_PLAYERS, - CONF_POWER_CONTROL, CONF_TTS_PRE_ANNOUNCE, - CONF_VOLUME_CONTROL, ) from music_assistant.helpers.api import api_command from music_assistant.helpers.tags import async_parse_tags from music_assistant.helpers.throttle_retry import Throttler -from music_assistant.helpers.util import TaskManager, get_changed_values +from music_assistant.helpers.util import TaskManager from music_assistant.models.core_controller import CoreController +from music_assistant.models.player import Player, PlayerMedia, PlayerState from music_assistant.models.player_provider import PlayerProvider from music_assistant.models.plugin import PluginProvider, PluginSource @@ -76,7 +85,7 @@ _R = TypeVar("_R") _P = ParamSpec("_P") -def handle_player_command( +def handle_player_command[PlayerControllerT: "PlayerController", **P, R]( func: Callable[Concatenate[_PlayerControllerT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_PlayerControllerT, _P], Coroutine[Any, Any, _R | None]]: """Check and log commands to players.""" @@ -117,8 +126,7 @@ class PlayerController(CoreController): super().__init__(*args, **kwargs) self._players: dict[str, Player] = {} self._controls: dict[str, PlayerControl] = {} - self._prev_states: dict[str, dict] = {} - self.manifest.name = "Players controller" + self.manifest.name = "Player Controller" self.manifest.description = ( "Music Assistant's core controller which manages all players from all providers." ) @@ -141,37 +149,68 @@ class PlayerController(CoreController): """Return all loaded/running MusicProviders.""" return self.mass.get_providers(ProviderType.PLAYER) # type: ignore=return-value - def __iter__(self) -> Iterator[Player]: - """Iterate over (available) players.""" - return iter(self._players.values()) - - @api_command("players/all") def all( self, return_unavailable: bool = True, return_disabled: bool = False, + provider_filter: str | None = None, ) -> list[Player]: - """Return all registered players.""" + """ + Return all registered players. + + :param return_unavailable [bool]: Include unavailable players. + :param return_disabled [bool]: Include disabled players. + :param provider_filter [str]: Optional filter by provider ID. + + :return: List of Player objects. + """ return [ player for player in self._players.values() - if (player.available or return_unavailable) and (player.enabled or return_disabled) + if (player.available or return_unavailable) + and (player.enabled or return_disabled) + and (provider_filter is None or player.provider.instance_id == provider_filter) ] - @api_command("players/player_controls") - def player_controls( + @api_command("players/all") + def all_states( self, - ) -> list[PlayerControl]: - """Return all registered playercontrols.""" - return list(self._controls.values()) + return_unavailable: bool = True, + return_disabled: bool = False, + provider_filter: str | None = None, + ) -> list[PlayerState]: + """ + Return PlayerState for all registered players. + + :param return_unavailable [bool]: Include unavailable players. + :param return_disabled [bool]: Include disabled players. + :param provider_filter [str]: Optional filter by provider ID. + + :return: List of PlayerState objects. + """ + return [ + player.state + for player in self.all( + return_unavailable=return_unavailable, + return_disabled=return_disabled, + provider_filter=provider_filter, + ) + ] - @api_command("players/get") def get( self, player_id: str, raise_unavailable: bool = False, ) -> Player | None: - """Return Player by player_id.""" + """ + Return Player by player_id. + + :param player_id [str]: ID of the player. + :param raise_unavailable [bool]: Raise if player is unavailable. + + :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True. + :return: Player object or None. + """ if player := self._players.get(player_id): if (not player.available or not player.enabled) and raise_unavailable: msg = f"Player {player_id} is not available" @@ -182,11 +221,97 @@ class PlayerController(CoreController): raise PlayerUnavailableError(msg) return None - @api_command("players/get_by_name") - def get_by_name(self, name: str) -> Player | None: - """Return Player by name or None if no match is found.""" + @api_command("players/get") + def get_state( + self, + player_id: str, + raise_unavailable: bool = False, + ) -> PlayerState | None: + """ + Return PlayerState by player_id. + + :param player_id [str]: ID of the player. + :param raise_unavailable [bool]: Raise if player is unavailable. + + :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True. + :return: Player object or None. + """ + if player := self.get(player_id, raise_unavailable): + return player.state + return None + + def get_player_by_name(self, name: str) -> Player | None: + """ + Return Player by name. + + :param name: Name of the player. + :return: Player object or None. + """ return next((x for x in self._players.values() if x.name == name), None) + @api_command("players/get_by_name") + def get_player_state_by_name(self, name: str) -> PlayerState | None: + """ + Return PlayerState by name. + + :param name: Name of the player. + :return: PlayerState object or None. + """ + if player := self.get_player_by_name(name): + return player.state + return None + + @api_command("players/player_controls") + def player_controls( + self, + ) -> list[PlayerControl]: + """Return all registered playercontrols.""" + return list(self._controls.values()) + + @api_command("players/player_control") + def get_player_control( + self, + control_id: str, + ) -> PlayerControl | None: + """ + Return PlayerControl by control_id. + + :param control_id: ID of the player control. + :return: PlayerControl object or None. + """ + if control := self._controls.get(control_id): + return control + return None + + @api_command("players/plugin_sources") + def get_plugin_sources(self) -> list[PluginSource]: + """Return all available plugin sources.""" + return [ + plugin_prov.get_source() + for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN) + if isinstance(plugin_prov, PluginProvider) + and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features + ] + + @api_command("players/plugin_source") + def get_plugin_source( + self, + source_id: str, + ) -> PluginSource | None: + """ + Return PluginSource by source_id. + + :param source_id: ID of the plugin source. + :return: PluginSource object or None. + """ + for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN): + assert isinstance(plugin_prov, PluginProvider) # for type checking + if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features: + continue + if (source := plugin_prov.get_source()) and source.id == source_id: + return source + return None + # Player commands @api_command("players/cmd/stop") @@ -198,13 +323,12 @@ class PlayerController(CoreController): """ player = self._get_player_with_redirect(player_id) # Redirect to queue controller if it is active - if active_queue := self.mass.player_queues.get(player.active_source): + if active_queue := self.get_active_queue(player): await self.mass.player_queues.stop(active_queue.queue_id) return - # send to player provider + # handle command on player directly async with self._player_throttlers[player.player_id]: - if player_provider := self.get_player_provider(player.player_id): - await player_provider.cmd_stop(player.player_id) + await player.stop() @api_command("players/cmd/play") @handle_player_command @@ -214,20 +338,18 @@ class PlayerController(CoreController): - player_id: player_id of the player to handle the command. """ player = self._get_player_with_redirect(player_id) - if player.state == PlayerState.PLAYING: + if player.playback_state == PlaybackState.PLAYING: self.logger.info( "Ignore PLAY request to player %s: player is already playing", player.display_name ) return # Redirect to queue controller if it is active - active_source = player.active_source or player.player_id - if (active_queue := self.mass.player_queues.get(active_source)) and active_queue.items: + if active_queue := self.get_active_queue(player): await self.mass.player_queues.play(active_queue.queue_id) return - # send to player provider - player_provider = self.get_player_provider(player.player_id) + # handle command on player directly async with self._player_throttlers[player.player_id]: - await player_provider.cmd_play(player.player_id) + await player.play() @api_command("players/cmd/pause") @handle_player_command @@ -238,7 +360,7 @@ class PlayerController(CoreController): """ player = self._get_player_with_redirect(player_id) # Redirect to queue controller if it is active - if active_queue := self.mass.player_queues.get(player.active_source): + if active_queue := self.get_active_queue(player): await self.mass.player_queues.pause(active_queue.queue_id) return if PlayerFeature.PAUSE not in player.supported_features: @@ -249,8 +371,8 @@ class PlayerController(CoreController): ) await self.cmd_stop(player.player_id) return - player_provider = self.get_player_provider(player.player_id) - await player_provider.cmd_pause(player.player_id) + # handle command on player directly + await player.pause() @api_command("players/cmd/play_pause") async def cmd_play_pause(self, player_id: str) -> None: @@ -259,7 +381,7 @@ class PlayerController(CoreController): - player_id: player_id of the player to handle the command. """ player = self._get_player_with_redirect(player_id) - if player.state == PlayerState.PLAYING: + if player.playback_state == PlaybackState.PLAYING: await self.cmd_pause(player.player_id) else: await self.cmd_play(player.player_id) @@ -273,15 +395,14 @@ class PlayerController(CoreController): """ player = self._get_player_with_redirect(player_id) # Redirect to queue controller if it is active - active_source = player.active_source or player.player_id - if active_queue := self.mass.player_queues.get(active_source): + if active_queue := self.get_active_queue(player): await self.mass.player_queues.seek(active_queue.queue_id, position) return if PlayerFeature.SEEK not in player.supported_features: msg = f"Player {player.display_name} does not support seeking" raise UnsupportedFeaturedException(msg) - player_prov = self.get_player_provider(player.player_id) - await player_prov.cmd_seek(player.player_id, position) + # handle command on player directly + await player.seek(position) @api_command("players/cmd/next") async def cmd_next_track(self, player_id: str) -> None: @@ -289,8 +410,8 @@ class PlayerController(CoreController): player = self._get_player_with_redirect(player_id) active_source_id = player.active_source or player.player_id - if active_queue := self.mass.player_queues.get(active_source_id): - # active source is a MA queue + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): await self.mass.player_queues.next(active_queue.queue_id) return @@ -298,8 +419,7 @@ class PlayerController(CoreController): # player has some other source active and native next/previous support active_source = next((x for x in player.source_list if x.id == active_source_id), None) if active_source and active_source.can_next_previous: - player_provider = self.get_player_provider(player.player_id) - await player_provider.cmd_next(player.player_id) + await player.next_track() return msg = "This action is (currently) unavailable for this source." raise PlayerCommandFailed(msg) @@ -312,8 +432,8 @@ class PlayerController(CoreController): """Handle PREVIOUS TRACK command for given player.""" player = self._get_player_with_redirect(player_id) active_source_id = player.active_source or player.player_id - if active_queue := self.mass.player_queues.get(active_source_id): - # active source is a MA queue + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): await self.mass.player_queues.previous(active_queue.queue_id) return @@ -321,8 +441,7 @@ class PlayerController(CoreController): # player has some other source active and native next/previous support active_source = next((x for x in player.source_list if x.id == active_source_id), None) if active_source and active_source.can_next_previous: - player_provider = self.get_player_provider(player.player_id) - await player_provider.cmd_previous(player.player_id) + await player.previous_track() return msg = "This action is (currently) unavailable for this source." raise PlayerCommandFailed(msg) @@ -339,8 +458,16 @@ class PlayerController(CoreController): - powered: bool if player should be powered on or off. """ player = self.get(player_id, True) + assert player is not None # for type checking + player_state = player.state - if player.powered == powered: + if player_state.powered == powered: + self.logger.debug( + "Ignoring power %s command for player %s: already in state %s", + "ON" if powered else "OFF", + player_state.name, + "ON" if player_state.powered else "OFF", + ) return # nothing to do # ungroup player at power off @@ -354,12 +481,14 @@ class PlayerController(CoreController): if ( not powered and not player_was_synced - and player.state in (PlayerState.PLAYING, PlayerState.PAUSED) + and player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) ): await self.cmd_stop(player_id) + # short sleep: allow the stop command to process and prevent race conditions + await asyncio.sleep(0.2) # power off all synced childs when player is a sync leader - elif not powered and player.type == PlayerType.PLAYER and player.group_childs: + elif not powered and player.type == PlayerType.PLAYER and player.group_members: async with TaskManager(self.mass) as tg: for member in self.iter_group_members(player, True): if member.power_control == PLAYER_CONTROL_NONE: @@ -373,17 +502,15 @@ class PlayerController(CoreController): ) if player.power_control == PLAYER_CONTROL_NATIVE: # player supports power command natively: forward to player provider - player_provider = self.get_player_provider(player_id) async with self._player_throttlers[player_id]: - await player_provider.cmd_power(player_id, powered) + await player.power(powered) elif player.power_control == PLAYER_CONTROL_FAKE: # user wants to use fake power control - so we (optimistically) update the state # and store the state in the cache + player.extra_data[ATTR_FAKE_POWER] = powered await self.mass.cache.set( player_id, powered, category=CACHE_CATEGORY_PLAYERS, base_key=CACHE_KEY_PLAYER_POWER ) - # short sleep: allow the stop command to process and prevent race conditions - await asyncio.sleep(0.2) else: # handle external player control player_control = self._controls.get(player.power_control) @@ -394,27 +521,29 @@ class PlayerController(CoreController): f"Player control {control_name} is not available" ) if powered: + assert player_control.power_on is not None # for type checking await player_control.power_on() else: + assert player_control.power_off is not None # for type checking await player_control.power_off() # always optimistically set the power state to update the UI # as fast as possible and prevent race conditions - player.powered = powered + player_state.powered = powered # reset active source on power off if not powered: - player.active_source = None + player_state.active_source = None if not skip_update: - self.update(player_id) + player.update_state() # handle 'auto play on power on' feature if ( not player.active_group and powered - and self.mass.config.get_raw_player_config_value(player_id, CONF_AUTO_PLAY, False) + and player.config.get_value(CONF_AUTO_PLAY) and player.active_source in (None, player_id) - and not player.announcement_in_progress + and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS) ): await self.mass.player_queues.resume(player_id) @@ -426,10 +555,10 @@ class PlayerController(CoreController): - player_id: player_id of the player to handle the command. - volume_level: volume level (0..100) to set on the player. """ - # TODO: Implement PlayerControl player = self.get(player_id, True) + assert player is not None # for type checker if player.type == PlayerType.GROUP: - # redirect to group volume control + # redirect to special group volume control await self.cmd_group_volume(player_id, volume_level) return @@ -437,22 +566,36 @@ class PlayerController(CoreController): raise UnsupportedFeaturedException( f"Player {player.display_name} does not support volume control" ) + + if player.mute_control != PLAYER_CONTROL_NONE and player.volume_muted: + # if player is muted, we unmute it first + self.logger.debug( + "Unmuting player %s before setting volume", + player.display_name, + ) + await self.cmd_volume_mute(player_id, False) + if player.volume_control == PLAYER_CONTROL_NATIVE: - # player supports volume command natively: forward to player provider - player_provider = self.get_player_provider(player_id) - async with self._player_throttlers[player_id]: - await player_provider.cmd_volume_set(player_id, volume_level) - else: - # handle external player control - player_control = self._controls.get(player.volume_control) - control_name = player_control.name if player_control else player.volume_control - self.logger.debug("Redirecting volume command to PlayerControl %s", control_name) - if not player_control or not player_control.supports_volume: - raise UnsupportedFeaturedException( - f"Player control {control_name} is not available" - ) + # player supports volume command natively: forward to player async with self._player_throttlers[player_id]: - await player_control.volume_set(volume_level) + await player.volume_set(volume_level) + return + if player.volume_control == PLAYER_CONTROL_FAKE: + # user wants to use fake volume control - so we (optimistically) update the state + # and store the state in the cache + player.extra_data[ATTR_FAKE_VOLUME] = volume_level + # trigger update + player.update_state() + return + # else: handle external player control + player_control = self._controls.get(player.volume_control) + control_name = player_control.name if player_control else player.volume_control + self.logger.debug("Redirecting volume command to PlayerControl %s", control_name) + if not player_control or not player_control.supports_volume: + raise UnsupportedFeaturedException(f"Player control {control_name} is not available") + async with self._player_throttlers[player_id]: + assert player_control.volume_set is not None + await player_control.volume_set(volume_level) @api_command("players/cmd/volume_up") @handle_player_command @@ -463,13 +606,14 @@ class PlayerController(CoreController): """ if not (player := self.get(player_id)): return - if player.volume_level < 5 or player.volume_level > 95: + current_volume = player.volume_state or 0 + if current_volume < 5 or current_volume > 95: step_size = 1 - elif player.volume_level < 20 or player.volume_level > 80: + elif current_volume < 20 or current_volume > 80: step_size = 2 else: step_size = 5 - new_volume = min(100, self._players[player_id].volume_level + step_size) + new_volume = min(100, current_volume + step_size) await self.cmd_volume_set(player_id, new_volume) @api_command("players/cmd/volume_down") @@ -481,42 +625,43 @@ class PlayerController(CoreController): """ if not (player := self.get(player_id)): return - if player.volume_level < 5 or player.volume_level > 95: + current_volume = player.volume_state or 0 + if current_volume < 5 or current_volume > 95: step_size = 1 - elif player.volume_level < 20 or player.volume_level > 80: + elif current_volume < 20 or current_volume > 80: step_size = 2 else: step_size = 5 - new_volume = max(0, self._players[player_id].volume_level - step_size) + new_volume = max(0, current_volume - step_size) await self.cmd_volume_set(player_id, new_volume) @api_command("players/cmd/group_volume") @handle_player_command - async def cmd_group_volume(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given playergroup. + async def cmd_group_volume( + self, + player_id: str, + volume_level: int, + ) -> None: + """ + Handle adjusting the overall/group volume to a playergroup (or synced players). + + Will set a new (overall) volume level to a group player or syncgroup. - Will send the new (average) volume level to group child's. - - player_id: player_id of the playergroup to handle the command. - - volume_level: volume level (0..100) to set on the player. + :param group_player: dedicated group player or syncleader to handle the command. + :param volume_level: volume level (0..100) to set to the group. """ - 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.group_volume - new_volume = volume_level - volume_dif = new_volume - cur_volume - coros = [] - for child_player in self.iter_group_members( - group_player, only_powered=True, exclude_self=False - ): - if child_player.volume_control == PLAYER_CONTROL_NONE: - continue - cur_child_volume = child_player.volume_level - new_child_volume = int(cur_child_volume + volume_dif) - new_child_volume = max(0, new_child_volume) - new_child_volume = min(100, new_child_volume) - coros.append(self.cmd_volume_set(child_player.player_id, new_child_volume)) - await asyncio.gather(*coros) + player = self.get(player_id, True) + assert player is not None # for type checker + if player.type == PlayerType.GROUP or player.group_members: + # dedicated group player or sync leader + await self.set_group_volume(player, volume_level) + return + if player.synced_to and (sync_leader := self.get(player.synced_to)): + # redirect to sync leader + await self.set_group_volume(sync_leader, volume_level) + return + # treat as normal player volume change + await self.cmd_volume_set(player_id, volume_level) @api_command("players/cmd/group_volume_up") @handle_player_command @@ -571,23 +716,24 @@ class PlayerController(CoreController): f"Player {player.display_name} does not support muting" ) if player.mute_control == PLAYER_CONTROL_NATIVE: - # player supports mute command natively: forward to player provider - player_provider = self.get_player_provider(player_id) + # player supports mute command natively: forward to player async with self._player_throttlers[player_id]: - await player_provider.cmd_volume_mute(player_id, muted) - elif player.power_control == PLAYER_CONTROL_FAKE: + await player.volume_mute(muted) + elif player.mute_control == PLAYER_CONTROL_FAKE: # user wants to use fake mute control - so we use volume instead self.logger.debug( "Using volume for muting for player %s", player.display_name, ) if muted: - player.previous_volume_level = player.volume_level - player.volume_muted = True + player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_state + player.extra_data[ATTR_FAKE_MUTE] = True await self.cmd_volume_set(player_id, 0) else: - player.volume_muted = False - await self.cmd_volume_set(player_id, player.previous_volume_level) + player._attr_volume_muted = False + prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1) + player.extra_data[ATTR_FAKE_MUTE] = False + await self.cmd_volume_set(player_id, prev_volume) else: # handle external player control player_control = self._controls.get(player.mute_control) @@ -598,6 +744,7 @@ class PlayerController(CoreController): f"Player control {control_name} is not available" ) async with self._player_throttlers[player_id]: + assert player_control.mute_set is not None await player_control.mute_set(muted) @api_command("players/cmd/play_announcement") @@ -610,6 +757,7 @@ class PlayerController(CoreController): ) -> None: """Handle playback of an announcement (url) on given player.""" player = self.get(player_id, True) + assert player is not None # for type checking if not url.startswith("http"): raise PlayerCommandFailed("Only URLs are supported for announcements") # prevent multiple announcements at the same time to the same player with a lock @@ -620,7 +768,7 @@ class PlayerController(CoreController): async with lock: try: # mark announcement_in_progress on player - player.announcement_in_progress = True + player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True # determine if the player has native announcements support native_announce_support = ( PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features @@ -641,7 +789,7 @@ class PlayerController(CoreController): ): # forward the request to each individual player async with TaskManager(self.mass) as tg: - for group_member in player.group_childs: + for group_member in player.group_members: tg.create_task( self.play_announcement( group_member, @@ -667,14 +815,13 @@ class PlayerController(CoreController): ) # handle native announce support if native_announce_support: - if prov := self.mass.get_provider(player.provider): - announcement_volume = self.get_announcement_volume(player_id, volume_level) - await prov.play_announcement(player_id, announcement, announcement_volume) - return + announcement_volume = self.get_announcement_volume(player_id, volume_level) + await player.play_announcement(announcement, announcement_volume) + return # use fallback/default implementation await self._play_announcement(player, announcement, volume_level) finally: - player.announcement_in_progress = False + player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False @handle_player_command async def play_media(self, player_id: str, media: PlayerMedia) -> None: @@ -687,11 +834,7 @@ class PlayerController(CoreController): # power on the player if needed if player.powered is False and player.power_control != PLAYER_CONTROL_NONE: await self.cmd_power(player.player_id, True) - player_prov = self.get_player_provider(player.player_id) - await player_prov.play_media( - player_id=player.player_id, - media=media, - ) + await player.play_media(media) @api_command("players/cmd/select_source") async def select_source(self, player_id: str, source: str) -> None: @@ -702,17 +845,18 @@ class PlayerController(CoreController): - source: The ID of the source that needs to be activated/selected. """ player = self.get(player_id, True) + assert player is not None # for type checking if player.synced_to or player.active_group: raise PlayerCommandFailed(f"Player {player.display_name} is currently grouped") # check if player is already playing and source is different # in that case we need to stop the player first prev_source = player.active_source if prev_source and source != prev_source: - if player.state != PlayerState.IDLE: + if player.playback_state != PlaybackState.IDLE: await self.cmd_stop(player_id) await asyncio.sleep(0.5) # small delay to allow stop to process - player.active_source = None - player.current_media = None + player._attr_active_source = None + player._attr_current_media = None # check if source is a pluginsource # in that case the source id is the instance_id of the plugin provider if plugin_prov := self.mass.get_provider(source): @@ -721,8 +865,7 @@ class PlayerController(CoreController): # check if source is a mass queue # this can be used to restore the queue after a source switch if mass_queue := self.mass.player_queues.get(source): - player.active_source = mass_queue.queue_id - self.update(player_id) + await self.mass.player_queues.play(mass_queue.queue_id) return # basic check if player supports source selection if PlayerFeature.SELECT_SOURCE not in player.supported_features: @@ -734,20 +877,26 @@ class PlayerController(CoreController): raise PlayerCommandFailed( f"{source} is an invalid source for player {player.display_name}" ) - # forward to player provider - provider = self.mass.get_provider(player.provider) - await provider.select_source(player_id, source) + # forward to player + await player.select_source(source) async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of a next media item on the player.""" + """ + Handle enqueuing of a next media item on the player. + + :param player_id: player_id of the player to handle the command. + :param media: The Media that needs to be enqueued on the player. + :raises UnsupportedFeaturedException: if the player does not support enqueueing. + :raises PlayerUnavailableError: if the player is not available. + """ player = self.get(player_id, raise_unavailable=True) + assert player is not None # for type checking if PlayerFeature.ENQUEUE not in player.supported_features: raise UnsupportedFeaturedException( f"Player {player.display_name} does not support enqueueing" ) - player_prov = self.mass.get_provider(player.provider) async with self._player_throttlers[player_id]: - await player_prov.enqueue_next_media(player_id=player_id, media=media) + await player.enqueue_next_media(media) @api_command("players/cmd/group") @handle_player_command @@ -758,16 +907,32 @@ class PlayerController(CoreController): If the target player itself is already synced to another player, this may fail. If the player can not be synced with the given target player, this may fail. - - player_id: player_id of the player to handle the command. - - target_player: player_id of the syncgroup leader or group player. + :param player_id: player_id of the player to handle the command. + :param target_player: player_id of the syncgroup leader or group player. + + :raises UnsupportedFeaturedException: if the target player does not support grouping. + :raises PlayerCommandFailed: if the target player is already synced to another player. + :raises PlayerUnavailableError: if the target player is not available. + :raises PlayerCommandFailed: if the player is already grouped to another player. """ await self.cmd_group_many(target_player, [player_id]) @api_command("players/cmd/group_many") async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None: - """Join given player(s) to target player.""" - parent_player: Player = self.get(target_player, True) - prev_group_childs = parent_player.group_childs.copy() + """ + Join given player(s) to target player. + + Will add the given player(s) to the target player (sync leader or group player). + + :param target_player: player_id of the syncgroup leader or group player. + :param child_player_ids: list of player_ids to add to the target player. + + :raises UnsupportedFeaturedException: if the target player does not support grouping. + :raises PlayerCommandFailed: if the target player is already synced to another player. + :raises PlayerUnavailableError: if the target player is not available. + """ + parent_player: Player | None = self.get(target_player, True) + assert parent_player is not None # for type checking if PlayerFeature.SET_MEMBERS not in parent_player.supported_features: msg = f"Player {parent_player.name} does not support group commands" raise UnsupportedFeaturedException(msg) @@ -787,52 +952,50 @@ class PlayerController(CoreController): if not (child_player := self.get(child_player_id)) or not child_player.available: self.logger.warning("Player %s is not available", child_player_id) continue + # check if player can be synced/grouped with the target player if not ( child_player_id in parent_player.can_group_with - or child_player.provider in parent_player.can_group_with + or child_player.provider.instance_id in parent_player.can_group_with + or "*" in parent_player.can_group_with ): raise UnsupportedFeaturedException( f"Player {child_player.name} can not be grouped with {parent_player.name}" ) - if child_player.synced_to and child_player.synced_to == target_player: + if ( + child_player.synced_to + and child_player.synced_to == target_player + and child_player_id in parent_player.group_members + ): continue # already synced to this target # perform some sanity checks on the child player # if we're not joining a group player - if parent_player.provider != "player_group": - if child_player.group_childs and child_player.state != PlayerState.IDLE: - # guard edge case: childplayer is already a sync leader on its own - raise PlayerCommandFailed( - f"Player {child_player.name} is already synced with other players, " - "you need to ungroup it first before you can join it to another player.", - ) - if child_player.synced_to: - # player already synced to another player, ungroup first - self.logger.warning( - "Player %s is already synced to another player, ungrouping first", - child_player.name, - ) - await self.cmd_ungroup(child_player.player_id) + if ( + parent_player.type == PlayerType.PLAYER + and child_player.group_members + and child_player.playback_state != PlaybackState.IDLE + ): + # guard edge case: childplayer is already a sync leader on its own + raise PlayerCommandFailed( + f"Player {child_player.name} is already synced with other players, " + "you need to ungroup it first before you can join it to another player.", + ) # power on the player if needed if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE: await self.cmd_power(child_player.player_id, True, skip_update=True) # if we reach here, all checks passed final_player_ids.append(child_player_id) - # set active source if player is synced - child_player.active_source = parent_player.player_id - # forward command to the player provider after all (base) sanity checks - player_provider = self.get_player_provider(target_player) + # forward command to the player after all (base) sanity checks async with self._player_throttlers[target_player]: - try: - await player_provider.cmd_group_many(target_player, final_player_ids) - except Exception: - # restore sync state if the command failed - parent_player.group_childs.set(prev_group_childs) - raise + await parent_player.set_members( + player_ids_to_add=[ + x for x in final_player_ids if x not in parent_player.group_members + ] + ) @api_command("players/cmd/ungroup") @handle_player_command @@ -852,18 +1015,13 @@ class PlayerController(CoreController): if ( player.active_group and (group_player := self.get(player.active_group)) - and ( - PlayerFeature.SET_MEMBERS in group_player.supported_features - or group_player.provider.startswith("player_group") - ) + and (PlayerFeature.SET_MEMBERS in group_player.supported_features) ): # the player is part of a (permanent) groupplayer and the user tries to ungroup - # redirect the command to the group provider - group_provider = self.mass.get_provider(group_player.provider) - await group_provider.cmd_ungroup_member(player_id, group_player.player_id) + await group_player.set_members(player_ids_to_remove=[player_id]) return - if not (player.synced_to or player.group_childs): + if not (player.synced_to or player.group_members): return # nothing to do if PlayerFeature.SET_MEMBERS not in player.supported_features: @@ -874,28 +1032,21 @@ class PlayerController(CoreController): # we dissolve the entire syncgroup in this case. # while maybe not strictly needed to do this for all player providers, # we do this to keep the functionality consistent across all providers - if player.group_childs: + if player.group_members: self.logger.warning( "Detected ungroup command to player %s which is a sync(group) leader, " "all sync members will be ungrouped!", player.name, ) async with TaskManager(self.mass) as tg: - for group_child_id in player.group_childs: + for group_child_id in player.group_members: if group_child_id == player_id: continue tg.create_task(self.cmd_ungroup(group_child_id)) return - # (optimistically) reset active source player if it is ungrouped - player.active_source = None - - # forward command to the player provider - if player_provider := self.get_player_provider(player_id): - await player_provider.cmd_ungroup(player_id) - # if the command succeeded we optimistically reset the sync state - # this is to prevent race conditions and to update the UI as fast as possible - player.synced_to = None + # forward command to the player once all checks passed + await player.ungroup() @api_command("players/cmd/ungroup_many") async def cmd_ungroup_many(self, player_ids: list[str]) -> None: @@ -903,39 +1054,120 @@ class PlayerController(CoreController): for player_id in list(player_ids): await self.cmd_ungroup(player_id) - def set(self, player: Player) -> None: - """Set/Update player details on the controller.""" - if player.player_id not in self._players: - # new player - self.register(player) + @api_command("players/create_group_player") + async def create_group_player( + self, provider: str, name: str, members: list[str], dynamic: bool = True + ): + """ + Create a new (permanent) Group Player. + + :param provider: The provider to create the group player for + :param name: Name of the new group player + :param members: List of player ids to add to the group + :param dynamic: Whether the group is dynamic (members can change) + """ + if not (provider_instance := self.mass.get_provider(provider)): + raise ProviderUnavailableError(f"Provider {provider} not found") + provider_instance.check_feature(ProviderFeature.CREATE_GROUP_PLAYER) + provider_instance = cast("PlayerProvider", provider_instance) + # create the group player + return await provider_instance.create_group_player(name, members, dynamic) + + @api_command("players/remove_group_player") + async def remove_group_player(self, player_id: str) -> None: + """ + Remove a group player. + + :param player_id: ID of the group player to remove. + """ + if not (player := self.get(player_id)): + raise PlayerUnavailableError(f"Player {player_id} not found") + if player.type != PlayerType.GROUP: + raise UnsupportedFeaturedException( + f"Player {player.display_name} is not a group player" + ) + provider = self.mass.get_provider(player.provider.instance_id) + provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER) + provider = cast("PlayerProvider", provider) + await provider.remove_group_player(player_id) + + @api_command("players/add_currently_playing_to_favorites") + async def add_currently_playing_to_favorites(self, player_id: str) -> None: + """ + Add the currently playing item/track on given player to the favorites. + + This tries to resolve the currently playing media to an actual media item + and add that to the favorites in the library. + + Will raise an error if the player is not currently playing anything + or if the currently playing media can not be resolved to a media item. + """ + player = self._get_player_with_redirect(player_id) + # handle mass player queue active + if mass_queue := self.get_active_queue(player): + if not (current_item := mass_queue.current_item) or not current_item.media_item: + raise PlayerCommandFailed("No current item to add to favorites") + # if we're playing a radio station, try to resolve the currently playing track + if current_item.media_item.media_type == MediaType.RADIO: + if not ( + (streamdetails := mass_queue.current_item.streamdetails) + and (stream_title := streamdetails.stream_title) + and " - " in stream_title + ): + # no stream title available, so we can't resolve the track + # this can happen if the radio station does not provide metadata + # or there's a commercial break + # Possible future improvement could be to actually detect the song with a + # shazam-like approach. + raise PlayerCommandFailed("No current item to add to favorites") + # send the streamtitle into a global search query + search_artist, search_title_title = stream_title.split(" - ", 1) + if track := await self.mass.music.get_track_by_name( + search_title_title, search_artist + ): + # we found a track, so add it to the favorites + await self.mass.music.add_item_to_favorites(track) + return + # we could not resolve the track, so raise an error + raise PlayerCommandFailed("No current item to add to favorites") + + # else: any other media item, just add it to the favorites directly + await self.mass.music.add_item_to_favorites(current_item.media_item) return - self._players[player.player_id] = player - self.update(player.player_id) + + # guard for player with no active source + if not player.active_source: + raise PlayerCommandFailed("Player has no active source") + # handle other source active using the current_media with uri + if current_media := player.current_media: + # prefer the uri of the current media item + if current_media.uri: + with suppress(MusicAssistantError): + await self.mass.music.add_item_to_favorites(current_media.uri) + return + # fallback to search based on artist and title (and album if available) + if current_media.artist and current_media.title: + if track := await self.mass.music.get_track_by_name( + current_media.title, + current_media.artist, + current_media.album, + ): + # we found a track, so add it to the favorites + await self.mass.music.add_item_to_favorites(track) + return + # if we reach here, we could not resolve the currently playing item + raise PlayerCommandFailed("No current item to add to favorites") async def register(self, player: Player) -> None: - """Register a new player on the controller.""" + """Register a player on the Player Controller.""" if self.mass.closing: return player_id = player.player_id if player_id in self._players: - msg = f"Player {player_id} is already registered" + msg = f"Player {player_id} is already registered!" raise AlreadyRegisteredError(msg) - # make sure that the player's provider is set to the instance_id - prov = self.mass.get_provider(player.provider) - if not prov or prov.instance_id != player.provider: - raise RuntimeError(f"Invalid provider ID given: {player.provider}") - - # make sure a default config exists - self.mass.config.create_default_player_config( - player_id, player.provider, player.name, player.enabled_by_default - ) - # mark player as unavailable during the add process - player_available = player.available - player.available = False - player.enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True) - # ignore disabled players if not player.enabled: return @@ -944,203 +1176,190 @@ class PlayerController(CoreController): self.mass.create_task(self.mass.player_queues.on_player_register(player)) # register throttler for this player - self._player_throttlers[player_id] = Throttler(1, 0.2) + self._player_throttlers[player_id] = Throttler(1, 0.05) + + # restore 'fake' power state from cache if available + cached_value = await self.mass.cache.get( + player.player_id, + default=False, + category=CACHE_CATEGORY_PLAYERS, + base_key=CACHE_KEY_PLAYER_POWER, + ) + if cached_value is not None: + player.extra_data[ATTR_FAKE_POWER] = cached_value + # finally actually register it self._players[player_id] = player - # ensure initial player state gets populated with values from config + + # ensure we fetch and set the latest/full config for the player player_config = await self.mass.config.get_player_config(player_id) - await self._set_player_state_from_config(player, player_config) + player.set_config(player_config) + # always call update to fix special attributes like display name, group volume etc. + player.update_state() self.logger.info( "Player registered: %s/%s", player_id, player.display_name, ) - player.available = player_available + # signal event that a player was added 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) async def register_or_update(self, player: Player) -> None: """Register a new player on the controller or update existing one.""" if self.mass.closing: return - if (existing := self.get(player.player_id)) and not existing.available and player.available: - # player was previously unavailable, but is now available again - self.logger.info("Player %s is available again", player.name) - del self._players[player.player_id] - if player.player_id in self._players: self._players[player.player_id] = player - self.update(player.player_id) + player.update_state() return await self.register(player) - def remove(self, player_id: str, cleanup_config: bool = True) -> None: - """Remove a player from the player manager.""" + def trigger_player_update(self, player_id: str, force_update: bool = False) -> None: + """Trigger an update for the given player.""" + if self.mass.closing: + return + player = self.get(player_id, True) + assert player is not None # for type checker + self.mass.loop.call_soon(player.update_state, force_update) + + async def unregister(self, player_id: str, permanent: bool = False) -> None: + """ + Unregister a player from the player controller. + + Called (by a PlayerProvider) when a player is removed + or no longer available (for a longer period of time). + + This will remove the player from the player controller and + optionally remove the player's config from the mass config. + + - player_id: player_id of the player to unregister. + - permanent: if True, remove the player permanently by deleting + the player's config from the mass config. If False, the player config will not be removed, + allowing for re-registration (with the same config) later. + + If the player is not registered, this will silently be ignored. + """ player = self._players.pop(player_id, None) if player is None: return self.logger.info("Player removed: %s", player.name) - self.mass.player_queues.on_player_remove(player_id) - if cleanup_config: + self.mass.player_queues.on_player_remove(player_id, permanent=permanent) + await player.on_unload() + if permanent: self.mass.config.remove(f"players/{player_id}") - self._prev_states.pop(player_id, None) self.mass.signal_event(EventType.PLAYER_REMOVED, player_id) - def update( # noqa: PLR0915 - self, player_id: str, skip_forward: bool = False, force_update: bool = False - ) -> None: - """Update player state.""" - if self.mass.closing: - return - if player_id not in self._players: - return - player = self._players[player_id] - prev_state = self._prev_states.get(player_id, {}) - player.active_source = self._get_active_source(player) - # set player sources - self._set_player_sources(player) - # prefer any overridden name from config - player.display_name = ( - self.mass.config.get_raw_player_config_value(player.player_id, "name") - or player.name - # use prev value (e.g. some fallback) - or player.display_name - ) - # handle player controls - if player.power_control == PLAYER_CONTROL_NONE: - player.powered = None - elif player.power_control == PLAYER_CONTROL_FAKE: - player.powered = False if player.powered is None else player.powered - elif player.power_control != PLAYER_CONTROL_NATIVE: - if player_control := self._controls.get(player.power_control): - player.powered = player_control.power_state - if player.volume_control == PLAYER_CONTROL_NONE: - player.volume_level = None - elif player.volume_control != PLAYER_CONTROL_NATIVE: - if player_control := self._controls.get(player.volume_control): - player.volume_level = player_control.volume_level - if player.mute_control == PLAYER_CONTROL_NONE: - player.volume_muted = None - elif player.mute_control not in (PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_FAKE): - if player_control := self._controls.get(player.mute_control): - player.volume_muted = player_control.volume_muted - # correct group_members if needed - if player.group_childs == [player.player_id]: - player.group_childs.clear() - elif ( - player.group_childs - and player.player_id not in player.group_childs - and player.type == PlayerType.PLAYER - ): - player.group_childs.set([player.player_id, *player.group_childs]) - if player.active_group and player.active_group == player.player_id: - player.active_group = None - # Auto correct player state if player is synced (or group child) - # This is because some players/providers do not accurately update this info - # for the sync child's. - if player.synced_to and (sync_leader := self.get(player.synced_to)): - player.state = sync_leader.state - player.elapsed_time = sync_leader.elapsed_time - player.elapsed_time_last_updated = sync_leader.elapsed_time_last_updated - # calculate group volume - player.group_volume = self._get_group_volume_level(player) - if player.type == PlayerType.GROUP: - player.volume_level = player.group_volume + async def remove(self, player_id: str) -> None: + """ + Remove a player from a provider. - # correct available state if needed - if not player.enabled: - player.available = False + Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER. + """ + player = self.get(player_id, True) + assert player is not None # for type checker + if ProviderFeature.REMOVE_PLAYER not in player.provider.supported_features: + raise UnsupportedFeaturedException( + f"Provider {player.provider.name} does not support removing players" + ) + await player.provider.remove_player(player_id) - # basic throttle: do not send state changed events if player did not actually change - new_state = self._players[player_id].to_dict() - changed_values = get_changed_values( - prev_state, - new_state, - ignore_keys=[ - "elapsed_time_last_updated", - "seq_no", - "last_poll", - ], - ) - self._prev_states[player_id] = new_state + def signal_player_state_update( + self, + player: Player, + changed_values: dict[str, tuple[Any, Any]], + force_update: bool = False, + skip_forward: bool = False, + ) -> None: + """ + Signal a player state update. - if not player.enabled and not force_update: - # ignore updates for disabled players + Called by a Player when its state has changed. + This will update the player state in the controller and signal the event bus. + """ + player_id = player.player_id + if self.mass.closing: return - # always signal update to the playerqueue (regardless of changes) - self.mass.player_queues.on_player_update(player, changed_values) + # ignore updates for disabled players + if not player.enabled and "enabled" not in changed_values: + return if len(changed_values) == 0 and not force_update: # nothing changed return + # always signal update to the playerqueue + self.mass.player_queues.on_player_update(player, changed_values) + if changed_values.keys() == {"elapsed_time"} and not force_update: # ignore elapsed_time only changes - return + prev_value = changed_values["elapsed_time"][0] or 0 + new_value = changed_values["elapsed_time"][1] or 0 + if abs(prev_value - new_value) < 30: + # ignore small changes in elapsed time + return + + # handle DSP reload of the leader when grouping/ungrouping + if ATTR_GROUP_MEMBERS in changed_values: + new_group_members: list[str] = changed_values[ATTR_GROUP_MEMBERS][1] + prev_group_members: list[str] = changed_values[ATTR_GROUP_MEMBERS][0] or [] + prev_child_count = len(prev_group_members) + new_child_count = len(new_group_members) + is_player_group = player.type == PlayerType.GROUP + + # handle special case for PlayerGroups: since there are no leaders, + # DSP still always work with a single player in the group. + multi_device_dsp_threshold = 1 if is_player_group else 0 + + prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold + new_is_multiple_devices = new_child_count > multi_device_dsp_threshold - # handle DSP reload of the leader when on grouping and ungrouping - prev_child_count = len(prev_state.get("group_childs", [])) - new_child_count = len(new_state.get("group_childs", [])) - is_player_group = player.provider.startswith("player_group") - - # handle special case for PlayerGroups: since there are no leaders, - # DSP still always work with a single player in the group. - multi_device_dsp_threshold = 1 if is_player_group else 0 - - prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold - new_is_multiple_devices = new_child_count > multi_device_dsp_threshold - - if prev_is_multiple_devices != new_is_multiple_devices: - supports_multi_device_dsp = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features - dsp_enabled: bool - if is_player_group: - # Since player groups do not have leaders, we will use the only child - # that was in the group before and after the change - if prev_is_multiple_devices: - if childs := new_state.get("group_childs"): - # We shrank the group from multiple players to a single player - # So the now only child will control the DSP + if prev_is_multiple_devices != new_is_multiple_devices: + supports_multi_device_dsp = ( + PlayerFeature.MULTI_DEVICE_DSP in player.supported_features + ) + dsp_enabled: bool + if player.type == PlayerType.GROUP: + # Since player groups do not have leaders, we will use the only child + # that was in the group before and after the change + if prev_is_multiple_devices: + if childs := new_group_members: + # We shrank the group from multiple players to a single player + # So the now only child will control the DSP + dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled + else: + dsp_enabled = False + elif childs := prev_group_members: + # We grew the group from a single player to multiple players, + # let's see if the previous single player had DSP enabled dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled else: dsp_enabled = False - elif childs := prev_state.get("group_childs"): - # We grew the group from a single player to multiple players, - # let's see if the previous single player had DSP enabled - dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled else: - dsp_enabled = False - else: - dsp_enabled = self.mass.config.get_player_dsp_config(player_id).enabled - if dsp_enabled and not supports_multi_device_dsp: - # We now know that that the group configuration has changed so: - # - multi-device DSP is not supported - # - we switched from a group with multiple players to a single player - # (or vice versa) - # - the leader has DSP enabled - self.mass.create_task(self.mass.players.on_player_dsp_change(player_id)) + dsp_enabled = self.mass.config.get_player_dsp_config(player_id).enabled + if dsp_enabled and not supports_multi_device_dsp: + # We now know that that the group configuration has changed so: + # - multi-device DSP is not supported + # - we switched from a group with multiple players to a single player + # (or vice versa) + # - the leader has DSP enabled + self.mass.create_task(self.mass.players.on_player_dsp_change(player_id)) # signal player update on the eventbus self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player) - # handle player becoming unavailable - if "available" in changed_values and not player.available: - self._handle_player_unavailable(player) - if skip_forward and not force_update: return # update/signal group player(s) child's when group updates for child_player in self.iter_group_members(player, exclude_self=True): - self.update(child_player.player_id, skip_forward=True) + self.mass.loop.call_soon(child_player.update_state, True) # update/signal group player(s) when child updates for group_player in self._get_player_groups(player, powered_only=False): - if player_prov := self.mass.get_provider(group_player.provider): - self.mass.create_task(player_prov.poll_player(group_player.player_id)) + self.mass.loop.call_soon(group_player.update_state, True) async def register_player_control(self, player_control: PlayerControl) -> None: """Register a new PlayerControl on the controller.""" @@ -1185,7 +1404,7 @@ class PlayerController(CoreController): # update all players that are using this control for player in self._players.values(): if control_id in (player.power_control, player.volume_control, player.mute_control): - self.update(player.player_id) + self.mass.loop.call_soon(player.update_state) def remove_player_control(self, control_id: str) -> None: """Remove a player_control from the player manager.""" @@ -1198,8 +1417,42 @@ class PlayerController(CoreController): def get_player_provider(self, player_id: str) -> PlayerProvider: """Return PlayerProvider for given player.""" player = self._players[player_id] - player_provider = self.mass.get_provider(player.provider) - return cast("PlayerProvider", player_provider) + assert player # for type checker + return player.provider + + def get_active_queue(self, player: Player) -> PlayerQueue | None: + """Return the current active queue for a player (if any).""" + # account for player that is synced (sync child) + if player.synced_to and player.synced_to != player.player_id: + if sync_leader := self.get(player.synced_to): + return self.get_active_queue(sync_leader) + # handle active group player + if player.active_group and player.active_group != player.player_id: + if group_player := self.get(player.active_group): + return self.get_active_queue(group_player) + # active_source may be filled queue id (or None) + active_source = player.active_source or player.player_id + if active_queue := self.mass.player_queues.get(active_source): + return active_queue + return None + + async def set_group_volume(self, group_player: Player, volume_level: int) -> None: + """Handle adjusting the overall/group volume to a playergroup (or synced players).""" + cur_volume = group_player.state.group_volume + volume_dif = volume_level - cur_volume + coros = [] + # handle group volume by only applying the volume to powered members + for child_player in self.iter_group_members( + group_player, only_powered=True, exclude_self=False + ): + if child_player.volume_control == PLAYER_CONTROL_NONE: + continue + cur_child_volume = child_player.volume_level or 0 + new_child_volume = int(cur_child_volume + volume_dif) + new_child_volume = max(0, new_child_volume) + new_child_volume = min(100, new_child_volume) + coros.append(self.cmd_volume_set(child_player.player_id, new_child_volume)) + await asyncio.gather(*coros) def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None: """Get the (player specific) volume for a announcement.""" @@ -1250,7 +1503,7 @@ class PlayerController(CoreController): exclude_self: bool = True, ) -> Iterator[Player]: """Get (child) players attached to a group player or syncgroup.""" - for child_id in list(group_player.group_childs): + for child_id in list(group_player.group_members): if child_player := self.get(child_id, False): if not child_player.available or not child_player.enabled: continue @@ -1260,9 +1513,9 @@ class PlayerController(CoreController): continue if exclude_self and child_player.player_id == group_player.player_id: continue - if only_playing and child_player.state not in ( - PlayerState.PLAYING, - PlayerState.PAUSED, + if only_playing and child_player.playback_state not in ( + PlaybackState.PLAYING, + PlaybackState.PAUSED, ): continue yield child_player @@ -1270,7 +1523,7 @@ class PlayerController(CoreController): async def wait_for_state( self, player: Player, - wanted_state: PlayerState, + wanted_state: PlaybackState, timeout: float = 60.0, minimal_time: float = 0, ) -> None: @@ -1281,7 +1534,7 @@ class PlayerController(CoreController): ) try: async with asyncio.timeout(timeout): - while player.state != wanted_state: + while player.playback_state != wanted_state: await asyncio.sleep(0.1) except TimeoutError: @@ -1312,25 +1565,30 @@ class PlayerController(CoreController): async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: """Call (by config manager) when the configuration of a player changes.""" player_disabled = "enabled" in changed_keys and not config.enabled - if not (player := self.get(config.player_id)): - return + # signal player provider that the player got enabled/disabled + if player_provider := self.mass.get_provider(config.provider): + assert isinstance(player_provider, PlayerProvider) # for type checking + if "enabled" in changed_keys and not config.enabled: + player_provider.on_player_disabled(config.player_id) + elif "enabled" in changed_keys and config.enabled: + player_provider.on_player_enabled(config.player_id) # ensure player state gets updated with any updated config - await self._set_player_state_from_config(player, config) + if not (player := self.get(config.player_id)): + return # guard against player not being registered (yet) + player.set_config(config) + player.update_state() + assert player.active_source is not None # for type checking resume_queue: PlayerQueue | None = self.mass.player_queues.get(player.active_source) - # signal player provider that the config changed - if player_provider := self.mass.get_provider(config.provider): - with suppress(PlayerUnavailableError): - await player_provider.on_player_config_change(config, changed_keys) if player_disabled: # edge case: ensure that the player is powered off if the player gets disabled if player.power_control != PLAYER_CONTROL_NONE: await self.cmd_power(config.player_id, False) - elif player.state != PlayerState.IDLE: + elif player.playback_state != PlaybackState.IDLE: await self.cmd_stop(config.player_id) player.available = False # if the PlayerQueue was playing, restart playback # TODO: add property to ConfigEntry if it requires a restart of playback on change - elif not player_disabled and resume_queue and resume_queue.state == PlayerState.PLAYING: + elif not player_disabled and resume_queue and resume_queue.state == PlaybackState.PLAYING: # always stop first to ensure the player uses the new config await self.mass.player_queues.stop(resume_queue.queue_id) self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id, False) @@ -1338,26 +1596,29 @@ class PlayerController(CoreController): if player_disabled and player.active_group and player_provider: # try to remove from the group group_player = self.get(player.active_group) + assert group_player is not None # for type checking with suppress(UnsupportedFeaturedException, PlayerCommandFailed): - await player_provider.set_members( - player.active_group, - [x for x in group_player.group_childs if x != player.player_id], - ) - player.enabled = config.enabled + await group_player.set_members(player_ids_to_remove=[player.player_id]) async def on_player_dsp_change(self, player_id: str) -> None: """Call (by config manager) when the DSP settings of a player change.""" # signal player provider that the config changed if not (player := self.get(player_id)): return - if player.state == PlayerState.PLAYING: + if player.playback_state == PlaybackState.PLAYING: self.logger.info("Restarting playback of Player %s after DSP change", player_id) - # this will restart ffmpeg with the new settings - self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False) + # this will restart the queue stream/playback + if player.mass_queue_active: + self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False) + return + # if the player is not using a queue, we need to stop and start playback + await self.cmd_stop(player_id) + await self.cmd_play(player_id) def _get_player_with_redirect(self, player_id: str) -> Player: """Get player with check if playback related command should be redirected.""" player = self.get(player_id, True) + assert player is not None # for type checking if player.synced_to and (sync_leader := self.get(player.synced_to)): self.logger.info( "Player %s is synced to %s and can not accept " @@ -1381,106 +1642,16 @@ class PlayerController(CoreController): self, player: Player, available_only: bool = True, powered_only: bool = False ) -> Iterator[Player]: """Return all groupplayers the given player belongs to.""" - for _player in self: + for _player in self.all(return_unavailable=not available_only): if _player.player_id == player.player_id: continue if _player.type != PlayerType.GROUP: continue - if available_only and not _player.available: - continue if powered_only and _player.powered is False: continue - if player.player_id in _player.group_childs: + if player.player_id in _player.group_members: yield _player - def _get_active_source(self, player: Player) -> str: - """Return the active_source id for given player.""" - # if player is synced, return group leader's active source - if player.synced_to and (parent_player := self.get(player.synced_to)): - return parent_player.active_source - # if player has group active, return those details - if player.active_group and (group_player := self.get(player.active_group)): - return self._get_active_source(group_player) - # if player has plugin source active return that - for plugin_source in self._get_plugin_sources(): - if ( - player.active_source == plugin_source.id - or plugin_source.in_use_by == player.player_id - ): - # copy/set current media if available - if plugin_source.metadata: - player.set_current_media( - uri=plugin_source.metadata.uri, - media_type=plugin_source.metadata.media_type, - title=plugin_source.metadata.title, - artist=plugin_source.metadata.artist, - album=plugin_source.metadata.album, - image_url=plugin_source.metadata.image_url, - duration=plugin_source.metadata.duration, - ) - return plugin_source.id - # defaults to the player's own player id if no active source set - return player.active_source or player.player_id - - def _get_group_volume_level(self, player: Player) -> int: - """Calculate a group volume from the grouped members.""" - if len(player.group_childs) == 0: - # player is not a group or syncgroup - return player.volume_level or 0 - # calculate group volume from all (turned on) players - group_volume = 0 - active_players = 0 - for child_player in self.iter_group_members(player, only_powered=True, exclude_self=False): - if child_player.volume_control == PLAYER_CONTROL_NONE: - continue - if child_player.volume_level is None: - continue - group_volume += child_player.volume_level - active_players += 1 - if active_players: - group_volume = group_volume / active_players - return int(group_volume) - - def _handle_player_unavailable(self, player: Player) -> None: - """Handle a player becoming unavailable.""" - if player.synced_to: - self.mass.create_task(self.cmd_ungroup(player.player_id)) - # also set this optimistically because the above command will most likely fail - player.synced_to = None - return - for group_child_id in player.group_childs: - if group_child_id == player.player_id: - continue - if child_player := self.get(group_child_id): - self.mass.create_task(self.cmd_power(group_child_id, False, True)) - # also set this optimistically because the above command will most likely fail - child_player.synced_to = None - player.group_childs.clear() - if player.active_group and (group_player := self.get(player.active_group)): - # remove player from group if its part of a group - group_player = self.get(player.active_group) - if player.player_id in group_player.group_childs: - group_player.group_childs.remove(player.player_id) - - async def _set_player_state_from_config(self, player: Player, config: PlayerConfig) -> None: - """Set player state from config.""" - player.display_name = config.name or player.name or config.default_name or player.player_id - player.hide_player_in_ui = { - HidePlayerOption(x) for x in config.get_value(CONF_HIDE_PLAYER_IN_UI) - } - player.expose_to_ha = bool(config.get_value(CONF_EXPOSE_PLAYER_TO_HA)) - player.icon = config.get_value(CONF_ENTRY_PLAYER_ICON.key) - player.power_control = config.get_value(CONF_POWER_CONTROL) - if player.power_control == PLAYER_CONTROL_FAKE: - player.powered = await self.mass.cache.get( - player.player_id, - default=False, - category=CACHE_CATEGORY_PLAYERS, - base_key=CACHE_KEY_PLAYER_POWER, - ) - player.volume_control = config.get_value(CONF_VOLUME_CONTROL) - player.mute_control = config.get_value(CONF_MUTE_CONTROL) - async def _play_announcement( # noqa: PLR0915 self, player: Player, @@ -1502,22 +1673,41 @@ class PlayerController(CoreController): (provider) has no native support for the PLAY_ANNOUNCEMENT feature. """ prev_power = player.powered - prev_state = player.state + prev_state = player.playback_state prev_synced_to = player.synced_to - prev_active_source = player.active_source - queue = self.mass.player_queues.get(player.active_source) - prev_queue_active = queue and queue.active + prev_group = self.get(player.active_group) if player.active_group else None + prev_source = player.active_source + prev_queue = self.get_active_queue(player) prev_media = player.current_media prev_media_name = prev_media.title or prev_media.uri if prev_media else None - # ungroup player if its currently synced if prev_synced_to: + # ungroup player if its currently synced self.logger.debug( - "Announcement to player %s - ungrouping player...", + "Announcement to player %s - ungrouping player from %s...", player.display_name, + prev_synced_to, ) await self.cmd_ungroup(player.player_id) - # stop player if its currently playing - elif prev_state in (PlayerState.PLAYING, PlayerState.PAUSED): + elif prev_group: + # if the player is part of a group player, we need to ungroup it + if PlayerFeature.SET_MEMBERS in prev_group.supported_features: + self.logger.debug( + "Announcement to player %s - ungrouping from group player %s...", + player.display_name, + prev_group.display_name, + ) + await prev_group.set_members(player_ids_to_remove=[player.player_id]) + else: + # if the player is part of a group player that does not support ungrouping, + # we need to power off the groupplayer instead + self.logger.debug( + "Announcement to player %s - turning off group player %s...", + player.display_name, + prev_group.display_name, + ) + await self.cmd_power(player.player_id, False) + elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + # normal/standalone player: stop player if its currently playing self.logger.debug( "Announcement to player %s - stop existing content (%s)...", player.display_name, @@ -1525,12 +1715,12 @@ class PlayerController(CoreController): ) await self.cmd_stop(player.player_id) # wait for the player to stop - await self.wait_for_state(player, PlayerState.IDLE, 10, 0.4) + await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4) # adjust volume if needed # in case of a (sync) group, we need to do this for all child players prev_volumes: dict[str, int] = {} async with TaskManager(self.mass) as tg: - for volume_player_id in player.group_childs or (player.player_id,): + for volume_player_id in player.group_members or (player.player_id,): if not (volume_player := self.get(volume_player_id)): continue # catch any players that have a different source active @@ -1541,7 +1731,7 @@ class PlayerController(CoreController): volume_player.player_id, None, ) - and volume_player.state == PlayerState.PLAYING + and volume_player.playback_state == PlaybackState.PLAYING ): self.logger.warning( "Detected announcement to playergroup %s while group member %s is playing " @@ -1575,7 +1765,7 @@ class PlayerController(CoreController): ) await self.play_media(player_id=player.player_id, media=announcement) # wait for the player(s) to play - await self.wait_for_state(player, PlayerState.PLAYING, 10, minimal_time=0.1) + await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1) # wait for the player to stop playing if not announcement.duration: media_info = await async_parse_tags( @@ -1584,9 +1774,9 @@ class PlayerController(CoreController): announcement.duration = media_info.duration await self.wait_for_state( player, - PlayerState.IDLE, - max(announcement.duration * 2, 60), - announcement.duration + 2, + PlaybackState.IDLE, + timeout=announcement.duration + 6, + minimal_time=announcement.duration, ) self.logger.debug( "Announcement to player %s - restore previous state...", player.display_name @@ -1595,57 +1785,80 @@ class PlayerController(CoreController): async with TaskManager(self.mass) as tg: for volume_player_id, prev_volume in prev_volumes.items(): tg.create_task(self.cmd_volume_set(volume_player_id, prev_volume)) - await asyncio.sleep(0.2) - player.current_media = prev_media - player.active_source = prev_active_source + player._attr_current_media = prev_media + player._attr_active_source = prev_source # either power off the player or resume playing if not prev_power and player.power_control != PLAYER_CONTROL_NONE: await self.cmd_power(player.player_id, False) return elif prev_synced_to: await self.cmd_group(player.player_id, prev_synced_to) - elif prev_queue_active and prev_state == PlayerState.PLAYING: - await self.mass.player_queues.resume(queue.queue_id, True) - await self.wait_for_state(player, PlayerState.PLAYING, 5) - - elif prev_state == PlayerState.PLAYING: + elif prev_group: + if PlayerFeature.SET_MEMBERS in prev_group.supported_features: + self.logger.debug( + "Announcement to player %s - grouping back to group player %s...", + player.display_name, + prev_group.display_name, + ) + await prev_group.set_members(player_ids_to_add=[player.player_id]) + elif prev_state == PlaybackState.PLAYING: + # if the player is part of a group player that does not support set_members, + # we need to restart the groupplayer + self.logger.debug( + "Announcement to player %s - restarting playback on group player %s...", + player.display_name, + prev_group.display_name, + ) + await self.cmd_play(prev_group.player_id) + elif prev_queue and prev_state == PlaybackState.PLAYING: + await self.mass.player_queues.resume(prev_queue.queue_id, True) + await self.wait_for_state(player, PlaybackState.PLAYING, 5) + elif prev_state == PlaybackState.PLAYING: # player was playing something else - try to resume that here - self.logger.warning("Can not resume %s on %s", prev_media_name, player.display_name) - # TODO !! + for source in player.source_list_state: + if source.id == prev_source and not source.passive: + await player.select_source(source.id) + break + else: + # no source found, try to resume the previous media + await self.cmd_play(player.player_id) async def _poll_players(self) -> None: """Background task that polls players for updates.""" while True: for player in list(self._players.values()): - player_id = player.player_id # if the player is playing, update elapsed time every tick # to ensure the queue has accurate details - player_playing = player.state == PlayerState.PLAYING + player_playing = player.playback_state == PlaybackState.PLAYING if player_playing: - self.mass.loop.call_soon(self.update, player_id) + self.mass.loop.call_soon( + self.mass.player_queues.on_player_update, + player, + {"corrected_elapsed_time": player.corrected_elapsed_time}, + ) # Poll player; if not player.needs_poll: continue - if (self.mass.loop.time() - player.last_poll) < player.poll_interval: + try: + last_poll: float = player.extra_data[ATTR_LAST_POLL] + except KeyError: + last_poll = 0.0 + if (self.mass.loop.time() - last_poll) < player.poll_interval: continue - player.last_poll = self.mass.loop.time() - if player_prov := self.get_player_provider(player_id): - try: - await player_prov.poll_player(player_id) - except PlayerUnavailableError: - player.available = False - player.state = PlayerState.IDLE - except Exception as err: - self.logger.warning( - "Error while requesting latest state from player %s: %s", - player.display_name, - str(err), - exc_info=err if self.logger.isEnabledFor(10) else None, - ) - finally: - # always update player state - self.mass.loop.call_soon(self.update, player_id) + player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time() + try: + await player.poll() + except Exception as err: + self.logger.warning( + "Error while requesting latest state from player %s: %s", + player.display_name, + str(err), + exc_info=err if self.logger.isEnabledFor(10) else None, + ) + finally: + # always update player state + self.mass.loop.call_soon(player.update_state) await asyncio.sleep(1) async def _handle_select_plugin_source( @@ -1653,7 +1866,6 @@ class PlayerController(CoreController): ) -> None: """Handle playback/select of given plugin source on player.""" plugin_source = plugin_prov.get_source() - player.active_source = plugin_source.id stream_url = await self.mass.streams.get_plugin_source_url( plugin_source.id, player.player_id ) @@ -1671,24 +1883,9 @@ class PlayerController(CoreController): }, ), ) + # trigger player update to ensure the source is set + self.trigger_player_update(player.player_id) - def _get_plugin_sources(self) -> list[PluginSource]: - """Return all available plugin sources.""" - return [ - plugin_prov.get_source() - for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN) - if ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features - ] - - def _set_player_sources(self, player: Player) -> None: - """Set all available player sources.""" - player_source_ids = [x.id for x in player.source_list] - for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN): - if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features: - continue - plugin_source = plugin_prov.get_source() - if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id: - continue - if plugin_source.id in player_source_ids: - continue - player.source_list.append(plugin_source) + def __iter__(self) -> Iterator[Player]: + """Iterate over all players.""" + return iter(self._players.values()) diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index 3eb2e141..8f4cc06e 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -80,10 +80,11 @@ from music_assistant.models.plugin import PluginProvider if TYPE_CHECKING: from music_assistant_models.config_entries import CoreConfig - from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.queue_item import QueueItem + from music_assistant.models.player import Player + isfile = wrap(os.path.isfile) @@ -131,12 +132,18 @@ class StreamsController(CoreController): self._audio_cache_dir = os.path.join("/tmp/.audio") # noqa: S108 self.allow_cache_default = "auto" self._crossfade_data: dict[str, CrossfadeData] = {} + self._bind_ip: str = "0.0.0.0" @property def base_url(self) -> str: """Return the base_url for the streamserver.""" return self._server.base_url + @property + def bind_ip(self) -> str: + """Return the IP address this streamserver is bound to.""" + return self._bind_ip + @property def audio_cache_dir(self) -> str: """Return the directory where (temporary) audio cache files are stored.""" @@ -267,6 +274,7 @@ class StreamsController(CoreController): # start the webserver self.publish_port = config.get_value(CONF_BIND_PORT) self.publish_ip = config.get_value(CONF_PUBLISH_IP) + self._bind_ip = bind_ip = str(config.get_value(CONF_BIND_IP)) # print a big fat message in the log where the streamserver is running # because this is a common source of issues for people with more complex setups self.logger.log( @@ -282,7 +290,7 @@ class StreamsController(CoreController): self.publish_port, ) await self._server.setup( - bind_ip=config.get_value(CONF_BIND_IP), + bind_ip=bind_ip, bind_port=self.publish_port, base_url=f"http://{self.publish_ip}:{self.publish_port}", static_routes=[ diff --git a/music_assistant/helpers/api.py b/music_assistant/helpers/api.py index 72b22e8f..1cc336ef 100644 --- a/music_assistant/helpers/api.py +++ b/music_assistant/helpers/api.py @@ -92,7 +92,7 @@ def parse_arguments( def parse_utc_timestamp(datetime_string: str) -> datetime: """Parse datetime from string.""" - return datetime.fromisoformat(datetime_string.replace("Z", "+00:00")) + return datetime.fromisoformat(datetime_string) def parse_value( # noqa: PLR0911 diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index d9ccb257..bea29133 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -47,6 +47,7 @@ from music_assistant.constants import ( from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER from music_assistant.helpers.util import clean_stream_title, remove_file +from music_assistant.models.player import SyncGroupPlayer from .datetime import utc from .dsp import filter_to_ffmpeg_params @@ -57,13 +58,12 @@ from .util import detect_charset, has_enough_space if TYPE_CHECKING: from music_assistant_models.config_entries import CoreConfig, PlayerConfig - from music_assistant_models.player import Player from music_assistant_models.queue_item import QueueItem from music_assistant_models.streamdetails import StreamDetails from music_assistant.mass import MusicAssistant from music_assistant.models.music_provider import MusicProvider - from music_assistant.providers.player_group import PlayerGroupProvider + from music_assistant.models.player import Player LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.audio") @@ -482,7 +482,7 @@ def get_player_dsp_details( filters=dsp_config.filters, output_gain=dsp_config.output_gain, output_limiter=output_limiter, - output_format=player.output_format, + output_format=player.extra_data.get("output_format", None), ) @@ -498,19 +498,10 @@ def get_stream_dsp_details( output_format = None is_external_group = False - if player.provider.startswith("player_group"): + if player.type == PlayerType.GROUP and isinstance(player, SyncGroupPlayer): if group_preventing_dsp: - try: - # We need a bit of a hack here since only the leader knows the correct output format - provider = mass.get_provider(player.provider) - if TYPE_CHECKING: # avoid circular import - assert isinstance(provider, PlayerGroupProvider) - if provider: - output_format = provider._get_sync_leader(player).output_format - except RuntimeError: - # _get_sync_leader will raise a RuntimeError if this group has no players - # just ignore this and continue without output_format - LOGGER.warning("Unable to get the sync group leader for %s", queue_id) + if sync_leader := player.sync_leader: + output_format = sync_leader.extra_data.get("output_format", None) else: # We only add real players (so skip the PlayerGroups as they only sync containing players) details = get_player_dsp_details(mass, player) @@ -518,14 +509,14 @@ def get_stream_dsp_details( if group_preventing_dsp: # The leader is responsible for sending the (combined) audio stream, so get # the output format from the leader. - output_format = player.output_format + output_format = player.extra_data.get("output_format", None) is_external_group = player.type in (PlayerType.GROUP, PlayerType.STEREO_PAIR) # We don't enumerate all group members in case this group is externally created # (e.g. a Chromecast group from the Google Home app) - if player and player.group_childs and not is_external_group: + if player and player.group_members and not is_external_group: # grouped playback, get DSP details for each player in the group - for child_id in player.group_childs: + for child_id in player.group_members: # skip if we already have the details (so if it's the group leader) if child_id in dsp: continue @@ -1422,10 +1413,10 @@ def is_grouping_preventing_dsp(player: Player) -> bool: # We require the caller to handle non-leader cases themselves since player.synced_to # can be unreliable in some edge cases multi_device_dsp_supported = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features - child_count = len(player.group_childs) if player.group_childs else 0 + child_count = len(player.group_members) if player.group_members else 0 is_multiple_devices: bool - if player.provider.startswith("player_group"): + if player.provider.domain == "player_group": # PlayerGroups have no leader, so having a child count of 1 means # the group actually contains only a single player. is_multiple_devices = child_count > 1 @@ -1476,15 +1467,15 @@ def get_player_filter_params( # We can not correctly apply DSP to a grouped player without multi-device DSP support, # so we disable it. dsp.enabled = False - elif player.provider.startswith("player_group") and ( + elif player.provider.domain == "player_group" and ( PlayerFeature.MULTI_DEVICE_DSP not in player.supported_features ): # This is a special case! We have a player group where: # - The group leader does not support MULTI_DEVICE_DSP # - But only contains a single player (since nothing is preventing DSP) # We can still apply the DSP of that single player. - if player.group_childs: - child_player = mass.players.get(player.group_childs[0]) + if player.group_members: + child_player = mass.players.get(player.group_members[0]) assert child_player is not None # for type checking dsp = mass.config.get_player_dsp_config(child_player.player_id) else: @@ -1494,7 +1485,7 @@ def get_player_filter_params( # We here implicitly know what output format is used for the player # in the audio processing steps. We save this information to # later be able to show this to the user in the UI. - player.output_format = output_format + player.extra_data["output_format"] = output_format limiter_enabled = is_output_limiter_enabled(mass, player) diff --git a/music_assistant/helpers/database.py b/music_assistant/helpers/database.py index c393d386..3dd534ed 100644 --- a/music_assistant/helpers/database.py +++ b/music_assistant/helpers/database.py @@ -66,7 +66,7 @@ def query_params(query: str, params: dict[str, Any] | None) -> tuple[str, dict[s params_str = ",".join(f":{x}" for x in subparams) result_query = result_query.replace(f" :{key}", f" ({params_str})") else: - result_params[key] = params[key] + result_params[key] = value return (result_query, result_params) diff --git a/music_assistant/helpers/json.py b/music_assistant/helpers/json.py index 49b85ba7..96e93470 100644 --- a/music_assistant/helpers/json.py +++ b/music_assistant/helpers/json.py @@ -63,7 +63,9 @@ json_loads = orjson.loads TargetT = TypeVar("TargetT", bound=DataClassORJSONMixin) -async def load_json_file(path: str, target_class: type[TargetT]) -> TargetT: +async def load_json_file[TargetT: DataClassORJSONMixin]( + path: str, target_class: type[TargetT] +) -> TargetT: """Load JSON from file.""" async with aiofiles.open(path) as _file: content = await _file.read() diff --git a/music_assistant/helpers/logging.py b/music_assistant/helpers/logging.py index ffecb3c8..21362471 100644 --- a/music_assistant/helpers/logging.py +++ b/music_assistant/helpers/logging.py @@ -17,9 +17,7 @@ import queue import traceback from collections.abc import Callable, Coroutine from functools import partial, wraps -from typing import Any, TypeVar, cast, overload - -_T = TypeVar("_T") +from typing import Any, cast, overload class LoggingQueueHandler(logging.handlers.QueueHandler): @@ -157,37 +155,3 @@ def catch_log_exception( wrapper_func = wrapper return wrapper_func - - -def catch_log_coro_exception( - target: Coroutine[Any, Any, _T], format_err: Callable[..., Any], *args: Any -) -> Coroutine[Any, Any, _T | None]: - """Decorate a coroutine to catch and log exceptions.""" - - async def coro_wrapper(*args: Any) -> _T | None: - """Catch and log exception.""" - try: - return await target - except Exception: - log_exception(format_err, *args) - return None - - return coro_wrapper(*args) - - -def async_create_catching_coro(target: Coroutine[Any, Any, _T]) -> Coroutine[Any, Any, _T | None]: - """Wrap a coroutine to catch and log exceptions. - - The exception will be logged together with a stacktrace of where the - coroutine was wrapped. - - target: target coroutine. - """ - trace = traceback.extract_stack() - return catch_log_coro_exception( - target, - lambda: "Exception in {} called from\n {}".format( - target.__name__, - "".join(traceback.format_list(trace[:-1])), - ), - ) diff --git a/music_assistant/helpers/throttle_retry.py b/music_assistant/helpers/throttle_retry.py index fff16ca2..339b7b24 100644 --- a/music_assistant/helpers/throttle_retry.py +++ b/music_assistant/helpers/throttle_retry.py @@ -9,7 +9,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine from contextlib import asynccontextmanager from contextvars import ContextVar from types import TracebackType -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate from music_assistant_models.errors import ResourceTemporarilyUnavailable, RetriesExhausted @@ -18,9 +18,6 @@ from music_assistant.constants import MASS_LOGGER_NAME if TYPE_CHECKING: from music_assistant.models.provider import Provider -_ProviderT = TypeVar("_ProviderT", bound="Provider") -_R = TypeVar("_R") -_P = ParamSpec("_P") LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.throttle_retry") BYPASS_THROTTLER: ContextVar[bool] = ContextVar("BYPASS_THROTTLER", default=False) @@ -108,13 +105,13 @@ class ThrottlerManager: ... -def throttle_with_retries( - func: Callable[Concatenate[_ProviderT, _P], Awaitable[_R]], -) -> Callable[Concatenate[_ProviderT, _P], Coroutine[Any, Any, _R]]: +def throttle_with_retries[ProviderT: "Provider", **P, R]( + func: Callable[Concatenate[ProviderT, P], Awaitable[R]], +) -> Callable[Concatenate[ProviderT, P], Coroutine[Any, Any, R]]: """Call async function using the throttler with retries.""" @functools.wraps(func) - async def wrapper(self: _ProviderT, *args: _P.args, **kwargs: _P.kwargs) -> _R: + async def wrapper(self: ProviderT, *args: P.args, **kwargs: P.kwargs) -> R: """Call async function using the throttler with retries.""" # the trottler attribute must be present on the class throttler: ThrottlerManager = self.throttler # type: ignore[attr-defined] diff --git a/music_assistant/helpers/upnp.py b/music_assistant/helpers/upnp.py index de289156..3a0f13a0 100644 --- a/music_assistant/helpers/upnp.py +++ b/music_assistant/helpers/upnp.py @@ -11,7 +11,7 @@ from music_assistant_models.enums import MediaType from music_assistant.constants import MASS_LOGO_ONLINE if TYPE_CHECKING: - from music_assistant_models.player import PlayerMedia + from music_assistant.models.player import PlayerMedia # ruff: noqa: E501 diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index 8aa995d2..4a3238a1 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -38,6 +38,8 @@ if TYPE_CHECKING: from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderModuleType +from dataclasses import fields, is_dataclass + LOGGER = logging.getLogger(__name__) HA_WHEELS = "https://wheels.home-assistant.io/musllinux/" @@ -286,7 +288,12 @@ async def is_port_in_use(port: int) -> bool: return True return False - return await asyncio.to_thread(_is_port_in_use) + try: + if await check_output(f"lsof -i :{port}"): + return True + except Exception: + # lsof not available (or some other error), fallback to socket check + return await asyncio.to_thread(_is_port_in_use) async def select_free_port(range_start: int, range_end: int) -> int: @@ -336,17 +343,17 @@ async def get_folder_size(folderpath: str) -> float: def get_changed_keys( dict1: dict[str, Any], dict2: dict[str, Any], - ignore_keys: list[str] | None = None, recursive: bool = False, ) -> set[str]: """Compare 2 dicts and return set of changed keys.""" - return set(get_changed_values(dict1, dict2, ignore_keys, recursive).keys()) + # TODO: Check with Marcel whether we should calculate new dicts based on ignore_keys + return set(get_changed_dict_values(dict1, dict2, recursive).keys()) + # return set(get_changed_dict_values(dict1, dict2, ignore_keys, recursive).keys()) -def get_changed_values( +def get_changed_dict_values( dict1: dict[str, Any], dict2: dict[str, Any], - ignore_keys: list[str] | None = None, recursive: bool = False, ) -> dict[str, tuple[Any, Any]]: """ @@ -362,22 +369,52 @@ def get_changed_values( return {key: (None, value) for key, value in dict1.items()} changed_values = {} for key, value in dict2.items(): - if ignore_keys and key in ignore_keys: + if isinstance(value, dict) and isinstance(dict1[key], dict) and recursive: + changed_subvalues = get_changed_dict_values(dict1[key], value, recursive) + for subkey, subvalue in changed_subvalues.items(): + changed_values[f"{key}.{subkey}"] = subvalue continue if key not in dict1: changed_values[key] = (None, value) - elif isinstance(value, dict) or isinstance(dict1[key], dict): - changed_subvalues = get_changed_values(dict1[key], value, ignore_keys, recursive) - if recursive: - changed_values.update(changed_subvalues) - elif changed_subvalues: - changed_values[key] = (dict1[key], value) - elif dict1[key] != value: + continue + if dict1[key] != value: changed_values[key] = (dict1[key], value) return changed_values -def empty_queue(q: asyncio.Queue[T]) -> None: +def get_changed_dataclass_values( + obj1: T, + obj2: T, + recursive: bool = False, +) -> dict[str, tuple[Any, Any]]: + """ + Compare 2 dataclass instances of the same type and return dict of changed field values. + + dict key is the changed field name, value is tuple of old and new values. + """ + if not (is_dataclass(obj1) and is_dataclass(obj2)): + raise ValueError("Both objects must be dataclass instances") + + changed_values: dict[str, tuple[Any, Any]] = {} + for field in fields(obj1): + val1 = getattr(obj1, field.name, None) + val2 = getattr(obj2, field.name, None) + if recursive and is_dataclass(val1) and is_dataclass(val2): + sub_changes = get_changed_dataclass_values(val1, val2, recursive) + for sub_field, sub_value in sub_changes.items(): + changed_values[f"{field.name}.{sub_field}"] = sub_value + continue + if recursive and isinstance(val1, dict) and isinstance(val2, dict): + sub_changes = get_changed_dict_values(val1, val2, recursive=recursive) + for sub_field, sub_value in sub_changes.items(): + changed_values[f"{field.name}.{sub_field}"] = sub_value + continue + if val1 != val2: + changed_values[field.name] = (val1, val2) + return changed_values + + +def empty_queue[T](q: asyncio.Queue[T]) -> None: """Empty an asyncio Queue.""" for _ in range(q.qsize()): try: @@ -666,7 +703,7 @@ _R = TypeVar("_R") _P = ParamSpec("_P") -def lock( +def lock[**P, R]( # type: ignore[valid-type] func: Callable[_P, Awaitable[_R]], ) -> Callable[_P, Coroutine[Any, Any, _R]]: """Call async function using a Lock.""" diff --git a/music_assistant/mass.py b/music_assistant/mass.py index e583142e..c477990d 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import logging import os +import pathlib import threading from collections.abc import Awaitable, Callable, Coroutine from typing import TYPE_CHECKING, Any, Self, TypeGuard, TypeVar, cast @@ -77,7 +78,6 @@ EventSubscriptionType = tuple[ EventCallBackType, tuple[EventType, ...] | None, tuple[str, ...] | None ] -ENABLE_DEBUG = os.environ.get("PYTHONDEVMODE") == "1" LOGGER = logging.getLogger(MASS_LOGGER_NAME) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -127,10 +127,15 @@ class MusicAssistant: self.closing = False self.running_as_hass_addon: bool = False self.version: str = "0.0.0" + self.dev_mode = ( + os.environ.get("PYTHONDEVMODE") == "1" + or pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath(".venv").exists() + ) async def start(self) -> None: """Start running the Music Assistant server.""" self.loop = asyncio.get_running_loop() + self.loop_thread_id = getattr(self.loop, "_thread_id") # noqa: B009 self.running_as_hass_addon = await is_hass_supervisor() self.version = await get_package_version("music_assistant") or "0.0.0" # create shared zeroconf instance @@ -140,7 +145,7 @@ class MusicAssistant: self.http_session = ClientSession( loop=self.loop, connector=TCPConnector( - ssl=False, + ssl=True, limit=4096, limit_per_host=100, ), @@ -296,10 +301,7 @@ class MusicAssistant: if self.closing: return - if ENABLE_DEBUG and not isinstance(threading.current_thread(), threading._MainThread): # type: ignore[attr-defined] - raise RuntimeError( - "Non-Async operation detected: This method may only be called from the eventloop." - ) + self.verify_event_loop_thread("signal_event") if LOGGER.isEnabledFor(VERBOSE_LOG_LEVEL): # do not log queue time updated events because that is too chatty @@ -363,10 +365,7 @@ class MusicAssistant: existing.cancel() else: return existing - if ENABLE_DEBUG and not isinstance(threading.current_thread(), threading._MainThread): # type: ignore[attr-defined] - raise RuntimeError( - "Non-Async operation detected: This method may only be called from the eventloop." - ) + self.verify_event_loop_thread("create_task") if asyncio.iscoroutinefunction(target): # coroutine function @@ -416,17 +415,14 @@ class MusicAssistant: Use task_id for debouncing. """ + self.verify_event_loop_thread("call_later") + if not task_id: task_id = uuid4().hex if existing := self._tracked_timers.get(task_id): existing.cancel() - if ENABLE_DEBUG and not isinstance(threading.current_thread(), threading._MainThread): # type: ignore[attr-defined] - raise RuntimeError( - "Non-Async operation detected: This method may only be called from the eventloop." - ) - def _create_task(_target: Coroutine[Any, Any, _R]) -> None: self._tracked_timers.pop(task_id) self.create_task(_target, *args, task_id=task_id, abort_existing=True, **kwargs) @@ -578,10 +574,9 @@ class MusicAssistant: if dep_prov.manifest.depends_on == provider.domain: await self.unload_provider(dep_prov.instance_id) if is_player_provider(provider): - # mark all players of this provider as unavailable + # unregister all players of this provider for player in provider.players: - player.available = False - self.players.update(player.player_id) + await self.players.unregister(player.player_id, permanent=is_removed) try: await provider.unload(is_removed) except Exception as err: @@ -598,6 +593,13 @@ class MusicAssistant: self.config.set(f"{CONF_PROVIDERS}/{instance_id}/last_error", error) await self.unload_provider(instance_id) + def verify_event_loop_thread(self, what: str) -> None: + """Report and raise if we are not running in the event loop thread.""" + if self.loop_thread_id != threading.get_ident(): + raise RuntimeError( + f"Non-Async operation detected: {what} may only be called from the eventloop." + ) + def _register_api_commands(self) -> None: """Register all methods decorated as api_command within a class(instance).""" for cls in ( @@ -699,7 +701,7 @@ class MusicAssistant: async def load_provider_manifest(provider_domain: str, provider_path: str) -> None: """Preload all available provider manifest files.""" # get files in subdirectory - for file_str in os.listdir(provider_path): # noqa: PTH208, RUF100 + for file_str in await asyncio.to_thread(os.listdir, provider_path): # noqa: PTH208, RUF100 file_path = os.path.join(provider_path, file_str) if not await isfile(file_path): continue @@ -732,11 +734,13 @@ class MusicAssistant: ) async with TaskManager(self) as tg: - for dir_str in os.listdir(PROVIDERS_PATH): # noqa: PTH208, RUF100 - if dir_str.startswith(("_", ".")): + for dir_str in await asyncio.to_thread(os.listdir, PROVIDERS_PATH): # noqa: PTH208, RUF100 + if dir_str.startswith("."): + # skip hidden directories continue dir_path = os.path.join(PROVIDERS_PATH, dir_str) - if dir_str == "test" and not ENABLE_DEBUG: + if dir_str.startswith("_") and not self.dev_mode: + # only load demo/test providers if debug mode is enabled (e.g. for development) continue if not await isdir(dir_path): continue diff --git a/music_assistant/models/metadata_provider.py b/music_assistant/models/metadata_provider.py index bb16c06b..8e4ee60a 100644 --- a/music_assistant/models/metadata_provider.py +++ b/music_assistant/models/metadata_provider.py @@ -11,7 +11,6 @@ from .provider import Provider if TYPE_CHECKING: from music_assistant_models.media_items import Album, Artist, MediaItemMetadata, Track -# ruff: noqa: ARG001, ARG002 DEFAULT_SUPPORTED_FEATURES = { ProviderFeature.ARTIST_METADATA, diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 1a0129a1..04de8a5d 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -45,8 +45,6 @@ if TYPE_CHECKING: from music_assistant.controllers.media.radio import RadioController from music_assistant.controllers.media.tracks import TracksController -# ruff: noqa: ARG001, ARG002 - class MusicProvider(Provider): """Base representation of a Music Provider (controller). diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py new file mode 100644 index 00000000..c6480649 --- /dev/null +++ b/music_assistant/models/player.py @@ -0,0 +1,1591 @@ +""" +Base class/model for a Player within Music Assistant. + +All providerspecific players should inherit from this class and implement the required methods. + +Note that the serverside Player object is not the same as the clientside Player object, +which is a dataclass in the models package containing the player state. +""" + +from __future__ import annotations + +import asyncio +import time +from abc import ABC, abstractmethod +from collections.abc import Callable +from copy import deepcopy +from typing import TYPE_CHECKING, Any, cast, final + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, PlayerConfig +from music_assistant_models.constants import ( + PLAYER_CONTROL_FAKE, + PLAYER_CONTROL_NATIVE, + PLAYER_CONTROL_NONE, +) +from music_assistant_models.enums import ( + ConfigEntryType, + HidePlayerOption, + MediaType, + PlaybackState, + PlayerFeature, + PlayerType, +) +from music_assistant_models.errors import UnsupportedFeaturedException +from music_assistant_models.player import ( + EXTRA_ATTRIBUTES_TYPES, + DeviceInfo, + PlayerMedia, + PlayerSource, +) +from music_assistant_models.player import Player as PlayerState +from music_assistant_models.unique_list import UniqueList +from propcache import under_cached_property as cached_property + +from music_assistant.constants import ( + ATTR_FAKE_MUTE, + ATTR_FAKE_POWER, + ATTR_FAKE_VOLUME, + CONF_CROSSFADE, + CONF_CROSSFADE_DURATION, + CONF_DYNAMIC_GROUP_MEMBERS, + CONF_ENABLE_ICY_METADATA, + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_AUTO_PLAY, + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_CROSSFADE_DURATION, + CONF_ENTRY_EXPOSE_PLAYER_TO_HA, + CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED, + CONF_ENTRY_FLOW_MODE, + CONF_ENTRY_HIDE_PLAYER_IN_UI, + CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT, + CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_OUTPUT_CHANNELS, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_OUTPUT_LIMITER, + CONF_ENTRY_PLAYER_ICON, + CONF_ENTRY_PLAYER_ICON_GROUP, + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_TTS_PRE_ANNOUNCE, + CONF_ENTRY_VOLUME_NORMALIZATION, + CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, + CONF_EXPOSE_PLAYER_TO_HA, + CONF_FLOW_MODE, + CONF_GROUP_MEMBERS, + CONF_HIDE_PLAYER_IN_UI, + CONF_HTTP_PROFILE, + CONF_MUTE_CONTROL, + CONF_OUTPUT_CODEC, + CONF_POWER_CONTROL, + CONF_SAMPLE_RATES, + CONF_VOLUME_CONTROL, +) +from music_assistant.helpers.util import get_changed_dataclass_values + +if TYPE_CHECKING: + from .player_provider import PlayerProvider + + +BASE_CONFIG_ENTRIES = [ + # config entries that are valid for all player types + CONF_ENTRY_PLAYER_ICON, + CONF_ENTRY_FLOW_MODE, + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_CROSSFADE_DURATION, + CONF_ENTRY_VOLUME_NORMALIZATION, + CONF_ENTRY_OUTPUT_LIMITER, + CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, + CONF_ENTRY_TTS_PRE_ANNOUNCE, + CONF_ENTRY_HTTP_PROFILE, +] + + +class Player(ABC): + """ + Base representation of a Player within the Music Assistant Server. + + Player Provider implementations should inherit from this base model. + """ + + _attr_type: PlayerType = PlayerType.PLAYER + _attr_supported_features: set[PlayerFeature] + _attr_group_members: list[str] + _attr_device_info: DeviceInfo + _attr_can_group_with: set[str] + _attr_source_list: list[PlayerSource] + _attr_available: bool = True + _attr_name: str | None = None + _attr_powered: bool | None = None + _attr_playback_state: PlaybackState = PlaybackState.IDLE + _attr_volume_level: int | None = None + _attr_volume_muted: bool | None = None + _attr_elapsed_time: float | None = None + _attr_elapsed_time_last_updated: float | None = None + _attr_active_source: str | None = None + _attr_current_media: PlayerMedia | None = None + _attr_needs_poll: bool = False + _attr_poll_interval: int = 30 + _attr_hidden_by_default: bool = False + _attr_expose_to_ha_by_default: bool = False + _attr_enabled_by_default: bool = True + + def __init__(self, provider: PlayerProvider, player_id: str) -> None: + """Initialize the Player.""" + # set mass as public variable + self.mass = provider.mass + self.logger = provider.logger + # initialize mutable attributes + self._attr_supported_features = set() + self._attr_group_members = [] + self._attr_device_info = DeviceInfo() + self._attr_can_group_with = set() + self._attr_source_list = [] + # do not override/overwrite these private attributes below! + self._cache: dict[str, Any] = {} # storage dict for cached properties + self._player_id = player_id + self._provider = provider + self.mass.config.create_default_player_config( + player_id, self.provider_id, self.name, self.enabled_by_default + ) + self._config = self.mass.config.get_base_player_config(player_id, self.provider_id) + self._extra_data: dict[str, Any] = {} + self._extra_attributes: dict[str, Any] = {} + self._on_unload_callbacks: list[Callable[[], None]] = [] + # The PlayerState is the (snapshotted) final state of the player + # after applying any config overrides and other transformations, + # such as the display name and player controls. + # the state is updated when calling 'update_state' and is what is sent over the API. + self._state = PlayerState( + player_id=self.player_id, + provider=self.provider_id, + type=self.type, + name=self.display_name, + available=self.available, + device_info=self.device_info, + supported_features=self.supported_features, + playback_state=self.playback_state, + ) + + @property + def type(self) -> PlayerType: + """Return the type of the player.""" + return self._attr_type + + @property + def available(self) -> bool: + """Return if the player is available.""" + return self._attr_available + + @available.setter + def available(self, value: bool) -> None: + """ + Set the availability of the player. + + :param value: bool if the player is available or not. + """ + if self._attr_available != value: + self._attr_available = value + # also update the state + self._state.available = value + + @property + def name(self) -> str | None: + """Return the name of the player.""" + return self._attr_name + + @property + def supported_features(self) -> set[PlayerFeature]: + """Return the supported features of the player.""" + return self._attr_supported_features + + @property + def powered(self) -> bool | None: + """ + Return if the player is powered on. + + If the player does not support PlayerFeature.POWER, + or the state is (currently) unknown, this property may return None. + """ + return self._attr_powered + + @property + def playback_state(self) -> PlaybackState: + """Return the current playback state of the player.""" + return self._attr_playback_state + + @property + def volume_level(self) -> int | None: + """ + Return the current volume level (0..100) of the player. + + If the player does not support PlayerFeature.VOLUME_SET, + or the state is (currently) unknown, this property may return None. + """ + return self._attr_volume_level + + @property + def volume_muted(self) -> bool | None: + """ + Return the current mute state of the player. + + If the player does not support PlayerFeature.VOLUME_MUTE, + or the state is (currently) unknown, this property may return None. + """ + return self._attr_volume_muted + + @cached_property + def flow_mode(self) -> bool: + """ + Return if the player needs flow mode. + + Will by default be set to True if the player does not support PlayerFeature.ENQUEUE + or has a flow mode config entry set to True. + """ + if bool(self._config.get_value(CONF_FLOW_MODE)) is True: + return True + return PlayerFeature.ENQUEUE not in self.supported_features + + @property + def device_info(self) -> DeviceInfo: + """Return the device info of the player.""" + return self._attr_device_info + + @property + def elapsed_time(self) -> float | None: + """Return the elapsed time in (fractional) seconds of the current track (if any).""" + return self._attr_elapsed_time + + @elapsed_time.setter + def elapsed_time(self, value: float | None) -> None: + """Set the elapsed time on the player.""" + if self._attr_elapsed_time != value: + self._attr_elapsed_time = value + # also update the state + self._state.elapsed_time = value + # update the last updated time + self._attr_elapsed_time_last_updated = time.time() + + @property + def elapsed_time_last_updated(self) -> float | None: + """ + Return when the elapsed time was last updated. + + return: The (UTC) timestamp when the elapsed time was last updated, + or None if it was never updated (or unknown). + """ + return self._attr_elapsed_time_last_updated + + @property + def group_members(self) -> list[str]: + """ + Return the group members of the player. + + If there are other players synced/grouped with this player, + this should return the id's of players synced to this player, + and this should include the player's own id (as first item in the list). + + If there are currently no group members, this should return an empty list. + """ + return self._attr_group_members + + @property + def can_group_with(self) -> set[str]: + """ + Return the id's of players this player can group with. + + This should return set of player_id's this player can group/sync with + or just the provider's instance_id if all players can group with each other. + """ + return self._attr_can_group_with + + @property + def active_source(self) -> str | None: + """ + Return the (id of) the active source of the player. + + Set to None if the player is not currently playing a source or + the player_id if the player is currently playing a MA queue. + """ + return self._attr_active_source + + @property + def source_list(self) -> list[PlayerSource]: + """Return list of available (native) sources for this player.""" + return self._attr_source_list + + @property + def current_media(self) -> PlayerMedia | None: + """Return the current media being played by the player.""" + return self._attr_current_media + + @property + def needs_poll(self) -> bool: + """Return if the player needs to be polled for state updates.""" + return self._attr_needs_poll + + @property + def poll_interval(self) -> int: + """ + Return the (dynamic) poll interval for the player. + + Only used if 'needs_poll' is set to True. + This should return the interval in seconds. + """ + return self._attr_poll_interval + + @property + def hidden_by_default(self) -> bool: + """Return if the player should be hidden in the UI by default.""" + return self._attr_hidden_by_default + + @property + def expose_to_ha_by_default(self) -> bool: + """Return if the player should be exposed to Home Assistant by default.""" + return self._attr_expose_to_ha_by_default + + @property + def enabled_by_default(self) -> bool: + """Return if the player should be enabled by default.""" + return self._attr_enabled_by_default + + async def power(self, powered: bool) -> None: + """ + Handle POWER command on the player. + + Will only be called if the PlayerFeature.POWER is supported. + + :param powered: bool if player should be powered on or off. + """ + raise NotImplementedError("power needs to be implemented when PlayerFeature.POWER is set") + + async def volume_set(self, volume_level: int) -> None: + """ + Handle VOLUME_SET command on the player. + + Will only be called if the PlayerFeature.VOLUME_SET is supported. + + :param volume_level: volume level (0..100) to set on the player. + """ + raise NotImplementedError( + "volume_set needs to be implemented when PlayerFeature.VOLUME_SET is set" + ) + + async def volume_mute(self, muted: bool) -> None: + """ + Handle VOLUME MUTE command on the player. + + Will only be called if the PlayerFeature.VOLUME_MUTE is supported. + + :param muted: bool if player should be muted. + """ + raise NotImplementedError( + "volume_mute needs to be implemented when PlayerFeature.VOLUME_MUTE is set" + ) + + async def play(self) -> None: + """Handle PLAY command on the player.""" + raise NotImplementedError("play needs to be implemented") + + @abstractmethod + async def stop(self) -> None: + """ + Handle STOP command on the player. + + Will only be called if the player reports PlayerFeature.PAUSE is supported or + player supports resuming of stopped playback. + """ + raise NotImplementedError("stop needs to be implemented") + + async def pause(self) -> None: + """ + Handle PAUSE command on the player. + + Will only be called if the player reports PlayerFeature.PAUSE is supported. + """ + raise NotImplementedError("pause needs to be implemented when PlayerFeature.PAUSE is set") + + async def next_track(self) -> None: + """ + Handle NEXT_TRACK command on the player. + + Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS + is supported and the player is not currently playing a MA queue. + """ + raise NotImplementedError( + "next_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set" + ) + + async def previous_track(self) -> None: + """ + Handle PREVIOUS_TRACK command on the player. + + Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS + is supported and the player is not currently playing a MA queue. + """ + raise NotImplementedError( + "previous_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set" + ) + + async def seek(self, position: int) -> None: + """ + Handle SEEK command on the player. + + Seek to a specific position in the current track. + Will only be called if the player reports PlayerFeature.SEEK is + supported and the player is NOT currently playing a MA queue. + + :param position: The position to seek to, in seconds. + """ + raise NotImplementedError("seek needs to be implemented when PlayerFeature.SEEK is set") + + @abstractmethod + async def play_media( + self, + media: PlayerMedia, + ) -> None: + """ + Handle PLAY MEDIA command on given player. + + This is called by the Player controller to start playing Media on the player, + which can be a MA queue item/stream or a native source. + The provider's own implementation should work out how to handle this request. + + :param media: Details of the item that needs to be played on the player. + """ + raise NotImplementedError("play_media needs to be implemented") + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """ + Handle enqueuing of the next (queue) item on the player. + + Called when player reports it started buffering a queue item + and when the queue items updated. + + A PlayerProvider implementation is in itself responsible for handling this + so that the queue items keep playing until its empty or the player stopped. + + Will only be called if the player reports PlayerFeature.ENQUEUE is + supported and the player is currently playing a MA queue. + + This will NOT be called if the end of the queue is reached (and repeat disabled). + This will NOT be called if the player is using flow mode to playback the queue. + + :param media: Details of the item that needs to be enqueued on the player. + """ + raise NotImplementedError( + "enqueue_next_media needs to be implemented when PlayerFeature.ENQUEUE is set" + ) + + async def play_announcement( + self, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """ + Handle (native) playback of an announcement on the player. + + Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported. + + :param announcement: Details of the announcement that needs to be played on the player. + :param volume_level: The volume level to play the announcement at (0..100). + If not set, the player should use the current volume level. + """ + raise NotImplementedError( + "play_announcement needs to be implemented when PlayerFeature.PLAY_ANNOUNCEMENT is set" + ) + + async def select_source(self, source: str) -> None: + """ + Handle SELECT SOURCE command on the player. + + Will only be called if the PlayerFeature.SELECT_SOURCE is supported. + + :param source: The source(id) to select, as defined in the source_list. + """ + raise NotImplementedError( + "select_source needs to be implemented when PlayerFeature.SELECT_SOURCE is set" + ) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """ + Handle SET_MEMBERS command on the player. + + Group or ungroup the given child player(s) to/from this player. + Will only be called if the PlayerFeature.SET_MEMBERS is supported. + + :param player_ids_to_add: List of player_id's to add to the group. + :param player_ids_to_remove: List of player_id's to remove from the group. + """ + raise NotImplementedError( + "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set" + ) + + async def poll(self) -> None: + """ + Poll player for state updates. + + This is called by the Player Manager; + if the 'needs_poll' property is True. + """ + raise NotImplementedError("poll needs to be implemented when needs_poll is True") + + async def get_config_entries( + self, + ) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + # Return all base config entries for a player. + # Feel free to override but ensure to include the base entries by calling super() first. + # To override the default config entries, simply define an entry with the same key + # and it will be used instead of the default one. + return [ + # config entries that are valid for all players + *BASE_CONFIG_ENTRIES, + # add player control entries + *self._create_player_control_config_entries(), + CONF_ENTRY_AUTO_PLAY, + # audio-related config entries + CONF_ENTRY_SAMPLE_RATES, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_OUTPUT_CHANNELS, + # add default entries for announce feature + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + # add default entries to hide player in UI and expose to HA + ( + CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT + if self.hidden_by_default + else CONF_ENTRY_HIDE_PLAYER_IN_UI + ), + ( + CONF_ENTRY_EXPOSE_PLAYER_TO_HA + if self.expose_to_ha_by_default + else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED + ), + ] + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + for callback in self._on_unload_callbacks: + try: + callback() + except Exception as err: + self.logger.error( + "Error calling on_unload callback for player %s: %s", + self.player_id, + err, + ) + + async def group_with(self, target_player_id: str) -> None: + """ + Handle GROUP_WITH command on the player. + + Group this player to the given syncleader/target. + Will only be called if the PlayerFeature.SET_MEMBERS is supported. + + :param target_player: player_id of the target player / sync leader. + """ + # convenience helper method + # no need to implement unless your player/provider has an optimized way to execute this + # default implementation will simply call set_members + # to add the target player to the group. + target_player = self.mass.players.get(target_player_id, raise_unavailable=True) + assert target_player # for type checking + await target_player.set_members(player_ids_to_add=[self.player_id]) + + async def ungroup(self) -> None: + """ + Handle UNGROUP command on the player. + + Remove the player from any (sync)groups it currently is grouped to. + If this player is the sync leader (or group player), + all child's will be ungrouped and the group dissolved. + + Will only be called if the PlayerFeature.SET_MEMBERS is supported. + """ + # convenience helper method + # no need to implement unless your player/provider has an optimized way to execute this + # default implementation will simply call set_members + if self.synced_to: + if parent_player := self.mass.players.get(self.synced_to): + # if this player is synced to another player, remove self from that group + await parent_player.set_members(player_ids_to_remove=[self.player_id]) + elif self.group_members: + await self.set_members(player_ids_to_remove=self.group_members) + + @cached_property + def synced_to(self) -> str | None: + """ + Return the id of the player this player is synced to (sync leader). + + If this player is not synced to another player (or is the sync leader itself), + this should return None. + """ + # default implementation: feel free to override + for player in self.mass.players.all(): + if player.player_id == self.player_id: + # skip self + continue + if self.player_id in player.group_members: + # this player is a member of the group of the other player + return player.player_id + return None + + # DO NOT OVERWRITE BELOW ! + # These properties and methods are either managed by core logic or they + # are used to perform a very specific function. Overwriting these may + # produce undesirable effects. + + @property + @final + def player_id(self) -> str: + """Return the id of the player.""" + return self._player_id + + @property + @final + def provider(self) -> PlayerProvider: + """Return the provider of the player.""" + return self._provider + + @property + @final + def provider_id(self) -> str: + """Return the provider id of the player.""" + return self._provider.lookup_key + + @property + @final + def config(self) -> PlayerConfig: + """Return the config of the player.""" + return self._config + + @property + @final + def extra_attributes(self) -> dict[str, EXTRA_ATTRIBUTES_TYPES]: + """ + Return the extra attributes of the player. + + This is a dict that can be used to pass any extra (serializable) + attributes over the API, to be consumed by the UI (or another APi client, such as HA). + This is not persisted and not used or validated by the core logic. + """ + return self._extra_attributes + + @property + @final + def extra_data(self) -> dict[str, Any]: + """ + Return the extra data of the player. + + This is a dict that can be used to store any extra data + that is not part of the player state or config. + This is not persisted and not exposed on the API. + """ + return self._extra_data + + @cached_property + @final + def display_name(self) -> str: + """Return the display name of the player.""" + if custom_name := self._config.name: + # always prefer the custom name over the default name + return custom_name + return self.name or self._config.default_name or self.player_id + + @cached_property + @final + def power_state(self) -> bool | None: + """ + Return the FINAL power state of the player. + + This is a convenience property which calculates the final power state + based on the playercontrol which may have been set-up. + """ + power_control = self.power_control + if power_control == PLAYER_CONTROL_FAKE: + return bool(self.extra_data.get(ATTR_FAKE_POWER, False)) + if power_control == PLAYER_CONTROL_NATIVE: + return self.powered + if power_control == PLAYER_CONTROL_NONE: + return None + if control := self.mass.players.get_player_control(power_control): + return control.power_state + return None + + @cached_property + @final + def volume_state(self) -> int | None: + """ + Return the FINAL volume level of the player. + + This is a convenience property which calculates the final volume level + based on the playercontrol which may have been set-up. + """ + volume_control = self.volume_control + if volume_control == PLAYER_CONTROL_FAKE: + return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0)) + if volume_control == PLAYER_CONTROL_NATIVE: + return self.volume_level + if volume_control == PLAYER_CONTROL_NONE: + return None + if control := self.mass.players.get_player_control(volume_control): + return control.volume_level + return None + + @cached_property + @final + def volume_muted_state(self) -> bool | None: + """ + Return the FINAL mute state of the player. + + This is a convenience property which calculates the final mute state + based on the playercontrol which may have been set-up. + """ + mute_control = self.mute_control + if mute_control == PLAYER_CONTROL_FAKE: + return bool(self.extra_data.get(ATTR_FAKE_MUTE, False)) + if mute_control == PLAYER_CONTROL_NATIVE: + return self.volume_muted + if mute_control == PLAYER_CONTROL_NONE: + return None + if control := self.mass.players.get_player_control(mute_control): + return control.volume_muted + return None + + @cached_property + @final + def active_source_state(self) -> str | None: + """ + Return the FINAL active source of the player. + + This is a convenience property which calculates the final active source + based on any group memberships or source plugins that can be active. + """ + # if the player is grouped/synced, use the active source of the group/parent player + if parent_player_id := (self.synced_to or self.active_group): + if parent_player := self.mass.players.get(parent_player_id): + return parent_player.active_source_state + # in case player's source is None, return the player_id (to indicate MA is active source) + return self.active_source or self.player_id + + @cached_property + @final + def source_list_state(self) -> UniqueList[PlayerSource]: + """ + Return the FINAL source list of the player. + + This is a convenience property which calculates the final source list + based on any group memberships or source plugins that can be active. + """ + sources = UniqueList(self.source_list) + # always ensure the Music Assistant Queue is in the source list + mass_source = next((x for x in sources if x.id == self.player_id), None) + if mass_source is None: + # if the MA queue is not in the source list, add it + mass_source = PlayerSource( + id=self.player_id, + name="Music Assistant Queue", + passive=False, + # TODO: Do we want to dynamically set these based on the queue state ? + can_play_pause=True, + can_seek=True, + can_next_previous=True, + ) + sources.append(mass_source) + # if the player is grouped/synced, add the active source list of the group/parent player + if parent_player_id := (self.synced_to or self.active_group): + if parent_player := self.mass.players.get(parent_player_id): + for source in parent_player.source_list_state: + if source.id == parent_player.active_source_state: + sources.append( + PlayerSource( + id=source.id, + name=f"{source.name} ({parent_player.display_name})", + passive=source.passive, + can_play_pause=source.can_play_pause, + can_seek=source.can_seek, + can_next_previous=source.can_next_previous, + ) + ) + # append all/any plugin sources + sources.extend(self.mass.players.get_plugin_sources()) + return sources + + @cached_property + @final + def enabled(self) -> bool: + """Return if the player is enabled.""" + return self._config.enabled + + @property + def corrected_elapsed_time(self) -> float | None: + """Return the corrected/realtime elapsed time.""" + if self.elapsed_time is None or self.elapsed_time_last_updated is None: + return None + if self.playback_state == PlaybackState.PLAYING: + return self.elapsed_time + (time.time() - self.elapsed_time_last_updated) + return self.elapsed_time + + @property + @final + def active_group(self) -> str | None: + """ + Return the player id of the (first) playergroup that is currently active for this player. + + This will return the id of the groupplayer if a group is active. + If no group is currently active, this will return None. + """ + for player in self.mass.players.all(return_unavailable=False, return_disabled=False): + if player.type != PlayerType.GROUP: + continue + if not (player.powered or player.playback_state == PlaybackState.PLAYING): + continue + if self.player_id in player.group_members: + return player.player_id + return None + + @cached_property + @final + def current_media_state(self) -> PlayerMedia | None: + """ + Return the current media being played by the player. + + This is a convenience property which calculates the current media + based on any group memberships or source plugins that can be active. + """ + # if the player is grouped/synced, use the current_media of the group/parent player + if parent_player_id := (self.synced_to or self.active_group): + if parent_player := self.mass.players.get(parent_player_id): + return parent_player.current_media_state + # if a pluginsource is currently active, return those details + if self.active_source_state and ( + source := self.mass.players.get_plugin_source(self.active_source_state) + ): + return source.metadata + + return None + + @cached_property + @final + def icon(self) -> str: + """Return the player icon.""" + return cast("str", self._config.get_value(CONF_ENTRY_PLAYER_ICON.key)) + + @cached_property + @final + def power_control(self) -> str: + """Return the power control type.""" + if conf := self._config.get_value(CONF_POWER_CONTROL): + return str(conf) + return PLAYER_CONTROL_NONE + + @cached_property + @final + def volume_control(self) -> str: + """Return the volume control type.""" + if conf := self._config.get_value(CONF_VOLUME_CONTROL): + return str(conf) + return PLAYER_CONTROL_NONE + + @cached_property + @final + def mute_control(self) -> str: + """Return the mute control type.""" + if conf := self._config.get_value(CONF_MUTE_CONTROL): + return str(conf) + return PLAYER_CONTROL_NONE + + @cached_property + @final + def group_volume(self) -> int: + """ + Return the group volume level. + + If this player is a group player or syncgroup, this will return the average volume + level of all (powered on) child players in the group. + If the player is not a group player or syncgroup, this will return the volume level + of the player itself (if set), or 0 if not set. + """ + if len(self.group_members) == 0: + # player is not a group or syncgroup + return self.volume_level or 0 + # calculate group volume from all (turned on) players + group_volume = 0 + active_players = 0 + for child_player in self.mass.players.iter_group_members( + self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER + ): + if (child_volume := child_player.volume_state) is None: + continue + group_volume += child_volume + active_players += 1 + if active_players: + group_volume = int(group_volume / active_players) + return group_volume + + @cached_property + @final + def hide_player_in_ui(self) -> set[HidePlayerOption]: + """ + Return the hide player in UI options. + + This is a convenience property based on the config entry. + """ + return { + HidePlayerOption(x) + for x in cast("list[str]", self._config.get_value(CONF_HIDE_PLAYER_IN_UI, [])) + } + + @cached_property + @final + def expose_to_ha(self) -> bool: + """ + Return if the player should be exposed to Home Assistant. + + This is a convenience property that returns True if the player is set to be exposed + to Home Assistant, based on the config entry. + """ + return bool(self._config.get_value(CONF_EXPOSE_PLAYER_TO_HA)) + + @cached_property + @final + def mass_queue_active(self) -> bool: + """ + Return if the/a Music Assistant Queue is currently active for this player. + + This is a convenience property that returns True if the + player currently has a Music Assistant Queue as active source. + """ + return bool(self.mass.players.get_active_queue(self)) + + @property + @final + def state(self) -> PlayerState: + """Return the current PlayerState of the player.""" + return self._state + + @final + def update_state(self, force_update: bool = False) -> None: + """ + Update the PlayerState with the current state of the player. + + This method should be called to update the player's state + and signal any changes to the PlayerController. + + :param force_update: If True, a state update event will be + pushed even if the state has not actually changed. + """ + self.mass.verify_event_loop_thread("player.update_state") + # clear the dict for the cached properties + self._cache.clear() + # calculate the new state + changed_values = self.__calculate_state() + # ignore some values that are not relevant for the state + changed_values.pop("elapsed_time_last_updated", None) + changed_values.pop("extra_attributes.seq_no", None) + changed_values.pop("extra_attributes.last_poll", None) + # return early if nothing changed (unless force_update is True) + if len(changed_values) == 0 and not force_update: + return + # signal the state update to the PlayerController + self.mass.players.signal_player_state_update(self, changed_values) + + @final + def set_current_media( # noqa: PLR0913 + self, + uri: str, + media_type: MediaType = MediaType.UNKNOWN, + title: str | None = None, + artist: str | None = None, + album: str | None = None, + image_url: str | None = None, + duration: int | None = None, + queue_id: str | None = None, + queue_item_id: str | None = None, + custom_data: dict[str, Any] | None = None, + clear_all: bool = False, + ) -> None: + """ + Set current_media helper. + + Assumes use of '_attr_current_media'. + """ + if self._attr_current_media is None or clear_all: + self._attr_current_media = PlayerMedia( + uri=uri, + media_type=media_type, + ) + self._attr_current_media.uri = uri + if media_type != MediaType.UNKNOWN: + self._attr_current_media.media_type = media_type + if title: + self._attr_current_media.title = title + if artist: + self._attr_current_media.artist = artist + if album: + self._attr_current_media.album = album + if image_url: + self._attr_current_media.image_url = image_url + if duration: + self._attr_current_media.duration = duration + if queue_id: + self._attr_current_media.queue_id = queue_id + if queue_item_id: + self._attr_current_media.queue_item_id = queue_item_id + if custom_data: + self._attr_current_media.custom_data = custom_data + + @final + def set_config(self, config: PlayerConfig) -> None: + """ + Set/update the player config. + + May only be called by the PlayerController. + """ + # TODO: validate that caller is the PlayerController ? + self._config = config + + @final + def to_dict(self) -> dict[str, Any]: + """Return the (serializable) dict representation of the Player.""" + return self.state.to_dict() + + @final + def supports_feature(self, feature: PlayerFeature) -> bool: + """Return True if this player supports the given feature.""" + return feature in self.supported_features + + @final + def check_feature(self, feature: PlayerFeature) -> None: + """Check if this player supports the given feature.""" + if not self.supports_feature(feature): + raise UnsupportedFeaturedException( + f"Player {self.display_name} does not support feature {feature.name}" + ) + + def _create_player_control_config_entries( + self, + ) -> list[ConfigEntry]: + """Create config entries for player controls.""" + all_controls = self.mass.players.player_controls() + power_controls = [x for x in all_controls if x.supports_power] + volume_controls = [x for x in all_controls if x.supports_volume] + mute_controls = [x for x in all_controls if x.supports_mute] + # work out player supported features + supports_power = PlayerFeature.POWER in self.supported_features + supports_volume = PlayerFeature.VOLUME_SET in self.supported_features + supports_mute = PlayerFeature.VOLUME_MUTE in self.supported_features + # create base options per control type (and add defaults like native and fake) + base_power_options: list[ConfigValueOption] = [ + ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), + ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE), + ] + if supports_power: + base_power_options.append( + ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE), + ) + base_volume_options: list[ConfigValueOption] = [ + ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), + ] + if supports_volume: + base_volume_options.append( + ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE), + ) + base_mute_options: list[ConfigValueOption] = [ + ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), + ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE), + ] + if supports_mute: + base_mute_options.append( + ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE), + ) + # return final config entries for all options + return [ + # Power control config entry + ConfigEntry( + key=CONF_POWER_CONTROL, + type=ConfigEntryType.STRING, + label="Power Control", + default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE, + required=True, + options=[ + *base_power_options, + *(ConfigValueOption(x.name, x.id) for x in power_controls), + ], + category="player_controls", + ), + # Volume control config entry + ConfigEntry( + key=CONF_VOLUME_CONTROL, + type=ConfigEntryType.STRING, + label="Volume Control", + default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE, + required=True, + options=[ + *base_volume_options, + *(ConfigValueOption(x.name, x.id) for x in volume_controls), + ], + category="player_controls", + ), + # Mute control config entry + ConfigEntry( + key=CONF_MUTE_CONTROL, + type=ConfigEntryType.STRING, + label="Mute Control", + default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE, + required=True, + options=[ + *base_mute_options, + *[ConfigValueOption(x.name, x.id) for x in mute_controls], + ], + category="player_controls", + ), + ] + + def __calculate_state( + self, + ) -> dict[str, tuple[Any, Any]]: + """ + Calculate the (current) PlayerState. + + This method is called when we're updating the player, + and we compare the current state with the previous state to determine + if we need to signal a state change to API consumers. + + Returns a dict with the state attributes that have changed. + """ + prev_state = deepcopy(self._state) + self._state.name = self.display_name + self._state.available = self.available + self._state.device_info = self.device_info + self._state.supported_features = self.supported_features + self._state.playback_state = self.playback_state + self._state.elapsed_time = self.elapsed_time + self._state.elapsed_time_last_updated = self.elapsed_time_last_updated + self._state.powered = self.power_state + self._state.volume_level = self.volume_state + self._state.volume_muted = self.volume_muted_state + self._state.group_members = UniqueList(self.group_members) + self._state.can_group_with = self.can_group_with + self._state.synced_to = self.synced_to + self._state.active_source = self.active_source_state + self._state.source_list = self.source_list_state + self._state.active_group = self.active_group + self._state.current_media = self.current_media + self._state.enabled = self.enabled + self._state.hide_player_in_ui = self.hide_player_in_ui + self._state.expose_to_ha = self.expose_to_ha + self._state.icon = self.icon + self._state.group_volume = self.group_volume + self._state.extra_attributes = self.extra_attributes + self._state.power_control = self.power_control + self._state.volume_control = self.volume_control + self._state.mute_control = self.mute_control + + # correct available state if needed + if not self._state.enabled: + self._state.available = False + + # correct group_members if needed + if self._state.group_members == [self.player_id]: + self._state.group_members.clear() + elif ( + self._state.group_members + and self.player_id not in self._state.group_members + and self.type == PlayerType.PLAYER + ): + self._state.group_members.set([self.player_id, *self._state.group_members]) + + # Auto correct player state if player is synced (or group child) + # This is because some players/providers do not accurately update this info + # for the sync child's. + if self._state.synced_to and (sync_leader := self.mass.players.get(self._state.synced_to)): + self._state.playback_state = sync_leader.playback_state + self._state.elapsed_time = sync_leader.elapsed_time + self._state.elapsed_time_last_updated = sync_leader.elapsed_time_last_updated + + return get_changed_dataclass_values( + prev_state, + self._state, + recursive=True, + ) + + def __hash__(self) -> int: + """Return a hash of the Player.""" + return hash(self.player_id) + + def __str__(self) -> str: + """Return a string representation of the Player.""" + return f"Player {self.name} ({self.player_id})" + + def __repr__(self) -> str: + """Return a string representation of the Player.""" + return f"" + + def __eq__(self, other: object) -> bool: + """Check equality of two Player objects.""" + if not isinstance(other, Player): + return False + return self.player_id == other.player_id + + def __ne__(self, other: object) -> bool: + """Check inequality of two Player objects.""" + return not self.__eq__(other) + + +class GroupPlayer(Player): + """Helper class for a (generic) group player.""" + + _attr_type: PlayerType = PlayerType.GROUP + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + # Return all base config entries for a group player. + # Feel free to override but ensure to include the base entries by calling super() first. + # To override the default config entries, simply define an entry with the same key + # and it will be used instead of the default one. + return [ + *BASE_CONFIG_ENTRIES, + CONF_ENTRY_PLAYER_ICON_GROUP, + # add player control entries as hidden entries + ConfigEntry( + key=CONF_POWER_CONTROL, + type=ConfigEntryType.STRING, + label=CONF_POWER_CONTROL, + default_value=PLAYER_CONTROL_NATIVE, + hidden=True, + ), + ConfigEntry( + key=CONF_VOLUME_CONTROL, + type=ConfigEntryType.STRING, + label=CONF_VOLUME_CONTROL, + default_value=PLAYER_CONTROL_NATIVE, + hidden=True, + ), + ConfigEntry( + key=CONF_MUTE_CONTROL, + type=ConfigEntryType.STRING, + label=CONF_MUTE_CONTROL, + # disable mute control for group players for now + # TODO: work out if all child players support mute control + default_value=PLAYER_CONTROL_NONE, + hidden=True, + ), + CONF_ENTRY_AUTO_PLAY, + # add default entries to hide player in UI and expose to HA + ( + CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT + if self.hidden_by_default + else CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER + ), + ( + CONF_ENTRY_EXPOSE_PLAYER_TO_HA + if self.expose_to_ha_by_default + else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED + ), + ] + + async def volume_set(self, volume_level: int) -> None: + """ + Handle VOLUME_SET command on the player. + + :param volume_level: volume level (0..100) to set on the player. + """ + # Default implementation: + # This will set the (relative) volume level on all child players. + # free to override if you want to handle this differently. + await self.mass.players.set_group_volume(self, volume_level) + + +class SyncGroupPlayer(GroupPlayer): + """Helper class for a (provider specific) SyncGroup player.""" + + _attr_type: PlayerType = PlayerType.GROUP + + @cached_property + def is_dynamic(self) -> bool: + """Return if the player is a dynamic group player.""" + return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False)) + + def __init__( + self, + provider: PlayerProvider, + player_id: str, + ) -> None: + """Initialize GroupPlayer instance.""" + super().__init__(provider, player_id) + self._attr_name = self.config.name or f"SyncGroup {player_id}" + self._attr_available = True + self._attr_powered = False # group players are always powered off by default + self._attr_active_source = player_id + self._attr_group_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) + self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name) + self._set_attributes() + + def _set_attributes(self) -> None: + """Set player attributes.""" + player_features = { + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + } + if self.is_dynamic: + player_features.add(PlayerFeature.SET_MEMBERS) + + self._attr_supported_features = player_features + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + entries: list[ConfigEntry] = [ + # default entries for player groups + *await super().get_config_entries(), + # add syncgroup specific entries + ConfigEntry( + key=CONF_GROUP_MEMBERS, + type=ConfigEntryType.STRING, + multi_value=True, + label="Group members", + default_value=[], + description="Select all players you want to be part of this group", + required=False, # needed for dynamic members (which allows empty members list) + options=[ + ConfigValueOption(x.display_name, x.player_id) for x in self.provider.players + ], + ), + ConfigEntry( + key="dynamic_members", + type=ConfigEntryType.BOOLEAN, + label="Enable dynamic members", + description="Allow (un)joining members dynamically, so the group more or less" + "behaves the same like manually syncing players together, " + "with the main difference being that the groupplayer will hold the queue.", + default_value=False, + required=False, + ), + ] + # combine base group entries with (base) player entries for this player type + child_player = next((x for x in self.provider.players if x.type != PlayerType.GROUP), None) + if child_player: + allowed_conf_entries = ( + CONF_HTTP_PROFILE, + CONF_ENABLE_ICY_METADATA, + CONF_CROSSFADE, + CONF_CROSSFADE_DURATION, + CONF_OUTPUT_CODEC, + CONF_FLOW_MODE, + CONF_SAMPLE_RATES, + ) + child_config_entries = await child_player.get_config_entries() + entries.extend( + [entry for entry in child_config_entries if entry.key in allowed_conf_entries] + ) + return entries + + async def stop(self) -> None: + """Send STOP command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.stop() + + async def play(self) -> None: + """Send PLAY command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.play() + + async def pause(self) -> None: + """Send PAUSE command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.pause() + + async def power(self, powered: bool) -> None: + """Handle POWER command to group player.""" + # always stop at power off + if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + await self.stop() + + if powered: + await self._form_syncgroup() + + # optimistically set the group state + prev_power = self._attr_powered + self._attr_powered = powered + self.update_state() + + if powered: + # handle TURN_ON of the group player by turning on all members + for member in self.mass.players.iter_group_members( + self, only_powered=False, active_only=False + ): + if member.active_group is not None and member.active_group != self.player_id: + # collision: child player is part of multiple groups + # and another group already active ! + # solve this by trying to leave the group first + if ( + other_group := self.mass.players.get(member.active_group) + ) and PlayerFeature.SET_MEMBERS in other_group.supported_features: + await other_group.set_members(player_ids_to_remove=[member.player_id]) + else: + # if the other group does not support SET_MEMBERS, + # we need to power it off to leave the group + await self.mass.players.cmd_power(member.active_group, False) + await asyncio.sleep(1) + await asyncio.sleep(1) + if not member.powered and member.power_control != PLAYER_CONTROL_NONE: + await self.mass.players.cmd_power(member.player_id, True) + elif not prev_power: + # handle TURN_OFF of the group player by ungrouping and turning off all members + if (sync_leader := self.sync_leader) and sync_leader.group_members: + # dissolve the temporary syncgroup from the sync leader + sync_childs = [x for x in sync_leader.group_members if x != sync_leader.player_id] + if sync_childs: + await sync_leader.set_members(player_ids_to_remove=sync_childs) + # turn off all group members + for member in self.mass.players.iter_group_members( + self, only_powered=True, active_only=True + ): + if member.powered and member.power_control != PLAYER_CONTROL_NONE: + await self.mass.players.cmd_power(member.player_id, False) + + if not powered: + # reset the original group members when powered off + self._attr_group_members = cast( + "list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []) + ) + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + # group volume is already handled in the player manager + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + # power on (which will also resync if needed) + await self.power(True) + # simply forward the command to the sync leader + if sync_leader := self.sync_leader: + await sync_leader.play_media(media) + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing of a next media item on the player.""" + if sync_leader := self.sync_leader: + await sync_leader.enqueue_next_media(media) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + if not self.is_dynamic: + raise UnsupportedFeaturedException( + f"Group {self.display_name} does not allow dynamically adding/removing members!" + ) + # handle additions + final_players_to_add: list[str] = [] + for player_id in player_ids_to_add or []: + if player_id in self._attr_group_members: + continue + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot add {self.display_name} to itself as a member!" + ) + self._attr_group_members.append(player_id) + final_players_to_add.append(player_id) + # handle removals + final_players_to_remove: list[str] = [] + static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) + for player_id in player_ids_to_remove or []: + if player_id not in self._attr_group_members: + continue + if player_id in static_members: + raise UnsupportedFeaturedException( + f"Cannot remove {player_id} from {self.display_name} " + "as it is a static member of this group" + ) + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot remove {self.display_name} from itself as a member!" + ) + self._attr_group_members.remove(player_id) + final_players_to_remove.append(player_id) + self.update_state() + if self.powered and (player_ids_to_add or player_ids_to_remove): + # if the group is powered on, we need to (re)sync the members + sync_leader = await self._select_sync_leader() + await sync_leader.set_members( + player_ids_to_add=final_players_to_add, + player_ids_to_remove=final_players_to_remove, + ) + + @property + def sync_leader(self) -> Player | None: + """Get the active sync leader player for the syncgroup.""" + for child_player in self.mass.players.iter_group_members( + self, only_powered=False, only_playing=False, active_only=False + ): + # the syncleader is just the first player in the group + return child_player + return None + + async def _form_syncgroup(self) -> None: + """Form syncgroup by sync all (possible) members.""" + sync_leader = await self._select_sync_leader() + # ensure the sync leader is first in the list + self._attr_group_members = [ + sync_leader.player_id, + *[x for x in self._attr_group_members if x != sync_leader.player_id], + ] + self.update_state() + members_to_sync: list[str] = [] + for member in self.mass.players.iter_group_members(self, active_only=False): + if member.synced_to and member.synced_to != sync_leader.player_id: + # ungroup first + await self.mass.players.cmd_ungroup(member.player_id) + if member.player_id == sync_leader.player_id: + # skip sync leader + continue + if ( + member.synced_to == sync_leader.player_id + and member.player_id in sync_leader.group_members + ): + # already synced + continue + members_to_sync.append(member.player_id) + if members_to_sync: + await sync_leader.set_members(members_to_sync) + + async def _select_sync_leader(self) -> Player: + """Select the active sync leader player for a syncgroup.""" + for prefer_sync_leader in (True, False): + for child_player in self.mass.players.iter_group_members(self): + if prefer_sync_leader and child_player.synced_to: + # prefer the first player that already has sync childs + continue + if child_player.active_group not in ( + None, + self.player_id, + child_player.player_id, + ): + # this should not happen (because its already handled in the power on logic), + # but guard it just in case bad things happen + continue + return child_player + raise RuntimeError("No players available to form syncgroup") + + +__all__ = [ + # explicitly re-export the models we imported from the models package, + # for convenience reasons + "EXTRA_ATTRIBUTES_TYPES", + "DeviceInfo", + "GroupPlayer", + "Player", + "PlayerMedia", + "PlayerSource", + "PlayerState", + "SyncGroupPlayer", +] diff --git a/music_assistant/models/player_provider.py b/music_assistant/models/player_provider.py index 0e1234d7..0f516676 100644 --- a/music_assistant/models/player_provider.py +++ b/music_assistant/models/player_provider.py @@ -2,60 +2,29 @@ from __future__ import annotations -from abc import abstractmethod from typing import TYPE_CHECKING -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption -from music_assistant_models.constants import ( - PLAYER_CONTROL_FAKE, - PLAYER_CONTROL_NATIVE, - PLAYER_CONTROL_NONE, -) -from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerType -from music_assistant_models.errors import UnsupportedFeaturedException +import shortuuid +from music_assistant_models.enums import ProviderFeature from zeroconf import ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo from music_assistant.constants import ( - CONF_ENTRY_ANNOUNCE_VOLUME, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN, - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, - CONF_ENTRY_AUTO_PLAY, - CONF_ENTRY_CROSSFADE, - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED, - CONF_ENTRY_EXPOSE_PLAYER_TO_HA, - CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED, - CONF_ENTRY_FLOW_MODE, - CONF_ENTRY_HIDE_PLAYER_IN_UI, - CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT, - CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER, - CONF_ENTRY_OUTPUT_CHANNELS, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_OUTPUT_LIMITER, - CONF_ENTRY_PLAYER_ICON, - CONF_ENTRY_PLAYER_ICON_GROUP, - CONF_ENTRY_SAMPLE_RATES, - CONF_ENTRY_TTS_PRE_ANNOUNCE, - CONF_ENTRY_VOLUME_NORMALIZATION, - CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, - CONF_MUTE_CONTROL, - CONF_POWER_CONTROL, - CONF_VOLUME_CONTROL, + CONF_DYNAMIC_GROUP_MEMBERS, + CONF_GROUP_MEMBERS, + SYNCGROUP_PREFIX, ) +from music_assistant.models.player import SyncGroupPlayer from .provider import Provider if TYPE_CHECKING: - from music_assistant_models.config_entries import PlayerConfig - from music_assistant_models.player import Player, PlayerMedia - -# ruff: noqa: ARG001, ARG002 + from music_assistant.models.player import Player class PlayerProvider(Provider): - """Base representation of a Player Provider (controller). + """ + Base representation of a Player Provider (controller). Player Provider implementations should inherit from this base model. """ @@ -64,276 +33,68 @@ class PlayerProvider(Provider): """Call after the provider has been loaded.""" await self.discover_players() - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = ( - # config entries that are valid for all/most players - CONF_ENTRY_PLAYER_ICON, - CONF_ENTRY_FLOW_MODE, - CONF_ENTRY_CROSSFADE, - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_VOLUME_NORMALIZATION, - CONF_ENTRY_OUTPUT_LIMITER, - CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, - CONF_ENTRY_TTS_PRE_ANNOUNCE, - ) - if not (player := self.mass.players.get(player_id)): - return base_entries - - if PlayerFeature.GAPLESS_PLAYBACK not in player.supported_features: - base_entries += (CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,) - - if player.type == PlayerType.GROUP: - # return group player specific entries - return ( - *base_entries, - CONF_ENTRY_PLAYER_ICON_GROUP, - CONF_ENTRY_HIDE_PLAYER_IN_UI_GROUP_PLAYER, - # add player control entries as hidden entries - ConfigEntry( - key=CONF_POWER_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_POWER_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_VOLUME_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_MUTE_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_MUTE_CONTROL, - # disable mute control for group players for now - # TODO: work out if all child players support mute control - default_value=PLAYER_CONTROL_NONE, - hidden=True, - ), - CONF_ENTRY_AUTO_PLAY, - ( - CONF_ENTRY_EXPOSE_PLAYER_TO_HA - if player and player.expose_to_ha_by_default - else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED - ), - ) - return ( - # config entries that are valid for all players - *base_entries, - ( - CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT - if player and player.hidden_by_default - else CONF_ENTRY_HIDE_PLAYER_IN_UI - ), - ( - CONF_ENTRY_EXPOSE_PLAYER_TO_HA - if player and player.expose_to_ha_by_default - else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED - ), - # add player control entries - *self._create_player_control_config_entries(player), - CONF_ENTRY_AUTO_PLAY, - CONF_ENTRY_SAMPLE_RATES, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_OUTPUT_CHANNELS, - # add default entries for announce feature - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, - CONF_ENTRY_ANNOUNCE_VOLUME, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX, - ( - CONF_ENTRY_HIDE_PLAYER_IN_UI_ALWAYS_DEFAULT - if player and player.hidden_by_default - else CONF_ENTRY_HIDE_PLAYER_IN_UI - ), - ( - CONF_ENTRY_EXPOSE_PLAYER_TO_HA - if player and player.expose_to_ha_by_default - else CONF_ENTRY_EXPOSE_PLAYER_TO_HA_DEFAULT_DISABLED - ), - # add player control entries - *self._create_player_control_config_entries(player), - CONF_ENTRY_AUTO_PLAY, - ) - - async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: - """Call (by config manager) when the configuration of a player changes.""" - # default implementation: feel free to override - if ( - "enabled" in changed_keys - and config.enabled - and not self.mass.players.get(config.player_id) - ): - # if a player gets enabled, trigger discovery - task_id = f"discover_players_{self.instance_id}" - self.mass.call_later(5, self.discover_players, task_id=task_id) - else: - await self.poll_player(config.player_id) - - @abstractmethod - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player. - - - player_id: player_id of the player to handle the command. - """ - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY (unpause) command to given player. - - - player_id: player_id of the player to handle the command. - """ - # will only be called for players with Pause feature set. - raise NotImplementedError - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player. - - - player_id: player_id of the player to handle the command. - """ - # will only be called for players with Pause feature set. - raise NotImplementedError - - @abstractmethod - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player. - - This is called by the Players controller to start playing a mediaitem on the given player. - The provider's own implementation should work out how to handle this request. - - - player_id: player_id of the player to handle the command. - - media: Details of the item that needs to be played on the player. - """ - raise NotImplementedError - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """ - Handle enqueuing of the next (queue) item on the player. - - Called when player reports it started buffering a queue item - and when the queue items updated. - - A PlayerProvider implementation is in itself responsible for handling this - so that the queue items keep playing until its empty or the player stopped. - - This will NOT be called if the end of the queue is reached (and repeat disabled). - This will NOT be called if the player is using flow mode to playback the queue. - """ - # will only be called for players with ENQUEUE feature set. - raise NotImplementedError - - async def play_announcement( - self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None - ) -> None: - """Handle (provider native) playback of an announcement on given player.""" - # will only be called for players with PLAY_ANNOUNCEMENT feature set. - raise NotImplementedError - - async def select_source(self, player_id: str, source: str) -> None: - """Handle SELECT SOURCE command on given player.""" - # will only be called for sources that are defined in 'source_list'. - raise NotImplementedError - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Send POWER command to given player. + def on_player_enabled(self, player_id: str) -> None: + """Call (by config manager) when a player gets enabled.""" + # default implementation: trigger discovery - feel free to override + task_id = f"discover_players_{self.instance_id}" + self.mass.call_later(5, self.discover_players, task_id=task_id) - - player_id: player_id of the player to handle the command. - - powered: bool if player should be powered on or off. - """ - # will only be called for players with Power feature set. - raise NotImplementedError - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player. + def on_player_disabled(self, player_id: str) -> None: + """Call (by config manager) when a player gets disabled.""" - - player_id: player_id of the player to handle the command. - - volume_level: volume level (0..100) to set on the player. - """ - # will only be called for players with Volume feature set. + async def remove_player(self, player_id: str) -> None: + """Remove a player from this provider.""" + # will only be called for providers with REMOVE_PLAYER feature set. raise NotImplementedError - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player. - - - player_id: player_id of the player to handle the command. - - muted: bool if player should be muted. + async def create_group_player( + self, name: str, members: list[str], dynamic: bool = True + ) -> Player: """ - # will only be called for players with Mute feature set. - raise NotImplementedError + Create new Group Player. - async def cmd_seek(self, player_id: str, position: int) -> None: - """Handle SEEK command for given player. + Only called for providers that support CREATE_GROUP_PLAYER feature. - - player_id: player_id of the player to handle the command. - - position: position in seconds to seek to in the current playing item. + :param name: Name of the group player + :param members: List of player ids to add to the group + :param dynamic: Whether the group is dynamic (members can change) """ - # will only be called for players with Seek feature set. - raise NotImplementedError - - async def cmd_next(self, player_id: str) -> None: - """Handle NEXT TRACK command for given player.""" - # will only be called for players with 'next_previous' feature set. - raise NotImplementedError - - async def cmd_previous(self, player_id: str) -> None: - """Handle PREVIOUS TRACK command for given player.""" - # will only be called for players with 'next_previous' feature set. - raise NotImplementedError - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the sync leader. - """ - # will only be called for players with SET_MEMBERS feature set. - raise NotImplementedError - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - # will only be called for players with SET_MEMBERS feature set. + # default implementation for providers that support syncing players + if ProviderFeature.SYNC_PLAYERS in self.supported_features: + # we simply create a new syncgroup player with the given members + # feel free to override or extend this method in your provider + members = [x for x in members if x in [y.player_id for y in self.players]] + player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}" + self.mass.config.create_default_player_config( + player_id=player_id, + provider=self.lookup_key, + name=name, + enabled=True, + values={ + CONF_GROUP_MEMBERS: members, + CONF_DYNAMIC_GROUP_MEMBERS: dynamic, + }, + ) + return await self._register_syncgroup_player(player_id) + # all other providers should implement this method raise NotImplementedError - async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None: - """Create temporary sync group by joining given players to target player.""" - for child_id in child_player_ids: - # default implementation, simply call the cmd_group for all child players - await self.cmd_group(child_id, target_player) - - async def cmd_ungroup_member(self, player_id: str, target_player: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player(id) from the given (master) player/sync group. - - - player_id: player_id of the (child) player to ungroup from the group. - - target_player: player_id of the group player. + async def remove_group_player(self, player_id: str) -> None: """ - # can only be called for groupplayers with SET_MEMBERS feature set. - raise NotImplementedError + Remove a group player. - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates. + Only called for providers that support REMOVE_GROUP_PLAYER feature. - This is called by the Player Manager; - if 'needs_poll' is set to True in the player object. + :param player_id: ID of the group player to remove. """ - - async def remove_player(self, player_id: str) -> None: - """Remove a player.""" - # will only be called for players with REMOVE_PLAYER feature set. + # default implementation for providers that support syncing players + if ProviderFeature.SYNC_PLAYERS in self.supported_features and player_id.startswith( + SYNCGROUP_PREFIX + ): + # we simply permanently unregister the syncgroup player and wipe its config + await self.mass.players.unregister(player_id, True) + return + # all other providers should implement this method raise NotImplementedError async def discover_players(self) -> None: @@ -353,98 +114,22 @@ class PlayerProvider(Provider): await self.on_mdns_service_state_change( mdns_name, ServiceStateChange.Added, info ) + # discover syncgroup players + if ( + ProviderFeature.SYNC_PLAYERS in self.supported_features + and ProviderFeature.CREATE_GROUP_PLAYER in self.supported_features + ): + for player_conf in await self.mass.config.get_player_configs(self.lookup_key): + if player_conf.player_id.startswith(SYNCGROUP_PREFIX): + await self._register_syncgroup_player(player_conf.player_id) - async def set_members(self, player_id: str, members: list[str]) -> None: - """Set members for a groupplayer.""" - # will only be called for (group)players with SET_MEMBERS feature set. - raise UnsupportedFeaturedException - - # DO NOT OVERRIDE BELOW + async def _register_syncgroup_player(self, player_id: str) -> Player: + """Register a syncgroup player.""" + syncgroup = SyncGroupPlayer(self, player_id) + await self.mass.players.register_or_update(syncgroup) + return syncgroup @property def players(self) -> list[Player]: """Return all players belonging to this provider.""" - return [ - player - for player in self.mass.players - if player.provider in (self.instance_id, self.domain) - ] - - def _create_player_control_config_entries( - self, player: Player | None - ) -> tuple[ConfigEntry, ...]: - """Create config entries for player controls.""" - all_controls = self.mass.players.player_controls() - power_controls = [x for x in all_controls if x.supports_power] - volume_controls = [x for x in all_controls if x.supports_volume] - mute_controls = [x for x in all_controls if x.supports_mute] - # work out player supported features - supports_power = PlayerFeature.POWER in player.supported_features if player else False - supports_volume = PlayerFeature.VOLUME_SET in player.supported_features if player else False - supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features if player else False - # create base options per control type (and add defaults like native and fake) - base_power_options: list[ConfigValueOption] = [ - ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), - ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE), - ] - if supports_power: - base_power_options.append( - ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE), - ) - base_volume_options: list[ConfigValueOption] = [ - ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), - ] - if supports_volume: - base_volume_options.append( - ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE), - ) - base_mute_options: list[ConfigValueOption] = [ - ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE), - ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE), - ] - if supports_mute: - base_mute_options.append( - ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE), - ) - # return final config entries for all options - return ( - # Power control config entry - ConfigEntry( - key=CONF_POWER_CONTROL, - type=ConfigEntryType.STRING, - label="Power Control", - default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE, - required=True, - options=[ - *base_power_options, - *(ConfigValueOption(x.name, x.id) for x in power_controls), - ], - category="player_controls", - ), - # Volume control config entry - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label="Volume Control", - default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE, - required=True, - options=[ - *base_volume_options, - *(ConfigValueOption(x.name, x.id) for x in volume_controls), - ], - category="player_controls", - ), - # Mute control config entry - ConfigEntry( - key=CONF_MUTE_CONTROL, - type=ConfigEntryType.STRING, - label="Mute Control", - default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE, - required=True, - options=[ - *base_mute_options, - *[ConfigValueOption(x.name, x.id) for x in mute_controls], - ], - category="player_controls", - ), - ) + return self.mass.players.all(provider_filter=self.lookup_key) diff --git a/music_assistant/models/plugin.py b/music_assistant/models/plugin.py index a6b302d5..55f24aa8 100644 --- a/music_assistant/models/plugin.py +++ b/music_assistant/models/plugin.py @@ -8,14 +8,13 @@ from dataclasses import dataclass, field from mashumaro import field_options, pass_through from music_assistant_models.enums import StreamType from music_assistant_models.media_items.audio_format import AudioFormat # noqa: TC002 -from music_assistant_models.player import PlayerMedia, PlayerSource -from .provider import Provider +from music_assistant.models.player import PlayerMedia, PlayerSource -# ruff: noqa: ARG001, ARG002 +from .provider import Provider -@dataclass() +@dataclass class PluginSource(PlayerSource): """ Model for a PluginSource, which is a player (audio)source provided by a plugin. @@ -73,8 +72,11 @@ class PluginProvider(Provider): """ def get_source(self) -> PluginSource: - """Get (audio)source details for this plugin.""" + """ + Get (audio)source details for this plugin. + # Will only be called if ProviderFeature.AUDIO_SOURCE is declared + """ raise NotImplementedError async def get_audio_stream(self, player_id: str) -> AsyncGenerator[bytes, None]: diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index b089a09d..fb6c24b3 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, final +from music_assistant_models.errors import UnsupportedFeaturedException + from music_assistant.constants import CONF_LOG_LEVEL, MASS_LOGGER_NAME if TYPE_CHECKING: @@ -149,3 +151,14 @@ class Provider: "available": self.available, "is_streaming_provider": getattr(self, "is_streaming_provider", None), } + + def supports_feature(self, feature: ProviderFeature) -> bool: + """Return True if this provider supports the given feature.""" + return feature in self.supported_features + + def check_feature(self, feature: ProviderFeature) -> None: + """Check if this provider supports the given feature.""" + if not self.supports_feature(feature): + raise UnsupportedFeaturedException( + f"Provider {self.name} does not support feature {feature.name}" + ) diff --git a/music_assistant/providers/_template_music_provider/__init__.py b/music_assistant/providers/_demo_music_provider/__init__.py similarity index 100% rename from music_assistant/providers/_template_music_provider/__init__.py rename to music_assistant/providers/_demo_music_provider/__init__.py diff --git a/music_assistant/providers/_template_music_provider/icon.svg b/music_assistant/providers/_demo_music_provider/icon.svg similarity index 100% rename from music_assistant/providers/_template_music_provider/icon.svg rename to music_assistant/providers/_demo_music_provider/icon.svg diff --git a/music_assistant/providers/_demo_music_provider/manifest.json b/music_assistant/providers/_demo_music_provider/manifest.json new file mode 100644 index 00000000..cfaba46b --- /dev/null +++ b/music_assistant/providers/_demo_music_provider/manifest.json @@ -0,0 +1,9 @@ +{ + "type": "music", + "domain": "_demo_music_provider", + "name": "Demo Music Provider", + "description": "Test/Example Music Provider for Music Assistant.", + "codeowners": ["@yourgithubusername"], + "requirements": [], + "documentation": "https://music-assistant.io/music-providers/demo/" +} diff --git a/music_assistant/providers/_demo_player_provider/__init__.py b/music_assistant/providers/_demo_player_provider/__init__.py new file mode 100644 index 00000000..8ea2674e --- /dev/null +++ b/music_assistant/providers/_demo_player_provider/__init__.py @@ -0,0 +1,91 @@ +""" +DEMO/TEST/DUMMY/TEMPLATE Player Provider for Music Assistant. + +This is an empty player provider with a test/demo implementation. +Its meant to get started developing a new player provider for Music Assistant. + +Use it as a reference to discover what methods exists and what they should return. +Also it is good to look at existing player providers to get a better understanding, +due to the fact that providers may be flexible and support different features and/or +ways to discover players on the network. + +In general, the actual device communication should reside in a separate library. +You can then reference your library in the manifest in the requirements section, +which is a list of (versioned!) python modules (pip syntax) that should be installed +when the provider is selected by the user. + +To add a new player provider to Music Assistant, you need to create a new folder +in the providers folder with the name of your provider (e.g. 'my_player_provider'). +In that folder you should create (at least) a __init__.py file and a manifest.json file. + +Optional is an icon.svg file that will be used as the icon for the provider in the UI, +but we also support that you specify a material design icon in the manifest.json file. + +IMPORTANT NOTE: +We strongly recommend developing on either macOS or Linux and start your development +environment by running the setup.sh scripts in the scripts folder of the repository. +This will create a virtual environment and install all dependencies needed for development. + +For all development instructions, please refer to the developer documentation: +https://developers.music-assistant.io +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +from .constants import CONF_NUMBER_OF_PLAYERS +from .provider import DemoPlayerprovider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + # setup is called when the user wants to setup a new provider instance. + # you are free to do any preflight checks here and but you must return + # an instance of your provider. + return DemoPlayerprovider(mass, manifest, config) + + +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 + # Config Entries are used to configure the Player Provider if needed. + # See the models of ConfigEntry and ConfigValueType for more information what is supported. + # The ConfigEntry is a dataclass that represents a single configuration entry. + # The ConfigValueType is an Enum that represents the type of value that + # can be stored in a ConfigEntry. + # If your provider does not need any configuration, you can return an empty tuple. + return ( + # example of a ConfigEntry for the number of players to create + ConfigEntry( + key=CONF_NUMBER_OF_PLAYERS, + type=ConfigEntryType.INTEGER, + label="Number of Players", + required=True, + default_value="2", + description="Number of demo players to create.", + ), + ) diff --git a/music_assistant/providers/_demo_player_provider/constants.py b/music_assistant/providers/_demo_player_provider/constants.py new file mode 100644 index 00000000..9b67a7aa --- /dev/null +++ b/music_assistant/providers/_demo_player_provider/constants.py @@ -0,0 +1,4 @@ +"""Constants for the Demo Player Provider.""" + +CONF_NUMBER_OF_PLAYERS = "number_of_players" +CONF_PLAYER_NAME_PREFIX = "player_name_prefix" diff --git a/music_assistant/providers/_template_player_provider/icon.svg b/music_assistant/providers/_demo_player_provider/icon.svg similarity index 100% rename from music_assistant/providers/_template_player_provider/icon.svg rename to music_assistant/providers/_demo_player_provider/icon.svg diff --git a/music_assistant/providers/_demo_player_provider/manifest.json b/music_assistant/providers/_demo_player_provider/manifest.json new file mode 100644 index 00000000..d5ad14e2 --- /dev/null +++ b/music_assistant/providers/_demo_player_provider/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "player", + "domain": "_demo_player_provider", + "name": "Demo Player Provider", + "description": "Test/Example Player Provider for Music Assistant.", + "codeowners": ["@yourgithubusername"], + "requirements": [], + "documentation": "https://music-assistant.io/player-support/demo/", + "mdns_discovery": ["_demo_service_type._tcp.local."] +} diff --git a/music_assistant/providers/_demo_player_provider/player.py b/music_assistant/providers/_demo_player_provider/player.py new file mode 100644 index 00000000..d782fafd --- /dev/null +++ b/music_assistant/providers/_demo_player_provider/player.py @@ -0,0 +1,323 @@ +"""Demo Player implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.player import PlayerSource + +from music_assistant.models.player import Player, PlayerMedia + +if TYPE_CHECKING: + from .provider import DemoPlayerprovider + + +class DemoPlayer(Player): + """DemoPlayer in Music Assistant.""" + + def __init__(self, provider: DemoPlayerprovider, player_id: str) -> None: + """Initialize the Player.""" + super().__init__(provider, player_id) + # init some static variables + self._attr_name = f"Demo Player {player_id}" + self._attr_type = PlayerType.PLAYER + self._attr_supported_features = { + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PLAY_ANNOUNCEMENT, + } + self._set_attributes() + + @property + def needs_poll(self) -> bool: + """Return if the player needs to be polled for state updates.""" + # MANDATORY + # this should return True if the player needs to be polled for state updates, + # If you player does not need to be polled, you can return False. + return True + + @property + def poll_interval(self) -> int: + """Return the interval in seconds to poll the player for state updates.""" + # OPTIONAL + # used in conjunction with the needs_poll property. + # this should return the interval in seconds to poll the player for state updates. + return 5 if self.playback_state == PlaybackState.PLAYING else 30 + + @property + def source_list(self) -> list[PlayerSource]: + """Return list of available (native) sources for this player.""" + # OPTIONAL - required only if you specified PlayerFeature.SELECT_SOURCE + # this is an optional property that you can implement if your + # player supports (external) source control (aux, HDMI, etc.). + # If your player does not support sources, you can leave this out completely. + return [ + PlayerSource( + id="line_in", + name="Line-In", + passive=False, + can_play_pause=False, + can_next_previous=False, + can_seek=False, + ), + PlayerSource( + id="spotify_connect", + name="Spotify", + # by specifying passive=True, we indicate that this source + # is not actively selectable by the user from the UI. + passive=True, + can_play_pause=True, + can_next_previous=True, + can_seek=True, + ), + ] + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + # OPTIONAL + # this method is optional and should be implemented if you need player specific + # configuration entries. If you do not need player specific configuration entries, + # you can leave this method out completely to accept the default implementation. + # Please note that you need to call the super() method to get the default entries. + default_entries = await super().get_config_entries() + return [ + *default_entries, + # example of a player specific config entry + # you can also override a default entry by specifying the same key + # as a default entry, but with a different type or default value. + ConfigEntry( + key="demo_player_setting", + type=ConfigEntryType.STRING, + label="Demo Player Setting", + required=False, + default_value="default_value", + description="This is a demo player setting.", + ), + ] + + async def power(self, powered: bool) -> None: + """Handle POWER command on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.POWER + # this method should send a power on/off command to the given player. + logger = self.provider.logger.getChild(self.player_id) + if powered: + # In this demo implementation we just set the power state to ON + # and optimistically update the state. + # In a real implementation you would read the actual value from the player + # either from a callback or by polling the player. + logger.info("Received POWER ON command on player %s", self.display_name) + self._attr_powered = True + else: + # In this demo implementation we just set the power state to OFF + # and optimistically update the state. + # In a real implementation you would read the actual value from the player + # either from a callback or by polling the player. + logger.info("Received POWER OFF command on player %s", self.display_name) + self._attr_powered = False + # update the player state in the player manager + self.update_state() + + async def volume_set(self, volume_level: int) -> None: + """Handle VOLUME_SET command on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.VOLUME_SET + # this method should send a volume set command to the given player. + + # In this demo implementation we just set the volume level + # and optimistically update the state. + # In a real implementation you would send a command to the actual player and + # get the actual value from the player either from a callback or by polling the player. + logger = self.provider.logger.getChild(self.player_id) + logger.info( + "Received VOLUME_SET command on player %s with level %s", + self.display_name, + volume_level, + ) + self._attr_volume_level = volume_level # volume level is between 0 and 100 + # update the player state in the player manager + self.update_state() + + async def volume_mute(self, muted: bool) -> None: + """Handle VOLUME MUTE command on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.VOLUME_MUTE + # this method should send a volume mute command to the given player. + logger = self.provider.logger.getChild(self.player_id) + logger.info( + "Received VOLUME_MUTE command on player %s with muted %s", self.display_name, muted + ) + self._attr_volume_muted = muted + self.update_state() + + async def play(self) -> None: + """Play command.""" + # MANDATORY + # this method is mandatory and should be implemented. + # this method should send a play/resume command to the given player. + # normally this is the point where you would resume playback + # on your actual player device. + + # In this demo implementation we just set the playback state to PLAYING + # and optimistically set the playback state to PLAYING. + # In a real implementation you actually send a command to the player + # wait for the player to report a new state before updating the playback state. + logger = self.provider.logger.getChild(self.player_id) + logger.info("Received PLAY command on player %s", self.display_name) + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def stop(self) -> None: + """Stop command.""" + # MANDATORY + # this method is mandatory and should be implemented. + # this method should send a stop command to the given player. + # normally this is the point where you would stop playback + # on your actual player device. + + # In this demo implementation we just set the playback state to IDLE + # and optimistically set the playback state to IDLE. + # In a real implementation you actually send a command to the player + # wait for the player to report a new state before updating the playback state. + logger = self.provider.logger.getChild(self.player_id) + logger.info("Received STOP command on player %s", self.display_name) + self._attr_playback_state = PlaybackState.IDLE + self.update_state() + + async def pause(self) -> None: + """Pause command.""" + # OPTIONAL - required only if you specified PlayerFeature.PAUSE + # this method should send a pause command to the given player. + + # In this demo implementation we just set the playback state to PAUSED + # and optimistically set the playback state to PAUSED. + # In a real implementation you actually send a command to the player + # wait for the player to report a new state before updating the playback state. + logger = self.provider.logger.getChild(self.player_id) + logger.info("Received PAUSE command on player %s", self.display_name) + self._attr_playback_state = PlaybackState.PAUSED + self.update_state() + + async def next_track(self) -> None: + """Next command.""" + # OPTIONAL - required only if you specified PlayerFeature.NEXT_PREVIOUS + # this method should send a next track command to the given player. + # Note that this is only needed/used if the player is playing a 3rd party + # stream (e.g. Spotify, YouTube, etc.) and the player supports skipping to the next track. + # When the player is playing MA content, this is already handled in the Queue controller. + + async def previous_track(self) -> None: + """Previous command.""" + # OPTIONAL - required only if you specified PlayerFeature.NEXT_PREVIOUS + # this method should send a previous track command to the given player. + # Note that this is only needed/used if the player is playing a 3rd party + # stream (e.g. Spotify, YouTube, etc.) and the player supports skipping to the next track. + # When the player is playing MA content, this is already handled in the Queue controller. + + async def seek(self, position: int) -> None: + """SEEK command on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.SEEK + # this method should send a seek command to the given player. + # the position is the position in seconds to seek to in the current playing item. + + async def play_media(self, media: PlayerMedia) -> None: + """Play media command.""" + # MANDATORY + # This method is mandatory and should be implemented. + # This method should handle the play_media command for the given player. + # It will be called when media needs to be played on the player. + # The media object contains all the details needed to play the item. + + # In 99% of the cases this will be called by the Queue controller to play + # a single item from the queue on the player and the uri within the media + # object will then contain the URL to play that single queue item. + + # If your player provider does not support enqueuing of items, + # the queue controller will simply call this play_media method for + # each item in the queue to play them one by one. + + # In order to support true gapless and/or enqueuing, we offer the option of + # 'flow_mode' playback. In that case the queue controller will stitch together + # all songs in the playbook queue into a single stream and send that to the player. + # In that case the URI (and metadata) received here is that of the 'flow mode' stream. + + # Examples of player providers that use flow mode for playback by default are AirPlay, + # SnapCast and Fully Kiosk. + + # Examples of player providers that optionally use 'flow mode' are Google Cast and + # Home Assistant. They provide a config entry to enable flow mode playback. + + # Examples of player providers that natively support enqueuing of items are Sonos, + # Slimproto and Google Cast. + + # In this demo implementation we just optimistically set the state. + # In a real implementation you actually send a command to the player + # wait for the player to report a new state before updating the playback state. + logger = self.provider.logger.getChild(self.player_id) + logger.info( + "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri + ) + self._attr_current_media = media + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing of the next (queue) item on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.ENQUEUE + # This method is optional and should be implemented if you want to support + # enqueuing of the next item on the player. + # This will be called when the player reports it started buffering a queue item + # and when the queue items updated. + # A PlayerProvider implementation is in itself responsible for handling this + # so that the queue items keep playing until its empty or the player stopped. + + async def play_announcement( + self, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """Handle (native) playback of an announcement on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.PLAY_ANNOUNCEMENT + # This method is optional and should be implemented if the player supports + # NATIVE playback of announcements (with ducking etc.). + # The announcement object contains all the details needed to play the announcement. + # The volume_level is optional and can be used to set the volume level for the announcement. + # If you do not use the announcement playerfeature, the default behavior is to play the + # announcement as a regular media item using the play_media method and the MA player manager + # will take care of setting the volume level for the announcement and resuming etc. + + async def select_source(self, source: str) -> None: + """Handle SELECT SOURCE command on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.SELECT_SOURCE + # This method is optional and should be implemented if the player supports + # selecting a source (e.g. HDMI, AUX, etc.) on the player. + # The source is the source ID to select on the player. + # available sources are specified in the Player.source_list property + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + # OPTIONAL - required only if you specified PlayerFeature.SET_MEMBERS + # This method is optional and should be implemented if the player supports + # syncing/grouping with other players. + + async def poll(self) -> None: + """Poll player for state updates.""" + # OPTIONAL - This is called by the Player Manager if the 'needs_poll' property is True. + self._set_attributes() + self.update_state() + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + # OPTIONAL + # this method is optional and should be implemented if you need to handle + # any logic when the player is unloaded from the Player controller. + # This is called when the player is removed from the Player controller. + self.logger.info("Player %s unloaded", self.name) + + def _set_attributes(self) -> None: + """Update/set (dynamic) properties.""" + self._attr_powered = True + self._attr_volume_muted = False + self._attr_volume_level = 50 diff --git a/music_assistant/providers/_demo_player_provider/provider.py b/music_assistant/providers/_demo_player_provider/provider.py new file mode 100644 index 00000000..b699ad8d --- /dev/null +++ b/music_assistant/providers/_demo_player_provider/provider.py @@ -0,0 +1,183 @@ +"""Demo Player Provider implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from music_assistant_models.enums import ProviderFeature +from zeroconf import ServiceStateChange + +from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf +from music_assistant.models.player_provider import PlayerProvider + +from .constants import CONF_NUMBER_OF_PLAYERS +from .player import DemoPlayer + +if TYPE_CHECKING: + from zeroconf.asyncio import AsyncServiceInfo + + +class DemoPlayerprovider(PlayerProvider): + """ + Example/demo Player provider. + + Note that this is always subclassed from PlayerProvider, + which in turn is a subclass of the generic Provider model. + + The base implementation already takes care of some convenience methods, + such as the mass object and the logger. Take a look at the base class + for more information on what is available. + + Just like with any other subclass, make sure that if you override + any of the default methods (such as __init__), you call the super() method. + In most cases its not needed to override any of the builtin methods and you only + implement the abc methods with your actual implementation. + """ + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + # MANDATORY + # you should return a set of provider-level (optional) features + # here that your player provider supports or an empty set if none. + # for example 'ProviderFeature.SYNC_PLAYERS' if you can sync players. + return {ProviderFeature.SYNC_PLAYERS} + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + # OPTIONAL + # this is an optional method that you can implement if + # relevant or leave out completely if not needed. + # it will be called when the provider is initialized in Music Assistant. + # you can use this to do any async initialization of the provider, + # such as loading configuration, setting up connections, etc. + self.logger.info("Initializing DemoPlayerProvider with config: %s", self.config) + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + # OPTIONAL + # this is an optional method that you can implement if + # relevant or leave out completely if not needed. + # it will be called after the provider has been fully loaded into Music Assistant. + # you can use this for instance to trigger custom (non-mdns) discovery of players + # or any other logic that needs to run after the provider is fully loaded. + self.logger.info("DemoPlayerProvider loaded") + await self.discover_players() + + async def unload(self, is_removed: bool = False) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + is_removed will be set to True when the provider is removed from the configuration. + """ + # OPTIONAL + # this is an optional method that you can implement if + # relevant or leave out completely if not needed. + # it will be called when the provider is unloaded from Music Assistant. + # this means also when the provider is getting reloaded + for player in self.players: + # if you have any cleanup logic for the players, you can do that here. + # e.g. disconnecting from the player, closing connections, etc. + self.logger.debug("Unloading player %s", player.name) + await self.mass.players.unregister(player.player_id) + + def on_player_enabled(self, player_id: str) -> None: + """Call (by config manager) when a player gets enabled.""" + # OPTIONAL + # this is an optional method that you can implement if + # you want to do something special when a player is enabled. + + def on_player_disabled(self, player_id: str) -> None: + """Call (by config manager) when a player gets disabled.""" + # OPTIONAL + # this is an optional method that you can implement if + # you want to do something special when a player is disabled. + # e.g. you can stop polling the player or disconnect from it. + + async def remove_player(self, player_id: str) -> None: + """Remove a player from this provider.""" + # OPTIONAL - required only if you specified ProviderFeature.REMOVE_PLAYER + # this is used to actually remove a player. + + async def on_mdns_service_state_change( + self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None + ) -> None: + """Handle MDNS service state callback.""" + # MANDATORY IF YOU WANT TO USE MDNS DISCOVERY + # OPTIONAL if you dont use mdns for discovery of players + # If you specify a mdns service type in the manifest.json, this method will be called + # automatically on mdns changes for the specified service type. + + # If no mdns service type is specified, this method is omitted and you + # can completely remove it from your provider implementation. + + if not info: + return # guard + + # NOTE: If you do not use mdns for discovery of players on the network, + # you must implement your own discovery mechanism and logic to add new players + # and update them on state changes when needed. + # Below is a bit of example implementation but we advise to look at existing + # player providers for more inspiration. + name = name.split("@", 1)[1] if "@" in name else name + player_id = info.decoded_properties["uuid"] # this is just an example! + if not player_id: + return # guard, we need a player_id to work with + + # handle removed player + if state_change == ServiceStateChange.Removed: + # check if the player manager has an existing entry for this player + if mass_player := self.mass.players.get(player_id): + # the player has become unavailable + self.logger.debug("Player offline: %s", mass_player.display_name) + await self.mass.players.unregister(player_id) + return + # handle update for existing device + # (state change is either updated or added) + # check if we have an existing player in the player manager + # note that you can use this point to update the player connection info + # if that changed (e.g. ip address) + if mass_player := self.mass.players.get(player_id): + # existing player found in the player manager, + # this is an existing player that has been updated/reconnected + # or simply a re-announcement on mdns. + cur_address = get_primary_ip_address_from_zeroconf(info) + if cur_address and cur_address != mass_player.device_info.ip_address: + self.logger.debug( + "Address updated to %s for player %s", cur_address, mass_player.display_name + ) + # inform the player manager of any changes to the player object + # note that you would normally call this from some other callback from + # the player's native api/library which informs you of changes in the player state. + # as a last resort you can also choose to let the player manager + # poll the player for state changes + mass_player.update_state() + return + # handle new player + self.logger.debug("Discovered device %s on %s", name, cur_address) + # your own connection logic will probably be implemented here where + # you connect to the player etc. using your device/provider specific library. + + async def discover_players(self) -> None: + """Discover players for this provider.""" + # This is an optional method that you can implement if + # you want to (manually) discover players on the + # network and you do not use mdns discovery. + number_of_players = cast("int", self.config.get_value(CONF_NUMBER_OF_PLAYERS, 0)) + self.logger.info( + "Discovering %s demo players", + number_of_players, + ) + for i in range(number_of_players): + player = DemoPlayer( + provider=self, + player_id=f"demo_{i}", + ) + # register the player with the player manager + await self.mass.players.register(player) + # once the player is registered, you can either instruct the player manager to + # poll the player for state changes or you can implement your own logic to + # listen for state changes from the player and update the player object accordingly. + # if the player state needs to be updated, you can call the update method on the player: + # player.update_state() diff --git a/music_assistant/providers/_template_plugin_provider/__init__.py b/music_assistant/providers/_demo_plugin_provider/__init__.py similarity index 100% rename from music_assistant/providers/_template_plugin_provider/__init__.py rename to music_assistant/providers/_demo_plugin_provider/__init__.py diff --git a/music_assistant/providers/_template_plugin_provider/icon.svg b/music_assistant/providers/_demo_plugin_provider/icon.svg similarity index 100% rename from music_assistant/providers/_template_plugin_provider/icon.svg rename to music_assistant/providers/_demo_plugin_provider/icon.svg diff --git a/music_assistant/providers/_demo_plugin_provider/manifest.json b/music_assistant/providers/_demo_plugin_provider/manifest.json new file mode 100644 index 00000000..b82f0f37 --- /dev/null +++ b/music_assistant/providers/_demo_plugin_provider/manifest.json @@ -0,0 +1,9 @@ +{ + "type": "plugin", + "domain": "_demo_plugin_provider", + "name": "Demo Plugin Provider", + "description": "Test/Example Plugin Provider for Music Assistant.", + "codeowners": ["@yourgithubusername"], + "requirements": [], + "documentation": "https://music-assistant.io/plugin-support/demo/" +} diff --git a/music_assistant/providers/_template_music_provider/manifest.json b/music_assistant/providers/_template_music_provider/manifest.json deleted file mode 100644 index 15d6b83a..00000000 --- a/music_assistant/providers/_template_music_provider/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "music", - "domain": "template_player_provider", - "name": "Name of the Player provider goes here", - "description": "Short description of the player provider goes here", - "codeowners": ["@yourgithubusername"], - "requirements": [], - "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).", - "mdns_discovery": ["_optional_mdns_service_type._tcp.local."] -} diff --git a/music_assistant/providers/_template_player_provider/__init__.py b/music_assistant/providers/_template_player_provider/__init__.py deleted file mode 100644 index a2c37b05..00000000 --- a/music_assistant/providers/_template_player_provider/__init__.py +++ /dev/null @@ -1,391 +0,0 @@ -""" -DEMO/TEMPLATE Player Provider for Music Assistant. - -This is an empty player provider with no actual implementation. -Its meant to get started developing a new player provider for Music Assistant. - -Use it as a reference to discover what methods exists and what they should return. -Also it is good to look at existing player providers to get a better understanding, -due to the fact that providers may be flexible and support different features and/or -ways to discover players on the network. - -In general, the actual device communication should reside in a separate library. -You can then reference your library in the manifest in the requirements section, -which is a list of (versioned!) python modules (pip syntax) that should be installed -when the provider is selected by the user. - -To add a new player provider to Music Assistant, you need to create a new folder -in the providers folder with the name of your provider (e.g. 'my_player_provider'). -In that folder you should create (at least) a __init__.py file and a manifest.json file. - -Optional is an icon.svg file that will be used as the icon for the provider in the UI, -but we also support that you specify a material design icon in the manifest.json file. - -IMPORTANT NOTE: -We strongly recommend developing on either macOS or Linux and start your development -environment by running the setup.sh scripts in the scripts folder of the repository. -This will create a virtual environment and install all dependencies needed for development. -See also our general DEVELOPMENT.md guide in the repository for more information. - -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant_models.enums import PlayerFeature, PlayerType, ProviderFeature -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia -from zeroconf import ServiceStateChange - -from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf -from music_assistant.models.player_provider import PlayerProvider - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ( - ConfigEntry, - ConfigValueType, - PlayerConfig, - ProviderConfig, - ) - from music_assistant_models.provider import ProviderManifest - from zeroconf.asyncio import AsyncServiceInfo - - from music_assistant.mass import MusicAssistant - from music_assistant.models import ProviderInstanceType - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - # setup is called when the user wants to setup a new provider instance. - # you are free to do any preflight checks here and but you must return - # an instance of the provider. - return MyDemoPlayerprovider(mass, manifest, config) - - -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 - # Config Entries are used to configure the Player Provider if needed. - # See the models of ConfigEntry and ConfigValueType for more information what is supported. - # The ConfigEntry is a dataclass that represents a single configuration entry. - # The ConfigValueType is an Enum that represents the type of value that - # can be stored in a ConfigEntry. - # If your provider does not need any configuration, you can return an empty tuple. - return () - - -class MyDemoPlayerprovider(PlayerProvider): - """ - Example/demo Player provider. - - Note that this is always subclassed from PlayerProvider, - which in turn is a subclass of the generic Provider model. - - The base implementation already takes care of some convenience methods, - such as the mass object and the logger. Take a look at the base class - for more information on what is available. - - Just like with any other subclass, make sure that if you override - any of the default methods (such as __init__), you call the super() method. - In most cases its not needed to override any of the builtin methods and you only - implement the abc methods with your actual implementation. - """ - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - # MANDATORY - # you should return a set of provider-level features - # here that your player provider supports or an empty set if none. - # for example 'ProviderFeature.SYNC_PLAYERS' if you can sync players. - return {ProviderFeature.SYNC_PLAYERS} - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - # OPTIONAL - # this is an optional method that you can implement if - # relevant or leave out completely if not needed. - # it will be called after the provider has been fully loaded into Music Assistant. - # you can use this for instance to trigger custom (non-mdns) discovery of players - # or any other logic that needs to run after the provider is fully loaded. - - async def unload(self, is_removed: bool = False) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - is_removed will be set to True when the provider is removed from the configuration. - """ - # OPTIONAL - # this is an optional method that you can implement if - # relevant or leave out completely if not needed. - # it will be called when the provider is unloaded from Music Assistant. - # this means also when the provider is getting reloaded - - async def on_mdns_service_state_change( - self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None - ) -> None: - """Handle MDNS service state callback.""" - # MANDATORY IF YOU WANT TO USE MDNS DISCOVERY - # OPTIONAL if you dont use mdns for discovery of players - # If you specify a mdns service type in the manifest.json, this method will be called - # automatically on mdns changes for the specified service type. - - # If no mdns service type is specified, this method is omitted and you - # can completely remove it from your provider implementation. - - if not info: - return - - # NOTE: If you do not use mdns for discovery of players on the network, - # you must implement your own discovery mechanism and logic to add new players - # and update them on state changes when needed. - # Below is a bit of example implementation but we advise to look at existing - # player providers for more inspiration. - name = name.split("@", 1)[1] if "@" in name else name - player_id = info.decoded_properties["uuid"] # this is just an example! - - if not player_id: - return - - # handle removed player - if state_change == ServiceStateChange.Removed: - # check if the player manager has an existing entry for this player - if mass_player := self.mass.players.get(player_id): - # the player has become unavailable - self.logger.debug("Player offline: %s", mass_player.display_name) - mass_player.available = False - self.mass.players.update(player_id) - return - # handle update for existing device - # (state change is either updated or added) - # check if we have an existing player in the player manager - # note that you can use this point to update the player connection info - # if that changed (e.g. ip address) - if mass_player := self.mass.players.get(player_id): - # existing player found in the player manager, - # this is an existing player that has been updated/reconnected - # or simply a re-announcement on mdns. - cur_address = get_primary_ip_address_from_zeroconf(info) - if cur_address and cur_address != mass_player.device_info.ip_address: - self.logger.debug( - "Address updated to %s for player %s", cur_address, mass_player.display_name - ) - mass_player.device_info = DeviceInfo( - model=mass_player.device_info.model, - manufacturer=mass_player.device_info.manufacturer, - ip_address=str(cur_address), - ) - if not mass_player.available: - # if the player was marked offline and you now receive an mdns update - # it means the player is back online and we should try to connect to it - self.logger.debug("Player back online: %s", mass_player.display_name) - # you can try to connect to the player here if needed - mass_player.available = True - # inform the player manager of any changes to the player object - # note that you would normally call this from some other callback from - # the player's native api/library which informs you of changes in the player state. - # as a last resort you can also choose to let the player manager - # poll the player for state changes - self.mass.players.update(player_id) - return - # handle new player - self.logger.debug("Discovered device %s on %s", name, cur_address) - # your own connection logic will probably be implemented here where - # you connect to the player etc. using your device/provider specific library. - - # Instantiate the MA Player object and register it with the player manager - mass_player = Player( - player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=name, - available=True, - powered=False, - device_info=DeviceInfo( - model="Model XYX", - manufacturer="Super Brand", - ip_address=cur_address, - ), - # set the supported features for this player only with - # the ones the player actually supports - supported_features={ - PlayerFeature.POWER, # if the player can be turned on/off - PlayerFeature.VOLUME_SET, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PLAY_ANNOUNCEMENT, # see play_announcement method - }, - ) - # register the player with the player manager - await self.mass.players.register(mass_player) - - # once the player is registered, you can either instruct the player manager to - # poll the player for state changes or you can implement your own logic to - # listen for state changes from the player and update the player object accordingly. - # in any case, you need to call the update method on the player manager: - self.mass.players.update(player_id) - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - # OPTIONAL - # this method is optional and should be implemented if you need player specific - # configuration entries. If you do not need player specific configuration entries, - # you can leave this method out completely to accept the default implementation. - # Please note that you need to call the super() method to get the default entries. - return await super().get_player_config_entries(player_id) - - async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: - """Call (by config manager) when the configuration of a player changes.""" - # OPTIONAL - # this will be called whenever a player config changes - # you can use this to react to changes in player configuration - # but this is completely optional and you can leave it out if not needed. - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - # MANDATORY - # this method is mandatory and should be implemented. - # this method should send a stop command to the given player. - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - # MANDATORY - # this method is mandatory and should be implemented. - # this method should send a play command to the given player. - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - # OPTIONAL - required only if you specified PlayerFeature.PAUSE - # this method should send a pause command to the given player. - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - # OPTIONAL - required only if you specified PlayerFeature.VOLUME_SET - # this method should send a volume set command to the given player. - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - # OPTIONAL - required only if you specified PlayerFeature.VOLUME_MUTE - # this method should send a volume mute command to the given player. - - async def cmd_seek(self, player_id: str, position: int) -> None: - """Handle SEEK command for given queue. - - - player_id: player_id of the player to handle the command. - - position: position in seconds to seek to in the current playing item. - """ - # OPTIONAL - required only if you specified PlayerFeature.SEEK - # this method should handle the seek command for the given player. - # the position is the position in seconds to seek to in the current playing item. - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player. - - This is called by the Players controller to start playing a mediaitem on the given player. - The provider's own implementation should work out how to handle this request. - - - player_id: player_id of the player to handle the command. - - media: Details of the item that needs to be played on the player. - """ - # MANDATORY - # this method is mandatory and should be implemented. - # this method should handle the play_media command for the given player. - # It will be called when media needs to be played on the player. - # The media object contains all the details needed to play the item. - - # In 99% of the cases this will be called by the Queue controller to play - # a single item from the queue on the player and the uri within the media - # object will then contain the URL to play that single queue item. - - # If your player provider does not support enqueuing of items, - # the queue controller will simply call this play_media method for - # each item in the queue to play them one by one. - - # In order to support true gapless and/or enqueuing, we offer the option of - # 'flow_mode' playback. In that case the queue controller will stitch together - # all songs in the playback queue into a single stream and send that to the player. - # In that case the URI (and metadata) received here is that of the 'flow mode' stream. - - # Examples of player providers that use flow mode for playback by default are AirPlay, - # SnapCast and Fully Kiosk. - - # Examples of player providers that optionally use 'flow mode' are Google Cast and - # Home Assistant. They provide a config entry to enable flow mode playback. - - # Examples of player providers that natively support enqueuing of items are Sonos, - # Slimproto and Google Cast. - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """ - Handle enqueuing of the next (queue) item on the player. - - Called when player reports it started buffering a queue item - and when the queue items updated. - - A PlayerProvider implementation is in itself responsible for handling this - so that the queue items keep playing until its empty or the player stopped. - - This will NOT be called if the end of the queue is reached (and repeat disabled). - This will NOT be called if the player is using flow mode to playback the queue. - """ - # this method should handle the enqueuing of the next queue item on the player. - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the syncgroup master or group player. - """ - # OPTIONAL - required only if you specified ProviderFeature.SYNC_PLAYERS - # this method should handle the sync command for the given player. - # you should join the given player to the target_player/syncgroup. - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - # OPTIONAL - required only if you specified ProviderFeature.SYNC_PLAYERS - # this method should handle the ungroup command for the given player. - # you should unjoin the given player from the target_player/syncgroup. - - async def play_announcement( - self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None - ) -> None: - """Handle (provider native) playback of an announcement on given player.""" - # OPTIONAL - required only if you specified PlayerFeature.PLAY_ANNOUNCEMENT - # This method should handle the playback of an announcement on the given player. - # The announcement object contains all the details needed to play the announcement. - # The volume_level is optional and can be used to set the volume level for the announcement. - # If you do not use the announcement playerfeature, the default behavior is to play the - # announcement as a regular media item using the play_media method and the MA player manager - # will take care of setting the volume level for the announcement and resuming etc. - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - # OPTIONAL - # This method is optional and should be implemented if you specified 'needs_poll' - # on the Player object. This method should poll the player for state changes - # and update the player object in the player manager if needed. - # This method will be called at the interval specified in the poll_interval attribute. diff --git a/music_assistant/providers/_template_player_provider/manifest.json b/music_assistant/providers/_template_player_provider/manifest.json deleted file mode 100644 index db8b9341..00000000 --- a/music_assistant/providers/_template_player_provider/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "player", - "domain": "template_player_provider", - "name": "Name of the Player provider goes here", - "description": "Short description of the player provider goes here", - "codeowners": ["@yourgithubusername"], - "requirements": [], - "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).", - "mdns_discovery": ["_optional_mdns_service_type._tcp.local."] -} diff --git a/music_assistant/providers/_template_plugin_provider/manifest.json b/music_assistant/providers/_template_plugin_provider/manifest.json deleted file mode 100644 index db8b9341..00000000 --- a/music_assistant/providers/_template_plugin_provider/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "player", - "domain": "template_player_provider", - "name": "Name of the Player provider goes here", - "description": "Short description of the player provider goes here", - "codeowners": ["@yourgithubusername"], - "requirements": [], - "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).", - "mdns_discovery": ["_optional_mdns_service_type._tcp.local."] -} diff --git a/music_assistant/providers/airplay/__init__.py b/music_assistant/providers/airplay/__init__.py index ad7dfce1..9f4d8bea 100644 --- a/music_assistant/providers/airplay/__init__.py +++ b/music_assistant/providers/airplay/__init__.py @@ -2,19 +2,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING -from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig -from music_assistant_models.enums import ConfigEntryType +from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest from music_assistant.mass import MusicAssistant -from .const import CONF_BIND_INTERFACE from .provider import AirPlayProvider if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig from music_assistant_models.provider import ProviderManifest from music_assistant.models import ProviderInstanceType @@ -34,16 +32,7 @@ async def get_config_entries( values: the (intermediate) raw values for config entries sent with the action. """ # ruff: noqa: ARG001 - return ( - ConfigEntry( - key=CONF_BIND_INTERFACE, - type=ConfigEntryType.STRING, - default_value=cast("str", mass.streams.publish_ip), - label="Bind interface", - description="Interface to bind to for AirPlay streaming.", - category="advanced", - ), - ) + return () async def setup( diff --git a/music_assistant/providers/airplay/const.py b/music_assistant/providers/airplay/constants.py similarity index 97% rename from music_assistant/providers/airplay/const.py rename to music_assistant/providers/airplay/constants.py index 526ff5e5..db4dc41c 100644 --- a/music_assistant/providers/airplay/const.py +++ b/music_assistant/providers/airplay/constants.py @@ -13,8 +13,8 @@ CONF_ENCRYPTION = "encryption" CONF_ALAC_ENCODE = "alac_encode" CONF_VOLUME_START = "volume_start" CONF_PASSWORD = "password" -CONF_BIND_INTERFACE = "bind_interface" CONF_READ_AHEAD_BUFFER = "read_ahead_buffer" +CONF_IGNORE_VOLUME = "ignore_volume" BACKOFF_TIME_LOWER_LIMIT = 15 # seconds BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes diff --git a/music_assistant/providers/airplay/helpers.py b/music_assistant/providers/airplay/helpers.py index f7f47f9c..3089990a 100644 --- a/music_assistant/providers/airplay/helpers.py +++ b/music_assistant/providers/airplay/helpers.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from zeroconf import IPVersion from music_assistant.helpers.process import check_output -from music_assistant.providers.airplay.const import BROKEN_RAOP_MODELS +from music_assistant.providers.airplay.constants import BROKEN_RAOP_MODELS if TYPE_CHECKING: from zeroconf.asyncio import AsyncServiceInfo @@ -68,7 +68,7 @@ def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]: return (manufacturer or "AirPlay", model) -def get_primary_ip_address(discovery_info: AsyncServiceInfo) -> str | None: +def get_primary_ip_address_from_zeroconf(discovery_info: AsyncServiceInfo) -> str | None: """Get primary IP address from zeroconf discovery info.""" for address in discovery_info.parsed_addresses(IPVersion.V4Only): if address.startswith("127"): diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 110532e3..868a6de0 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -1,49 +1,415 @@ -"""AirPlay Player definition.""" +"""AirPlay Player implementation.""" from __future__ import annotations import asyncio -from typing import TYPE_CHECKING +import time +from typing import TYPE_CHECKING, cast + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ( + ConfigEntryType, + ContentType, + MediaType, + PlaybackState, + PlayerFeature, + PlayerType, +) +from music_assistant_models.media_items import AudioFormat + +from music_assistant.constants import ( + CONF_ENTRY_DEPRECATED_EQ_BASS, + CONF_ENTRY_DEPRECATED_EQ_MID, + CONF_ENTRY_DEPRECATED_EQ_TREBLE, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_OUTPUT_CODEC_HIDDEN, + CONF_ENTRY_SYNC_ADJUST, + create_sample_rates_config_entry, +) +from music_assistant.helpers.ffmpeg import get_ffmpeg_stream +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.providers.universal_group.constants import UGP_PREFIX + +from .constants import ( + AIRPLAY_FLOW_PCM_FORMAT, + AIRPLAY_PCM_FORMAT, + CACHE_KEY_PREV_VOLUME, + CONF_ALAC_ENCODE, + CONF_ENCRYPTION, + CONF_IGNORE_VOLUME, + CONF_PASSWORD, + CONF_READ_AHEAD_BUFFER, + FALLBACK_VOLUME, +) +from .helpers import get_primary_ip_address_from_zeroconf, is_broken_raop_model +from .raop import RaopStreamSession if TYPE_CHECKING: from zeroconf.asyncio import AsyncServiceInfo + from music_assistant.providers.universal_group import UniversalGroupPlayer + from .provider import AirPlayProvider from .raop import RaopStream -class AirPlayPlayer: - """Holds the details of the (discovered) AirPlay (RAOP) player.""" +BROKEN_RAOP_WARN = ConfigEntry( + key="broken_raop", + type=ConfigEntryType.ALERT, + default_value=None, + required=False, + label="This player is known to have broken AirPlay 1 (RAOP) support. " + "Playback may fail or simply be silent. There is no workaround for this issue at the moment.", +) + + +class AirPlayPlayer(Player): + """AirPlay Player implementation.""" def __init__( - self, prov: AirPlayProvider, player_id: str, discovery_info: AsyncServiceInfo, address: str + self, + provider: AirPlayProvider, + player_id: str, + discovery_info: AsyncServiceInfo, + address: str, + display_name: str, + manufacturer: str, + model: str, + initial_volume: int = FALLBACK_VOLUME, ) -> None: """Initialize AirPlayPlayer.""" - self.prov = prov - self.mass = prov.mass - self.player_id = player_id + super().__init__(provider, player_id) self.discovery_info = discovery_info self.address = address - self.logger = prov.logger.getChild(player_id) self.raop_stream: RaopStream | None = None self.last_command_sent = 0.0 self._lock = asyncio.Lock() - async def cmd_stop(self) -> None: + # Set (static) player attributes + self._attr_type = PlayerType.PLAYER + self._attr_name = display_name + self._attr_available = True + self._attr_device_info = DeviceInfo( + model=model, + manufacturer=manufacturer, + ip_address=address, + ) + self._attr_supported_features = { + PlayerFeature.PAUSE, + PlayerFeature.SET_MEMBERS, + PlayerFeature.MULTI_DEVICE_DSP, + PlayerFeature.VOLUME_SET, + } + self._attr_volume_level = initial_volume + self._attr_can_group_with = {provider.instance_id} + self._attr_enabled_by_default = not is_broken_raop_model(manufacturer, model) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + base_entries = [ + *await super().get_config_entries(), + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_DEPRECATED_EQ_BASS, + CONF_ENTRY_DEPRECATED_EQ_MID, + CONF_ENTRY_DEPRECATED_EQ_TREBLE, + CONF_ENTRY_OUTPUT_CODEC_HIDDEN, + ConfigEntry( + key=CONF_ENCRYPTION, + type=ConfigEntryType.BOOLEAN, + default_value=False, + label="Enable encryption", + description="Enable encrypted communication with the player, " + "some (3rd party) players require this.", + category="airplay", + ), + ConfigEntry( + key=CONF_ALAC_ENCODE, + type=ConfigEntryType.BOOLEAN, + default_value=True, + label="Enable compression", + description="Save some network bandwidth by sending the audio as " + "(lossless) ALAC at the cost of a bit CPU.", + category="airplay", + ), + CONF_ENTRY_SYNC_ADJUST, + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + default_value=None, + required=False, + label="Device password", + description="Some devices require a password to connect/play.", + category="airplay", + ), + ConfigEntry( + key=CONF_READ_AHEAD_BUFFER, + type=ConfigEntryType.INTEGER, + default_value=1000, + required=False, + label="Audio buffer (ms)", + description="Amount of buffer (in milliseconds), " + "the player should keep to absorb network throughput jitter. " + "If you experience audio dropouts, try increasing this value.", + category="airplay", + range=(500, 3000), + ), + # airplay has fixed sample rate/bit depth so make this config entry static and hidden + create_sample_rates_config_entry( + supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True + ), + ConfigEntry( + key=CONF_IGNORE_VOLUME, + type=ConfigEntryType.BOOLEAN, + default_value=False, + label="Ignore volume reports sent by the device itself", + description=( + "The AirPlay protocol allows devices to report their own volume " + "level. \n" + "For some devices this is not reliable and can cause unexpected " + "volume changes. \n" + "Enable this option to ignore these reports." + ), + category="airplay", + ), + ] + + if is_broken_raop_model(self.device_info.manufacturer, self.device_info.model): + base_entries.insert(-1, BROKEN_RAOP_WARN) + + return base_entries + + async def stop(self) -> None: """Send STOP command to player.""" if self.raop_stream and self.raop_stream.session: # forward stop to the entire stream session await self.raop_stream.session.stop() - async def cmd_play(self) -> None: + async def play(self) -> None: """Send PLAY (unpause) command to player.""" async with self._lock: if self.raop_stream and self.raop_stream.running: await self.raop_stream.send_cli_command("ACTION=PLAY") - async def cmd_pause(self) -> None: + async def pause(self) -> None: """Send PAUSE command to player.""" + if self.group_members: + # pause is not supported while synced, use stop instead + self.logger.debug("Player is synced, using STOP instead of PAUSE") + await self.stop() + return + async with self._lock: if not self.raop_stream or not self.raop_stream.running: return await self.raop_stream.send_cli_command("ACTION=PAUSE") + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + if self.synced_to: + # this should not happen, but guard anyways + raise RuntimeError("Player is synced") + + # set the active source for the player to the media queue + # this accounts for syncgroups and linked players (e.g. sonos) + self._attr_active_source = media.queue_id + self._attr_current_media = media + + # select audio source + if media.media_type == MediaType.ANNOUNCEMENT: + # special case: stream announcement + assert media.custom_data + input_format = AIRPLAY_PCM_FORMAT + audio_source = self.mass.streams.get_announcement_stream( + media.custom_data["url"], + output_format=AIRPLAY_PCM_FORMAT, + use_pre_announce=media.custom_data["use_pre_announce"], + ) + elif media.media_type == MediaType.PLUGIN_SOURCE: + # special case: plugin source stream + input_format = AIRPLAY_PCM_FORMAT + assert media.custom_data + audio_source = self.mass.streams.get_plugin_source_stream( + plugin_source_id=media.custom_data["source_id"], + output_format=AIRPLAY_PCM_FORMAT, + # need to pass player_id from the PlayerMedia object + # because this could have been a group + player_id=media.custom_data["player_id"], + ) + elif media.queue_id and media.queue_id.startswith(UGP_PREFIX): + # special case: UGP stream + ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.queue_id)) + ugp_stream = ugp_player.stream + assert ugp_stream is not None # for type checker + input_format = ugp_stream.base_pcm_format + audio_source = ugp_stream.subscribe_raw() + elif media.queue_id and media.queue_item_id: + # regular queue (flow) stream request + input_format = AIRPLAY_FLOW_PCM_FORMAT + queue = self.mass.player_queues.get(media.queue_id) + assert queue + start_queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id) + assert start_queue_item + audio_source = self.mass.streams.get_queue_flow_stream( + queue=queue, + start_queue_item=start_queue_item, + pcm_format=input_format, + ) + else: + # assume url or some other direct path + # NOTE: this will fail if its an uri not playable by ffmpeg + input_format = AIRPLAY_PCM_FORMAT + audio_source = get_ffmpeg_stream( + audio_input=media.uri, + input_format=AudioFormat(content_type=ContentType.try_parse(media.uri)), + output_format=AIRPLAY_PCM_FORMAT, + ) + + # if an existing stream session is running, we could replace it with the new stream + if self.raop_stream and self.raop_stream.running: + # check if we need to replace the stream + if self.raop_stream.prevent_playback: + # player is in prevent playback mode, we need to stop the stream + await self.stop() + else: + await self.raop_stream.session.replace_stream(audio_source) + return + + # setup RaopStreamSession for player (and its sync childs if any) + sync_clients = self._get_sync_clients() + provider = cast("AirPlayProvider", self.provider) + raop_stream_session = RaopStreamSession(provider, sync_clients, input_format, audio_source) + await raop_stream_session.start() + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + if self.raop_stream and self.raop_stream.running: + await self.raop_stream.send_cli_command(f"VOLUME={volume_level}\n") + self._attr_volume_level = volume_level + self.update_state() + # store last state in cache + await self.mass.cache.set(self.player_id, volume_level, base_key=CACHE_KEY_PREV_VOLUME) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + if self.synced_to: + # this should not happen, but guard anyways + raise RuntimeError("Player is synced, cannot set members") + if not player_ids_to_add and not player_ids_to_remove: + # nothing to do + return + + raop_session = self.raop_stream.session if self.raop_stream else None + # handle removals first + if player_ids_to_remove: + if self.player_id in player_ids_to_remove: + # dissolve the entire sync group + if self.raop_stream and self.raop_stream.running: + # stop the stream session if it is running + await self.raop_stream.session.stop() + self._attr_group_members = [] + self.update_state() + return + + for child_player in self._get_sync_clients(): + if child_player.player_id in player_ids_to_remove: + if raop_session: + await raop_session.remove_client(child_player) + self._attr_group_members.remove(child_player.player_id) + + # handle additions + for player_id in player_ids_to_add or []: + if player_id == self.player_id or player_id in self.group_members: + # nothing to do: player is already part of the group + continue + child_player_to_add: AirPlayPlayer | None = cast( + "AirPlayPlayer | None", self.mass.players.get(player_id) + ) + if not child_player_to_add: + # should not happen, but guard against it + continue + if child_player_to_add.synced_to and child_player_to_add.synced_to != self.player_id: + raise RuntimeError("Player is already synced to another player") + + # ensure the child does not have an existing stream session active + if child_player_to_add := cast( + "AirPlayPlayer | None", self.mass.players.get(player_id) + ): + if ( + child_player_to_add.raop_stream + and child_player_to_add.raop_stream.running + and child_player_to_add.raop_stream.session != raop_session + ): + await child_player_to_add.raop_stream.session.remove_client(child_player_to_add) + + # add new child to the existing raop session (if any) + self._attr_group_members.append(player_id) + if raop_session: + await raop_session.add_client(child_player_to_add) + + # always update the state after modifying group members + self.update_state() + + def update_volume_from_device(self, volume: int) -> None: + """Update volume from device feedback.""" + ignore_volume_report = ( + self.mass.config.get_raw_player_config_value(self.player_id, CONF_IGNORE_VOLUME, False) + or self.device_info.manufacturer.lower() == "apple" + ) + + if ignore_volume_report: + return + + cur_volume = self.volume_level or 0 + if abs(cur_volume - volume) > 3 or (time.time() - self.last_command_sent) > 3: + self.mass.create_task(self.volume_set(volume)) + else: + self._attr_volume_level = volume + self.update_state() + + def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None: + """Set/update the discovery info for the player.""" + self._attr_name = display_name + self.discovery_info = discovery_info + cur_address = self.address + new_address = get_primary_ip_address_from_zeroconf(discovery_info) + assert new_address # should always be set, but guard against None + if cur_address != new_address: + self.logger.debug("Address updated from %s to %s", cur_address, new_address) + self.address = cur_address + self._attr_device_info.ip_address = new_address + self.update_state() + + def set_state_from_raop( + self, state: PlaybackState | None = None, elapsed_time: float | None = None + ) -> None: + """Set the playback state from RAOP.""" + if state is not None: + self._attr_playback_state = state + if elapsed_time is not None: + self._attr_elapsed_time = elapsed_time + self._attr_elapsed_time_last_updated = time.time() + self.update_state() + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + await super().on_unload() + if self.raop_stream: + # stop the stream session if it is running + if self.raop_stream.running: + self.mass.create_task(self.raop_stream.session.stop()) + self.raop_stream = None + + def _get_sync_clients(self) -> list[AirPlayPlayer]: + """Get all sync clients for a player.""" + sync_clients: list[AirPlayPlayer] = [] + # we need to return the player itself too + group_child_ids = {self.player_id} + group_child_ids.update(self.group_members) + for child_id in group_child_ids: + if client := cast("AirPlayPlayer | None", self.mass.players.get(child_id)): + sync_clients.append(client) + return sync_clients diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 6628fbe0..5b5a1864 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -4,135 +4,26 @@ from __future__ import annotations import asyncio import socket -import time from random import randrange from typing import cast -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.enums import ( - ConfigEntryType, - ContentType, - MediaType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, -) -from music_assistant_models.errors import PlayerUnavailableError -from music_assistant_models.media_items import AudioFormat -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.enums import PlaybackState, ProviderFeature from zeroconf import ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo -from music_assistant.constants import ( - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_ENTRY_SYNC_ADJUST, - create_sample_rates_config_entry, -) from music_assistant.helpers.datetime import utc -from music_assistant.helpers.ffmpeg import get_ffmpeg_stream -from music_assistant.helpers.util import get_ip_pton, lock, select_free_port +from music_assistant.helpers.util import get_ip_pton, select_free_port from music_assistant.models.player_provider import PlayerProvider -from music_assistant.providers.airplay.raop import RaopStreamSession -from music_assistant.providers.player_group import PlayerGroupProvider -from .const import ( - AIRPLAY_FLOW_PCM_FORMAT, - AIRPLAY_PCM_FORMAT, - CACHE_KEY_PREV_VOLUME, - CONF_ALAC_ENCODE, - CONF_ENCRYPTION, - CONF_PASSWORD, - CONF_READ_AHEAD_BUFFER, - FALLBACK_VOLUME, -) +from .constants import CACHE_KEY_PREV_VOLUME, CONF_IGNORE_VOLUME, FALLBACK_VOLUME from .helpers import ( convert_airplay_volume, get_cliraop_binary, get_model_info, - get_primary_ip_address, - is_broken_raop_model, + get_primary_ip_address_from_zeroconf, ) from .player import AirPlayPlayer -CONF_IGNORE_VOLUME = "ignore_volume" - -PLAYER_CONFIG_ENTRIES = ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - ConfigEntry( - key=CONF_ENCRYPTION, - type=ConfigEntryType.BOOLEAN, - default_value=False, - label="Enable encryption", - description="Enable encrypted communication with the player, " - "some (3rd party) players require this.", - category="airplay", - ), - ConfigEntry( - key=CONF_ALAC_ENCODE, - type=ConfigEntryType.BOOLEAN, - default_value=True, - label="Enable compression", - description="Save some network bandwidth by sending the audio as " - "(lossless) ALAC at the cost of a bit CPU.", - category="airplay", - ), - CONF_ENTRY_SYNC_ADJUST, - ConfigEntry( - key=CONF_PASSWORD, - type=ConfigEntryType.SECURE_STRING, - default_value=None, - required=False, - label="Device password", - description="Some devices require a password to connect/play.", - category="airplay", - ), - ConfigEntry( - key=CONF_READ_AHEAD_BUFFER, - type=ConfigEntryType.INTEGER, - default_value=1000, - required=False, - label="Audio buffer (ms)", - description="Amount of buffer (in milliseconds), " - "the player should keep to absorb network throughput jitter. " - "If you experience audio dropouts, try increasing this value.", - category="airplay", - range=(500, 3000), - ), - # airplay has fixed sample rate/bit depth so make this config entry static and hidden - create_sample_rates_config_entry( - supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True - ), - ConfigEntry( - key=CONF_IGNORE_VOLUME, - type=ConfigEntryType.BOOLEAN, - default_value=False, - label="Ignore volume reports sent by the device itself", - description="The AirPlay protocol allows devices to report their own volume level. \n" - "For some devices this is not reliable and can cause unexpected volume changes. \n" - "Enable this option to ignore these reports.", - category="airplay", - ), -) - -BROKEN_RAOP_WARN = ConfigEntry( - key="broken_raop", - type=ConfigEntryType.ALERT, - default_value=None, - required=False, - label="This player is known to have broken AirPlay 1 (RAOP) support. " - "Playback may fail or simply be silent. There is no workaround for this issue at the moment.", -) - - # TODO: AirPlay provider # - Implement authentication for Apple TV # - Implement volume control for Apple devices using pyatv @@ -146,7 +37,6 @@ class AirPlayProvider(PlayerProvider): """Player provider for AirPlay based players.""" cliraop_bin: str | None - _players: dict[str, AirPlayPlayer] _dacp_server: asyncio.Server _dacp_info: AsyncServiceInfo @@ -157,8 +47,9 @@ class AirPlayProvider(PlayerProvider): async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" - self._players = {} + # we locate the cliraop binary here, so we can fail early if it is not available self.cliraop_bin: str | None = await get_cliraop_binary() + # register DACP zeroconf service dacp_port = await select_free_port(39831, 49831) self.dacp_id = dacp_id = f"{randrange(2**64):X}" self.logger.debug("Starting DACP ActiveRemote %s on port %s", dacp_id, dacp_port) @@ -187,7 +78,6 @@ class AirPlayProvider(PlayerProvider): ) -> None: """Handle MDNS service state callback.""" if not info: - # When info are not provided for the service if state_change == ServiceStateChange.Removed and "@" in name: # Service name is enough to mark the player as unavailable on 'Removed' notification raw_id, display_name = name.split(".")[0].split("@", 1) @@ -204,44 +94,23 @@ class AirPlayProvider(PlayerProvider): player_id = f"ap{raw_id.lower()}" # handle removed player if state_change == ServiceStateChange.Removed: - if mass_player := self.mass.players.get(player_id): - if not mass_player.available: - return + if _player := self.mass.players.get(player_id): # the player has become unavailable - self.logger.debug("Player offline: %s", display_name) - mass_player.available = False - self.mass.players.update(player_id) + self.logger.debug("Player offline: %s", _player.display_name) + await self.mass.players.unregister(player_id) return # handle update for existing device assert info is not None # type guard - if airplay_player := self._players.get(player_id): - if mass_player := self.mass.players.get(player_id): - cur_address = get_primary_ip_address(info) - if cur_address and cur_address != airplay_player.address: - airplay_player.logger.debug( - "Address updated from %s to %s", airplay_player.address, cur_address - ) - airplay_player.address = cur_address - mass_player.device_info = DeviceInfo( - model=mass_player.device_info.model, - manufacturer=mass_player.device_info.manufacturer, - ip_address=str(cur_address), - ) - if not mass_player.available: - self.logger.debug("Player back online: %s", display_name) - mass_player.available = True - # always update the latest discovery info - airplay_player.discovery_info = info - self.mass.players.update(player_id) + player: AirPlayPlayer | None + if player := cast("AirPlayPlayer | None", self.mass.players.get(player_id)): + # update the latest discovery info for existing player + player.set_discovery_info(info, display_name) return # handle new player await self._setup_player(player_id, display_name, info) async def unload(self, is_removed: bool = False) -> None: """Handle unload/close of the provider.""" - # power off all players (will disconnect and close cliraop) - for player in self._players.values(): - await player.cmd_stop() # shutdown DACP server if self._dacp_server: self._dacp_server.close() @@ -249,260 +118,19 @@ class AirPlayProvider(PlayerProvider): if self._dacp_info: await self.mass.aiozc.async_unregister_service(self._dacp_info) - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - - if player := self.mass.players.get(player_id): - if is_broken_raop_model(player.device_info.manufacturer, player.device_info.model): - return (*base_entries, BROKEN_RAOP_WARN, *PLAYER_CONFIG_ENTRIES) - return (*base_entries, *PLAYER_CONFIG_ENTRIES) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player. - - - player_id: player_id of the player to handle the command. - """ - if airplay_player := self._players.get(player_id): - await airplay_player.cmd_stop() - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY (unpause) command to given player. - - - player_id: player_id of the player to handle the command. - """ - if airplay_player := self._players.get(player_id): - await airplay_player.cmd_play() - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player. - - - player_id: player_id of the player to handle the command. - """ - player = self.mass.players.get(player_id) - if not player: - return - if player.group_childs: - # pause is not supported while synced, use stop instead - self.logger.debug("Player is synced, using STOP instead of PAUSE") - await self.cmd_stop(player_id) - return - airplay_player = self._players[player_id] - await airplay_player.cmd_pause() - - @lock - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - if not (player := self.mass.players.get(player_id)): - # this should not happen, but guard anyways - raise PlayerUnavailableError - if player.synced_to: - # this should not happen, but guard anyways - raise RuntimeError("Player is synced") - if not (airplay_player := self._players.get(player_id)): - # this should not happen, but guard anyways - raise PlayerUnavailableError - # set the active source for the player to the media queue - # this accounts for syncgroups and linked players (e.g. sonos) - player.active_source = media.queue_id - player.current_media = media - - # select audio source - if media.media_type == MediaType.ANNOUNCEMENT: - # special case: stream announcement - assert media.custom_data - input_format = AIRPLAY_PCM_FORMAT - audio_source = self.mass.streams.get_announcement_stream( - media.custom_data["url"], - output_format=AIRPLAY_PCM_FORMAT, - use_pre_announce=media.custom_data["use_pre_announce"], - ) - elif media.media_type == MediaType.PLUGIN_SOURCE: - # special case: plugin source stream - input_format = AIRPLAY_PCM_FORMAT - assert media.custom_data - audio_source = self.mass.streams.get_plugin_source_stream( - plugin_source_id=media.custom_data["source_id"], - output_format=AIRPLAY_PCM_FORMAT, - # need to pass player_id from the PlayerMedia object - # because this could have been a group - player_id=media.custom_data["player_id"], - ) - elif media.queue_id and media.queue_id.startswith("ugp_"): - # special case: UGP stream - ugp_provider = cast("PlayerGroupProvider", self.mass.get_provider("player_group")) - ugp_stream = ugp_provider.ugp_streams[media.queue_id] - input_format = ugp_stream.base_pcm_format - audio_source = ugp_stream.subscribe_raw() - elif media.queue_id and media.queue_item_id: - # regular queue (flow) stream request - input_format = AIRPLAY_FLOW_PCM_FORMAT - queue = self.mass.player_queues.get(media.queue_id) - assert queue - start_queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id) - assert start_queue_item - audio_source = self.mass.streams.get_queue_flow_stream( - queue=queue, - start_queue_item=start_queue_item, - pcm_format=input_format, - ) - else: - # assume url or some other direct path - # NOTE: this will fail if its an uri not playable by ffmpeg - input_format = AIRPLAY_PCM_FORMAT - audio_source = get_ffmpeg_stream( - audio_input=media.uri, - input_format=AudioFormat(content_type=ContentType.try_parse(media.uri)), - output_format=AIRPLAY_PCM_FORMAT, - ) - - # if an existing stream session is running, we could replace it with the new stream - if airplay_player.raop_stream and airplay_player.raop_stream.running: - # check if we need to replace the stream - if airplay_player.raop_stream.prevent_playback: - # player is in prevent playback mode, we need to stop the stream - await airplay_player.cmd_stop() - else: - await airplay_player.raop_stream.session.replace_stream(audio_source) - return - - # setup RaopStreamSession for player (and its sync childs if any) - sync_clients = self._get_sync_clients(player_id) - raop_stream_session = RaopStreamSession(self, sync_clients, input_format, audio_source) - await raop_stream_session.start() - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player. - - - player_id: player_id of the player to handle the command. - - volume_level: volume level (0..100) to set on the player. - """ - airplay_player = self._players[player_id] - if airplay_player.raop_stream and airplay_player.raop_stream.running: - await airplay_player.raop_stream.send_cli_command(f"VOLUME={volume_level}\n") - mass_player = self.mass.players.get(player_id) - if not mass_player: - return - mass_player.volume_level = volume_level - mass_player.volume_muted = volume_level == 0 - self.mass.players.update(player_id) - # store last state in cache - await self.mass.cache.set(player_id, volume_level, base_key=CACHE_KEY_PREV_VOLUME) - - @lock - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the syncgroup master or group player. - """ - if player_id == target_player: - return - child_player = self.mass.players.get(player_id) - assert child_player # guard - parent_player = self.mass.players.get(target_player) - assert parent_player # guard - if parent_player.synced_to: - raise RuntimeError("Player is already synced") - if child_player.synced_to and child_player.synced_to != target_player: - raise RuntimeError("Player is already synced to another player") - if player_id in parent_player.group_childs: - # nothing to do: player is already part of the group - return - # ensure the child does not have an existing steam session active - if airplay_player := self._players.get(player_id): - if airplay_player.raop_stream and airplay_player.raop_stream.running: - await airplay_player.raop_stream.session.remove_client(airplay_player) - # always make sure that the parent player is part of the sync group - parent_player.group_childs.append(parent_player.player_id) - parent_player.group_childs.append(child_player.player_id) - child_player.synced_to = parent_player.player_id - - # check if we should (re)start or join a stream session - active_queue = self.mass.player_queues.get_active_queue(parent_player.player_id) - if active_queue.state == PlayerState.PLAYING: - # playback needs to be restarted to form a new multi client stream session - # TODO: allow late joining to existing stream - await self.mass.player_queues.stop(active_queue.queue_id) - # this could potentially be called by multiple players at the exact same time - # so we debounce the resync a bit here with a timer - self.mass.call_later( - 0.5, - self.mass.player_queues.resume, - active_queue.queue_id, - fade_in=False, - task_id=f"resume_{active_queue.queue_id}", - ) - else: - # make sure that the player manager gets an update - self.mass.players.update(child_player.player_id, skip_forward=True) - self.mass.players.update(parent_player.player_id, skip_forward=True) - - @lock - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - mass_player = self.mass.players.get(player_id, raise_unavailable=True) - if not mass_player or not mass_player.synced_to: - return - ap_player = self._players[player_id] - if ap_player.raop_stream and ap_player.raop_stream.running: - await ap_player.raop_stream.session.remove_client(ap_player) - group_leader = self.mass.players.get(mass_player.synced_to, raise_unavailable=True) - assert group_leader - if player_id in group_leader.group_childs: - group_leader.group_childs.remove(player_id) - mass_player.synced_to = None - mass_player.active_source = None - mass_player.state = PlayerState.IDLE - airplay_player = self._players.get(player_id) - if airplay_player: - await airplay_player.cmd_stop() - # make sure that the player manager gets an update - self.mass.players.update(mass_player.player_id, skip_forward=True) - self.mass.players.update(group_leader.player_id, skip_forward=True) - - def _get_sync_clients(self, player_id: str) -> list[AirPlayPlayer]: - """Get all sync clients for a player.""" - mass_player = self.mass.players.get(player_id, True) - assert mass_player - sync_clients: list[AirPlayPlayer] = [] - # we need to return the player itself too - group_child_ids = {player_id} - group_child_ids.update(mass_player.group_childs) - for child_id in group_child_ids: - if client := self._players.get(child_id): - sync_clients.append(client) - return sync_clients - async def _setup_player( - self, player_id: str, display_name: str, info: AsyncServiceInfo + self, player_id: str, display_name: str, discovery_info: AsyncServiceInfo ) -> None: """Handle setup of a new player that is discovered using mdns.""" - address = get_primary_ip_address(info) - if address is None: - return - self.logger.debug("Discovered AirPlay device %s on %s", display_name, address) - # prefer airplay mdns info as it has more details # fallback to raop info if airplay info is not available airplay_info = AsyncServiceInfo( - "_airplay._tcp.local.", info.name.split("@")[-1].replace("_raop", "_airplay") + "_airplay._tcp.local.", discovery_info.name.split("@")[-1].replace("_raop", "_airplay") ) if await airplay_info.async_request(self.mass.aiozc.zeroconf, 3000): manufacturer, model = get_model_info(airplay_info) else: - manufacturer, model = get_model_info(info) + manufacturer, model = get_model_info(discovery_info) if not self.mass.config.get_raw_player_config_value(player_id, "enabled", True): self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name) @@ -517,36 +145,33 @@ class AirPlayProvider(PlayerProvider): ) return + address = get_primary_ip_address_from_zeroconf(discovery_info) + if not address: + return # should not happen, but guard just in case + + # if we reach this point, all preflights are ok and we can create the player + self.logger.debug("Discovered AirPlay device %s on %s", display_name, address) + # append airplay to the default display name for generic (non-apple) devices # this makes it easier for users to distinguish between airplay and non-airplay devices if manufacturer.lower() != "apple" and "airplay" not in display_name.lower(): display_name += " (AirPlay)" - self._players[player_id] = AirPlayPlayer(self, player_id, info, address) + # Get volume from cache if not (volume := await self.mass.cache.get(player_id, base_key=CACHE_KEY_PREV_VOLUME)): volume = FALLBACK_VOLUME - mass_player = Player( + + player = AirPlayPlayer( + provider=self, player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=display_name, - available=True, - device_info=DeviceInfo( - model=model, - manufacturer=manufacturer, - ip_address=address, - ), - supported_features={ - PlayerFeature.PAUSE, - PlayerFeature.SET_MEMBERS, - PlayerFeature.MULTI_DEVICE_DSP, - PlayerFeature.VOLUME_SET, - }, - volume_level=volume, - can_group_with={self.instance_id}, - enabled_by_default=not is_broken_raop_model(manufacturer, model), + discovery_info=discovery_info, + address=address, + display_name=display_name, + manufacturer=manufacturer, + model=model, + initial_volume=volume, ) - await self.mass.players.register_or_update(mass_player) + await self.mass.players.register(player) async def _handle_dacp_request( # noqa: PLR0915 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter @@ -579,33 +204,38 @@ class AirPlayProvider(PlayerProvider): headers[x.strip()] = y.strip() active_remote = headers.get("Active-Remote") _, path, _ = headers_split[0].split(" ") - airplay_player = next( + # lookup airplay player by active remote id + player = next( ( x - for x in self._players.values() + for x in self.get_players() if x.raop_stream and x.raop_stream.active_remote_id == active_remote ), None, ) self.logger.debug( "DACP request for %s (%s): %s -- %s", - airplay_player.discovery_info.name if airplay_player else "UNKNOWN PLAYER", + player.discovery_info.name if player else "UNKNOWN PLAYER", active_remote, path, body, ) - if not airplay_player: + if not player: return - player_id = airplay_player.player_id - mass_player = self.mass.players.get(player_id) - if not mass_player: - return + player_id = player.player_id ignore_volume_report = ( self.mass.config.get_raw_player_config_value(player_id, CONF_IGNORE_VOLUME, False) - or mass_player.device_info.manufacturer.lower() == "apple" + or player.device_info.manufacturer.lower() == "apple" ) active_queue = self.mass.player_queues.get_active_queue(player_id) + if not active_queue: + self.logger.warning( + "DACP request for %s (%s) but no active queue found, ignoring request", + player.display_name, + player_id, + ) + return if path == "/ctrl-int/1/nextitem": self.mass.create_task(self.mass.player_queues.next(active_queue.queue_id)) elif path == "/ctrl-int/1/previtem": @@ -613,7 +243,7 @@ class AirPlayProvider(PlayerProvider): elif path == "/ctrl-int/1/play": # sometimes this request is sent by a device as confirmation of a play command # we ignore this if the player is already playing - if mass_player.state != PlayerState.PLAYING: + if player.playback_state != PlaybackState.PLAYING: self.mass.create_task(self.mass.player_queues.play(active_queue.queue_id)) elif path == "/ctrl-int/1/playpause": self.mass.create_task(self.mass.player_queues.play_pause(active_queue.queue_id)) @@ -633,7 +263,7 @@ class AirPlayProvider(PlayerProvider): elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"): # sometimes this request is sent by a device as confirmation of a play command # we ignore this if the player is already playing - if mass_player.state == PlayerState.PLAYING: + if player.playback_state == PlaybackState.PLAYING: self.mass.create_task(self.mass.player_queues.pause(active_queue.queue_id)) elif "dmcp.device-volume=" in path and not ignore_volume_report: # This is a bit annoying as this can be either the device confirming a new volume @@ -642,37 +272,19 @@ class AirPlayProvider(PlayerProvider): # to prevent an endless pingpong of volume changes raop_volume = float(path.split("dmcp.device-volume=", 1)[-1]) volume = convert_airplay_volume(raop_volume) - cur_volume = mass_player.volume_level or 0 - if ( - abs(cur_volume - volume) > 3 - or (time.time() - airplay_player.last_command_sent) > 3 - ): - self.mass.create_task(self.cmd_volume_set(player_id, volume)) - else: - mass_player.volume_level = volume - self.mass.players.update(player_id) + player.update_volume_from_device(volume) elif "dmcp.volume=" in path: # volume change request from device (e.g. volume buttons) volume = int(path.split("dmcp.volume=", 1)[-1]) - cur_volume = mass_player.volume_level or 0 - if ( - abs(cur_volume - volume) > 2 - or (time.time() - airplay_player.last_command_sent) > 3 - ): - self.mass.create_task(self.cmd_volume_set(player_id, volume)) + player.update_volume_from_device(volume) elif "device-prevent-playback=1" in path: # device switched to another source (or is powered off) - if raop_stream := airplay_player.raop_stream: + if raop_stream := player.raop_stream: raop_stream.prevent_playback = True - if mass_player.synced_to: - self.mass.create_task(self.cmd_ungroup(airplay_player.player_id)) - else: - self.mass.create_task( - airplay_player.raop_stream.session.remove_client(airplay_player) - ) + self.mass.create_task(player.raop_stream.session.remove_client(player)) elif "device-prevent-playback=0" in path: # device reports that its ready for playback again - if raop_stream := airplay_player.raop_stream: + if raop_stream := player.raop_stream: raop_stream.prevent_playback = False # send response @@ -687,3 +299,11 @@ class AirPlayProvider(PlayerProvider): await writer.drain() finally: writer.close() + + def get_players(self) -> list[AirPlayPlayer]: + """Return all airplay players belonging to this instance.""" + return cast("list[AirPlayPlayer]", self.players) + + def get_player(self, player_id: str) -> AirPlayPlayer | None: + """Return AirplayPlayer by id.""" + return cast("AirPlayPlayer | None", self.mass.players.get(player_id)) diff --git a/music_assistant/providers/airplay/raop.py b/music_assistant/providers/airplay/raop.py index 195ea31f..22829413 100644 --- a/music_assistant/providers/airplay/raop.py +++ b/music_assistant/providers/airplay/raop.py @@ -12,7 +12,7 @@ from contextlib import suppress from random import randint from typing import TYPE_CHECKING -from music_assistant_models.enums import PlayerState +from music_assistant_models.enums import PlaybackState from music_assistant_models.errors import PlayerCommandFailed from music_assistant.constants import CONF_SYNC_ADJUST, VERBOSE_LOG_LEVEL @@ -21,10 +21,9 @@ from music_assistant.helpers.ffmpeg import FFMpeg from music_assistant.helpers.process import AsyncProcess, check_output from music_assistant.helpers.util import TaskManager, close_async_generator -from .const import ( +from .constants import ( AIRPLAY_PCM_FORMAT, CONF_ALAC_ENCODE, - CONF_BIND_INTERFACE, CONF_ENCRYPTION, CONF_PASSWORD, CONF_READ_AHEAD_BUFFER, @@ -111,7 +110,21 @@ class RaopStreamSession: """Add a sync client to the session.""" # TODO: Add the ability to add a new client to an existing session # e.g. by counting the number of frames sent etc. - raise NotImplementedError("Adding clients to a session is not yet supported") + + # temp solution: just restart the whole playback session when new client(s) join + sync_leader = self.sync_clients[0] + if not sync_leader.raop_stream or not sync_leader.raop_stream.running: + return + + await self.stop() # we need to stop the current session to add a new client + # this could potentially be called by multiple players at the exact same time + # so we debounce the resync a bit here with a timer + if sync_leader.current_media: + self.mass.call_later( + 0.5, + sync_leader.play_media(sync_leader.current_media), + task_id=f"resync_session_{sync_leader.player_id}", + ) async def replace_stream(self, audio_source: AsyncGenerator[bytes, None]) -> None: """Replace the audio source of the stream.""" @@ -184,13 +197,13 @@ class RaopStream: def __init__( self, session: RaopStreamSession, - airplay_player: AirPlayPlayer, + player: AirPlayPlayer, ) -> None: """Initialize RaopStream.""" self.session = session self.prov = session.prov self.mass = session.prov.mass - self.airplay_player = airplay_player + self.player = player # always generate a new active remote id to prevent race conditions # with the named pipe used to send audio @@ -219,22 +232,14 @@ class RaopStream: """Initialize CLIRaop process for a player.""" assert self.prov.cliraop_bin extra_args: list[str] = [] - player_id = self.airplay_player.player_id - mass_player = self.mass.players.get(player_id) - if not mass_player: - return - bind_ip = str( - await self.mass.config.get_provider_config_value( - self.prov.instance_id, CONF_BIND_INTERFACE - ) - ) - extra_args += ["-if", bind_ip] + player_id = self.player.player_id + extra_args += ["-if", self.mass.streams.bind_ip] if self.mass.config.get_raw_player_config_value(player_id, CONF_ENCRYPTION, False): extra_args += ["-encrypt"] if self.mass.config.get_raw_player_config_value(player_id, CONF_ALAC_ENCODE, True): extra_args += ["-alac"] for prop in ("et", "md", "am", "pk", "pw"): - if prop_value := self.airplay_player.discovery_info.decoded_properties.get(prop): + if prop_value := self.player.discovery_info.decoded_properties.get(prop): extra_args += [f"-{prop}", prop_value] sync_adjust = self.mass.config.get_raw_player_config_value(player_id, CONF_SYNC_ADJUST, 0) assert isinstance(sync_adjust, int) @@ -264,21 +269,21 @@ class RaopStream: "-ntpstart", str(start_ntp), "-port", - str(self.airplay_player.discovery_info.port), + str(self.player.discovery_info.port), "-wait", str(wait_start - sync_adjust), "-latency", str(read_ahead), "-volume", - str(mass_player.volume_level), + str(self.player.volume_level), *extra_args, "-dacp", self.prov.dacp_id, "-activeremote", self.active_remote_id, "-udn", - self.airplay_player.discovery_info.name, - self.airplay_player.address, + self.player.discovery_info.name, + self.player.address, "-", ] self._cliraop_proc = AsyncProcess(cliraop_args, stdin=True, stderr=True, name="cliraop") @@ -288,7 +293,7 @@ class RaopStream: # read first 20 lines of stderr to get the initial status for _ in range(20): line = (await self._cliraop_proc.read_stderr()).decode("utf-8", errors="ignore") - self.airplay_player.logger.debug(line) + self.player.logger.debug(line) if "connected to " in line: self._started.set() break @@ -299,7 +304,7 @@ class RaopStream: # repeat sending the volume level to the player because some players seem # to ignore it the first time # https://github.com/music-assistant/support/issues/3330 - await self.send_cli_command(f"VOLUME={mass_player.volume_level}\n") + await self.send_cli_command(f"VOLUME={self.player.volume_level}\n") # start reading the stderr of the cliraop process from another task self._stderr_reader_task = self.mass.create_task(self._stderr_reader()) @@ -315,9 +320,7 @@ class RaopStream: await self._cliraop_proc.close(True) if self._ffmpeg_proc and not self._ffmpeg_proc.closed: await self._ffmpeg_proc.close(True) - if mass_player := self.mass.players.get(self.airplay_player.player_id): - mass_player.state = PlayerState.IDLE - self.mass.players.update(mass_player.player_id) + self.player.set_state_from_raop(state=PlaybackState.IDLE, elapsed_time=0) async def write_chunk(self, chunk: bytes) -> None: """Write a (pcm) audio chunk.""" @@ -349,8 +352,8 @@ class RaopStream: f.write(command) named_pipe = f"/tmp/raop-{self.active_remote_id}" # noqa: S108 - self.airplay_player.logger.log(VERBOSE_LOG_LEVEL, "sending command %s", command) - self.airplay_player.last_command_sent = time.time() + self.player.logger.log(VERBOSE_LOG_LEVEL, "sending command %s", command) + self.player.last_command_sent = time.time() await asyncio.to_thread(send_data) def start_ffmpeg_stream(self) -> None: @@ -371,14 +374,12 @@ class RaopStream: output_format=AIRPLAY_PCM_FORMAT, filter_params=get_player_filter_params( self.mass, - self.airplay_player.player_id, + self.player.player_id, self.session.input_format, AIRPLAY_PCM_FORMAT, ), ) self._stream_bytes_sent = 0 - mass_player = self.mass.players.get(self.airplay_player.player_id) - assert mass_player # for type checker await self._ffmpeg_proc.start() chunksize = get_chunksize(AIRPLAY_PCM_FORMAT) # wait for cliraop to be ready @@ -394,20 +395,18 @@ class RaopStream: del chunk # we base elapsed time on the amount of bytes sent # so we can account for reusing the same session for multiple streams - mass_player.elapsed_time = self._stream_bytes_sent / chunksize - mass_player.elapsed_time_last_updated = time.time() + self.player.set_state_from_raop( + elapsed_time=self._stream_bytes_sent / chunksize, + ) # if we reach this point, the process exited, most likely because the stream ended if self._cliraop_proc and not self._cliraop_proc.closed: await self._cliraop_proc.write_eof() async def _stderr_reader(self) -> None: """Monitor stderr for the running CLIRaop process.""" - airplay_player = self.airplay_player - mass_player = self.mass.players.get(airplay_player.player_id) - if not mass_player or not mass_player.active_source: - return - queue = self.mass.player_queues.get_active_queue(mass_player.active_source) - logger = airplay_player.logger + player = self.player + queue = self.mass.players.get_active_queue(player) + logger = player.logger lost_packets = 0 prev_metadata_checksum: str = "" prev_progress_report: float = 0 @@ -417,13 +416,13 @@ class RaopStream: if "elapsed milliseconds:" in line: # this is received more or less every second while playing # millis = int(line.split("elapsed milliseconds: ")[1]) - # mass_player.elapsed_time = (millis / 1000) - self.elapsed_time_correction - # mass_player.elapsed_time_last_updated = time.time() + # self.player.elapsed_time = (millis / 1000) - self.elapsed_time_correction + # self.player.elapsed_time_last_updated = time.time() # send metadata to player(s) if needed # NOTE: this must all be done in separate tasks to not disturb audio now = time.time() if ( - (mass_player.elapsed_time or 0) > 2 + (player.elapsed_time or 0) > 2 and queue and queue.current_item and queue.current_item.streamdetails @@ -441,20 +440,15 @@ class RaopStream: prev_progress_report = now self.mass.create_task(self._send_progress(queue)) if "set pause" in line or "Pause at" in line: - mass_player.state = PlayerState.PAUSED - self.mass.players.update(airplay_player.player_id) + player.set_state_from_raop(state=PlaybackState.PAUSED) if "Restarted at" in line or "restarting w/ pause" in line: - mass_player.state = PlayerState.PLAYING - self.mass.players.update(airplay_player.player_id) + player.set_state_from_raop(state=PlaybackState.PLAYING) if "restarting w/o pause" in line: # streaming has started - mass_player.state = PlayerState.PLAYING - mass_player.elapsed_time = 0 - mass_player.elapsed_time_last_updated = time.time() - self.mass.players.update(airplay_player.player_id) + player.set_state_from_raop(state=PlaybackState.PLAYING, elapsed_time=0) if "lost packet out of backlog" in line: lost_packets += 1 - if lost_packets == 100: + if lost_packets == 100 and queue: logger.error("High packet loss detected, restarting playback...") self.mass.create_task(self.mass.player_queues.resume(queue.queue_id, False)) else: diff --git a/music_assistant/providers/alexa/__init__.py b/music_assistant/providers/alexa/__init__.py index 74cc17eb..86c7eecc 100644 --- a/music_assistant/providers/alexa/__init__.py +++ b/music_assistant/providers/alexa/__init__.py @@ -6,7 +6,7 @@ import asyncio import logging import os import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import aiohttp from aiohttp import BasicAuth, web @@ -14,13 +14,12 @@ from alexapy import AlexaAPI, AlexaLogin, AlexaProxy from music_assistant_models.config_entries import ConfigEntry from music_assistant_models.enums import ( ConfigEntryType, + PlaybackState, PlayerFeature, - PlayerState, - PlayerType, ProviderFeature, ) from music_assistant_models.errors import LoginFailed -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.player import DeviceInfo, PlayerMedia from music_assistant.constants import ( CONF_ENTRY_CROSSFADE, @@ -31,15 +30,13 @@ from music_assistant.constants import ( CONF_USERNAME, ) from music_assistant.helpers.auth import AuthenticationHelper +from music_assistant.models.player import Player from music_assistant.models.player_provider import PlayerProvider _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - from music_assistant_models.config_entries import ( - ConfigValueType, - ProviderConfig, - ) + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig from music_assistant_models.provider import ProviderManifest from music_assistant.mass import MusicAssistant @@ -237,20 +234,124 @@ async def delete_cookie(cookiefile: str) -> None: _LOGGER.debug("Cookie file %s does not exist, nothing to delete.", cookiefile) -class AlexaProvider(PlayerProvider): - """Implementation of an Alexa Device Provider.""" +class AlexaDevice: + """Representation of an Alexa Device.""" + + _device_type: str + device_serial_number: str + _device_family: str + _cluster_members: str + _locale: str + + +class AlexaPlayer(Player): + """Implementation of an Alexa Player.""" + + def __init__( + self, + provider: AlexaProvider, + player_id: str, + device: AlexaDevice, + ) -> None: + """Initialize AlexaPlayer.""" + super().__init__(provider, player_id) + self.device = device + self._attr_supported_features = { + PlayerFeature.VOLUME_SET, + PlayerFeature.PAUSE, + } + self._attr_name = player_id + self._attr_device_info = DeviceInfo() + self._attr_powered = False + self._attr_available = True + + @property + def api(self) -> AlexaAPI: + """Get the AlexaAPI instance for this player.""" + provider = cast("AlexaProvider", self.provider) + return AlexaAPI(self.device, provider.login) + + async def stop(self) -> None: + """Handle STOP command on the player.""" + await self.api.stop() + self._attr_playback_state = PlaybackState.IDLE + self.update_state() + + async def play(self) -> None: + """Handle PLAY command on the player.""" + await self.api.play() + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def pause(self) -> None: + """Handle PAUSE command on the player.""" + await self.api.pause() + self._attr_playback_state = PlaybackState.PAUSED + self.update_state() + + async def volume_set(self, volume_level: int) -> None: + """Handle VOLUME_SET command on the player.""" + await self.api.set_volume(volume_level / 100) + self._attr_volume_level = volume_level + self.update_state() + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on the player.""" + username = self.provider.config.get_value(CONF_API_BASIC_AUTH_USERNAME) + password = self.provider.config.get_value(CONF_API_BASIC_AUTH_PASSWORD) + + auth = None + if username is not None and password is not None: + auth = BasicAuth(str(username), str(password)) + + async with aiohttp.ClientSession() as session: + try: + async with session.post( + f"{self.provider.config.get_value(CONF_API_URL)}/ma/push-url", + json={"streamUrl": media.uri}, + timeout=aiohttp.ClientTimeout(total=10), + auth=auth, + ) as resp: + await resp.text() + except Exception as exc: + _LOGGER.error("Failed to push URL to Alexa: %s", exc) + return - class AlexaDevice: - """Representation of an Alexa Device.""" + await self.api.run_custom("Ask music assistant to play audio") - _device_type: str - device_serial_number: str - _device_family: str - _cluster_members: str - _locale: str + state = await self.api.get_state() + if state: + state = state.get("playerInfo", None) + + if state: + device_media = state.get("infoText") + if device_media: + media.title = device_media.get("title") + media.artist = device_media.get("subText1") + self._attr_current_media = media + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time.time() + if state.get("playbackState") == "PLAYING": + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + base_entries = await super().get_config_entries() + return [ + *base_entries, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_CROSSFADE_DURATION, + CONF_ENTRY_HTTP_PROFILE, + ] + + +class AlexaProvider(PlayerProvider): + """Implementation of an Alexa Device Provider.""" login: AlexaLogin - devices: dict[str, AlexaProvider.AlexaDevice] + devices: dict[str, AlexaDevice] @property def supported_features(self) -> set[ProviderFeature]: @@ -291,23 +392,8 @@ class AlexaProvider(PlayerProvider): if device.get("capabilities") and "MUSIC_SKILL" in device.get("capabilities"): dev_name = device["accountName"] player_id = dev_name - player = Player( - player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=player_id, - available=True, - powered=False, - device_info=DeviceInfo(), - supported_features={ - PlayerFeature.VOLUME_SET, - PlayerFeature.PAUSE, - PlayerFeature.VOLUME_MUTE, - }, - ) - await self.mass.players.register_or_update(player) - # Initialize AlexaDevice and store in self.devices - device_object = self.AlexaDevice() + # Initialize AlexaDevice + device_object = AlexaDevice() device_object._device_type = device["deviceType"] device_object.device_serial_number = device["serialNumber"] device_object._device_family = device["deviceOwnerCustomerId"] @@ -315,123 +401,6 @@ class AlexaProvider(PlayerProvider): device_object._locale = "en-US" self.devices[player_id] = device_object - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - return ( - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_CROSSFADE, - CONF_ENTRY_CROSSFADE_DURATION, - CONF_ENTRY_HTTP_PROFILE, - ) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - device_object = self.devices[player_id] - api = AlexaAPI(device_object, self.login) - await api.stop() - - player.state = PlayerState.IDLE - self.mass.players.update(player_id) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - device_object = self.devices[player_id] - api = AlexaAPI(device_object, self.login) - await api.play() - - player.state = PlayerState.PLAYING - self.mass.players.update(player_id) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - device_object = self.devices[player_id] - api = AlexaAPI(device_object, self.login) - await api.pause() - - player.state = PlayerState.PLAYING - self.mass.players.update(player_id) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - device_object = self.devices[player_id] - api = AlexaAPI(device_object, self.login) - await api.set_volume(volume_level / 100) - - player.volume_level = volume_level - self.mass.players.update(player_id) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - device_object = self.devices[player_id] - api = AlexaAPI(device_object, self.login) - await api.set_volume(0) - - player.volume_level = 0 - self.mass.players.update(player_id) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player. - - This is called by the Players controller to start playing a mediaitem on the given player. - The provider's own implementation should work out how to handle this request. - - - player_id: player_id of the player to handle the command. - - media: Details of the item that needs to be played on the player. - """ - if not (player := self.mass.players.get(player_id)): - return - - username = self.config.get_value(CONF_API_BASIC_AUTH_USERNAME) - password = self.config.get_value(CONF_API_BASIC_AUTH_PASSWORD) - - auth = None - if username is not None and password is not None: - auth = BasicAuth(str(username), str(password)) - - async with aiohttp.ClientSession() as session: - try: - async with session.post( - f"{self.config.get_value(CONF_API_URL)}/ma/push-url", - json={"streamUrl": media.uri}, - timeout=aiohttp.ClientTimeout(total=10), - auth=auth, - ) as resp: - await resp.text() - except Exception as exc: - _LOGGER.error("Failed to push URL to Alexa: %s", exc) - return - device_object = self.devices[player_id] - api = AlexaAPI(device_object, self.login) - await api.run_custom("Ask music assistant to play audio") - - state = await api.get_state() - if state: - state = state.get("playerInfo", None) - - if state: - device_media = state.get("infoText") - if device_media: - media.title = device_media.get("title") - media.artist = device_media.get("subText1") - player.current_media = media - player.elapsed_time = 0 - player.elapsed_time_last_updated = time.time() - if state.get("playbackState") == "PLAYING": - player.state = PlayerState.PLAYING - self.mass.players.update(player_id) + # Create AlexaPlayer instance + player = AlexaPlayer(self, player_id, device_object) + await self.mass.players.register_or_update(player) diff --git a/music_assistant/providers/bluesound/__init__.py b/music_assistant/providers/bluesound/__init__.py index 4a2dd272..4bb20631 100644 --- a/music_assistant/providers/bluesound/__init__.py +++ b/music_assistant/providers/bluesound/__init__.py @@ -2,70 +2,18 @@ from __future__ import annotations -import asyncio -import time -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING -from music_assistant_models.enums import PlayerFeature, PlayerState, PlayerType, ProviderFeature -from music_assistant_models.errors import PlayerCommandFailed -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia -from pyblu import Player as BluosPlayer -from pyblu import Status, SyncStatus -from zeroconf import ServiceStateChange - -from music_assistant.constants import ( - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE_FORCED_2, - CONF_ENTRY_OUTPUT_CODEC, - VERBOSE_LOG_LEVEL, -) -from music_assistant.helpers.util import ( - get_port_from_zeroconf, - get_primary_ip_address_from_zeroconf, -) -from music_assistant.models.player_provider import PlayerProvider +from .provider import BluesoundPlayerProvider if TYPE_CHECKING: from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig from music_assistant_models.provider import ProviderManifest - from zeroconf.asyncio import AsyncServiceInfo from music_assistant import MusicAssistant from music_assistant.models import ProviderInstanceType -PLAYER_FEATURES_BASE = { - PlayerFeature.SET_MEMBERS, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, -} - -PLAYBACK_STATE_MAP = { - "play": PlayerState.PLAYING, - "stream": PlayerState.PLAYING, - "stop": PlayerState.IDLE, - "pause": PlayerState.PAUSED, - "connecting": PlayerState.IDLE, -} - -PLAYBACK_STATE_POLL_MAP = { - "play": PlayerState.PLAYING, - "stream": PlayerState.PLAYING, - "stop": PlayerState.IDLE, - "pause": PlayerState.PAUSED, - "connecting": "CONNECTING", -} - -SOURCE_LINE_IN = "line_in" -SOURCE_AIRPLAY = "airplay" -SOURCE_SPOTIFY = "spotify" -SOURCE_UNKNOWN = "unknown" -SOURCE_RADIO = "radio" -POLL_STATE_STATIC = "static" -POLL_STATE_DYNAMIC = "dynamic" - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -82,321 +30,3 @@ async def get_config_entries( """Set up legacy BluOS devices.""" # ruff: noqa: ARG001 return () - - -class BluesoundDiscoveryInfo(TypedDict): - """Template for MDNS discovery info.""" - - _objectType: str - ip_address: str - port: str - mac: str - model: str - zs: bool - - -class BluesoundPlayer: - """Holds the details of the (discovered) BluOS player.""" - - def __init__( - self, - prov: BluesoundPlayerProvider, - player_id: str, - discovery_info: BluesoundDiscoveryInfo, - ip_address: str, - port: int, - ) -> None: - """Initialize the BluOS Player.""" - self.port = port - self.prov = prov - self.mass = prov.mass - self.player_id = player_id - self.discovery_info = discovery_info - self.ip_address = ip_address - self.logger = prov.logger.getChild(player_id) - self.connected: bool = True - self.client = BluosPlayer(self.ip_address, self.port, self.mass.http_session) - self.sync_status = SyncStatus - self.status = Status - self.poll_state = POLL_STATE_STATIC - self.dynamic_poll_count: int = 0 - self.mass_player: Player | None = None - self._listen_task: asyncio.Task | None = None - - async def disconnect(self) -> None: - """Disconnect the BluOS client and cleanup.""" - if self._listen_task and not self._listen_task.done(): - self._listen_task.cancel() - if self.client: - await self.client.close() - self.connected = False - self.logger.debug("Disconnected from player API") - - async def update_attributes(self) -> None: - """Update the BluOS player attributes.""" - self.logger.debug("updating %s attributes", self.player_id) - if self.dynamic_poll_count > 0: - self.dynamic_poll_count -= 1 - - self.sync_status = await self.client.sync_status() - self.status = await self.client.status() - - # Update timing - self.mass_player.elapsed_time = self.status.seconds - self.mass_player.elapsed_time_last_updated = time.time() - - if not self.mass_player: - return - if self.sync_status.volume == -1: - self.mass_player.volume_level = 100 - else: - self.mass_player.volume_level = self.sync_status.volume - self.mass_player.volume_muted = self.status.mute - - self.logger.log( - VERBOSE_LOG_LEVEL, - "Speaker state: %s vs reported state: %s", - PLAYBACK_STATE_POLL_MAP[self.status.state], - self.mass_player.state, - ) - - if ( - self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0 - ) or self.mass_player.state == PLAYBACK_STATE_POLL_MAP[self.status.state]: - self.logger.debug("Changing bluos poll state from %s to static", self.poll_state) - self.poll_state = POLL_STATE_STATIC - self.mass_player.poll_interval = 30 - self.mass.players.update(self.player_id) - - if self.status.state == "stream": - mass_active = self.mass.streams.base_url - elif self.status.state == "stream" and self.status.input_id == "input0": - self.mass_player.active_source = SOURCE_LINE_IN - elif self.status.state == "stream" and self.status.input_id == "Airplay": - self.mass_player.active_source = SOURCE_AIRPLAY - elif self.status.state == "stream" and self.status.input_id == "Spotify": - self.mass_player.active_source = SOURCE_SPOTIFY - elif self.status.state == "stream" and self.status.input_id == "RadioParadise": - self.mass_player.active_source = SOURCE_RADIO - elif self.status.state == "stream" and (mass_active not in self.status.stream_url): - self.mass_player.active_source = SOURCE_UNKNOWN - - # TODO check pair status - - # TODO fix pairing - - if self.sync_status.leader is None: - if self.sync_status.followers: - if len(self.sync_status.followers) > 1: - self.mass_player.group_childs.set(self.sync_status.followers) - else: - self.mass_player.group_childs.clear() - self.mass_player.synced_to = None - - if self.status.state == "stream": - self.mass_player.current_media = PlayerMedia( - uri=self.status.stream_url, - title=self.status.name, - artist=self.status.artist, - album=self.status.album, - image_url=self.status.image, - ) - else: - self.mass_player.current_media = None - - else: - self.mass_player.group_childs.clear() - self.mass_player.synced_to = self.sync_status.leader - self.mass_player.active_source = self.sync_status.leader - - self.mass_player.state = PLAYBACK_STATE_MAP[self.status.state] - self.mass.players.update(self.player_id) - - -class BluesoundPlayerProvider(PlayerProvider): - """Bluos compatible player provider, providing support for bluesound speakers.""" - - bluos_players: dict[str, BluesoundPlayer] - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.SYNC_PLAYERS} - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - self.bluos_players: dict[str, BluesoundPlayer] = {} - - async def on_mdns_service_state_change( - self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None - ) -> None: - """Handle MDNS service state callback for BluOS.""" - name = name.split(".", 1)[0] - self.player_id = info.decoded_properties["mac"] - # Handle removed player - - if state_change == ServiceStateChange.Removed: - # Check if the player manager has an existing entry for this player - if mass_player := self.mass.players.get(self.player_id): - # The player has become unavailable - self.logger.debug("Player offline: %s", mass_player.display_name) - mass_player.available = False - self.mass.players.update(self.player_id) - return - - if bluos_player := self.bluos_players.get(self.player_id): - if mass_player := self.mass.players.get(self.player_id): - cur_address = get_primary_ip_address_from_zeroconf(info) - cur_port = get_port_from_zeroconf(info) - if cur_address and cur_address != mass_player.device_info.ip_address: - self.logger.debug( - "Address updated to %s for player %s", cur_address, mass_player.display_name - ) - bluos_player.ip_address = cur_address - bluos_player.port = cur_port - mass_player.device_info = DeviceInfo( - model=mass_player.device_info.model, - manufacturer=mass_player.device_info.manufacturer, - ip_address=str(cur_address), - ) - if not mass_player.available: - self.logger.debug("Player back online: %s", mass_player.display_name) - bluos_player.client.sync() - bluos_player.discovery_info = info - self.mass.players.update(self.player_id) - return - # handle new player - cur_address = get_primary_ip_address_from_zeroconf(info) - cur_port = get_port_from_zeroconf(info) - self.logger.debug("Discovered device %s on %s", name, cur_address) - - self.bluos_players[self.player_id] = bluos_player = BluesoundPlayer( - self, self.player_id, discovery_info=info, ip_address=cur_address, port=cur_port - ) - - bluos_player.mass_player = mass_player = Player( - player_id=self.player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=name, - available=True, - device_info=DeviceInfo( - model="BluOS speaker", - manufacturer="Bluesound", - ip_address=cur_address, - ), - # Set the supported features for this player - supported_features={ - PlayerFeature.VOLUME_SET, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, - }, - needs_poll=True, - poll_interval=30, - can_group_with={self.instance_id}, - ) - await self.mass.players.register(mass_player) - - # TODO sync - await bluos_player.update_attributes() - self.mass.players.update(self.player_id) - - async def get_player_config_entries( - self, - player_id: str, - ) -> tuple[ConfigEntry, ...]: - """Return Config Entries for the given player.""" - base_entries = await super().get_player_config_entries(self.player_id) - if not self.bluos_players.get(player_id): - # TODO fix player entries - return (*base_entries,) - return ( - *base_entries, - CONF_ENTRY_HTTP_PROFILE_FORCED_2, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_ENABLE_ICY_METADATA, - ) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to BluOS player.""" - if bluos_player := self.bluos_players[player_id]: - play_state = await bluos_player.client.stop(timeout=1) - if play_state == "stop": - bluos_player.poll_state = POLL_STATE_DYNAMIC - bluos_player.dynamic_poll_count = 6 - bluos_player.mass_player.poll_interval = 0.5 - # Update media info then optimistically override playback state and source - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to BluOS player.""" - if bluos_player := self.bluos_players[player_id]: - play_state = await bluos_player.client.play(timeout=1) - if play_state == "stream": - bluos_player.poll_state = POLL_STATE_DYNAMIC - bluos_player.dynamic_poll_count = 6 - bluos_player.mass_player.poll_interval = 0.5 - # Optimistic state, reduces interface lag - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to BluOS player.""" - if bluos_player := self.bluos_players[player_id]: - play_state = await bluos_player.client.pause(timeout=1) - if play_state == "pause": - bluos_player.poll_state = POLL_STATE_DYNAMIC - bluos_player.dynamic_poll_count = 6 - bluos_player.mass_player.poll_interval = 0.5 - self.logger.debug("Set BluOS state to %s", play_state) - # Optimistic state, reduces interface lag - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to BluOS player.""" - if bluos_player := self.bluos_players[player_id]: - await bluos_player.client.volume(level=volume_level, timeout=1) - self.logger.debug("Set BluOS speaker volume to %s", volume_level) - mass_player = self.mass.players.get(player_id) - # Optimistic state, reduces interface lag - mass_player.volume_level = volume_level - await bluos_player.update_attributes() - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to BluOS player.""" - if bluos_player := self.bluos_players[player_id]: - await bluos_player.client.volume(mute=muted) - # Optimistic state, reduces interface lag - mass_player = self.mass.players.get(player_id) - mass_player.volume_mute = muted - await bluos_player.update_attributes() - - async def play_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle PLAY MEDIA for BluOS player using the provided URL.""" - self.logger.debug("Play_media called") - if bluos_player := self.bluos_players[player_id]: - self.mass.players.update(player_id) - play_state = await bluos_player.client.play_url(media.uri, timeout=1) - # Enable dynamic polling - if play_state == "stream": - bluos_player.poll_state = POLL_STATE_DYNAMIC - bluos_player.dynamic_poll_count = 6 - bluos_player.mass_player.poll_interval = 0.5 - self.logger.debug("Set BluOS state to %s", play_state) - await bluos_player.update_attributes() - - # Optionally, handle the playback_state or additional logic here - if play_state in ("PlayerUnexpectedResponseError", "PlayerUnreachableError"): - raise PlayerCommandFailed("Failed to start playback.") - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - if bluos_player := self.bluos_players[player_id]: - await bluos_player.update_attributes() - - # TODO fix sync & ungroup - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for BluOS player.""" - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for BluOS player.""" - if bluos_player := self.bluos_players[player_id]: - await bluos_player.client.player.leave_group() diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py new file mode 100644 index 00000000..e3cf08d2 --- /dev/null +++ b/music_assistant/providers/bluesound/player.py @@ -0,0 +1,300 @@ +"""Bluesound Player implementation.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, cast + +from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.errors import PlayerCommandFailed +from pyblu import Player as BluosPlayer +from pyblu import Status, SyncStatus + +from music_assistant.constants import ( + CONF_ENTRY_ENABLE_ICY_METADATA, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_HTTP_PROFILE_FORCED_2, + CONF_ENTRY_OUTPUT_CODEC, + VERBOSE_LOG_LEVEL, +) +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry + + from .provider import BluesoundDiscoveryInfo, BluesoundPlayerProvider + + +PLAYER_FEATURES_BASE = { + PlayerFeature.SET_MEMBERS, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PAUSE, +} + +PLAYBACK_STATE_MAP = { + "play": PlaybackState.PLAYING, + "stream": PlaybackState.PLAYING, + "stop": PlaybackState.IDLE, + "pause": PlaybackState.PAUSED, + "connecting": PlaybackState.IDLE, +} + +PLAYBACK_STATE_POLL_MAP = { + "play": PlaybackState.PLAYING, + "stream": PlaybackState.PLAYING, + "stop": PlaybackState.IDLE, + "pause": PlaybackState.PAUSED, + "connecting": "CONNECTING", +} + +SOURCE_LINE_IN = "line_in" +SOURCE_AIRPLAY = "airplay" +SOURCE_SPOTIFY = "spotify" +SOURCE_UNKNOWN = "unknown" +SOURCE_RADIO = "radio" +POLL_STATE_STATIC = "static" +POLL_STATE_DYNAMIC = "dynamic" + + +class BluesoundPlayer(Player): + """Holds the details of the (discovered) BluOS player.""" + + def __init__( + self, + provider: BluesoundPlayerProvider, + player_id: str, + discovery_info: BluesoundDiscoveryInfo, + name: str, + ip_address: str, + port: int, + ) -> None: + """Initialize the BluOS Player.""" + super().__init__(provider, player_id) + self.port = port + self.discovery_info = discovery_info + self.ip_address = ip_address + self.connected: bool = True + self.client = BluosPlayer(self.ip_address, self.port, self.mass.http_session) + self.sync_status = SyncStatus + self.status = Status + self.poll_state = POLL_STATE_STATIC + self.dynamic_poll_count: int = 0 + self._listen_task: asyncio.Task | None = None + # Set base player attributes + self._attr_type = PlayerType.PLAYER + self._attr_supported_features = PLAYER_FEATURES_BASE.copy() + self._attr_name = name + self._attr_device_info = DeviceInfo( + model=discovery_info.get("model", "BluOS Device"), + manufacturer="BluOS", + ip_address=ip_address, + ) + self._attr_available = True + self._attr_needs_poll = True + self._attr_poll_interval = 30 + self._attr_can_group_with = {provider.instance_id} + + async def setup(self) -> None: + """Set up the player.""" + # Add volume support if available + if self.discovery_info.get("zs"): + self._attr_supported_features.add(PlayerFeature.VOLUME_SET) + await self.mass.players.register_or_update(self) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + return [ + *await super().get_config_entries(), + CONF_ENTRY_HTTP_PROFILE_FORCED_2, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_ENABLE_ICY_METADATA, + ] + + async def disconnect(self) -> None: + """Disconnect the BluOS client and cleanup.""" + if self._listen_task and not self._listen_task.done(): + self._listen_task.cancel() + if self.client: + await self.client.close() + self.connected = False + self.logger.debug("Disconnected from player API") + + async def stop(self) -> None: + """Send STOP command to BluOS player.""" + play_state = await self.client.stop(timeout=1) + if play_state == "stop": + self.poll_state = POLL_STATE_DYNAMIC + self.dynamic_poll_count = 6 + self._attr_poll_interval = 0.5 + self._attr_playback_state = PlaybackState.IDLE + self.update_state() + + async def play(self) -> None: + """Send PLAY command to BluOS player.""" + play_state = await self.client.play(timeout=1) + if play_state == "stream": + self.poll_state = POLL_STATE_DYNAMIC + self.dynamic_poll_count = 6 + self._attr_poll_interval = 0.5 + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def pause(self) -> None: + """Send PAUSE command to BluOS player.""" + play_state = await self.client.pause(timeout=1) + if play_state == "pause": + self.poll_state = POLL_STATE_DYNAMIC + self.dynamic_poll_count = 6 + self._attr_poll_interval = 0.5 + self.logger.debug("Set BluOS state to %s", play_state) + self._attr_playback_state = PlaybackState.PAUSED + self.update_state() + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to BluOS player.""" + await self.client.volume(level=volume_level, timeout=1) + self.logger.debug("Set BluOS speaker volume to %s", volume_level) + self._attr_volume_level = volume_level + self.update_state() + + async def volume_mute(self, muted: bool) -> None: + """Send VOLUME MUTE command to BluOS player.""" + await self.client.volume(mute=muted) + self._attr_volume_muted = muted + self.update_state() + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA for BluOS player using the provided URL.""" + self.logger.debug("Play_media called") + play_state = await self.client.play_url(media.uri, timeout=1) + + # Enable dynamic polling + if play_state == "stream": + self.poll_state = POLL_STATE_DYNAMIC + self.dynamic_poll_count = 6 + self._attr_poll_interval = 0.5 + self._attr_playback_state = PlaybackState.PLAYING + + self.logger.debug("Set BluOS state to %s", play_state) + + # Optionally, handle the playback_state or additional logic here + if play_state in ("PlayerUnexpectedResponseError", "PlayerUnreachableError"): + raise PlayerCommandFailed("Failed to start playback.") + + self.update_state() + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle GROUP command for BluOS player.""" + # TODO: Implement grouping logic + + async def ungroup(self) -> None: + """Handle UNGROUP command for BluOS player.""" + await self.client.player.leave_group() + + async def poll(self) -> None: + """Poll player for state updates.""" + await self.update_attributes() + + async def update_attributes(self) -> None: + """Update the BluOS player attributes.""" + self.logger.debug("updating %s attributes", self.player_id) + if self.dynamic_poll_count > 0: + self.dynamic_poll_count -= 1 + + self.sync_status = await self.client.sync_status() + self.status = await self.client.status() + + # Update timing + self._attr_elapsed_time = self.status.seconds + self._attr_elapsed_time_last_updated = time.time() + + if self.sync_status.volume == -1: + self._attr_volume_level = 100 + else: + self._attr_volume_level = self.sync_status.volume + self._attr_volume_muted = self.status.mute + + self.logger.log( + VERBOSE_LOG_LEVEL, + "Speaker state: %s vs reported state: %s", + PLAYBACK_STATE_POLL_MAP[self.status.state], + self._attr_playback_state, + ) + + if ( + self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0 + ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]: + self.logger.debug("Changing bluos poll state from %s to static", self.poll_state) + self.poll_state = POLL_STATE_STATIC + self._attr_poll_interval = 30 + + if self.status.state == "stream": + mass_active = self.mass.streams.base_url + elif self.status.state == "stream" and self.status.input_id == "input0": + self._attr_active_source = SOURCE_LINE_IN + elif self.status.state == "stream" and self.status.input_id == "Airplay": + self._attr_active_source = SOURCE_AIRPLAY + elif self.status.state == "stream" and self.status.input_id == "Spotify": + self._attr_active_source = SOURCE_SPOTIFY + elif self.status.state == "stream" and self.status.input_id == "RadioParadise": + self._attr_active_source = SOURCE_RADIO + elif self.status.state == "stream" and (mass_active not in self.status.stream_url): + self._attr_active_source = SOURCE_UNKNOWN + + # TODO check pair status + + # TODO fix pairing + + # Create a lookup map of (ip, port) -> player_id for all known players. + player_map = { + (player.ip_address, player.port): player.player_id + for player in cast("list[BluesoundPlayer]", self.provider.players) + } + + if self.sync_status.leader is None: + if self.sync_status.followers: + if len(self.sync_status.followers) > 1: + self._attr_group_members = [ + player_map[f.ip, f.port] + for f in self.sync_status.followers + if (f.ip, f.port) in player_map + ] + else: + self._attr_group_members.clear() + + if self.status.state == "stream": + self._attr_current_media = PlayerMedia( + uri=self.status.stream_url, + title=self.status.name, + artist=self.status.artist, + album=self.status.album, + image_url=self.status.image, + ) + else: + self._attr_current_media = None + + else: + self._attr_group_members.clear() + leader = self.sync_status.leader + self._attr_active_source = player_map[leader.ip, leader.port] + + self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state] + self.update_state() + + @property + def synced_to(self) -> str | None: + """ + Return the id of the player this player is synced to (sync leader). + + If this player is not synced to another player (or is the sync leader itself), + this should return None. + """ + if self.sync_status.leader: + return self.sync_status.leader + return None diff --git a/music_assistant/providers/bluesound/provider.py b/music_assistant/providers/bluesound/provider.py new file mode 100644 index 00000000..16fea47b --- /dev/null +++ b/music_assistant/providers/bluesound/provider.py @@ -0,0 +1,105 @@ +"""Bluesound Player Provider implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from music_assistant_models.enums import ProviderFeature +from zeroconf import ServiceStateChange + +from music_assistant.helpers.util import ( + get_port_from_zeroconf, + get_primary_ip_address_from_zeroconf, +) +from music_assistant.models.player_provider import PlayerProvider + +from .player import BluesoundPlayer + +if TYPE_CHECKING: + from zeroconf.asyncio import AsyncServiceInfo + + +class BluesoundDiscoveryInfo(TypedDict): + """Template for MDNS discovery info.""" + + _objectType: str + ip_address: str + port: str + mac: str + model: str + zs: bool + + +class BluesoundPlayerProvider(PlayerProvider): + """Bluos compatible player provider, providing support for bluesound speakers.""" + + bluos_players: dict[str, BluesoundPlayer] = {} + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.SYNC_PLAYERS} + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + + async def on_mdns_service_state_change( + self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None + ) -> None: + """Handle MDNS service state callback for BluOS.""" + name = name.split(".", 1)[0] + assert info is not None + player_id = info.decoded_properties["mac"] + assert player_id is not None + + # Handle removed player + if state_change == ServiceStateChange.Removed: + # Check if the player manager has an existing entry for this player + if mass_player := self.mass.players.get(player_id): + # The player has become unavailable + self.logger.debug("Player offline: %s", mass_player.display_name) + mass_player._attr_available = False + mass_player.update_state() + return + + ip_address = get_primary_ip_address_from_zeroconf(info) + port = get_port_from_zeroconf(info) + + assert ip_address is not None + assert port is not None + + # Handle update of existing player + if bluos_player := self.bluos_players.get(player_id): + ip_changed = False + # Check if the IP address has changed + if ip_address and ip_address != bluos_player.ip_address: + self.logger.debug( + "IP address for player %s updated to %s", bluos_player.name, ip_address + ) + ip_changed = True # Always recreate the player on ip changes + + # Mark player as available if it was previously unavailable + if not bluos_player.available and not ip_changed: + self.logger.debug("Player back online: %s", bluos_player.name) + bluos_player._attr_available = True + bluos_player.update_state() + return + + # New player discovered + self.logger.debug("Discovered player: %s", name) + + discovery_info = BluesoundDiscoveryInfo( + _objectType=info.decoded_properties.get("_objectType", ""), + ip_address=ip_address, + port=str(port), + mac=info.decoded_properties["mac"], + model=info.decoded_properties.get("model", ""), + zs=info.decoded_properties.get("zs", False), + ) + + # Create BluOS player + bluos_player = BluesoundPlayer(self, player_id, discovery_info, name, ip_address, port) + self.bluos_players[player_id] = bluos_player + + # Register with Music Assistant + await bluos_player.setup() diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py index 91812366..22c67b48 100644 --- a/music_assistant/providers/builtin_player/__init__.py +++ b/music_assistant/providers/builtin_player/__init__.py @@ -16,57 +16,18 @@ listen for these events and respond accordingly to control playback and handle m from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass -from time import time -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING -import shortuuid -from aiohttp import web -from music_assistant_models.builtin_player import BuiltinPlayerEvent, BuiltinPlayerState -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.constants import PLAYER_CONTROL_NATIVE -from music_assistant_models.enums import ( - BuiltinPlayerEventType, - ConfigEntryType, - ContentType, - EventType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, -) -from music_assistant_models.errors import PlayerUnavailableError -from music_assistant_models.media_items import AudioFormat -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia - -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE_HIDDEN, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_MUTE_CONTROL, - CONF_POWER_CONTROL, - CONF_VOLUME_CONTROL, - DEFAULT_PCM_FORMAT, - DEFAULT_STREAM_HEADERS, - create_sample_rates_config_entry, -) -from music_assistant.helpers.audio import get_player_filter_params -from music_assistant.helpers.ffmpeg import get_ffmpeg_stream from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType -from music_assistant.models.player_provider import PlayerProvider + +from .provider import BuiltinPlayerProvider if TYPE_CHECKING: - from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig from music_assistant_models.provider import ProviderManifest -# If the player does not send an update within this time, it will be considered offline -DURATION_UNTIL_TIMEOUT = 90 # 30 second extra headroom -POLL_INTERVAL = 30 - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -89,384 +50,3 @@ async def get_config_entries( """ # ruff: noqa: ARG001 return () - - -@dataclass -class PlayerInstance: - """Dataclass for a connected instance.""" - - unregister_cbs: list[Callable[[], None]] - last_update: float - - -class BuiltinPlayerProvider(PlayerProvider): - """Builtin Player Provider for playing to the Music Assistant Web Interface.""" - - _unregister_cbs: list[Callable[[], None]] = [] - instances: dict[str, PlayerInstance] = {} - - def __init__( - self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig - ) -> None: - """Initialize the provider.""" - super().__init__(mass, manifest, config) - self._unregister_cbs = [ - self.mass.register_api_command("builtin_player/register", self.register_player), - self.mass.register_api_command("builtin_player/unregister", self.unregister_player), - self.mass.register_api_command("builtin_player/update_state", self.update_player_state), - ] - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.REMOVE_PLAYER} - - async def unload(self, is_removed: bool = False) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - is_removed will be set to True when the provider is removed from the configuration. - """ - for unload_cb in self._unregister_cbs: - unload_cb() - for instance in self.instances.values(): - for unregister_cb in instance.unregister_cbs: - unregister_cb() - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - return ( - *await super().get_player_config_entries(player_id), - CONF_ENTRY_FLOW_MODE_ENFORCED, - # Hide power/volume/mute control options since they are guaranteed to work - ConfigEntry( - key=CONF_POWER_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_POWER_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_VOLUME_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_MUTE_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_MUTE_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - # These options don't do anything here - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_ENTRY_HTTP_PROFILE_HIDDEN, - create_sample_rates_config_entry(max_sample_rate=48000, max_bit_depth=16), - ) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.STOP), - ) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.PLAY), - ) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.PAUSE), - ) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.SET_VOLUME, volume=volume_level), - ) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent( - type=BuiltinPlayerEventType.MUTE if muted else BuiltinPlayerEventType.UNMUTE - ), - ) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - url = f"builtin_player/flow/{player_id}.mp3" - player = cast("Player", self.mass.players.get(player_id, raise_unavailable=True)) - player.current_media = media - - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.PLAY_MEDIA, media_url=url), - ) - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Send POWER command to given player. - - - player_id: player_id of the player to handle the command. - - powered: bool if player should be powered on or off. - """ - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent( - type=BuiltinPlayerEventType.POWER_ON - if powered - else BuiltinPlayerEventType.POWER_OFF - ), - ) - if (not powered) and (player := self.mass.players.get(player_id)): - player.powered = False - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates. - - This is called by the Player Manager; - if 'needs_poll' is set to True in the player object. - """ - if instance := self.instances.get(player_id, None): - last_updated = time() - instance.last_update - if last_updated > DURATION_UNTIL_TIMEOUT: - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.TIMEOUT), - ) - raise PlayerUnavailableError("Connection to player timed out") - - async def remove_player(self, player_id: str) -> None: - """Remove a player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.TIMEOUT), - ) - await self.unregister_player(player_id) - - async def register_player(self, player_name: str, player_id: str | None) -> Player: - """Register a player. - - Every player must first be registered through this `builtin_player/register` API command - before any playback can occur. - Since players queues can time out, this command either will create a new player queue, - or restore it from the last session. - - - player_name: Human readable name of the player, will only be used in case this call - creates a new queue. - - player_id: the id of the builtin player, set to None on new sessions. The returned player - will have a new random player_id - """ - if player_id is None: - player_id = f"ma_{shortuuid.random(10).lower()}" - - already_registered = player_id in self.instances - - player_features = { - PlayerFeature.VOLUME_SET, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, - PlayerFeature.POWER, - } - - if not already_registered: - self.instances[player_id] = PlayerInstance( - unregister_cbs=[ - self.mass.webserver.register_dynamic_route( - f"/builtin_player/flow/{player_id}.mp3", self._serve_audio_stream - ), - ], - last_update=time(), - ) - - player = self.mass.players.get(player_id) - - if player is None: - player = Player( - player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=player_name, - available=True, - power_control=PLAYER_CONTROL_NATIVE, - powered=False, - device_info=DeviceInfo(), - supported_features=player_features, - needs_poll=True, - poll_interval=POLL_INTERVAL, - hidden_by_default=True, - expose_to_ha_by_default=False, - state=PlayerState.IDLE, - ) - else: - player.state = PlayerState.IDLE - player.name = player_name - player.available = True - player.powered = False - - await self.mass.players.register_or_update(player) - return player - - async def unregister_player(self, player_id: str) -> None: - """Manually unregister a player with `builtin_player/unregister`.""" - instance = self.instances.pop(player_id, None) - if instance is None: - return - for cb in instance.unregister_cbs: - cb() - if player := self.mass.players.get(player_id): - player.available = False - player.state = PlayerState.IDLE - player.powered = False - self.mass.players.update(player.player_id) - - async def update_player_state(self, player_id: str, state: BuiltinPlayerState) -> bool: - """Update current state of a player. - - A player must periodically update the state of through this `builtin_player/update_state` - API command. - - Returns False in case the player already timed out or simply doesn't exist. - In that case, register the player first with `builtin_player/register`. - """ - if not (player := self.mass.players.get(player_id)): - return False - - if player_id not in self.instances: - return False - instance = self.instances[player_id] - instance.last_update = time() - - if not player.powered and state.powered: - # The player was powered off, so this state message is already out of date - # Skip, it. - return True - - player.elapsed_time_last_updated = time() - player.elapsed_time = float(state.position) - player.volume_muted = state.muted - player.volume_level = state.volume - if not state.powered: - player.powered = False - player.state = PlayerState.IDLE - elif state.playing: - player.powered = True - player.state = PlayerState.PLAYING - elif state.paused: - player.powered = True - player.state = PlayerState.PAUSED - else: - player.powered = True - player.state = PlayerState.IDLE - - self.mass.players.update(player_id) - return True - - async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse: - """Serve the flow stream audio to a player.""" - player_id = request.path.rsplit(".")[0].rsplit("/")[-1] - format_str = request.path.rsplit(".")[-1] - # bitrate = request.query.get("bitrate") - queue = self.mass.player_queues.get(player_id) - self.logger.debug("Serving audio stream to %s", player_id) - - if not (player := self.mass.players.get(player_id)): - raise web.HTTPNotFound(reason=f"Unknown player: {player_id}") - - headers = { - **DEFAULT_STREAM_HEADERS, - "Content-Type": f"audio/{format_str}", - "Accept-Ranges": "none", - } - - resp = web.StreamResponse(status=200, reason="OK", headers=headers) - resp.enable_chunked_encoding() - - await resp.prepare(request) - - # return early if this is not a GET request - if request.method != "GET": - return resp - - # Check for a client probe request (from an iPhone/iPad) - if (range_header := request.headers.get("Range")) and range_header == "bytes=0-1": - self.logger.debug("Client is probing the stream.") - - # Avoids us to staring multiple ffmpeg instances for probe requests - return web.Response( - status=206, # Partial Content - headers=headers, - # Just send something - body=b"\x00\x00", - ) - - media = player.current_media - if queue is None or media is None: - raise web.HTTPNotFound(reason="No active queue or media found!") - - if media.queue_id is None: - raise web.HTTPError # TODO: better error - - queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id) - - if queue_item is None: - raise web.HTTPError # TODO: better error - - # TODO: set encoding quality using a bitrate parameter, - # maybe even dynamic with auto/semiauto switching with bad network? - if format_str == "mp3": - stream_format = AudioFormat(content_type=ContentType.MP3) - else: - stream_format = AudioFormat(content_type=ContentType.FLAC) - - pcm_format = AudioFormat( - sample_rate=stream_format.sample_rate, - content_type=DEFAULT_PCM_FORMAT.content_type, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, - channels=DEFAULT_PCM_FORMAT.channels, - ) - - async for chunk in get_ffmpeg_stream( - audio_input=self.mass.streams.get_queue_flow_stream( - queue=queue, - start_queue_item=queue_item, - pcm_format=pcm_format, - ), - input_format=pcm_format, - output_format=stream_format, - # Apple ignores "Accept-Ranges=none" on iOS and iPadOS for some reason, - # so we need to slowly feed the music to avoid the Browser stopping and later - # restarting the audio stream (from a wrong position!) by keeping the buffer short. - extra_input_args=["-readrate", "1.0", "-readrate_initial_burst", "15"], - filter_params=get_player_filter_params(self.mass, player_id, pcm_format, stream_format), - ): - try: - await resp.write(chunk) - except (ConnectionError, ConnectionResetError): - break - - return resp diff --git a/music_assistant/providers/builtin_player/player.py b/music_assistant/providers/builtin_player/player.py new file mode 100644 index 00000000..295aae0a --- /dev/null +++ b/music_assistant/providers/builtin_player/player.py @@ -0,0 +1,326 @@ +"""Player model implementation for the Built-in Player.""" + +from __future__ import annotations + +from collections.abc import Callable +from time import time + +from aiohttp import web +from music_assistant_models.builtin_player import BuiltinPlayerEvent, BuiltinPlayerState +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.constants import PLAYER_CONTROL_NATIVE +from music_assistant_models.enums import ( + BuiltinPlayerEventType, + ConfigEntryType, + ContentType, + EventType, + PlaybackState, + PlayerFeature, + PlayerType, +) +from music_assistant_models.media_items import AudioFormat + +from music_assistant.constants import ( + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_HTTP_PROFILE_HIDDEN, + CONF_ENTRY_OUTPUT_CODEC_HIDDEN, + CONF_MUTE_CONTROL, + CONF_POWER_CONTROL, + CONF_VOLUME_CONTROL, + DEFAULT_PCM_FORMAT, + DEFAULT_STREAM_HEADERS, + create_sample_rates_config_entry, +) +from music_assistant.helpers.audio import get_player_filter_params +from music_assistant.helpers.ffmpeg import get_ffmpeg_stream +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.models.player_provider import PlayerProvider + +# If the player does not send an update within this time, it will be considered offline +DURATION_UNTIL_TIMEOUT = 90 # 30 second extra headroom +POLL_INTERVAL = 30 + + +class BuiltinPlayer(Player): + """Representation of a Builtin Player.""" + + last_update: float + unregister_cbs: list[Callable[[], None]] = [] + + def __init__( + self, + player_id: str, + provider: PlayerProvider, + name: str, + features: tuple[PlayerFeature, ...], + ) -> None: + """Initialize the Builtin Player.""" + super().__init__(provider, player_id) + self._attr_type = PlayerType.PLAYER + self._attr_power_control = PLAYER_CONTROL_NATIVE + self._attr_device_info = DeviceInfo() + self._attr_supported_features = set(features) + self._attr_needs_poll = True + self._attr_poll_interval = POLL_INTERVAL + self._attr_hidden_by_default = True + self._attr_expose_to_ha_by_default = False + self.register(name, False) + + def unregister_routes(self) -> None: + """Unregister all routes associated with this player.""" + for cb in self.unregister_cbs: + cb() + self.unregister_cbs.clear() + self._attr_available = False + self._attr_playback_state = PlaybackState.IDLE + self._attr_powered = False + self._attr_needs_poll = False + self.update_state() + + def register(self, player_name: str, update_state: bool = True) -> None: + """Register the player for playback.""" + if not self.unregister_cbs: + self.unregister_cbs = [ + self.mass.webserver.register_dynamic_route( + f"/builtin_player/flow/{self.player_id}.mp3", self._serve_audio_stream + ), + ] + + self._attr_playback_state = PlaybackState.IDLE + self._attr_name = player_name + self._attr_available = True + self._attr_powered = False + self._attr_needs_poll = True + self.last_update = time() + if update_state: + self.update_state() + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + base_entries = await super().get_config_entries() + return [ + *base_entries, + CONF_ENTRY_FLOW_MODE_ENFORCED, + # Hide power/volume/mute control options since they are guaranteed to work + ConfigEntry( + key=CONF_POWER_CONTROL, + type=ConfigEntryType.STRING, + label=CONF_POWER_CONTROL, + default_value=PLAYER_CONTROL_NATIVE, + hidden=True, + ), + ConfigEntry( + key=CONF_VOLUME_CONTROL, + type=ConfigEntryType.STRING, + label=CONF_VOLUME_CONTROL, + default_value=PLAYER_CONTROL_NATIVE, + hidden=True, + ), + ConfigEntry( + key=CONF_MUTE_CONTROL, + type=ConfigEntryType.STRING, + label=CONF_MUTE_CONTROL, + default_value=PLAYER_CONTROL_NATIVE, + hidden=True, + ), + CONF_ENTRY_HTTP_PROFILE_HIDDEN, + CONF_ENTRY_OUTPUT_CODEC_HIDDEN, + create_sample_rates_config_entry([48000]), + ] + + async def stop(self) -> None: + """Send STOP command to player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.STOP), + ) + + async def play(self) -> None: + """Send PLAY command to player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.PLAY), + ) + + async def pause(self) -> None: + """Send PAUSE command to player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.PAUSE), + ) + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.SET_VOLUME, volume=volume_level), + ) + + async def volume_mute(self, muted: bool) -> None: + """Send VOLUME MUTE command to player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent( + type=BuiltinPlayerEventType.MUTE if muted else BuiltinPlayerEventType.UNMUTE + ), + ) + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on player.""" + url = f"builtin_player/flow/{self.player_id}.mp3" + self._attr_current_media = media + self._attr_playback_state = PlaybackState.PLAYING + self._attr_active_source = media.queue_id + self.update_state() + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.PLAY_MEDIA, media_url=url), + ) + + async def power(self, powered: bool) -> None: + """Send POWER ON command to player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent( + type=BuiltinPlayerEventType.POWER_ON + if powered + else BuiltinPlayerEventType.POWER_OFF + ), + ) + if not powered: + self._attr_powered = False + self.update_state() + + async def poll(self) -> None: + """ + Poll player for state updates. + + This is called by the Player Manager; + if the 'needs_poll' property is True. + """ + last_updated = time() - self.last_update + if last_updated > DURATION_UNTIL_TIMEOUT: + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + self.player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.TIMEOUT), + ) + self.unregister_routes() + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + self.unregister_routes() + + async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse: + """Serve the flow stream audio to a player.""" + player_id = request.path.rsplit(".")[0].rsplit("/")[-1] + format_str = request.path.rsplit(".")[-1] + # bitrate = request.query.get("bitrate") + queue = self.mass.player_queues.get(player_id) + self.logger.debug("Serving audio stream to %s", player_id) + + if not (player := self.mass.players.get(player_id)): + raise web.HTTPNotFound(reason=f"Unknown player: {player_id}") + + headers = { + **DEFAULT_STREAM_HEADERS, + "Content-Type": f"audio/{format_str}", + "Accept-Ranges": "none", + } + + resp = web.StreamResponse(status=200, reason="OK", headers=headers) + resp.enable_chunked_encoding() + + await resp.prepare(request) + + # return early if this is not a GET request + if request.method != "GET": + return resp + + # Check for a client probe request (from an iPhone/iPad) + if (range_header := request.headers.get("Range")) and range_header == "bytes=0-1": + self.logger.debug("Client is probing the stream.") + + # Avoids us to staring multiple ffmpeg instances for probe requests + return web.Response( + status=206, # Partial Content + headers=headers, + # Just send something + body=b"\x00\x00", + ) + + media = player.current_media + if queue is None or media is None: + raise web.HTTPNotFound(reason="No active queue or media found!") + + if media.queue_id is None: + raise web.HTTPError # TODO: better error + + queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id) + + if queue_item is None: + raise web.HTTPError # TODO: better error + + # TODO: set encoding quality using a bitrate parameter, + # maybe even dynamic with auto/semiauto switching with bad network? + if format_str == "mp3": + stream_format = AudioFormat(content_type=ContentType.MP3) + else: + stream_format = AudioFormat(content_type=ContentType.FLAC) + + pcm_format = AudioFormat( + sample_rate=stream_format.sample_rate, + content_type=DEFAULT_PCM_FORMAT.content_type, + bit_depth=DEFAULT_PCM_FORMAT.bit_depth, + channels=DEFAULT_PCM_FORMAT.channels, + ) + + async for chunk in get_ffmpeg_stream( + audio_input=self.mass.streams.get_queue_flow_stream( + queue=queue, + start_queue_item=queue_item, + pcm_format=pcm_format, + ), + input_format=pcm_format, + output_format=stream_format, + # Apple ignores "Accept-Ranges=none" on iOS and iPadOS for some reason, + # so we need to slowly feed the music to avoid the Browser stopping and later + # restarting the audio stream (from a wrong position!) by keeping the buffer short. + extra_input_args=["-readrate", "1.0", "-readrate_initial_burst", "15"], + filter_params=get_player_filter_params(self.mass, player_id, pcm_format, stream_format), + ): + try: + await resp.write(chunk) + except (ConnectionError, ConnectionResetError): + break + + return resp + + def update_builtin_player_state(self, state: BuiltinPlayerState) -> None: + """Update the current state of the player.""" + self._attr_elapsed_time_last_updated = time() + self.last_update = time() + self._attr_elapsed_time = float(state.position) + self._attr_volume_muted = state.muted + self._attr_volume_level = state.volume + if not state.powered: + self._attr_powered = False + self._attr_playback_state = PlaybackState.IDLE + elif state.playing: + self._attr_powered = True + self._attr_playback_state = PlaybackState.PLAYING + elif state.paused: + self._attr_powered = True + self._attr_playback_state = PlaybackState.PAUSED + else: + self._attr_powered = True + self._attr_playback_state = PlaybackState.IDLE + + self.update_state() diff --git a/music_assistant/providers/builtin_player/provider.py b/music_assistant/providers/builtin_player/provider.py new file mode 100644 index 00000000..92349324 --- /dev/null +++ b/music_assistant/providers/builtin_player/provider.py @@ -0,0 +1,135 @@ +"""Provider implementation for the Built-in Player.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, cast, override + +import shortuuid +from music_assistant_models.builtin_player import BuiltinPlayerEvent, BuiltinPlayerState +from music_assistant_models.enums import ( + BuiltinPlayerEventType, + EventType, + PlayerFeature, + ProviderFeature, +) + +from music_assistant.mass import MusicAssistant +from music_assistant.models.player import Player +from music_assistant.models.player_provider import PlayerProvider + +from .player import BuiltinPlayer + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + + +class BuiltinPlayerProvider(PlayerProvider): + """Builtin Player Provider for playing to the Music Assistant Web Interface.""" + + _unregister_cbs: list[Callable[[], None]] = [] + + def __init__( + self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig + ) -> None: + """Initialize the provider.""" + super().__init__(mass, manifest, config) + self._unregister_cbs = [ + self.mass.register_api_command("builtin_player/register", self.register_player), + self.mass.register_api_command("builtin_player/unregister", self.unregister_player), + self.mass.register_api_command("builtin_player/update_state", self.update_player_state), + ] + + @property + @override + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.REMOVE_PLAYER} + + @override + async def unload(self, is_removed: bool = False) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + is_removed will be set to True when the provider is removed from the configuration. + """ + for unload_cb in self._unregister_cbs: + unload_cb() + + @override + async def remove_player(self, player_id: str) -> None: + """Remove a player.""" + self.mass.signal_event( + EventType.BUILTIN_PLAYER, + player_id, + BuiltinPlayerEvent(type=BuiltinPlayerEventType.TIMEOUT), + ) + await self.unregister_player(player_id) + + async def register_player(self, player_name: str, player_id: str | None) -> Player: + """Register a player. + + Every player must first be registered through this `builtin_player/register` API command + before any playback can occur. + Since players queues can time out, this command either will create a new player queue, + or restore it from the last session. + + - player_name: Human readable name of the player, will only be used in case this call + creates a new queue. + - player_id: the id of the builtin player, set to None on new sessions. The returned player + will have a new random player_id + """ + if player_id is None: + player_id = f"ma_{shortuuid.random(10).lower()}" + + player_features = { + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PAUSE, + PlayerFeature.POWER, + } + + player = self.mass.players.get(player_id) + + if player is None: + player = BuiltinPlayer( + player_id=player_id, + provider=self, + name=player_name, + features=tuple(player_features), + ) + await self.mass.players.register_or_update(player) + else: + if TYPE_CHECKING: + player = cast("BuiltinPlayer", player) + player.register(player_name) + + return player + + async def unregister_player(self, player_id: str) -> None: + """Manually unregister a player with `builtin_player/unregister`.""" + if player := self.mass.players.get(player_id): + if TYPE_CHECKING: + player = cast("BuiltinPlayer", player) + player.unregister_routes() + + async def update_player_state(self, player_id: str, state: BuiltinPlayerState) -> bool: + """Update current state of a player. + + A player must periodically update the state of through this `builtin_player/update_state` + API command. + + Returns False in case the player already timed out or simply doesn't exist. + In that case, register the player first with `builtin_player/register`. + """ + if not (player := self.mass.players.get(player_id)): + return False + + if TYPE_CHECKING: + player = cast("BuiltinPlayer", player) + + player.update_builtin_player_state(state) + + return True diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index 04e23b3a..f463d083 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -2,99 +2,27 @@ from __future__ import annotations -import asyncio -import contextlib -import logging -import threading -import time -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any -from uuid import UUID +from typing import TYPE_CHECKING -import pychromecast -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.enums import ( - ConfigEntryType, - MediaType, - PlayerFeature, - PlayerState, - PlayerType, -) -from music_assistant_models.errors import PlayerUnavailableError -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia -from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE, MediaController -from pychromecast.controllers.multizone import MultizoneController, MultizoneManager -from pychromecast.discovery import CastBrowser, SimpleCastListener -from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED +from pychromecast.controllers.media import MediaController -from music_assistant.constants import ( - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_MANUAL_DISCOVERY_IPS, - CONF_ENTRY_OUTPUT_CODEC, - CONF_PLAYERS, - MASS_LOGO_ONLINE, - VERBOSE_LOG_LEVEL, - create_sample_rates_config_entry, -) -from music_assistant.models.player_provider import PlayerProvider +from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS -from .helpers import CastStatusListener, ChromecastInfo +from .provider import ChromecastProvider if TYPE_CHECKING: - from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig from music_assistant_models.provider import ProviderManifest - from pychromecast.controllers.media import MediaStatus - from pychromecast.controllers.receiver import CastStatus - from pychromecast.models import CastInfo - from pychromecast.socket_client import ConnectionStatus - from music_assistant import MusicAssistant + from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType -MASS_APP_ID = "C35B0678" -APP_MEDIA_RECEIVER = "CC1AD845" -CONF_USE_MASS_APP = "use_mass_app" - - -CAST_PLAYER_CONFIG_ENTRIES = ( - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_HTTP_PROFILE, - ConfigEntry( - key=CONF_USE_MASS_APP, - type=ConfigEntryType.BOOLEAN, - label="Use Music Assistant Cast App", - default_value=True, - description="By default, Music Assistant will use a special Music Assistant " - "Cast Receiver app to play media on cast devices. It is tweaked to provide " - "better metadata and future expansion. \n\n" - "If you want to use the official Google Cast Receiver app instead, disable this option, " - "for example if your device has issues with the Music Assistant app.", - category="advanced", - ), -) - -# originally/officially cast supports 96k sample rate (even for groups) -# but it seems a (recent?) update broke this ?! -# For now only set safe default values and let the user try out higher values -CONF_ENTRY_SAMPLE_RATES_CAST = create_sample_rates_config_entry( - max_sample_rate=192000, - max_bit_depth=24, - safe_max_sample_rate=48000, - safe_max_bit_depth=16, -) -CONF_ENTRY_SAMPLE_RATES_CAST_GROUP = create_sample_rates_config_entry( - max_sample_rate=96000, - max_bit_depth=24, - safe_max_sample_rate=48000, - safe_max_bit_depth=16, -) - # Monkey patch the Media controller here to store the queue items _patched_process_media_status_org = MediaController._process_media_status -def _patched_process_media_status(self, data) -> None: +def _patched_process_media_status(self: MediaController, data: dict) -> None: """Process STATUS message(s) of the media controller.""" _patched_process_media_status_org(self, data) for status_msg in data.get("status", []): @@ -103,6 +31,7 @@ def _patched_process_media_status(self, data) -> None: self.status.items = items +# Apply the monkey patch MediaController._process_media_status = _patched_process_media_status @@ -128,650 +57,3 @@ async def get_config_entries( """ # ruff: noqa: ARG001 return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,) - - -@dataclass -class CastPlayer: - """Wrapper around Chromecast with some additional attributes.""" - - player_id: str - cast_info: ChromecastInfo - cc: pychromecast.Chromecast - player: Player - status_listener: CastStatusListener | None = None - mz_controller: MultizoneController | None = None - active_group: str | None = None - last_poll: float = 0 - flow_meta_checksum: str | None = None - - -class ChromecastProvider(PlayerProvider): - """Player provider for Chromecast based players.""" - - mz_mgr: MultizoneManager | None = None - browser: CastBrowser | None = None - castplayers: dict[str, CastPlayer] - _discover_lock: threading.Lock - - def __init__( - self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig - ) -> None: - """Handle async initialization of the provider.""" - super().__init__(mass, manifest, config) - self._discover_lock = threading.Lock() - self.castplayers = {} - self.mz_mgr = MultizoneManager() - # Handle config option for manual IP's - manual_ip_config: list[str] = config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key) - self.browser = CastBrowser( - SimpleCastListener( - add_callback=self._on_chromecast_discovered, - remove_callback=self._on_chromecast_removed, - update_callback=self._on_chromecast_discovered, - ), - self.mass.aiozc.zeroconf, - known_hosts=manual_ip_config, - ) - # set-up pychromecast logging - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("pychromecast").setLevel(logging.DEBUG) - else: - logging.getLogger("pychromecast").setLevel(self.logger.level + 10) - - async def discover_players(self) -> None: - """Discover Cast players on the network.""" - await self.mass.loop.run_in_executor(None, self.browser.start_discovery) - - async def unload(self, is_removed: bool = False) -> None: - """Handle close/cleanup of the provider.""" - if not self.browser: - return - - # stop discovery - def stop_discovery() -> None: - """Stop the chromecast discovery threads.""" - if self.browser._zc_browser: - with contextlib.suppress(RuntimeError): - self.browser._zc_browser.cancel() - - self.browser.host_browser.stop.set() - self.browser.host_browser.join() - - await self.mass.loop.run_in_executor(None, stop_discovery) - # stop all chromecasts - for castplayer in list(self.castplayers.values()): - await self._disconnect_chromecast(castplayer) - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - cast_player = self.castplayers.get(player_id) - base_entries = await super().get_player_config_entries(player_id) - if cast_player and cast_player.player.type == PlayerType.GROUP: - return ( - *base_entries, - *CAST_PLAYER_CONFIG_ENTRIES, - CONF_ENTRY_SAMPLE_RATES_CAST_GROUP, - ) - - return (*base_entries, *CAST_PLAYER_CONFIG_ENTRIES, CONF_ENTRY_SAMPLE_RATES_CAST) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.media_controller.stop) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.media_controller.play) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.media_controller.pause) - - async def cmd_next(self, player_id: str) -> None: - """Handle NEXT TRACK command for given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.media_controller.queue_next) - - async def cmd_previous(self, player_id: str) -> None: - """Handle PREVIOUS TRACK command for given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.media_controller.queue_prev) - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Send POWER command to given player.""" - castplayer = self.castplayers[player_id] - if powered: - await self._launch_app(castplayer) - else: - castplayer.player.active_group = None - castplayer.player.active_source = None - await asyncio.to_thread(castplayer.cc.quit_app) - # optimistically update the group childs - if castplayer.player.type == PlayerType.GROUP: - active_group = castplayer.player.active_group or castplayer.player.player_id - for child_id in castplayer.player.group_childs: - if child := self.castplayers.get(child_id): - child.player.powered = powered - child.player.active_group = active_group if powered else None - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.set_volume, volume_level / 100) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - castplayer = self.castplayers[player_id] - await asyncio.to_thread(castplayer.cc.set_volume_muted, muted) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - castplayer = self.castplayers[player_id] - queuedata = { - "type": "LOAD", - "media": self._create_cc_media_item(media), - } - # make sure that our media controller app is launched - await self._launch_app(castplayer) - # send queue info to the CC - media_controller = castplayer.cc.media_controller - await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True) - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of the next item on the player.""" - castplayer = self.castplayers[player_id] - next_item_id = None - status = castplayer.cc.media_controller.status - # lookup position of current track in cast queue - cast_current_item_id = getattr(status, "current_item_id", 0) - cast_queue_items = getattr(status, "items", []) - cur_item_found = False - for item in cast_queue_items: - if item["itemId"] == cast_current_item_id: - cur_item_found = True - continue - if not cur_item_found: - continue - next_item_id = item["itemId"] - # check if the next queue item isn't already queued - if item.get("media", {}).get("customData", {}).get("uri") == media.uri: - return - queuedata = { - "type": "QUEUE_INSERT", - "insertBefore": next_item_id, - "items": [ - { - "autoplay": True, - "startTime": 0, - "preloadTime": 0, - "media": self._create_cc_media_item(media), - } - ], - } - media_controller = castplayer.cc.media_controller - queuedata["mediaSessionId"] = media_controller.status.media_session_id - await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True) - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - castplayer = self.castplayers[player_id] - # only update status of media controller if player is on - if not castplayer.player.powered: - return - if not castplayer.cc.media_controller.is_active: - return - try: - now = time.time() - if (now - castplayer.last_poll) >= 60: - castplayer.last_poll = now - await asyncio.to_thread(castplayer.cc.media_controller.update_status) - await self.update_flow_metadata(castplayer) - except ConnectionResetError as err: - raise PlayerUnavailableError from err - - ### Discovery callbacks - - def _on_chromecast_discovered(self, uuid, _) -> None: - """Handle Chromecast discovered callback.""" - if self.mass.closing: - return - - 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 - - player_id = str(disc_info.uuid) - - 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 - - self.logger.debug("Discovered new or updated chromecast %s", disc_info) - - castplayer = self.castplayers.get(player_id) - if castplayer: - # 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) - return - # new player discovered - cast_info = ChromecastInfo.from_cast_info(disc_info) - cast_info.fill_out_missing_chromecast_info(self.mass.aiozc.zeroconf) - if cast_info.is_dynamic_group: - self.logger.debug("Discovered a dynamic cast group which will be ignored.") - return - if cast_info.is_multichannel_child: - self.logger.debug( - "Discovered a passive (multichannel) endpoint which will be ignored." - ) - return - - # Disable TV's by default - # (can be enabled manually by the user) - enabled_by_default = True - for exclude in ("tv", "/12", "PUS", "OLED"): - if exclude.lower() in cast_info.friendly_name.lower(): - enabled_by_default = False - - if cast_info.is_audio_group and cast_info.is_multichannel_group: - player_type = PlayerType.STEREO_PAIR - elif cast_info.is_audio_group: - player_type = PlayerType.GROUP - else: - player_type = PlayerType.PLAYER - # Instantiate chromecast object - castplayer = CastPlayer( - player_id, - cast_info=cast_info, - cc=pychromecast.get_chromecast_from_cast_info( - disc_info, - self.mass.aiozc.zeroconf, - ), - player=Player( - player_id=player_id, - provider=self.instance_id, - type=player_type, - name=cast_info.friendly_name, - available=False, - powered=False, - device_info=DeviceInfo( - model=cast_info.model_name, - ip_address=f"{cast_info.host}:{cast_info.port}", - manufacturer=cast_info.manufacturer, - ), - supported_features={ - PlayerFeature.POWER, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.VOLUME_SET, - PlayerFeature.PAUSE, - PlayerFeature.NEXT_PREVIOUS, - PlayerFeature.ENQUEUE, - }, - enabled_by_default=enabled_by_default, - needs_poll=True, - ), - ) - self.castplayers[player_id] = castplayer - - castplayer.status_listener = CastStatusListener(self, castplayer, self.mz_mgr) - if castplayer.player.type == PlayerType.GROUP: - mz_controller = MultizoneController(cast_info.uuid) - castplayer.cc.register_handler(mz_controller) - castplayer.mz_controller = mz_controller - - castplayer.cc.start() - asyncio.run_coroutine_threadsafe( - self.mass.players.register_or_update(castplayer.player), loop=self.mass.loop - ) - - def _on_chromecast_removed(self, uuid, service, cast_info) -> None: - """Handle zeroconf discovery of a removed Chromecast.""" - player_id = str(service[1]) - friendly_name = service[3] - self.logger.debug("Chromecast removed: %s - %s", friendly_name, player_id) - # we ignore this event completely as the Chromecast socket client handles this itself - - ### Callbacks from Chromecast Statuslistener - - def on_new_cast_status(self, castplayer: CastPlayer, status: CastStatus) -> None: - """Handle updated CastStatus.""" - if status is None: - return # guard - self.logger.log( - VERBOSE_LOG_LEVEL, - "Received cast status for %s - app_id: %s - volume: %s", - castplayer.player.display_name, - status.app_id, - status.volume_level, - ) - # handle stereo pairs - if castplayer.cast_info.is_multichannel_group: - castplayer.player.type = PlayerType.STEREO_PAIR - castplayer.player.group_childs.clear() - # handle cast groups - if castplayer.cast_info.is_audio_group and not castplayer.cast_info.is_multichannel_group: - castplayer.player.type = PlayerType.GROUP - castplayer.player.group_childs.set( - str(UUID(x)) for x in castplayer.mz_controller.members - ) - castplayer.player.supported_features = { - PlayerFeature.POWER, - PlayerFeature.VOLUME_SET, - PlayerFeature.PAUSE, - PlayerFeature.ENQUEUE, - } - - # update player status - castplayer.player.name = castplayer.cast_info.friendly_name - castplayer.player.volume_level = int(status.volume_level * 100) - castplayer.player.volume_muted = status.volume_muted - new_powered = ( - castplayer.cc.app_id is not None and castplayer.cc.app_id != pychromecast.IDLE_APP_ID - ) - if ( - castplayer.player.powered - and not new_powered - and castplayer.player.type == PlayerType.GROUP - ): - # group is being powered off, update group childs - for child_id in castplayer.player.group_childs: - if child := self.castplayers.get(child_id): - child.player.powered = False - child.player.active_group = None - child.player.active_source = None - castplayer.player.powered = new_powered - # send update to player manager - self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) - - def on_new_media_status(self, castplayer: CastPlayer, status: MediaStatus) -> None: - """Handle updated MediaStatus.""" - self.logger.log( - VERBOSE_LOG_LEVEL, - "Received media status for %s update: %s", - castplayer.player.display_name, - status.player_state, - ) - # handle castplayer playing from a group - group_player: CastPlayer | None = None - if castplayer.active_group is not None: - if not (group_player := self.castplayers.get(castplayer.active_group)): - return - status = group_player.cc.media_controller.status - - # player state - castplayer.player.elapsed_time_last_updated = time.time() - if status.player_is_playing: - castplayer.player.state = PlayerState.PLAYING - castplayer.player.current_item_id = status.content_id - elif status.player_is_paused: - castplayer.player.state = PlayerState.PAUSED - castplayer.player.current_item_id = status.content_id - else: - castplayer.player.state = PlayerState.IDLE - castplayer.player.current_item_id = None - - # elapsed time - castplayer.player.elapsed_time_last_updated = time.time() - castplayer.player.elapsed_time = status.adjusted_current_time - if status.player_is_playing: - castplayer.player.elapsed_time = status.adjusted_current_time - else: - castplayer.player.elapsed_time = status.current_time - - # active source - if group_player: - castplayer.player.active_source = ( - group_player.player.active_source or group_player.player.player_id - ) - castplayer.player.active_group = ( - group_player.player.active_group or group_player.player.player_id - ) - elif castplayer.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER): - castplayer.player.active_source = castplayer.player_id - else: - castplayer.player.active_source = castplayer.cc.app_display_name - - if status.content_id and not status.player_is_idle: - castplayer.player.current_media = PlayerMedia( - uri=status.content_id, - title=status.title, - artist=status.artist, - album=status.album_name, - image_url=status.images[0].url if status.images else None, - duration=status.duration, - media_type=MediaType.TRACK, - ) - else: - castplayer.player.current_media = None - - # weird workaround which is needed for multichannel group childs - # (e.g. a stereo pair within a cast group) - # where it does not receive updates from the group, - # so we need to update the group child(s) manually - if castplayer.player.type == PlayerType.GROUP and castplayer.player.powered: - for child_id in castplayer.player.group_childs: - if child := self.castplayers.get(child_id): - if not child.cast_info.is_multichannel_group: - continue - child.player.state = castplayer.player.state - child.player.current_media = castplayer.player.current_media - child.player.elapsed_time = castplayer.player.elapsed_time - child.player.elapsed_time_last_updated = ( - castplayer.player.elapsed_time_last_updated - ) - child.player.active_source = castplayer.player.active_source - child.player.active_group = castplayer.player.active_group - - self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) - - def on_new_connection_status(self, castplayer: CastPlayer, status: ConnectionStatus) -> None: - """Handle updated ConnectionStatus.""" - self.logger.log( - VERBOSE_LOG_LEVEL, - "Received connection status update for %s - status: %s", - castplayer.player.display_name, - status.status, - ) - - if status.status == CONNECTION_STATUS_DISCONNECTED: - castplayer.player.available = False - self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) - return - - new_available = status.status == CONNECTION_STATUS_CONNECTED - if new_available != castplayer.player.available: - self.logger.debug( - "[%s] Cast device availability changed: %s", - castplayer.cast_info.friendly_name, - status.status, - ) - castplayer.player.available = new_available - castplayer.player.device_info = DeviceInfo( - model=castplayer.cast_info.model_name, - ip_address=f"{castplayer.cast_info.host}:{castplayer.cast_info.port}", - manufacturer=castplayer.cast_info.manufacturer, - ) - self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id) - if new_available and castplayer.player.type == PlayerType.PLAYER: - # Poll current group status - for group_uuid in self.mz_mgr.get_multizone_memberships(castplayer.cast_info.uuid): - group_media_controller = self.mz_mgr.get_multizone_mediacontroller(group_uuid) - if not group_media_controller: - continue - - ### Helpers / utils - - async def _launch_app(self, castplayer: CastPlayer) -> None: - """Launch the default Media Receiver App on a Chromecast.""" - event = asyncio.Event() - - if self.mass.config.get_raw_player_config_value( - castplayer.player_id, CONF_USE_MASS_APP, True - ): - app_id = MASS_APP_ID - else: - app_id = APP_MEDIA_RECEIVER - - if castplayer.cc.app_id == app_id: - return # already active - - def launched_callback(success: bool, response: dict[str, Any] | None) -> None: - self.mass.loop.call_soon_threadsafe(event.set) - - def launch() -> None: - # Quit the previous app before starting splash screen or media player - if castplayer.cc.app_id is not None: - castplayer.cc.quit_app() - self.logger.debug("Launching App %s.", app_id) - castplayer.cc.socket_client.receiver_controller.launch_app( - app_id, - force_launch=True, - callback_function=launched_callback, - ) - - await self.mass.loop.run_in_executor(None, launch) - await event.wait() - - async def _disconnect_chromecast(self, castplayer: CastPlayer) -> None: - """Disconnect Chromecast object if it is set.""" - self.logger.debug("Disconnecting from chromecast socket %s", castplayer.player.display_name) - await self.mass.loop.run_in_executor(None, castplayer.cc.disconnect, 10) - castplayer.mz_controller = None - castplayer.status_listener.invalidate() - castplayer.status_listener = None - self.castplayers.pop(castplayer.player_id, None) - - def _create_cc_media_item(self, media: PlayerMedia) -> dict[str, Any]: - """Create CC media item from MA PlayerMedia.""" - if media.media_type == MediaType.TRACK: - stream_type = STREAM_TYPE_BUFFERED - else: - stream_type = STREAM_TYPE_LIVE - metadata = { - "metadataType": 3, - "albumName": media.album or "", - "songName": media.title or "", - "artist": media.artist or "", - "title": media.title or "", - "images": [{"url": media.image_url}] if media.image_url else None, - } - return { - "contentId": media.uri, - "customData": { - "uri": media.uri, - "queue_item_id": media.uri, - "deviceName": "Music Assistant", - }, - "contentType": "audio/flac", - "streamType": stream_type, - "metadata": metadata, - "duration": media.duration, - } - - async def update_flow_metadata(self, castplayer: CastPlayer) -> None: - """Update the metadata of a cast player running the flow stream.""" - if not castplayer.player.powered: - castplayer.player.poll_interval = 300 - return - if not castplayer.cc.media_controller.status.player_is_playing: - return - if castplayer.active_group: - return - if castplayer.player.state != PlayerState.PLAYING: - return - if castplayer.player.announcement_in_progress: - return - if not (queue := self.mass.player_queues.get_active_queue(castplayer.player_id)): - return - if not (current_item := queue.current_item): - return - if not (queue.flow_mode or current_item.media_type == MediaType.RADIO): - return - castplayer.player.poll_interval = 10 - media_controller = castplayer.cc.media_controller - # update metadata of current item chromecast - if ( - media_controller.status.media_custom_data.get("queue_item_id") - != current_item.queue_item_id - ): - image_url = ( - self.mass.metadata.get_image_url(current_item.image, size=512) - if current_item.image - else MASS_LOGO_ONLINE - ) - if (streamdetails := current_item.streamdetails) and streamdetails.stream_title: - album = current_item.media_item.name - if " - " in streamdetails.stream_title: - artist, title = streamdetails.stream_title.split(" - ", 1) - else: - artist = "" - title = streamdetails.stream_title - elif media_item := current_item.media_item: - album = _album.name if (_album := getattr(media_item, "album", None)) else "" - artist = getattr(media_item, "artist_str", "") - title = media_item.name - else: - album = "" - artist = "" - title = current_item.name - flow_meta_checksum = title + image_url - if castplayer.flow_meta_checksum == flow_meta_checksum: - return - castplayer.flow_meta_checksum = flow_meta_checksum - queuedata = { - "type": "PLAY", - "mediaSessionId": media_controller.status.media_session_id, - "customData": { - "metadata": { - "metadataType": 3, - "albumName": album, - "songName": title, - "artist": artist, - "title": title, - "images": [{"url": image_url}], - } - }, - } - await asyncio.to_thread( - media_controller.send_message, data=queuedata, inc_session_id=True - ) - - if len(getattr(media_controller.status, "items", [])) < 2: - # In flow mode, all queue tracks are sent to the player as continuous stream. - # add a special 'command' item to the queue - # this allows for on-player next buttons/commands to still work - cmd_next_url = self.mass.streams.get_command_url(queue.queue_id, "next") - msg = { - "type": "QUEUE_INSERT", - "mediaSessionId": media_controller.status.media_session_id, - "items": [ - { - "media": { - "contentId": cmd_next_url, - "customData": { - "uri": cmd_next_url, - "queue_item_id": cmd_next_url, - "deviceName": "Music Assistant", - }, - "contentType": "audio/flac", - "streamType": STREAM_TYPE_LIVE, - "metadata": {}, - }, - "autoplay": True, - "startTime": 0, - "preloadTime": 0, - } - ], - } - await asyncio.to_thread(media_controller.send_message, data=msg, inc_session_id=True) diff --git a/music_assistant/providers/chromecast/constants.py b/music_assistant/providers/chromecast/constants.py new file mode 100644 index 00000000..af26e017 --- /dev/null +++ b/music_assistant/providers/chromecast/constants.py @@ -0,0 +1,49 @@ +"""Constants for Chromecast Player provider.""" + +from __future__ import annotations + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +from music_assistant.constants import ( + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_OUTPUT_CODEC, + create_sample_rates_config_entry, +) + +MASS_APP_ID = "C35B0678" +APP_MEDIA_RECEIVER = "CC1AD845" +CONF_USE_MASS_APP = "use_mass_app" + +CAST_PLAYER_CONFIG_ENTRIES = ( + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_HTTP_PROFILE, + ConfigEntry( + key=CONF_USE_MASS_APP, + type=ConfigEntryType.BOOLEAN, + label="Use Music Assistant Cast App", + default_value=True, + description="By default, Music Assistant will use a special Music Assistant " + "Cast Receiver app to play media on cast devices. It is tweaked to provide " + "better metadata and future expansion. \\n\\n" + "If you want to use the official Google Cast Receiver app instead, disable this option, " + "for example if your device has issues with the Music Assistant app.", + category="advanced", + ), +) + +# originally/officially cast supports 96k sample rate (even for groups) +# but it seems a (recent?) update broke this ?! +# For now only set safe default values and let the user try out higher values +CONF_ENTRY_SAMPLE_RATES_CAST = create_sample_rates_config_entry( + max_sample_rate=192000, + max_bit_depth=24, + safe_max_sample_rate=48000, + safe_max_bit_depth=16, +) +CONF_ENTRY_SAMPLE_RATES_CAST_GROUP = create_sample_rates_config_entry( + max_sample_rate=96000, + max_bit_depth=24, + safe_max_sample_rate=48000, + safe_max_bit_depth=16, +) diff --git a/music_assistant/providers/chromecast/helpers.py b/music_assistant/providers/chromecast/helpers.py index 97735e55..f1e717d4 100644 --- a/music_assistant/providers/chromecast/helpers.py +++ b/music_assistant/providers/chromecast/helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations import urllib.error from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING from uuid import UUID from pychromecast import dial @@ -20,7 +20,7 @@ if TYPE_CHECKING: from pychromecast.socket_client import ConnectionStatus from zeroconf import ServiceInfo, Zeroconf - from . import CastPlayer, ChromecastProvider + from .player import ChromecastPlayer DEFAULT_PORT = 8009 @@ -50,7 +50,7 @@ class ChromecastInfo: return self.cast_type == CAST_TYPE_GROUP @classmethod - def from_cast_info(cls: Self, cast_info: CastInfo) -> Self: + def from_cast_info(cls, cast_info: CastInfo) -> ChromecastInfo: """Instantiate ChromecastInfo from CastInfo.""" return cls(**asdict(cast_info)) @@ -128,23 +128,19 @@ class CastStatusListener: def __init__( self, - prov: ChromecastProvider, - castplayer: CastPlayer, + castplayer: ChromecastPlayer, mz_mgr: MultizoneManager, mz_only=False, ) -> None: """Initialize the status listener.""" - self.prov = prov self.castplayer = castplayer self._uuid = castplayer.cc.uuid self._valid = True self._mz_mgr = mz_mgr - if self.castplayer.cast_info.is_audio_group: self._mz_mgr.add_multizone(castplayer.cc) if mz_only: return - castplayer.cc.register_status_listener(self) castplayer.cc.socket_client.media_controller.register_status_listener(self) castplayer.cc.register_connection_listener(self) @@ -155,24 +151,24 @@ class CastStatusListener: """Handle updated CastStatus.""" if not self._valid: return - self.prov.on_new_cast_status(self.castplayer, status) + self.castplayer.on_new_cast_status(status) def new_media_status(self, status: MediaStatus) -> None: """Handle updated MediaStatus.""" if not self._valid: return - self.prov.on_new_media_status(self.castplayer, status) + self.castplayer.on_new_media_status(status) def new_connection_status(self, status: ConnectionStatus) -> None: """Handle updated ConnectionStatus.""" if not self._valid: return - self.prov.on_new_connection_status(self.castplayer, status) + self.castplayer.on_new_connection_status(status) def added_to_multizone(self, group_uuid) -> None: """Handle the cast added to a group.""" - self.prov.logger.debug( - "%s is added to multizone: %s", self.castplayer.player.display_name, group_uuid + self.castplayer.logger.debug( + "%s is added to multizone: %s", self.castplayer.display_name, group_uuid ) self.new_cast_status(self.castplayer.cc.status) @@ -180,25 +176,29 @@ class CastStatusListener: """Handle the cast removed from a group.""" if not self._valid: return - if group_uuid == self.castplayer.player.active_source: - self.castplayer.player.active_source = None - self.prov.logger.debug( - "%s is removed from multizone: %s", self.castplayer.player.display_name, group_uuid + if group_uuid == self.castplayer.active_source: + mass = self.castplayer.mass + mass.loop.call_soon_threadsafe(self.castplayer.update_state) + self.castplayer.logger.debug( + "%s is removed from multizone: %s", self.castplayer.display_name, group_uuid ) self.new_cast_status(self.castplayer.cc.status) def multizone_new_cast_status(self, group_uuid, cast_status) -> None: """Handle reception of a new CastStatus for a group.""" - if group_player := self.prov.castplayers.get(group_uuid): + mass = self.castplayer.mass + if group_player := mass.players.get(group_uuid): + if TYPE_CHECKING: + assert isinstance(group_player, ChromecastPlayer) if group_player.cc.media_controller.is_active: self.castplayer.active_group = group_uuid elif group_uuid == self.castplayer.active_group: self.castplayer.active_group = None - self.prov.logger.log( + self.castplayer.logger.log( VERBOSE_LOG_LEVEL, "%s got new cast status for group: %s", - self.castplayer.player.display_name, + self.castplayer.display_name, group_uuid, ) self.new_cast_status(self.castplayer.cc.status) @@ -207,17 +207,17 @@ class CastStatusListener: """Handle reception of a new MediaStatus for a group.""" if not self._valid: return - self.prov.logger.log( + self.castplayer.logger.log( VERBOSE_LOG_LEVEL, "%s got new media_status for group: %s", - self.castplayer.player.display_name, + self.castplayer.display_name, group_uuid, ) - self.prov.on_new_media_status(self.castplayer, media_status) + self.castplayer.on_new_media_status(media_status) def load_media_failed(self, queue_item_id, error_code) -> None: """Call when media failed to load.""" - self.prov.logger.warning( + self.castplayer.logger.warning( "Load media failed: %s - error code: %s", queue_item_id, error_code ) diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py new file mode 100644 index 00000000..e0e55704 --- /dev/null +++ b/music_assistant/providers/chromecast/player.py @@ -0,0 +1,539 @@ +"""Chromecast Player implementation.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.errors import PlayerUnavailableError +from pychromecast import IDLE_APP_ID +from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIVE +from pychromecast.controllers.multizone import MultizoneController +from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED + +from music_assistant.constants import ( + ATTR_ANNOUNCEMENT_IN_PROGRESS, + MASS_LOGO_ONLINE, + VERBOSE_LOG_LEVEL, +) +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia + +from .constants import ( + APP_MEDIA_RECEIVER, + CAST_PLAYER_CONFIG_ENTRIES, + CONF_ENTRY_SAMPLE_RATES_CAST, + CONF_ENTRY_SAMPLE_RATES_CAST_GROUP, + CONF_USE_MASS_APP, + MASS_APP_ID, +) +from .helpers import CastStatusListener, ChromecastInfo + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry + from pychromecast import Chromecast + from pychromecast.controllers.media import MediaStatus + from pychromecast.controllers.receiver import CastStatus + from pychromecast.socket_client import ConnectionStatus + +from .provider import ChromecastProvider + + +class ChromecastPlayer(Player): + """Chromecast Player.""" + + def __init__( + self, + provider: ChromecastProvider, + player_id: str, + cast_info: ChromecastInfo, + chromecast: Chromecast, + ) -> None: + """Init.""" + super().__init__(provider, player_id) + if cast_info.is_audio_group and cast_info.is_multichannel_group: + player_type = PlayerType.STEREO_PAIR + elif cast_info.is_audio_group: + player_type = PlayerType.GROUP + else: + player_type = PlayerType.PLAYER + self.cc = chromecast + self.status_listener: CastStatusListener | None + self.cast_info = cast_info + self.mz_controller: MultizoneController | None = None + self.last_poll = 0.0 + self.flow_meta_checksum: str | None = None + # set static variables + self._attr_supported_features = { + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + PlayerFeature.PAUSE, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.ENQUEUE, + } + self._attr_name = self.cast_info.friendly_name + self._attr_available = False + self._attr_powered = False + self._attr_needs_poll = True + self._attr_type = player_type + # Disable TV's by default + # (can be enabled manually by the user) + enabled_by_default = True + for exclude in ("tv", "/12", "PUS", "OLED"): + if exclude.lower() in cast_info.friendly_name.lower(): + enabled_by_default = False + self._attr_enabled_by_default = enabled_by_default + + self._attr_device_info = DeviceInfo( + model=self.cast_info.model_name, + ip_address=f"{self.cast_info.host}:{self.cast_info.port}", + manufacturer=self.cast_info.manufacturer or "", + ) + assert provider.mz_mgr is not None # for type checking + status_listener = CastStatusListener(self, provider.mz_mgr) + self.status_listener = status_listener + if player_type == PlayerType.GROUP: + mz_controller = MultizoneController(cast_info.uuid) + self.cc.register_handler(mz_controller) + self.mz_controller = mz_controller + self.cc.start() + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + base_entries = await super().get_config_entries() + if self.type == PlayerType.GROUP: + return [ + *base_entries, + *CAST_PLAYER_CONFIG_ENTRIES, + CONF_ENTRY_SAMPLE_RATES_CAST_GROUP, + ] + + return [*base_entries, *CAST_PLAYER_CONFIG_ENTRIES, CONF_ENTRY_SAMPLE_RATES_CAST] + + async def stop(self) -> None: + """Send STOP command to given player.""" + await asyncio.to_thread(self.cc.media_controller.stop) + + async def play(self) -> None: + """Send PLAY command to given player.""" + await asyncio.to_thread(self.cc.media_controller.play) + + async def pause(self) -> None: + """Send PAUSE command to given player.""" + await asyncio.to_thread(self.cc.media_controller.pause) + + async def next(self) -> None: + """Handle NEXT TRACK command for given player.""" + await asyncio.to_thread(self.cc.media_controller.queue_next) + + async def previous(self) -> None: + """Handle PREVIOUS TRACK command for given player.""" + await asyncio.to_thread(self.cc.media_controller.queue_prev) + + async def power(self, powered: bool) -> None: + """Send POWER command to given player.""" + if powered: + await self._launch_app() + self._attr_active_source = self.player_id + else: + self._attr_active_source = None + await asyncio.to_thread(self.cc.quit_app) + # optimistically update the state + self.mass.loop.call_soon_threadsafe(self.update_state) + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + await asyncio.to_thread(self.cc.set_volume, volume_level / 100) + + async def volume_mute(self, muted: bool) -> None: + """Send VOLUME MUTE command to given player.""" + await asyncio.to_thread(self.cc.set_volume_muted, muted) + + async def play_media( + self, + media: PlayerMedia, + ) -> None: + """Handle PLAY MEDIA on given player.""" + queuedata = { + "type": "LOAD", + "media": self._create_cc_media_item(media), + } + # make sure that our media controller app is launched + await self._launch_app() + # send queue info to the CC + media_controller = self.cc.media_controller + await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True) + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing of the next item on the player.""" + next_item_id = None + status = self.cc.media_controller.status + # lookup position of current track in cast queue + cast_current_item_id = getattr(status, "current_item_id", 0) + cast_queue_items = getattr(status, "items", []) + cur_item_found = False + for item in cast_queue_items: + if item["itemId"] == cast_current_item_id: + cur_item_found = True + continue + if not cur_item_found: + continue + next_item_id = item["itemId"] + # check if the next queue item isn't already queued + if item.get("media", {}).get("customData", {}).get("uri") == media.uri: + return + queuedata = { + "type": "QUEUE_INSERT", + "insertBefore": next_item_id, + "items": [ + { + "autoplay": True, + "startTime": 0, + "preloadTime": 0, + "media": self._create_cc_media_item(media), + } + ], + } + media_controller = self.cc.media_controller + queuedata["mediaSessionId"] = media_controller.status.media_session_id + await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True) + + async def poll(self) -> None: + """Poll player for state updates.""" + # only update status of media controller if player is on + if not self.powered: + return + if not self.cc.media_controller.is_active: + return + try: + now = time.time() + if (now - self.last_poll) >= 60: + self.last_poll = now + await asyncio.to_thread(self.cc.media_controller.update_status) + await self.update_flow_metadata() + except ConnectionResetError as err: + raise PlayerUnavailableError from err + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + await super().on_unload() + self.logger.debug("Disconnecting from chromecast socket %s", self.display_name) + await self.mass.loop.run_in_executor(None, self.cc.disconnect, 10) + self.mz_controller = None + if self.status_listener is not None: + self.status_listener.invalidate() + self.status_listener = None + + async def update_flow_metadata(self) -> None: + """Update the metadata of a cast player running the flow stream.""" + if not self.powered: + self._attr_poll_interval = 300 + return + if not self.cc.media_controller.status.player_is_playing: + return + if self.active_group: + return + if self.state != PlaybackState.PLAYING: + return + if self.extra_attributes[ATTR_ANNOUNCEMENT_IN_PROGRESS]: + return + if not (queue := self.mass.player_queues.get_active_queue(self.player_id)): + return + if not (current_item := queue.current_item): + return + if not (queue.flow_mode or current_item.media_type == MediaType.RADIO): + return + self._attr_poll_interval = 10 + media_controller = self.cc.media_controller + # update metadata of current item chromecast + if ( + media_controller.status.media_custom_data.get("queue_item_id") + != current_item.queue_item_id + ): + image_url = ( + self.mass.metadata.get_image_url(current_item.image, size=512) + if current_item.image + else MASS_LOGO_ONLINE + ) + if (streamdetails := current_item.streamdetails) and streamdetails.stream_title: + assert current_item.media_item is not None # for type checking + album = current_item.media_item.name + if " - " in streamdetails.stream_title: + artist, title = streamdetails.stream_title.split(" - ", 1) + else: + artist = "" + title = streamdetails.stream_title + elif media_item := current_item.media_item: + album = _album.name if (_album := getattr(media_item, "album", None)) else "" + artist = getattr(media_item, "artist_str", "") + title = media_item.name + else: + album = "" + artist = "" + title = current_item.name + flow_meta_checksum = title + image_url + if self.flow_meta_checksum == flow_meta_checksum: + return + self.flow_meta_checksum = flow_meta_checksum + queuedata = { + "type": "PLAY", + "mediaSessionId": media_controller.status.media_session_id, + "customData": { + "metadata": { + "metadataType": 3, + "albumName": album, + "songName": title, + "artist": artist, + "title": title, + "images": [{"url": image_url}], + } + }, + } + await asyncio.to_thread( + media_controller.send_message, data=queuedata, inc_session_id=True + ) + + if len(getattr(media_controller.status, "items", [])) < 2: + # In flow mode, all queue tracks are sent to the player as continuous stream. + # add a special 'command' item to the queue + # this allows for on-player next buttons/commands to still work + cmd_next_url = self.mass.streams.get_command_url(queue.queue_id, "next") + msg = { + "type": "QUEUE_INSERT", + "mediaSessionId": media_controller.status.media_session_id, + "items": [ + { + "media": { + "contentId": cmd_next_url, + "customData": { + "uri": cmd_next_url, + "queue_item_id": cmd_next_url, + "deviceName": "Music Assistant", + }, + "contentType": "audio/flac", + "streamType": STREAM_TYPE_LIVE, + "metadata": {}, + }, + "autoplay": True, + "startTime": 0, + "preloadTime": 0, + } + ], + } + await asyncio.to_thread(media_controller.send_message, data=msg, inc_session_id=True) + + async def _launch_app(self) -> None: + """Launch the default Media Receiver App on a Chromecast.""" + event = asyncio.Event() + + if self.config.get_value(CONF_USE_MASS_APP, True): + app_id = MASS_APP_ID + else: + app_id = APP_MEDIA_RECEIVER + + if self.cc.app_id == app_id: + return # already active + + def launched_callback(success: bool, response: dict[str, Any] | None) -> None: # noqa: ARG001 + self.mass.loop.call_soon(event.set) + + def launch() -> None: + # Quit the previous app before starting splash screen or media player + if self.cc.app_id is not None: + self.cc.quit_app() + self.logger.debug("Launching App %s.", app_id) + self.cc.socket_client.receiver_controller.launch_app( + app_id, + force_launch=True, + callback_function=launched_callback, + ) + + await self.mass.loop.run_in_executor(None, launch) + await event.wait() + + ### Callbacks from Chromecast Statuslistener + + def on_new_cast_status(self, status: CastStatus) -> None: + """Handle updated CastStatus.""" + if status is None: + return # guard + self.logger.log( + VERBOSE_LOG_LEVEL, + "Received cast status for %s - app_id: %s - volume: %s", + self.display_name, + status.app_id, + status.volume_level, + ) + # handle stereo pairs + if self.cast_info.is_multichannel_group: + self._attr_type = PlayerType.STEREO_PAIR + self.group_members.clear() + # handle cast groups + if self.cast_info.is_audio_group and not self.cast_info.is_multichannel_group: + assert self.mz_controller is not None # for type checking + self._attr_type = PlayerType.GROUP + self._attr_group_members = [str(UUID(x)) for x in self.mz_controller.members] + self._attr_supported_features = { + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + PlayerFeature.PAUSE, + PlayerFeature.ENQUEUE, + } + + # update player status + self._attr_name = self.cast_info.friendly_name + self._attr_volume_level = int(status.volume_level * 100) + self._attr_volume_muted = status.volume_muted + new_powered = self.cc.app_id is not None and self.cc.app_id != IDLE_APP_ID + self._attr_powered = new_powered + if self._attr_powered and not new_powered and self._attr_type == PlayerType.GROUP: + # group is being powered off, update group childs + for child_id in self.group_members: + if child := self.mass.players.get(child_id): + self.mass.loop.call_soon_threadsafe(child.update_state) + self.mass.loop.call_soon_threadsafe(self.update_state) + + def on_new_media_status(self, status: MediaStatus) -> None: + """Handle updated MediaStatus.""" + self.logger.log( + VERBOSE_LOG_LEVEL, + "Received media status for %s update: %s", + self.display_name, + status.player_state, + ) + # handle player playing from a group + group_player: ChromecastPlayer | None = None + if self.active_group is not None: + if not (group_player := self.mass.players.get(self.active_group)): + return + if not isinstance(group_player, ChromecastPlayer): + return + status = group_player.cc.media_controller.status + + # player state + self._attr_elapsed_time_last_updated = time.time() + if status.player_is_playing: + self._attr_playback_state = PlaybackState.PLAYING + self.set_current_media(uri=status.content_id or "", clear_all=True) + elif status.player_is_paused: + self._attr_playback_state = PlaybackState.PAUSED + self._attr_current_media = None + else: + self._attr_playback_state = PlaybackState.IDLE + self._attr_current_media = None + + # elapsed time + self._attr_elapsed_time_last_updated = time.time() + self._attr_elapsed_time = status.adjusted_current_time + if status.player_is_playing: + self._attr_elapsed_time = status.adjusted_current_time + else: + self._attr_elapsed_time = status.current_time + + # active source + if group_player: + self._attr_active_source = group_player.active_source or group_player.player_id + elif self.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER): + self._attr_active_source = self.player_id + else: + self._attr_active_source = self.cc.app_display_name + + if status.content_id and not status.player_is_idle: + self.set_current_media( + uri=status.content_id, + title=status.title, + artist=status.artist, + album=status.album_name, + image_url=status.images[0].url if status.images else None, + duration=int(status.duration) if status.duration is not None else None, + media_type=MediaType.TRACK, + ) + else: + self._attr_current_media = None + + # weird workaround which is needed for multichannel group childs + # (e.g. a stereo pair within a cast group) + # where it does not receive updates from the group, + # so we need to update the group child(s) manually + if self.type == PlayerType.GROUP and self.powered: + for child_id in self.group_members: + if child := self.mass.players.get(child_id): + assert isinstance(child, ChromecastPlayer) # for type checking + if not child.cast_info.is_multichannel_group: + continue + child._attr_playback_state = self.playback_state + child._attr_current_media = self.current_media + child._attr_elapsed_time = self.elapsed_time + child._attr_elapsed_time_last_updated = self.elapsed_time_last_updated + child._attr_active_source = self.active_source + self.mass.loop.call_soon_threadsafe(child.update_state) + self.mass.loop.call_soon_threadsafe(self.update_state) + + def on_new_connection_status(self, status: ConnectionStatus) -> None: + """Handle updated ConnectionStatus.""" + self.logger.log( + VERBOSE_LOG_LEVEL, + "Received connection status update for %s - status: %s", + self.display_name, + status.status, + ) + + if status.status == CONNECTION_STATUS_DISCONNECTED: + self._attr_available = False + self.mass.loop.call_soon_threadsafe(self.update_state) + return + + new_available = status.status == CONNECTION_STATUS_CONNECTED + if new_available != self.available: + self.logger.debug( + "[%s] Cast device availability changed: %s", + self.cast_info.friendly_name, + status.status, + ) + self._attr_available = new_available + self._attr_device_info = DeviceInfo( + model=self.cast_info.model_name, + ip_address=f"{self.cast_info.host}:{self.cast_info.port}", + manufacturer=self.cast_info.manufacturer or "", + ) + self.mass.loop.call_soon_threadsafe(self.update_state) + + if new_available and self.type == PlayerType.PLAYER: + # Poll current group status + provider = self.provider + assert isinstance(provider, ChromecastProvider) # for type checking + mz_mgr = provider.mz_mgr + assert mz_mgr is not None # for type checking + for group_uuid in mz_mgr.get_multizone_memberships(self.cast_info.uuid): + group_media_controller = mz_mgr.get_multizone_mediacontroller(UUID(group_uuid)) + if not group_media_controller: + continue + + def _create_cc_media_item(self, media: PlayerMedia) -> dict[str, Any]: + """Create CC media item from MA PlayerMedia.""" + if media.media_type == MediaType.TRACK: + stream_type = STREAM_TYPE_BUFFERED + else: + stream_type = STREAM_TYPE_LIVE + metadata = { + "metadataType": 3, + "albumName": media.album or "", + "songName": media.title or "", + "artist": media.artist or "", + "title": media.title or "", + "images": [{"url": media.image_url}] if media.image_url else None, + } + return { + "contentId": media.uri, + "customData": { + "uri": media.uri, + "queue_item_id": media.uri, + "deviceName": "Music Assistant", + }, + "contentType": "audio/flac", + "streamType": stream_type, + "metadata": metadata, + "duration": media.duration, + } diff --git a/music_assistant/providers/chromecast/provider.py b/music_assistant/providers/chromecast/provider.py new file mode 100644 index 00000000..1d7874d6 --- /dev/null +++ b/music_assistant/providers/chromecast/provider.py @@ -0,0 +1,148 @@ +"""Chromecast Player Provider implementation.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import threading +from typing import TYPE_CHECKING, cast + +import pychromecast +from pychromecast.controllers.multizone import MultizoneManager +from pychromecast.discovery import CastBrowser, SimpleCastListener + +from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL +from music_assistant.models.player_provider import PlayerProvider + +from .helpers import ChromecastInfo +from .player import ChromecastPlayer + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + from pychromecast.models import CastInfo + + from music_assistant.mass import MusicAssistant + + +class ChromecastProvider(PlayerProvider): + """Player provider for Chromecast based players.""" + + mz_mgr: MultizoneManager | None = None + browser: CastBrowser | None = None + _discover_lock: threading.Lock + + def __init__( + self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig + ) -> None: + """Handle async initialization of the provider.""" + super().__init__(mass, manifest, config) + self._discover_lock = threading.Lock() + self.mz_mgr = MultizoneManager() + # Handle config option for manual IP's + manual_ip_config = cast("list[str]", config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)) + self.browser = CastBrowser( + SimpleCastListener( + add_callback=self._on_chromecast_discovered, + remove_callback=self._on_chromecast_removed, + update_callback=self._on_chromecast_discovered, + ), + self.mass.aiozc.zeroconf, + known_hosts=manual_ip_config, + ) + # set-up pychromecast logging + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("pychromecast").setLevel(logging.DEBUG) + else: + logging.getLogger("pychromecast").setLevel(self.logger.level + 10) + + async def discover_players(self) -> None: + """Discover Cast players on the network.""" + assert self.browser is not None # for type checking + await self.mass.loop.run_in_executor(None, self.browser.start_discovery) + + async def unload(self, is_removed: bool = False) -> None: + """Handle close/cleanup of the provider.""" + if not self.browser: + return + + # stop discovery + def stop_discovery() -> None: + """Stop the chromecast discovery threads.""" + assert self.browser is not None # for type checking + if self.browser._zc_browser: + with contextlib.suppress(RuntimeError): + self.browser._zc_browser.cancel() + + self.browser.host_browser.stop.set() + self.browser.host_browser.join() + + await self.mass.loop.run_in_executor(None, stop_discovery) + + ### Discovery callbacks + + def _on_chromecast_discovered(self, uuid: str, _: object) -> None: + """ + Handle Chromecast discovered callback. + + NOTE: NOT async friendly! + """ + if self.mass.closing: + return + + assert self.browser is not None # for type checking + 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 + + player_id = str(disc_info.uuid) + + enabled = self.mass.config.get(f"players/{player_id}/enabled", True) + if not enabled: + self.logger.debug("Ignoring disabled player: %s", player_id) + return + + self.logger.debug("Discovered new or updated chromecast %s", disc_info) + + castplayer = self.mass.players.get(player_id) + if castplayer: + assert isinstance(castplayer, ChromecastPlayer) # for type checking + # 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(castplayer.update_state) + return + # new player discovered + + cast_info = ChromecastInfo.from_cast_info(disc_info) + cast_info.fill_out_missing_chromecast_info(self.mass.aiozc.zeroconf) + if cast_info.is_dynamic_group: + self.logger.debug("Discovered a dynamic cast group which will be ignored.") + return + if cast_info.is_multichannel_child: + self.logger.debug( + "Discovered a passive (multichannel) endpoint which will be ignored." + ) + return + # create new Chromecast instance + chromecast = pychromecast.get_chromecast_from_cast_info( + disc_info, + self.mass.aiozc.zeroconf, + ) + # create and register the new ChromeCastPlayer + castplayer = ChromecastPlayer( + self, player_id, cast_info=cast_info, chromecast=chromecast + ) + asyncio.run_coroutine_threadsafe( + self.mass.players.register_or_update(castplayer), loop=self.mass.loop + ) + + def _on_chromecast_removed(self, uuid: str, service: object, cast_info: object) -> None: + """Handle zeroconf discovery of a removed Chromecast.""" + player_id = str(service[1]) + friendly_name = service[3] + self.logger.debug("Chromecast removed: %s - %s", friendly_name, player_id) + # we ignore this event completely as the Chromecast socket client handles this itself diff --git a/music_assistant/providers/dlna/__init__.py b/music_assistant/providers/dlna/__init__.py index ca9d5526..0681ab9c 100644 --- a/music_assistant/providers/dlna/__init__.py +++ b/music_assistant/providers/dlna/__init__.py @@ -8,70 +8,22 @@ All rights/credits reserved. from __future__ import annotations -import asyncio -import functools -import logging -import time -from contextlib import suppress -from dataclasses import dataclass, field -from ipaddress import IPv4Address -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING -from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.client_factory import UpnpFactory -from async_upnp_client.exceptions import UpnpError, UpnpResponseError -from async_upnp_client.profiles.dlna import DmrDevice, TransportState -from async_upnp_client.search import async_search from music_assistant_models.config_entries import ConfigEntry, ConfigValueType -from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType -from music_assistant_models.errors import PlayerUnavailableError -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.enums import ConfigEntryType -from music_assistant.constants import ( - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_OUTPUT_CODEC, - CONF_PLAYERS, - VERBOSE_LOG_LEVEL, - create_sample_rates_config_entry, -) -from music_assistant.helpers.upnp import create_didl_metadata -from music_assistant.helpers.util import TaskManager -from music_assistant.models.player_provider import PlayerProvider - -from .helpers import DLNANotifyServer +from .constants import CONF_NETWORK_SCAN +from .provider import DLNAPlayerProvider if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Coroutine, Sequence - - from async_upnp_client.client import UpnpRequester, UpnpService, UpnpStateVariable - from async_upnp_client.utils import CaseInsensitiveDict - from music_assistant_models.config_entries import PlayerConfig, ProviderConfig + from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest from music_assistant import MusicAssistant from music_assistant.models import ProviderInstanceType -PLAYER_CONFIG_ENTRIES = ( - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_ENABLE_ICY_METADATA, - # enable flow mode by default because - # most dlna players do not support enqueueing - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, - create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24), -) - - -CONF_NETWORK_SCAN = "network_scan" - -_DLNAPlayerProviderT = TypeVar("_DLNAPlayerProviderT", bound="DLNAPlayerProvider") -_R = TypeVar("_R") -_P = ParamSpec("_P") - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -103,497 +55,3 @@ async def get_config_entries( "Can be used if (some of) your players are not automatically discovered.", ), ) - - -def catch_request_errors( - func: Callable[Concatenate[_DLNAPlayerProviderT, _P], Awaitable[_R]], -) -> Callable[Concatenate[_DLNAPlayerProviderT, _P], Coroutine[Any, Any, _R | None]]: - """Catch UpnpError errors.""" - - @functools.wraps(func) - async def wrapper(self: _DLNAPlayerProviderT, *args: _P.args, **kwargs: _P.kwargs) -> _R | None: - """Catch UpnpError errors and check availability before and after request.""" - player_id = kwargs["player_id"] if "player_id" in kwargs else args[0] - dlna_player = self.dlnaplayers[player_id] - dlna_player.last_command = time.time() - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - self.logger.debug( - "Handling command %s for player %s", - func.__name__, - dlna_player.player.display_name, - ) - if not dlna_player.available: - self.logger.warning("Device disappeared when trying to call %s", func.__name__) - return None - try: - return await func(self, *args, **kwargs) - except UpnpError as err: - dlna_player.force_poll = True - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - self.logger.exception("Error during call %s: %r", func.__name__, err) - else: - self.logger.error("Error during call %s: %r", func.__name__, str(err)) - return None - - return wrapper - - -@dataclass -class DLNAPlayer: - """Class that holds all dlna variables for a player.""" - - udn: str # = player_id - player: Player # mass player - description_url: str # last known location (description.xml) url - - device: DmrDevice | None = None - lock: asyncio.Lock = field( - default_factory=asyncio.Lock - ) # Held when connecting or disconnecting the device - force_poll: bool = False - ssdp_connect_failed: bool = False - - # Track BOOTID in SSDP advertisements for device changes - bootid: int | None = None - last_seen: float = field(default_factory=time.time) - last_command: float = field(default_factory=time.time) - - def update_attributes(self) -> None: - """Update attributes of the MA Player from DLNA state.""" - # generic attributes - - if self.available: - self.player.available = True - self.player.name = self.device.name - self.player.volume_level = int((self.device.volume_level or 0) * 100) - self.player.volume_muted = self.device.is_volume_muted or False - self.player.state = self.get_state(self.device) - self.player.current_item_id = self.device.current_track_uri or "" - if self.player.player_id in self.player.current_item_id: - self.player.active_source = self.player.player_id - elif "spotify" in self.player.current_item_id: - self.player.active_source = "spotify" - elif self.player.current_item_id.startswith("http"): - self.player.active_source = "http" - else: - # TODO: handle other possible sources here - self.player.active_source = None - if self.device.media_position: - # only update elapsed_time if the device actually reports it - self.player.elapsed_time = float(self.device.media_position) - if self.device.media_position_updated_at is not None: - self.player.elapsed_time_last_updated = ( - self.device.media_position_updated_at.timestamp() - ) - else: - # device is unavailable - self.player.available = False - - @property - def available(self) -> bool: - """Device is available when we have a connection to it.""" - return self.device is not None and self.device.profile_device.available - - @staticmethod - def get_state(device: DmrDevice) -> PlayerState: - """Return current PlayerState of the player.""" - if device.transport_state is None: - return PlayerState.IDLE - if device.transport_state in ( - TransportState.PLAYING, - TransportState.TRANSITIONING, - ): - return PlayerState.PLAYING - if device.transport_state in ( - TransportState.PAUSED_PLAYBACK, - TransportState.PAUSED_RECORDING, - ): - return PlayerState.PAUSED - if device.transport_state == TransportState.VENDOR_DEFINED: - # Unable to map this state to anything reasonable, fallback to idle - return PlayerState.IDLE - - return PlayerState.IDLE - - -class DLNAPlayerProvider(PlayerProvider): - """DLNA Player provider.""" - - dlnaplayers: dict[str, DLNAPlayer] | None = None - _discovery_running: bool = False - - lock: asyncio.Lock - requester: UpnpRequester - upnp_factory: UpnpFactory - notify_server: DLNANotifyServer - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - self.dlnaplayers = {} - self.lock = asyncio.Lock() - # silence the async_upnp_client logger - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("async_upnp_client").setLevel(logging.DEBUG) - else: - logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10) - self.requester = AiohttpSessionRequester(self.mass.http_session, with_sleep=True) - self.upnp_factory = UpnpFactory(self.requester, non_strict=True) - self.notify_server = DLNANotifyServer(self.requester, self.mass) - - async def unload(self, is_removed: bool = False) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - """ - self.mass.streams.unregister_dynamic_route("/notify", "NOTIFY") - async with TaskManager(self.mass) as tg: - for dlna_player in self.dlnaplayers.values(): - tg.create_task(self._device_disconnect(dlna_player)) - - async def get_player_config_entries( - self, - player_id: str, - ) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - return base_entries + PLAYER_CONFIG_ENTRIES - - async def on_player_config_change( - self, - config: PlayerConfig, - changed_keys: set[str], - ) -> None: - """Call (by config manager) when the configuration of a player changes.""" - if dlna_player := self.dlnaplayers.get(config.player_id): - # reset player features based on config values - self._set_player_features(dlna_player) - else: - # run discovery to catch any re-enabled players - self.mass.create_task(self.discover_players()) - - @catch_request_errors - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - dlna_player = self.dlnaplayers[player_id] - assert dlna_player.device is not None - await dlna_player.device.async_stop() - - @catch_request_errors - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - dlna_player = self.dlnaplayers[player_id] - assert dlna_player.device is not None - await dlna_player.device.async_play() - - @catch_request_errors - async def play_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on given player.""" - dlna_player = self.dlnaplayers[player_id] - # always clear queue (by sending stop) first - if dlna_player.device.can_stop: - await self.cmd_stop(player_id) - didl_metadata = create_didl_metadata(media) - title = media.title or media.uri - await dlna_player.device.async_set_transport_uri(media.uri, title, didl_metadata) - # Play it - await dlna_player.device.async_wait_for_can_play(10) - # optimistically set this timestamp to help in case of a player - # that does not report the progress - now = time.time() - dlna_player.player.elapsed_time = 0 - dlna_player.player.elapsed_time_last_updated = now - await dlna_player.device.async_play() - # force poll the device - for sleep in (1, 2): - await asyncio.sleep(sleep) - dlna_player.force_poll = True - await self.poll_player(dlna_player.udn) - - @catch_request_errors - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of the next queue item on the player.""" - dlna_player = self.dlnaplayers[player_id] - didl_metadata = create_didl_metadata(media) - title = media.title or media.uri - try: - await dlna_player.device.async_set_next_transport_uri(media.uri, title, didl_metadata) - except UpnpError: - self.logger.error( - "Enqueuing the next track failed for player %s - " - "the player probably doesn't support this. " - "Enable 'flow mode' for this player.", - dlna_player.player.display_name, - ) - - @catch_request_errors - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - dlna_player = self.dlnaplayers[player_id] - assert dlna_player.device is not None - if dlna_player.device.can_pause: - await dlna_player.device.async_pause() - else: - await dlna_player.device.async_stop() - - @catch_request_errors - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - dlna_player = self.dlnaplayers[player_id] - assert dlna_player.device is not None - await dlna_player.device.async_set_volume_level(volume_level / 100) - - @catch_request_errors - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - dlna_player = self.dlnaplayers[player_id] - assert dlna_player.device is not None - await dlna_player.device.async_mute_volume(muted) - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - dlna_player = self.dlnaplayers[player_id] - - # try to reconnect the device if the connection was lost - if not dlna_player.device: - if not dlna_player.force_poll: - return - try: - await self._device_connect(dlna_player) - except UpnpError as err: - raise PlayerUnavailableError from err - - assert dlna_player.device is not None - - try: - now = time.time() - do_ping = dlna_player.force_poll or (now - dlna_player.last_seen) > 60 - with suppress(ValueError): - await dlna_player.device.async_update(do_ping=do_ping) - dlna_player.last_seen = now if do_ping else dlna_player.last_seen - except UpnpError as err: - self.logger.debug("Device unavailable: %r", err) - await self._device_disconnect(dlna_player) - raise PlayerUnavailableError from err - finally: - dlna_player.force_poll = False - - async def discover_players(self, use_multicast: bool = False) -> None: - """Discover DLNA players on the network.""" - if self._discovery_running: - return - try: - self._discovery_running = True - self.logger.debug("DLNA discovery started...") - allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) - discovered_devices: set[str] = set() - - async def on_response(discovery_info: CaseInsensitiveDict) -> None: - """Process discovered device from ssdp search.""" - ssdp_st: str = discovery_info.get("st", discovery_info.get("nt")) - if not ssdp_st: - return - - if "MediaRenderer" not in ssdp_st: - # we're only interested in MediaRenderer devices - return - - ssdp_usn: str = discovery_info["usn"] - ssdp_udn: str | None = discovery_info.get("_udn") - if not ssdp_udn and ssdp_usn.startswith("uuid:"): - ssdp_udn = ssdp_usn.split("::")[0] - - if ssdp_udn in discovered_devices: - # already processed this device - return - if "rincon" in ssdp_udn.lower(): - # ignore Sonos devices - return - - discovered_devices.add(ssdp_udn) - - await self._device_discovered(ssdp_udn, discovery_info["location"]) - - # we iterate between using a regular and multicast search (if enabled) - if allow_network_scan and use_multicast: - await async_search(on_response, target=(str(IPv4Address("255.255.255.255")), 1900)) - else: - await async_search(on_response) - - finally: - self._discovery_running = False - - def reschedule() -> None: - self.mass.create_task(self.discover_players(use_multicast=not use_multicast)) - - # reschedule self once finished - self.mass.loop.call_later(300, reschedule) - - async def _device_disconnect(self, dlna_player: DLNAPlayer) -> None: - """ - Destroy connections to the device now that it's not available. - - Also call when removing this entity from MA to clean up connections. - """ - async with dlna_player.lock: - if not dlna_player.device: - self.logger.debug("Disconnecting from device that's not connected") - return - - self.logger.debug("Disconnecting from %s", dlna_player.device.name) - - dlna_player.device.on_event = None - old_device = dlna_player.device - dlna_player.device = None - await old_device.async_unsubscribe_services() - - async def _device_discovered(self, udn: str, description_url: str) -> None: - """Handle discovered DLNA player.""" - 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" - enabled = self.mass.config.get(conf_key, True) - # ignore disabled players - if not enabled: - self.logger.debug("Ignoring disabled player: %s", udn) - return - - dlna_player = DLNAPlayer( - udn=udn, - player=Player( - player_id=udn, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=udn, - available=False, - # device info will be discovered later after connect - device_info=DeviceInfo( - model="unknown", - ip_address=description_url, - manufacturer="unknown", - ), - needs_poll=True, - poll_interval=30, - ), - description_url=description_url, - ) - self.dlnaplayers[udn] = dlna_player - - await self._device_connect(dlna_player) - - self._set_player_features(dlna_player) - dlna_player.update_attributes() - await self.mass.players.register_or_update(dlna_player.player) - - async def _device_connect(self, dlna_player: DLNAPlayer) -> None: - """Connect DLNA/DMR Device.""" - self.logger.debug("Connecting to device at %s", dlna_player.description_url) - - async with dlna_player.lock: - if dlna_player.device: - self.logger.debug("Trying to connect when device already connected") - return - - # Connect to the base UPNP device - upnp_device = await self.upnp_factory.async_create_device(dlna_player.description_url) - - # Create profile wrapper - dlna_player.device = DmrDevice(upnp_device, self.notify_server.event_handler) - - # Subscribe to event notifications - try: - dlna_player.device.on_event = self._handle_event - await dlna_player.device.async_subscribe_services(auto_resubscribe=True) - except UpnpResponseError as err: - # Device rejected subscription request. This is OK, variables - # will be polled instead. - self.logger.debug("Device rejected subscription: %r", err) - except UpnpError as err: - # Don't leave the device half-constructed - dlna_player.device.on_event = None - dlna_player.device = None - self.logger.debug("Error while subscribing during device connect: %r", err) - raise - else: - # connect was successful, update device info - dlna_player.player.device_info = DeviceInfo( - model=dlna_player.device.model_name, - ip_address=dlna_player.device.device.presentation_url - or dlna_player.description_url, - manufacturer=dlna_player.device.manufacturer, - ) - - def _handle_event( - self, - service: UpnpService, - state_variables: Sequence[UpnpStateVariable], - ) -> None: - """Handle state variable(s) changed event from DLNA device.""" - udn = service.device.udn - dlna_player = self.dlnaplayers[udn] - - if not state_variables: - # Indicates a failure to resubscribe, check if device is still available - dlna_player.force_poll = True - return - - if service.service_id == "urn:upnp-org:serviceId:AVTransport": - for state_variable in state_variables: - # Force a state refresh when player begins or pauses playback - # to update the position info. - if state_variable.name == "TransportState" and state_variable.value in ( - TransportState.PLAYING, - TransportState.PAUSED_PLAYBACK, - ): - dlna_player.force_poll = True - self.mass.create_task(self.poll_player(dlna_player.udn)) - self.logger.debug( - "Received new state from event for Player %s: %s", - dlna_player.player.display_name, - state_variable.value, - ) - - dlna_player.last_seen = time.time() - self.mass.create_task(self._update_player(dlna_player)) - - async def _update_player(self, dlna_player: DLNAPlayer) -> None: - """Update DLNA Player.""" - prev_url = dlna_player.player.current_item_id - prev_state = dlna_player.player.state - dlna_player.update_attributes() - current_url = dlna_player.player.current_item_id - current_state = dlna_player.player.state - - if (prev_url != current_url) or (prev_state != current_state): - # fetch track details on state or url change - dlna_player.force_poll = True - - # let the MA player manager work out if something actually updated - self.mass.players.update(dlna_player.udn) - - def _set_player_features(self, dlna_player: DLNAPlayer) -> None: - """Set Player Features based on config values and capabilities.""" - if not dlna_player.device: - return - supported_features: set[PlayerFeature] = { - # there is no way to check if a dlna player support enqueuing - # so we simply assume it does and if it doesn't - # you'll find out at playback time and we log a warning - PlayerFeature.ENQUEUE, - PlayerFeature.GAPLESS_PLAYBACK, - } - if dlna_player.device.has_volume_level: - supported_features.add(PlayerFeature.VOLUME_SET) - if dlna_player.device.has_volume_mute: - supported_features.add(PlayerFeature.VOLUME_MUTE) - if dlna_player.device.has_pause: - supported_features.add(PlayerFeature.PAUSE) - dlna_player.player.supported_features = supported_features diff --git a/music_assistant/providers/dlna/constants.py b/music_assistant/providers/dlna/constants.py new file mode 100644 index 00000000..f51d8742 --- /dev/null +++ b/music_assistant/providers/dlna/constants.py @@ -0,0 +1,22 @@ +"""Constants for DLNA provider.""" + +from music_assistant.constants import ( + CONF_ENTRY_ENABLE_ICY_METADATA, + CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_OUTPUT_CODEC, + create_sample_rates_config_entry, +) + +PLAYER_CONFIG_ENTRIES = [ + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_ENABLE_ICY_METADATA, + # enable flow mode by default because + # most dlna players do not support enqueueing + CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, + create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24), +] + + +CONF_NETWORK_SCAN = "network_scan" diff --git a/music_assistant/providers/dlna/player.py b/music_assistant/providers/dlna/player.py new file mode 100644 index 00000000..7841b376 --- /dev/null +++ b/music_assistant/providers/dlna/player.py @@ -0,0 +1,384 @@ +"""DLNA Player.""" + +import asyncio +import functools +import time +from collections.abc import Awaitable, Callable, Coroutine, Sequence +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Concatenate + +from async_upnp_client.client import UpnpService, UpnpStateVariable +from async_upnp_client.exceptions import UpnpError, UpnpResponseError +from async_upnp_client.profiles.dlna import DmrDevice, TransportState +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import PlaybackState, PlayerFeature +from music_assistant_models.errors import PlayerUnavailableError +from music_assistant_models.player import DeviceInfo, PlayerMedia + +from music_assistant.constants import VERBOSE_LOG_LEVEL +from music_assistant.helpers.upnp import create_didl_metadata +from music_assistant.models.player import Player + +from .constants import PLAYER_CONFIG_ENTRIES + +if TYPE_CHECKING: + from .provider import DLNAPlayerProvider + + +def catch_request_errors[DLNAPlayerT: "DLNAPlayer", **P, R]( + func: Callable[Concatenate[DLNAPlayerT, P], Awaitable[R]], +) -> Callable[Concatenate[DLNAPlayerT, P], Coroutine[Any, Any, R | None]]: + """Catch UpnpError errors.""" + + @functools.wraps(func) + async def wrapper(self: DLNAPlayerT, *args: P.args, **kwargs: P.kwargs) -> R | None: + """Catch UpnpError errors and check availability before and after request.""" + self.last_command = time.time() + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + self.logger.debug( + "Handling command %s for player %s", + func.__name__, + self.display_name, + ) + if not self.available: + self.logger.warning("Device disappeared when trying to call %s", func.__name__) + return None + try: + return await func(self, *args, **kwargs) + except UpnpError as err: + self.force_poll = True + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + self.logger.exception("Error during call %s: %r", func.__name__, err) + else: + self.logger.error("Error during call %s: %r", func.__name__, str(err)) + return None + + return wrapper + + +class DLNAPlayer(Player): + """DLNA Player.""" + + def __init__( + self, + provider: "DLNAPlayerProvider", + player_id: str, + description_url: str, + device: DmrDevice | None = None, + ) -> None: + """Init Player. + + The player_id is the udn. + """ + super().__init__(provider, player_id) + + self.device = device + self.description_url = description_url # last known location (description.xml) url + + self.lock = asyncio.Lock() # Held when connecting or disconnecting the device + + self.force_poll = False # used, if connection is lost + + # ssdp_connect_failed: bool = False + # + # Track BOOTID in SSDP advertisements for device changes + self.bootid: int | None = None + self.last_seen = time.time() + self.last_command = time.time() + + async def _device_connect(self) -> None: + """Connect DLNA/DMR Device.""" + self.logger.debug("Connecting to device at %s", self.description_url) + + async with self.lock: + if self.device: + self.logger.debug("Trying to connect when device already connected") + return + + # Connect to the base UPNP device + if TYPE_CHECKING: + assert isinstance(self.provider, DLNAPlayerProvider) # for type checking + upnp_device = await self.provider.upnp_factory.async_create_device(self.description_url) + + # Create profile wrapper + self.device = DmrDevice(upnp_device, self.provider.notify_server.event_handler) + + # Subscribe to event notifications + try: + self.device.on_event = self._handle_event + await self.device.async_subscribe_services(auto_resubscribe=True) + except UpnpResponseError as err: + # Device rejected subscription request. This is OK, variables + # will be polled instead. + self.logger.debug("Device rejected subscription: %r", err) + except UpnpError as err: + # Don't leave the device half-constructed + self.device.on_event = None + self.device = None + self.logger.debug("Error while subscribing during device connect: %r", err) + raise + else: + # connect was successful, update device info + self._attr_device_info = DeviceInfo( + model=self.device.model_name, + ip_address=self.device.device.presentation_url or self.description_url, + manufacturer=self.device.manufacturer, + ) + + def _handle_event( + self, + service: UpnpService, + state_variables: Sequence[UpnpStateVariable], + ) -> None: + """Handle state variable(s) changed event from DLNA device.""" + if not state_variables: + # Indicates a failure to resubscribe, check if device is still available + self.force_poll = True + return + + if service.service_id == "urn:upnp-org:serviceId:AVTransport": + for state_variable in state_variables: + # Force a state refresh when player begins or pauses playback + # to update the position info. + if state_variable.name == "TransportState" and state_variable.value in ( + TransportState.PLAYING, + TransportState.PAUSED_PLAYBACK, + ): + self.force_poll = True + self.mass.create_task(self.poll()) + self.logger.debug( + "Received new state from event for Player %s: %s", + self.display_name, + state_variable.value, + ) + + self.last_seen = time.time() + self.mass.create_task(self._update_player()) + + async def _update_player(self) -> None: + """Update DLNA Player.""" + prev_url = self._attr_current_media.uri if self._attr_current_media is not None else "" + prev_state = self.state + await self.set_dynamic_attributes() + current_url = self._attr_current_media.uri if self._attr_current_media is not None else "" + current_state = self.state + + if (prev_url != current_url) or (prev_state != current_state): + # fetch track details on state or url change + self.force_poll = True + + try: + self.update_state() + except (KeyError, TypeError): + # at start the update might come faster than the config is initialized + await asyncio.sleep(2) + self.update_state() + + def _set_player_features(self) -> None: + """Set Player Features based on config values and capabilities.""" + assert self.device is not None # for type checking + supported_features: set[PlayerFeature] = { + # there is no way to check if a dlna player support enqueuing + # so we simply assume it does and if it doesn't + # you'll find out at playback time and we log a warning + PlayerFeature.ENQUEUE, + PlayerFeature.GAPLESS_PLAYBACK, + } + if self.device.has_volume_level: + supported_features.add(PlayerFeature.VOLUME_SET) + if self.device.has_volume_mute: + supported_features.add(PlayerFeature.VOLUME_MUTE) + if self.device.has_pause: + supported_features.add(PlayerFeature.PAUSE) + self._attr_supported_features = supported_features + + async def setup(self) -> None: + """Set up player in MA.""" + await self._device_connect() + self.set_static_attributes() + await self.mass.players.register_or_update(self) + + def set_static_attributes(self) -> None: + """Set static attributes.""" + self._attr_needs_poll = True + self._attr_poll_interval = 30 + self._set_player_features() + + async def set_dynamic_attributes(self) -> None: + """Set dynamic attributes.""" + available = self.device is not None and self.device.profile_device.available + self._attr_available = available + if not available: + return + assert self.device is not None # for type checking + self._attr_name = self.device.name + self._attr_volume_level = int((self.device.volume_level or 0) * 100) + self._attr_volume_muted = self.device.is_volume_muted or False + _playback_state = self._get_playback_state() + assert _playback_state is not None # for type checking + self._attr_playback_state = _playback_state + + _device_uri = self.device.current_track_uri or "" + self.set_current_media(uri=_device_uri, clear_all=True) + + if self.player_id in _device_uri: + self._attr_active_source = self.player_id + elif "spotify" in _device_uri: + self._attr_active_source = "spotify" + elif _device_uri.startswith("http"): + self._attr_active_source = "http" + else: + # TODO: handle other possible sources here + self._attr_active_source = None + if self.device.media_position: + # only update elapsed_time if the device actually reports it + self._attr_elapsed_time = float(self.device.media_position) + if self.device.media_position_updated_at is not None: + self._attr_elapsed_time_last_updated = ( + self.device.media_position_updated_at.timestamp() + ) + + def _get_playback_state(self) -> PlaybackState | None: + """Return current PlaybackState of the player.""" + if self.device is None: + return None + if self.device.transport_state is None: + return PlaybackState.IDLE + if self.device.transport_state in ( + TransportState.PLAYING, + TransportState.TRANSITIONING, + ): + return PlaybackState.PLAYING + if self.device.transport_state in ( + TransportState.PAUSED_PLAYBACK, + TransportState.PAUSED_RECORDING, + ): + return PlaybackState.PAUSED + if self.device.transport_state == TransportState.VENDOR_DEFINED: + # Unable to map this state to anything reasonable, fallback to idle + return PlaybackState.IDLE + + return PlaybackState.IDLE + + async def get_config_entries( + self, + ) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + base_entries = await super().get_config_entries() + return base_entries + PLAYER_CONFIG_ENTRIES + + # async def on_player_config_change( + # self, + # config: PlayerConfig, + # changed_keys: set[str], + # ) -> None: + # """Call (by config manager) when the configuration of a player changes.""" + # if dlna_player := self.dlnaplayers.get(config.player_id): + # # reset player features based on config values + # self._set_player_features(dlna_player) + # else: + # # run discovery to catch any re-enabled players + # self.mass.create_task(self.discover_players()) + + # COMMANDS + @catch_request_errors + async def stop(self) -> None: + """Send STOP command to given player.""" + assert self.device is not None # for type checking + await self.device.async_stop() + + @catch_request_errors + async def play(self) -> None: + """Send PLAY command to given player.""" + assert self.device is not None # for type checking + await self.device.async_play() + + @catch_request_errors + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + assert self.device is not None # for type checking + # always clear queue (by sending stop) first + if self.device.can_stop: + await self.stop() + didl_metadata = create_didl_metadata(media) + title = media.title or media.uri + await self.device.async_set_transport_uri(media.uri, title, didl_metadata) + # Play it + await self.device.async_wait_for_can_play(10) + # optimistically set this timestamp to help in case of a player + # that does not report the progress + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time.time() + await self.device.async_play() + # force poll the device + for sleep in (1, 2): + await asyncio.sleep(sleep) + self.force_poll = True + await self.poll() + + @catch_request_errors + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing of the next queue item on the player.""" + assert self.device is not None # for type checking + didl_metadata = create_didl_metadata(media) + title = media.title or media.uri + try: + await self.device.async_set_next_transport_uri(media.uri, title, didl_metadata) + except UpnpError: + self.logger.error( + "Enqueuing the next track failed for player %s - " + "the player probably doesn't support this. " + "Enable 'flow mode' for this player.", + self.display_name, + ) + + @catch_request_errors + async def pause(self) -> None: + """Send PAUSE command to given player.""" + assert self.device is not None # for type checking + if self.device.can_pause: + await self.device.async_pause() + else: + await self.device.async_stop() + + @catch_request_errors + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + assert self.device is not None # for type checking + await self.device.async_set_volume_level(volume_level / 100) + + @catch_request_errors + async def volume_mute(self, muted: bool) -> None: + """Send VOLUME MUTE command to given player.""" + assert self.device is not None # for type checking + await self.device.async_mute_volume(muted) + + async def poll(self) -> None: + """Poll player for state updates.""" + assert self.device is not None # for type checking + + # try to reconnect the device if the connection was lost + if not self.device: + if not self.force_poll: + return + try: + await self._device_connect() + except UpnpError as err: + raise PlayerUnavailableError from err + + assert self.device is not None + + try: + now = time.time() + do_ping = self.force_poll or (now - self.last_seen) > 60 + with suppress(ValueError): + await self.device.async_update(do_ping=do_ping) + self.last_seen = now if do_ping else self.last_seen + except UpnpError as err: + self.logger.debug("Device unavailable: %r", err) + if TYPE_CHECKING: + assert isinstance(self.provider, DLNAPlayerProvider) # for type checking + await self.provider._device_disconnect(self) + raise PlayerUnavailableError from err + finally: + self.force_poll = False diff --git a/music_assistant/providers/dlna/provider.py b/music_assistant/providers/dlna/provider.py new file mode 100644 index 00000000..98c76249 --- /dev/null +++ b/music_assistant/providers/dlna/provider.py @@ -0,0 +1,162 @@ +"""DLNA Player Provider.""" + +import asyncio +import logging +from ipaddress import IPv4Address + +from async_upnp_client.aiohttp import AiohttpSessionRequester +from async_upnp_client.client import UpnpRequester +from async_upnp_client.client_factory import UpnpFactory +from async_upnp_client.search import async_search +from async_upnp_client.utils import CaseInsensitiveDict +from music_assistant_models.player import DeviceInfo + +from music_assistant.constants import CONF_PLAYERS, VERBOSE_LOG_LEVEL +from music_assistant.helpers.util import TaskManager +from music_assistant.models.player_provider import PlayerProvider + +from .constants import CONF_NETWORK_SCAN +from .helpers import DLNANotifyServer +from .player import DLNAPlayer + + +class DLNAPlayerProvider(PlayerProvider): + """DLNA Player provider.""" + + dlnaplayers: dict[str, DLNAPlayer] = {} + _discovery_running: bool = False + + lock: asyncio.Lock + requester: UpnpRequester + upnp_factory: UpnpFactory + notify_server: DLNANotifyServer + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self.lock = asyncio.Lock() + # silence the async_upnp_client logger + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("async_upnp_client").setLevel(logging.DEBUG) + else: + logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10) + self.requester = AiohttpSessionRequester(self.mass.http_session, with_sleep=True) + self.upnp_factory = UpnpFactory(self.requester, non_strict=True) + self.notify_server = DLNANotifyServer(self.requester, self.mass) + + async def unload(self, is_removed: bool = False) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + """ + self.mass.streams.unregister_dynamic_route("/notify", "NOTIFY") + if self.dlnaplayers is None: + return + async with TaskManager(self.mass) as tg: + for dlna_player in self.dlnaplayers.values(): + tg.create_task(self._device_disconnect(dlna_player)) + + async def discover_players(self, use_multicast: bool = False) -> None: + """Discover DLNA players on the network.""" + if self._discovery_running: + return + try: + self._discovery_running = True + self.logger.debug("DLNA discovery started...") + allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) + discovered_devices: set[str] = set() + + async def on_response(discovery_info: CaseInsensitiveDict) -> None: + """Process discovered device from ssdp search.""" + ssdp_st: str = discovery_info.get("st", discovery_info.get("nt")) + if not ssdp_st: + return + + if "MediaRenderer" not in ssdp_st: + # we're only interested in MediaRenderer devices + return + + ssdp_usn: str = discovery_info["usn"] + ssdp_udn: str | None = discovery_info.get("_udn") + if not ssdp_udn and ssdp_usn.startswith("uuid:"): + ssdp_udn = ssdp_usn.split("::")[0] + + if ssdp_udn in discovered_devices: + # already processed this device + return + + assert ssdp_udn is not None # for type checking + + if "rincon" in ssdp_udn.lower(): + # ignore Sonos devices + return + + discovered_devices.add(ssdp_udn) + + await self._device_discovered(ssdp_udn, discovery_info["location"]) + + # we iterate between using a regular and multicast search (if enabled) + if allow_network_scan and use_multicast: + await async_search(on_response, target=(str(IPv4Address("255.255.255.255")), 1900)) + else: + await async_search(on_response) + + finally: + self._discovery_running = False + + def reschedule() -> None: + self.mass.create_task(self.discover_players(use_multicast=not use_multicast)) + + # reschedule self once finished + self.mass.loop.call_later(300, reschedule) + + async def _device_disconnect(self, dlna_player: DLNAPlayer) -> None: + """ + Destroy connections to the device now that it's not available. + + Also call when removing this entity from MA to clean up connections. + """ + async with dlna_player.lock: + if not dlna_player.device: + self.logger.debug("Disconnecting from device that's not connected") + return + + self.logger.debug("Disconnecting from %s", dlna_player.device.name) + + dlna_player.device.on_event = None + old_device = dlna_player.device + dlna_player.device = None + await old_device.async_unsubscribe_services() + + async def _device_discovered(self, udn: str, description_url: str) -> None: + """Handle discovered DLNA player.""" + async with self.lock: + if dlna_player := self.dlnaplayers.get(udn): + # existing player + if dlna_player.description_url == description_url and dlna_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" + enabled = self.mass.config.get(conf_key, True) + # ignore disabled players + if not enabled: + self.logger.debug("Ignoring disabled player: %s", udn) + return + + dlna_player = DLNAPlayer( + provider=self, + player_id=udn, + description_url=description_url, + ) + # will be updated later. + dlna_player._attr_device_info = DeviceInfo( + model="unknown", + ip_address=description_url, + manufacturer="unknown", + ) + self.dlnaplayers[udn] = dlna_player + await dlna_player.setup() diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index b97d32fa..c75d3a1f 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -607,7 +607,7 @@ class LocalFileSystemProvider(MusicProvider): async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" - # ruff: noqa: PLR0915, PLR0912 + # ruff: noqa: PLR0915 if not await self.exists(prov_track_id): msg = f"Track path does not exist: {prov_track_id}" raise MediaNotFoundError(msg) @@ -650,7 +650,7 @@ class LocalFileSystemProvider(MusicProvider): async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: """Get full audiobook details by id.""" - # ruff: noqa: PLR0915, PLR0912 + # ruff: noqa: PLR0915 if not await self.exists(prov_audiobook_id): msg = f"Audiobook path does not exist: {prov_audiobook_id}" raise MediaNotFoundError(msg) @@ -861,7 +861,7 @@ class LocalFileSystemProvider(MusicProvider): self, file_item: FileSystemItem, tags: AudioTags, full_album_metadata: bool = False ) -> Track: """Parse full track details from file tags.""" - # ruff: noqa: PLR0915, PLR0912 + # ruff: noqa: PLR0915 name, version = parse_title_and_version(tags.title, tags.version) track = Track( item_id=file_item.relative_path, @@ -1191,7 +1191,7 @@ class LocalFileSystemProvider(MusicProvider): self, file_item: FileSystemItem, tags: AudioTags ) -> PodcastEpisode: """Parse full PodcastEpisode details from file tags.""" - # ruff: noqa: PLR0915, PLR0912 + # ruff: noqa: PLR0915 podcast_name = tags.album or file_item.parent_name podcast_path = get_relative_path(self.base_path, file_item.parent_path) episode = PodcastEpisode( diff --git a/music_assistant/providers/fully_kiosk/__init__.py b/music_assistant/providers/fully_kiosk/__init__.py index 19ce41cc..a5f7d5cb 100644 --- a/music_assistant/providers/fully_kiosk/__init__.py +++ b/music_assistant/providers/fully_kiosk/__init__.py @@ -2,27 +2,14 @@ from __future__ import annotations -import asyncio -import logging -import time from typing import TYPE_CHECKING -from fullykiosk import FullyKiosk from music_assistant_models.config_entries import ConfigEntry, ConfigValueType -from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType -from music_assistant_models.errors import PlayerUnavailableError, SetupFailedError -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.enums import ConfigEntryType -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_PORT, - VERBOSE_LOG_LEVEL, -) -from music_assistant.models.player_provider import PlayerProvider +from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT + +from .provider import FullyKioskProvider if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig @@ -31,8 +18,6 @@ if TYPE_CHECKING: from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType -AUDIOMANAGER_STREAM_MUSIC = 3 - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig @@ -77,128 +62,3 @@ async def get_config_entries( category="advanced", ), ) - - -class FullyKioskProvider(PlayerProvider): - """Player provider for FullyKiosk based players.""" - - _fully: FullyKiosk - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - # set-up fullykiosk logging - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("fullykiosk").setLevel(logging.DEBUG) - else: - logging.getLogger("fullykiosk").setLevel(self.logger.level + 10) - self._fully = FullyKiosk( - self.mass.http_session, - self.config.get_value(CONF_IP_ADDRESS), - self.config.get_value(CONF_PORT), - self.config.get_value(CONF_PASSWORD), - ) - try: - async with asyncio.timeout(15): - await self._fully.getDeviceInfo() - except Exception as err: - msg = f"Unable to start the FullyKiosk connection ({err!s}" - raise SetupFailedError(msg) from err - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - # Add FullyKiosk device to Player controller. - player_id = self._fully.deviceInfo["deviceID"] - player = self.mass.players.get(player_id, raise_unavailable=False) - address = ( - f"http://{self.config.get_value(CONF_IP_ADDRESS)}:{self.config.get_value(CONF_PORT)}" - ) - if not player: - player = Player( - player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=self._fully.deviceInfo["deviceName"], - available=True, - device_info=DeviceInfo( - model=self._fully.deviceInfo["deviceModel"], - manufacturer=self._fully.deviceInfo["deviceManufacturer"], - ip_address=address, - ), - supported_features={PlayerFeature.VOLUME_SET}, - needs_poll=True, - poll_interval=10, - ) - await self.mass.players.register_or_update(player) - self._handle_player_update() - - def _handle_player_update(self) -> None: - """Update FullyKiosk player attributes.""" - player_id = self._fully.deviceInfo["deviceID"] - if not (player := self.mass.players.get(player_id)): - return - player.name = self._fully.deviceInfo["deviceName"] - for volume_dict in self._fully.deviceInfo.get("audioVolumes", []): - if str(AUDIOMANAGER_STREAM_MUSIC) in volume_dict: - volume = volume_dict[str(AUDIOMANAGER_STREAM_MUSIC)] - player.volume_level = volume - break - current_url = self._fully.deviceInfo.get("soundUrlPlaying") - player.current_item_id = current_url - if not current_url: - player.state = PlayerState.IDLE - player.available = True - self.mass.players.update(player_id) - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - return ( - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, - CONF_ENTRY_HTTP_PROFILE, - ) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - await self._fully.setAudioVolume(volume_level, AUDIOMANAGER_STREAM_MUSIC) - player.volume_level = volume_level - self.mass.players.update(player_id) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - return - await self._fully.stopSound() - player.state = PlayerState.IDLE - self.mass.players.update(player_id) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - if not (player := self.mass.players.get(player_id)): - return - await self._fully.playSound(media.uri, AUDIOMANAGER_STREAM_MUSIC) - player.current_media = media - player.elapsed_time = 0 - player.elapsed_time_last_updated = time.time() - player.state = PlayerState.PLAYING - self.mass.players.update(player_id) - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - try: - async with asyncio.timeout(15): - await self._fully.getDeviceInfo() - self._handle_player_update() - except Exception as err: - msg = f"Unable to start the FullyKiosk connection ({err!s}" - raise PlayerUnavailableError(msg) from err diff --git a/music_assistant/providers/fully_kiosk/player.py b/music_assistant/providers/fully_kiosk/player.py new file mode 100644 index 00000000..ddead9a2 --- /dev/null +++ b/music_assistant/providers/fully_kiosk/player.py @@ -0,0 +1,111 @@ +"""FullyKiosk Player implementation.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING + +from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError + +from music_assistant.constants import ( + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, +) +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia + +if TYPE_CHECKING: + from fullykiosk import FullyKiosk + from music_assistant_models.config_entries import ConfigEntry + + from .provider import FullyKioskProvider + +AUDIOMANAGER_STREAM_MUSIC = 3 + + +class FullyKioskPlayer(Player): + """FullyKiosk Player implementation.""" + + def __init__( + self, + provider: FullyKioskProvider, + player_id: str, + fully_kiosk: FullyKiosk, + address: str, + ) -> None: + """Initialize the FullyKiosk Player.""" + super().__init__(provider, player_id) + self.fully_kiosk = fully_kiosk + # Set player attributes + self._attr_type = PlayerType.PLAYER + self._attr_supported_features = {PlayerFeature.VOLUME_SET} + self._attr_name = self.fully_kiosk.deviceInfo["deviceName"] + self._attr_device_info = DeviceInfo( + model=self.fully_kiosk.deviceInfo["deviceModel"], + manufacturer=self.fully_kiosk.deviceInfo["deviceManufacturer"], + ip_address=address, + ) + self._attr_available = True + self._attr_needs_poll = True + self._attr_poll_interval = 10 + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + base_entries = await super().get_config_entries() + return [ + *base_entries, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, + CONF_ENTRY_HTTP_PROFILE, + ] + + def set_attributes(self) -> None: + """Set/update FullyKiosk player attributes.""" + self._attr_name = self.fully_kiosk.deviceInfo["deviceName"] + for volume_dict in self.fully_kiosk.deviceInfo.get("audioVolumes", []): + if str(AUDIOMANAGER_STREAM_MUSIC) in volume_dict: + volume = volume_dict[str(AUDIOMANAGER_STREAM_MUSIC)] + self._attr_volume_level = volume + break + current_url = self.fully_kiosk.deviceInfo.get("soundUrlPlaying") + if not current_url: + self._attr_playback_state = PlaybackState.IDLE + self._attr_available = True + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + await self.fully_kiosk.setAudioVolume(volume_level, AUDIOMANAGER_STREAM_MUSIC) + self._attr_volume_level = volume_level + self.update_state() + + async def stop(self) -> None: + """Send STOP command to given player.""" + await self.fully_kiosk.stopSound() + self._attr_playback_state = PlaybackState.IDLE + self.update_state() + + async def play(self) -> None: + """Handle PLAY command on the player.""" + raise PlayerCommandFailed("Playback can not be resumed.") + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + await self.fully_kiosk.playSound(media.uri, AUDIOMANAGER_STREAM_MUSIC) + self._attr_current_media = media + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time.time() + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def poll(self) -> None: + """Poll player for state updates.""" + try: + async with asyncio.timeout(15): + await self.fully_kiosk.getDeviceInfo() + self.set_attributes() + self.update_state() + except Exception as err: + msg = f"Unable to start the FullyKiosk connection ({err!s}" + raise PlayerUnavailableError(msg) from err diff --git a/music_assistant/providers/fully_kiosk/provider.py b/music_assistant/providers/fully_kiosk/provider.py new file mode 100644 index 00000000..fd998d8c --- /dev/null +++ b/music_assistant/providers/fully_kiosk/provider.py @@ -0,0 +1,45 @@ +"""FullyKiosk Player provider for Music Assistant.""" + +from __future__ import annotations + +import asyncio +import logging + +from fullykiosk import FullyKiosk +from music_assistant_models.errors import SetupFailedError + +from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, VERBOSE_LOG_LEVEL +from music_assistant.models.player_provider import PlayerProvider + +from .player import FullyKioskPlayer + + +class FullyKioskProvider(PlayerProvider): + """Player provider for FullyKiosk based players.""" + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + # set-up fullykiosk logging + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("fullykiosk").setLevel(logging.DEBUG) + else: + logging.getLogger("fullykiosk").setLevel(self.logger.level + 10) + fully_kiosk = FullyKiosk( + self.mass.http_session, + self.config.get_value(CONF_IP_ADDRESS), + self.config.get_value(CONF_PORT), + self.config.get_value(CONF_PASSWORD), + ) + try: + async with asyncio.timeout(15): + await fully_kiosk.getDeviceInfo() + except Exception as err: + msg = f"Unable to start the FullyKiosk connection ({err!s}" + raise SetupFailedError(msg) from err + player_id = fully_kiosk.deviceInfo["deviceID"] + address = ( + f"http://{self.config.get_value(CONF_IP_ADDRESS)}:{self.config.get_value(CONF_PORT)}" + ) + player = FullyKioskPlayer(self, player_id, fully_kiosk, address) + player.set_attributes() + await self.mass.players.register(player) diff --git a/music_assistant/providers/gpodder/__init__.py b/music_assistant/providers/gpodder/__init__.py index 8d1112ef..8cb361ca 100644 --- a/music_assistant/providers/gpodder/__init__.py +++ b/music_assistant/providers/gpodder/__init__.py @@ -138,7 +138,7 @@ async def get_config_entries( await asyncio.sleep(1) authenticated_nc = True - if values.get(CONF_TOKEN_NC, None) is None: + if values.get(CONF_TOKEN_NC) is None: authenticated_nc = False using_gpodder = bool(values.get(CONF_USING_GPODDER, False)) @@ -344,7 +344,6 @@ class GPodder(MusicProvider): return False async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: - # ruff: noqa: PLR0915 """Retrieve library/subscribed podcasts from the provider.""" try: subscriptions = await self._client.get_subscriptions() diff --git a/music_assistant/providers/hass/constants.py b/music_assistant/providers/hass/constants.py index a29486b0..db0b7bfb 100644 --- a/music_assistant/providers/hass/constants.py +++ b/music_assistant/providers/hass/constants.py @@ -4,7 +4,7 @@ from __future__ import annotations from enum import IntFlag -from music_assistant_models.enums import PlayerState +from music_assistant_models.enums import PlaybackState class MediaPlayerEntityFeature(IntFlag): @@ -35,14 +35,14 @@ class MediaPlayerEntityFeature(IntFlag): StateMap = { - "playing": PlayerState.PLAYING, - "paused": PlayerState.PAUSED, - "buffering": PlayerState.PLAYING, - "idle": PlayerState.IDLE, - "off": PlayerState.IDLE, - "standby": PlayerState.IDLE, - "unknown": PlayerState.IDLE, - "unavailable": PlayerState.IDLE, + "playing": PlaybackState.PLAYING, + "paused": PlaybackState.PAUSED, + "buffering": PlaybackState.PLAYING, + "idle": PlaybackState.IDLE, + "off": PlaybackState.IDLE, + "standby": PlaybackState.IDLE, + "unknown": PlaybackState.IDLE, + "unavailable": PlaybackState.IDLE, } # HA states that we consider as "powered off" diff --git a/music_assistant/providers/hass_players/__init__.py b/music_assistant/providers/hass_players/__init__.py index bbdcb4fa..dbf4966a 100644 --- a/music_assistant/providers/hass_players/__init__.py +++ b/music_assistant/providers/hass_players/__init__.py @@ -7,48 +7,19 @@ Requires the Home Assistant Plugin. from __future__ import annotations -import asyncio -import logging -import os -import time -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import TYPE_CHECKING, cast -from hass_client.exceptions import FailedCommand from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType -from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType -from music_assistant_models.errors import InvalidDataError, LoginFailed, SetupFailedError -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.enums import ConfigEntryType +from music_assistant_models.errors import SetupFailedError -from music_assistant.constants import ( - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN, - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_HTTP_PROFILE_FORCED_2, - CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, - HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES, - create_output_codec_config_entry, - create_sample_rates_config_entry, -) -from music_assistant.helpers.datetime import from_iso_string -from music_assistant.helpers.tags import async_parse_tags -from music_assistant.models.player_provider import PlayerProvider from music_assistant.providers.hass import DOMAIN as HASS_DOMAIN -from music_assistant.providers.hass.constants import ( - OFF_STATES, - UNAVAILABLE_STATES, - MediaPlayerEntityFeature, - StateMap, -) -if TYPE_CHECKING: - from collections.abc import AsyncGenerator +from .constants import CONF_PLAYERS +from .helpers import get_hass_media_players +from .provider import HomeAssistantPlayerProvider - from hass_client.models import CompressedState, EntityStateEvent - from hass_client.models import Device as HassDevice - from hass_client.models import Entity as HassEntity - from hass_client.models import State as HassState +if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest @@ -56,73 +27,17 @@ if TYPE_CHECKING: from music_assistant.models import ProviderInstanceType from music_assistant.providers.hass import HomeAssistantProvider -CONF_PLAYERS = "players" - - -DEFAULT_PLAYER_CONFIG_ENTRIES = ( - CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, - CONF_ENTRY_HTTP_PROFILE, - CONF_ENTRY_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_ENFORCED, -) - -BLOCKLISTED_HASS_INTEGRATIONS = ("alexa_media", "apple_tv") -WARN_HASS_INTEGRATIONS = ("cast", "dlna_dmr", "fully_kiosk", "sonos", "snapcast") - -CONF_ENTRY_WARN_HASS_INTEGRATION = ConfigEntry( - key="warn_hass_integration", - type=ConfigEntryType.ALERT, - label="Music Assistant has native support for this player type - " - "it is strongly recommended to use the native player provider for this player in " - "Music Assistant instead of the generic version provided by the Home Assistant provider.", -) - - -async def _get_hass_media_players( - hass_prov: HomeAssistantProvider, -) -> AsyncGenerator[HassState, None]: - """Return all HA state objects for (valid) media_player entities.""" - entity_registry = {x["entity_id"]: x for x in await hass_prov.hass.get_entity_registry()} - for state in await hass_prov.hass.get_states(): - if not state["entity_id"].startswith("media_player"): - continue - if "mass_player_type" in state["attributes"]: - # filter out mass players - continue - if "friendly_name" not in state["attributes"]: - # filter out invalid/unavailable players - continue - supported_features = MediaPlayerEntityFeature(state["attributes"]["supported_features"]) - if MediaPlayerEntityFeature.PLAY_MEDIA not in supported_features: - continue - if entity_registry_entry := entity_registry.get(state["entity_id"]): - hass_domain = entity_registry_entry["platform"] - if hass_domain in BLOCKLISTED_HASS_INTEGRATIONS: - continue - yield state - - -class ESPHomeSupportedAudioFormat(TypedDict): - """ESPHome Supported Audio Format.""" - - format: str # flac, wav or mp3 - sample_rate: int # e.g. 48000 - num_channels: int # 1 for announcements, 2 for media - purpose: int # 0 for media, 1 for announcements - sample_bytes: int # 1 for 8 bit, 2 for 16 bit, 4 for 32 bit - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - hass_prov: HomeAssistantProvider = mass.get_provider(HASS_DOMAIN) + hass_prov = mass.get_provider(HASS_DOMAIN) if not hass_prov: msg = "The Home Assistant Plugin needs to be set-up first" raise SetupFailedError(msg) - prov = HomeAssistantPlayers(mass, manifest, config) - prov.hass_prov = hass_prov - return prov + hass_prov = cast("HomeAssistantProvider", hass_prov) + return HomeAssistantPlayerProvider(mass, manifest, config, hass_prov) async def get_config_entries( @@ -138,10 +53,10 @@ async def get_config_entries( action: [optional] action key called from config entries UI. values: the (intermediate) raw values for config entries sent with the action. """ - hass_prov: HomeAssistantProvider = mass.get_provider(HASS_DOMAIN) + hass_prov = cast("HomeAssistantProvider|None", mass.get_provider(HASS_DOMAIN)) player_entities: list[ConfigValueOption] = [] if hass_prov and hass_prov.hass.connected: - async for state in _get_hass_media_players(hass_prov): + async for state in get_hass_media_players(hass_prov): name = f"{state['attributes']['friendly_name']} ({state['entity_id']})" player_entities.append(ConfigValueOption(name, state["entity_id"])) return ( @@ -158,493 +73,3 @@ async def get_config_entries( "compatible with Music Assistant.", ), ) - - -class HomeAssistantPlayers(PlayerProvider): - """Home Assistant PlayerProvider for Music Assistant.""" - - hass_prov: HomeAssistantProvider - on_unload_callbacks: list[callable] | None = None - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - await super().loaded_in_mass() - player_ids: list[str] = self.config.get_value(CONF_PLAYERS) - # prefetch the device- and entity registry - device_registry = {x["id"]: x for x in await self.hass_prov.hass.get_device_registry()} - entity_registry = { - x["entity_id"]: x for x in await self.hass_prov.hass.get_entity_registry() - } - # setup players from hass entities - async for state in _get_hass_media_players(self.hass_prov): - if state["entity_id"] not in player_ids: - continue - await self._setup_player(state, entity_registry, device_registry) - # register for entity state updates - self.on_unload_callbacks = [ - await self.hass_prov.hass.subscribe_entities(self._on_entity_state_update, player_ids) - ] - # remove any leftover players (after reconfigure of players) - for player in self.players: - if player.player_id not in player_ids: - self.mass.players.remove(player.player_id) - - async def unload(self, is_removed: bool = False) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - is_removed will be set to True when the provider is removed from the configuration. - """ - if self.on_unload_callbacks: - for callback in self.on_unload_callbacks: - callback() - - async def get_player_config_entries( - self, - player_id: str, - ) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - base_entries = (*base_entries, *DEFAULT_PLAYER_CONFIG_ENTRIES) - player = self.mass.players.get(player_id) - if player and player.extra_data.get("esphome_supported_audio_formats"): - # optimized config for new ESPHome mediaplayer - supported_sample_rates: list[int] = [] - supported_bit_depths: list[int] = [] - codec: str | None = None - supported_formats: list[ESPHomeSupportedAudioFormat] = player.extra_data[ - "esphome_supported_audio_formats" - ] - # sort on purpose field, so we prefer the media pipeline - # but allows fallback to announcements pipeline if no media pipeline is available - supported_formats.sort(key=lambda x: x["purpose"]) - for supported_format in supported_formats: - codec = supported_format["format"] - if supported_format["sample_rate"] not in supported_sample_rates: - supported_sample_rates.append(supported_format["sample_rate"]) - bit_depth = (supported_format["sample_bytes"] or 2) * 8 - if bit_depth not in supported_bit_depths: - supported_bit_depths.append(bit_depth) - if not supported_sample_rates or not supported_bit_depths: - # esphome device with no media pipeline configured - # simply use the default config of the media pipeline - supported_sample_rates = [48000] - supported_bit_depths = [16] - return ( - *base_entries, - # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE_FORCED_2, - create_output_codec_config_entry(True, codec), - CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN, - create_sample_rates_config_entry( - supported_sample_rates=supported_sample_rates, - supported_bit_depths=supported_bit_depths, - hidden=True, - ), - # although the Voice PE supports announcements, - # it does not support volume for announcements - *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES, - ) - - # add alert if player is a known player type that has a native provider in MA - if player and player.extra_data.get("hass_domain") in WARN_HASS_INTEGRATIONS: - base_entries = (CONF_ENTRY_WARN_HASS_INTEGRATION, *base_entries) - - # enable flow mode by default if player does not report enqueue support - if ( - player - and MediaPlayerEntityFeature.MEDIA_ENQUEUE - not in player.extra_data["hass_supported_features"] - ): - base_entries = (*base_entries, CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED) - - return base_entries - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player. - - - player_id: player_id of the player to handle the command. - """ - try: - await self.hass_prov.hass.call_service( - domain="media_player", - service="media_stop", - target={"entity_id": player_id}, - ) - except FailedCommand as exc: - # some HA players do not support STOP - if "does not support this service" not in str(exc): - raise - if player := self.mass.players.get(player_id): - if PlayerFeature.PAUSE in player.supported_features: - await self.cmd_pause(player_id) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY (unpause) command to given player. - - - player_id: player_id of the player to handle the command. - """ - await self.hass_prov.hass.call_service( - domain="media_player", service="media_play", target={"entity_id": player_id} - ) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player. - - - player_id: player_id of the player to handle the command. - """ - await self.hass_prov.hass.call_service( - domain="media_player", - service="media_pause", - target={"entity_id": player_id}, - ) - - async def play_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on given player.""" - player = self.mass.players.get(player_id, True) - assert player - extra_data = { - # passing metadata to the player - # so far only supported by google cast, but maybe others can follow - "metadata": { - "title": media.title, - "artist": media.artist, - "metadataType": 3, - "album": media.album, - "albumName": media.album, - "images": [{"url": media.image_url}] if media.image_url else None, - "imageUrl": media.image_url, - }, - } - if player.extra_data.get("hass_domain") == "esphome": - # tell esphome mediaproxy to bypass the proxy, - # as MA already delivers an optimized stream - extra_data["bypass_proxy"] = True - - # stop the player if it is already playing - if player.state == PlayerState.PLAYING: - await self.cmd_stop(player_id) - - await self.hass_prov.hass.call_service( - domain="media_player", - service="play_media", - service_data={ - "media_content_id": media.uri, - "media_content_type": "music", - "enqueue": "replace", - "extra": extra_data, - }, - target={"entity_id": player_id}, - ) - # optimistically set the elapsed_time as some HA players do not report this - if player := self.mass.players.get(player_id): - player.elapsed_time = 0 - player.elapsed_time_last_updated = time.time() - player.current_media = media - - async def play_announcement( - self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None - ) -> None: - """Handle (provider native) playback of an announcement on given player.""" - player = self.mass.players.get(player_id, True) - self.logger.info( - "Playing announcement %s on %s", - announcement.uri, - player.display_name, - ) - if volume_level is not None: - self.logger.warning( - "Announcement volume level is not supported for player %s", - player.display_name, - ) - await self.hass_prov.hass.call_service( - domain="media_player", - service="play_media", - service_data={ - "media_content_id": announcement.uri, - "media_content_type": "music", - "announce": True, - }, - target={"entity_id": player_id}, - ) - # Wait until the announcement is finished playing - # This is helpful for people who want to play announcements in a sequence - media_info = await async_parse_tags(announcement.uri, require_duration=True) - duration = media_info.duration or 5 - await asyncio.sleep(duration) - self.logger.debug( - "Playing announcement on %s completed", - player.display_name, - ) - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Send POWER command to given player. - - - player_id: player_id of the player to handle the command. - - powered: bool if player should be powered on or off. - """ - await self.hass_prov.hass.call_service( - domain="media_player", - service="turn_on" if powered else "turn_off", - target={"entity_id": player_id}, - ) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player. - - - player_id: player_id of the player to handle the command. - - volume_level: volume level (0..100) to set on the player. - """ - await self.hass_prov.hass.call_service( - domain="media_player", - service="volume_set", - service_data={"volume_level": volume_level / 100}, - target={"entity_id": player_id}, - ) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player. - - - player_id: player_id of the player to handle the command. - - muted: bool if player should be muted. - """ - await self.hass_prov.hass.call_service( - domain="media_player", - service="volume_mute", - service_data={"is_volume_muted": muted}, - target={"entity_id": player_id}, - ) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the syncgroup master or group player. - """ - # NOTE: not in use yet, as we do not support syncgroups in MA for HA players - await self.hass_prov.hass.call_service( - domain="media_player", - service="join", - service_data={"group_members": [player_id]}, - target={"entity_id": target_player}, - ) - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - # NOTE: not in use yet, as we do not support syncgroups in MA for HA players - await self.hass_prov.hass.call_service( - domain="media_player", - service="unjoin", - target={"entity_id": player_id}, - ) - - async def _setup_player( - self, - state: HassState, - entity_registry: dict[str, HassEntity], - device_registry: dict[str, HassDevice], - ) -> None: - """Handle setup of a Player from an hass entity.""" - hass_device: HassDevice | None = None - hass_domain: str | None = None - extra_player_data: dict[str, Any] = {} - if entity_registry_entry := entity_registry.get(state["entity_id"]): - hass_device = device_registry.get(entity_registry_entry["device_id"]) - hass_domain = entity_registry_entry["platform"] - extra_player_data["entity_registry_id"] = entity_registry_entry["id"] - extra_player_data["hass_domain"] = hass_domain - extra_player_data["hass_device_id"] = hass_device["id"] if hass_device else None - if hass_domain == "esphome": - # if the player is an ESPHome player, we need to check if it is a V2 player - # as the V2 player has different capabilities and needs different config entries - # The new media player component publishes its supported sample rates but that info - # is not exposed directly by HA, so we fetch it from the diagnostics. - esphome_supported_audio_formats = await self._get_esphome_supported_audio_formats( - entity_registry_entry["config_entry_id"] - ) - extra_player_data["esphome_supported_audio_formats"] = ( - esphome_supported_audio_formats - ) - - dev_info: dict[str, Any] = {} - if hass_device: - extra_player_data["hass_device_id"] = hass_device["id"] - if model := hass_device.get("model"): - dev_info["model"] = model - if manufacturer := hass_device.get("manufacturer"): - dev_info["manufacturer"] = manufacturer - if model_id := hass_device.get("model_id"): - dev_info["model_id"] = model_id - if sw_version := hass_device.get("sw_version"): - dev_info["software_version"] = sw_version - if connections := hass_device.get("connections"): - for key, value in connections: - if key == "mac": - dev_info["mac_address"] = value - - player = Player( - player_id=state["entity_id"], - provider=self.instance_id, - type=PlayerType.PLAYER, - name=state["attributes"]["friendly_name"], - available=state["state"] not in UNAVAILABLE_STATES, - device_info=DeviceInfo.from_dict(dev_info), - state=StateMap.get(state["state"], PlayerState.IDLE), - extra_data=extra_player_data, - ) - # work out supported features - hass_supported_features = MediaPlayerEntityFeature( - state["attributes"]["supported_features"] - ) - if MediaPlayerEntityFeature.PAUSE in hass_supported_features: - player.supported_features.add(PlayerFeature.PAUSE) - if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features: - player.supported_features.add(PlayerFeature.VOLUME_SET) - if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features: - player.supported_features.add(PlayerFeature.VOLUME_MUTE) - if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features: - player.supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT) - if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features: - player.supported_features.add(PlayerFeature.SET_MEMBERS) - player.can_group_with = { - x["entity_id"] - for x in entity_registry.values() - if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain - } - if ( - MediaPlayerEntityFeature.TURN_ON in hass_supported_features - and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features - ): - player.supported_features.add(PlayerFeature.POWER) - player.powered = state["state"] not in OFF_STATES - player.extra_data["hass_supported_features"] = hass_supported_features - - await self.mass.players.register_or_update(player) - self._update_player_attributes(player, state["attributes"]) - - def _on_entity_state_update(self, event: EntityStateEvent) -> None: - """Handle Entity State event.""" - - def update_player_from_state_msg(entity_id: str, state: CompressedState) -> None: - """Handle updating MA player with updated info in a HA CompressedState.""" - player = self.mass.players.get(entity_id) - if player is None: - # edge case - one of our subscribed entities was not available at startup - # and now came available - we should still set it up - player_ids: list[str] = self.config.get_value(CONF_PLAYERS) - if entity_id not in player_ids: - return # should not happen, but guard just in case - self.mass.create_task(self._late_add_player(entity_id)) - return - if "s" in state: - player.state = StateMap.get(state["s"], PlayerState.IDLE) - player.available = state["s"] not in UNAVAILABLE_STATES - if PlayerFeature.POWER in player.supported_features: - player.powered = state["s"] not in OFF_STATES - if "a" in state: - self._update_player_attributes(player, state["a"]) - self.mass.players.update(entity_id) - - if entity_additions := event.get("a"): - for entity_id, state in entity_additions.items(): - update_player_from_state_msg(entity_id, state) - if entity_changes := event.get("c"): - for entity_id, state_diff in entity_changes.items(): - if "+" not in state_diff: - continue - update_player_from_state_msg(entity_id, state_diff["+"]) - - def _update_player_attributes(self, player: Player, attributes: dict[str, Any]) -> None: - """Update Player attributes from HA state attributes.""" - for key, value in attributes.items(): - if key == "media_position": - player.elapsed_time = value - if key == "media_position_updated_at": - player.elapsed_time_last_updated = from_iso_string(value).timestamp() - if key == "volume_level": - player.volume_level = int(value * 100) - if key == "volume_muted": - player.volume_muted = value - if key == "media_content_id": - player.current_item_id = value - if key == "group_members": - group_members: list[str] = ( - [ - # ignore integrations that incorrectly set the group members attribute - # (e.g. linkplay) - x - for x in value - if x.startswith("media_player.") - ] - if value - else [] - ) - if group_members and group_members[0] == player.player_id: - # first in the list is the group leader - player.group_childs.set(group_members) - player.synced_to = None - elif group_members and group_members[0] != player.player_id: - # this player is not the group leader - player.group_childs.clear() - player.synced_to = group_members[0] - else: - player.group_childs.clear() - player.synced_to = None - - async def _late_add_player(self, entity_id: str) -> None: - """Handle setup of Player from HA entity that became available after startup.""" - # prefetch the device- and entity registry - device_registry = {x["id"]: x for x in await self.hass_prov.hass.get_device_registry()} - entity_registry = { - x["entity_id"]: x for x in await self.hass_prov.hass.get_entity_registry() - } - async for state in _get_hass_media_players(self.hass_prov): - if state["entity_id"] != entity_id: - continue - await self._setup_player(state, entity_registry, device_registry) - - async def _get_esphome_supported_audio_formats( - self, conf_entry_id: str - ) -> list[ESPHomeSupportedAudioFormat]: - """Get supported audio formats for an ESPHome device.""" - result: list[ESPHomeSupportedAudioFormat] = [] - try: - # TODO: expose this in the hass client lib instead of hacking around private vars - ws_url = self.hass_prov.hass._websocket_url or "ws://supervisor/core/websocket" - hass_url = ws_url.replace("ws://", "http://").replace("wss://", "https://") - hass_url = hass_url.replace("/api/websocket", "").replace("/websocket", "") - api_token = self.hass_prov.hass._token or os.environ.get("HASSIO_TOKEN") - url = f"{hass_url}/api/diagnostics/config_entry/{conf_entry_id}" - headers = { - "Authorization": f"Bearer {api_token}", - "content-type": "application/json", - } - async with self.mass.http_session.get(url, headers=headers) as response: - if response.status != 200: - raise LoginFailed("Unable to contact Home Assistant to retrieve diagnostics") - data = await response.json() - if "data" not in data or "storage_data" not in data["data"]: - return result - if "media_player" not in data["data"]["storage_data"]: - raise InvalidDataError("Media player info not found in ESPHome diagnostics") - for media_player_obj in data["data"]["storage_data"]["media_player"]: - if "supported_formats" not in media_player_obj: - continue - for supported_format_obj in media_player_obj["supported_formats"]: - result.append(cast("ESPHomeSupportedAudioFormat", supported_format_obj)) - except Exception as exc: - self.logger.warning( - "Failed to fetch diagnostics for ESPHome player: %s", - str(exc), - exc_info=exc if self.logger.isEnabledFor(logging.DEBUG) else None, - ) - return result diff --git a/music_assistant/providers/hass_players/constants.py b/music_assistant/providers/hass_players/constants.py new file mode 100644 index 00000000..3c3fe891 --- /dev/null +++ b/music_assistant/providers/hass_players/constants.py @@ -0,0 +1,20 @@ +"""Constants for the Home Assistant PlayerProvider.""" + +from __future__ import annotations + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +CONF_PLAYERS = "players" + +BLOCKLISTED_HASS_INTEGRATIONS = ("alexa_media", "apple_tv") +WARN_HASS_INTEGRATIONS = ("cast", "dlna_dmr", "fully_kiosk", "sonos", "snapcast") + + +CONF_ENTRY_WARN_HASS_INTEGRATION = ConfigEntry( + key="warn_hass_integration", + type=ConfigEntryType.ALERT, + label="Music Assistant has native support for this player type - " + "it is strongly recommended to use the native player provider for this player in " + "Music Assistant instead of the generic version provided by the Home Assistant provider.", +) diff --git a/music_assistant/providers/hass_players/helpers.py b/music_assistant/providers/hass_players/helpers.py new file mode 100644 index 00000000..c2d08790 --- /dev/null +++ b/music_assistant/providers/hass_players/helpers.py @@ -0,0 +1,92 @@ +"""Helpers and utilities for the Home Assistant PlayerProvider.""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, TypedDict, cast + +from music_assistant_models.errors import InvalidDataError, LoginFailed + +from music_assistant.providers.hass.constants import MediaPlayerEntityFeature + +from .constants import BLOCKLISTED_HASS_INTEGRATIONS + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from hass_client.models import State as HassState + + from music_assistant.providers.hass import HomeAssistantProvider + + +async def get_hass_media_players( + hass_prov: HomeAssistantProvider, +) -> AsyncGenerator[HassState, None]: + """Return all HA state objects for (valid) media_player entities.""" + entity_registry = {x["entity_id"]: x for x in await hass_prov.hass.get_entity_registry()} + for state in await hass_prov.hass.get_states(): + if not state["entity_id"].startswith("media_player"): + continue + if "mass_player_type" in state["attributes"]: + # filter out mass players + continue + if "friendly_name" not in state["attributes"]: + # filter out invalid/unavailable players + continue + supported_features = MediaPlayerEntityFeature(state["attributes"]["supported_features"]) + if MediaPlayerEntityFeature.PLAY_MEDIA not in supported_features: + continue + if entity_registry_entry := entity_registry.get(state["entity_id"]): + hass_domain = entity_registry_entry["platform"] + if hass_domain in BLOCKLISTED_HASS_INTEGRATIONS: + continue + yield state + + +class ESPHomeSupportedAudioFormat(TypedDict): + """ESPHome Supported Audio Format.""" + + format: str # flac, wav or mp3 + sample_rate: int # e.g. 48000 + num_channels: int # 1 for announcements, 2 for media + purpose: int # 0 for media, 1 for announcements + sample_bytes: int # 1 for 8 bit, 2 for 16 bit, 4 for 32 bit + + +async def get_esphome_supported_audio_formats( + hass_prov: HomeAssistantProvider, conf_entry_id: str +) -> list[ESPHomeSupportedAudioFormat]: + """Get supported audio formats for an ESPHome device.""" + result: list[ESPHomeSupportedAudioFormat] = [] + try: + # TODO: expose this in the hass client lib instead of hacking around private vars + ws_url = hass_prov.hass._websocket_url or "ws://supervisor/core/websocket" + hass_url = ws_url.replace("ws://", "http://").replace("wss://", "https://") + hass_url = hass_url.replace("/api/websocket", "").replace("/websocket", "") + api_token = hass_prov.hass._token or os.environ.get("HASSIO_TOKEN") + url = f"{hass_url}/api/diagnostics/config_entry/{conf_entry_id}" + headers = { + "Authorization": f"Bearer {api_token}", + "content-type": "application/json", + } + async with hass_prov.mass.http_session.get(url, headers=headers) as response: + if response.status != 200: + raise LoginFailed("Unable to contact Home Assistant to retrieve diagnostics") + data = await response.json() + if "data" not in data or "storage_data" not in data["data"]: + return result + if "media_player" not in data["data"]["storage_data"]: + raise InvalidDataError("Media player info not found in ESPHome diagnostics") + for media_player_obj in data["data"]["storage_data"]["media_player"]: + if "supported_formats" not in media_player_obj: + continue + for supported_format_obj in media_player_obj["supported_formats"]: + result.append(cast("ESPHomeSupportedAudioFormat", supported_format_obj)) + except Exception as exc: + hass_prov.logger.warning( + "Failed to fetch diagnostics for ESPHome player: %s", + str(exc), + exc_info=exc if hass_prov.logger.isEnabledFor(logging.DEBUG) else None, + ) + return result diff --git a/music_assistant/providers/hass_players/player.py b/music_assistant/providers/hass_players/player.py new file mode 100644 index 00000000..e613a8bb --- /dev/null +++ b/music_assistant/providers/hass_players/player.py @@ -0,0 +1,371 @@ +"""Home Assistant Player implementation.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any + +from hass_client.exceptions import FailedCommand +from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType + +from music_assistant.constants import ( + CONF_ENTRY_ENABLE_ICY_METADATA, + CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN, + CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_HTTP_PROFILE_FORCED_2, + CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, + HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES, + create_output_codec_config_entry, + create_sample_rates_config_entry, +) +from music_assistant.helpers.datetime import from_iso_string +from music_assistant.helpers.tags import async_parse_tags +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.models.player_provider import PlayerProvider +from music_assistant.providers.hass.constants import ( + OFF_STATES, + UNAVAILABLE_STATES, + MediaPlayerEntityFeature, + StateMap, +) + +from .constants import CONF_ENTRY_WARN_HASS_INTEGRATION, WARN_HASS_INTEGRATIONS +from .helpers import ESPHomeSupportedAudioFormat + +if TYPE_CHECKING: + from hass_client import HomeAssistantClient + from hass_client.models import CompressedState + from hass_client.models import Entity as HassEntity + from hass_client.models import State as HassState + from music_assistant_models.config_entries import ConfigEntry + + +DEFAULT_PLAYER_CONFIG_ENTRIES = ( + CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3, + CONF_ENTRY_HTTP_PROFILE, + CONF_ENTRY_ENABLE_ICY_METADATA, + CONF_ENTRY_FLOW_MODE_ENFORCED, +) + + +class HomeAssistantPlayer(Player): + """Home Assistant Player implementation.""" + + _attr_type = PlayerType.PLAYER + + def __init__( + self, + provider: PlayerProvider, + hass: HomeAssistantClient, + player_id: str, + hass_state: HassState, + dev_info: dict[str, Any], + extra_player_data: dict[str, Any], + entity_registry: dict[str, HassEntity], + ) -> None: + """Initialize the Home Assistant Player.""" + super().__init__(provider, player_id) + self.hass = hass + self.hass_state = hass_state + self._extra_data = extra_player_data + # Set base attributes from Home Assistant state + self._attr_available = hass_state["state"] not in UNAVAILABLE_STATES + self._attr_device_info = DeviceInfo.from_dict(dev_info) + self._attr_playback_state = StateMap.get(hass_state["state"], PlaybackState.IDLE) + # Work out supported features + self._attr_supported_features = set() + hass_supported_features = MediaPlayerEntityFeature( + hass_state["attributes"]["supported_features"] + ) + if MediaPlayerEntityFeature.PAUSE in hass_supported_features: + self._attr_supported_features.add(PlayerFeature.PAUSE) + if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features: + self._attr_supported_features.add(PlayerFeature.VOLUME_SET) + if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features: + self._attr_supported_features.add(PlayerFeature.VOLUME_MUTE) + if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features: + self._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT) + hass_domain = extra_player_data.get("hass_domain") + if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features: + self._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + self._attr_can_group_with = { + x["entity_id"] + for x in entity_registry.values() + if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain + } + if ( + MediaPlayerEntityFeature.TURN_ON in hass_supported_features + and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features + ): + self._attr_supported_features.add(PlayerFeature.POWER) + self._attr_powered = hass_state["state"] not in OFF_STATES + + self.extra_data["hass_supported_features"] = hass_supported_features + self._update_attributes(hass_state["attributes"]) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + base_entries = await super().get_config_entries() + base_entries = (*base_entries, *DEFAULT_PLAYER_CONFIG_ENTRIES) + if self.extra_data.get("esphome_supported_audio_formats"): + # optimized config for new ESPHome mediaplayer + supported_sample_rates: list[int] = [] + supported_bit_depths: list[int] = [] + codec: str | None = None + supported_formats: list[ESPHomeSupportedAudioFormat] = self.extra_data[ + "esphome_supported_audio_formats" + ] + # sort on purpose field, so we prefer the media pipeline + # but allows fallback to announcements pipeline if no media pipeline is available + supported_formats.sort(key=lambda x: x["purpose"]) + for supported_format in supported_formats: + codec = supported_format["format"] + if supported_format["sample_rate"] not in supported_sample_rates: + supported_sample_rates.append(supported_format["sample_rate"]) + bit_depth = (supported_format["sample_bytes"] or 2) * 8 + if bit_depth not in supported_bit_depths: + supported_bit_depths.append(bit_depth) + if not supported_sample_rates or not supported_bit_depths: + # esphome device with no media pipeline configured + # simply use the default config of the media pipeline + supported_sample_rates = [48000] + supported_bit_depths = [16] + return [ + *base_entries, + # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_HTTP_PROFILE_FORCED_2, + create_output_codec_config_entry(True, codec), + CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN, + create_sample_rates_config_entry( + supported_sample_rates=supported_sample_rates, + supported_bit_depths=supported_bit_depths, + hidden=True, + ), + # although the Voice PE supports announcements, + # it does not support volume for announcements + *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES, + ] + + # add alert if player is a known player type that has a native provider in MA + if self.extra_data.get("hass_domain") in WARN_HASS_INTEGRATIONS: + base_entries = (CONF_ENTRY_WARN_HASS_INTEGRATION, *base_entries) + + # enable flow mode by default if player does not report enqueue support + if MediaPlayerEntityFeature.MEDIA_ENQUEUE not in self.extra_data["hass_supported_features"]: + base_entries = (*base_entries, CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED) + + return list(base_entries) + + async def play(self) -> None: + """Handle PLAY command on the player.""" + await self.hass.call_service( + domain="media_player", + service="media_play", + target={"entity_id": self.player_id}, + ) + + async def pause(self) -> None: + """Handle PAUSE command on the player.""" + await self.hass.call_service( + domain="media_player", + service="media_pause", + target={"entity_id": self.player_id}, + ) + + async def stop(self) -> None: + """Send STOP command to player.""" + try: + await self.hass.call_service( + domain="media_player", + service="media_stop", + target={"entity_id": self.player_id}, + ) + except FailedCommand as exc: + # some HA players do not support STOP + if "does not support this service" not in str(exc): + raise + if PlayerFeature.PAUSE in self.supported_features: + await self.pause() + + async def volume_set(self, volume_level: int) -> None: + """Handle VOLUME_SET command on the player.""" + await self.hass.call_service( + domain="media_player", + service="volume_set", + target={"entity_id": self.player_id}, + service_data={"volume_level": volume_level / 100}, + ) + + async def volume_mute(self, muted: bool) -> None: + """Handle VOLUME MUTE command on the player.""" + await self.hass.call_service( + domain="media_player", + service="volume_mute", + target={"entity_id": self.player_id}, + service_data={"is_volume_muted": muted}, + ) + + async def power(self, powered: bool) -> None: + """Handle POWER command on the player.""" + await self.hass.call_service( + domain="media_player", + service="turn_on" if powered else "turn_off", + target={"entity_id": self.player_id}, + ) + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + extra_data = { + # passing metadata to the player + # so far only supported by google cast, but maybe others can follow + "metadata": { + "title": media.title, + "artist": media.artist, + "metadataType": 3, + "album": media.album, + "albumName": media.album, + "images": [{"url": media.image_url}] if media.image_url else None, + "imageUrl": media.image_url, + "duration": media.duration, + }, + } + if self.extra_data.get("hass_domain") == "esphome": + # tell esphome mediaproxy to bypass the proxy, + # as MA already delivers an optimized stream + extra_data["bypass_proxy"] = True + + # stop the player if it is already playing + if self.playback_state == PlaybackState.PLAYING: + await self.stop() + + await self.hass.call_service( + domain="media_player", + service="play_media", + target={"entity_id": self.player_id}, + service_data={ + "media_content_id": media.uri, + "media_content_type": "music", + "enqueue": "replace", + "extra": extra_data, + }, + ) + + # Optimistically update state + self._attr_current_media = media + self._attr_active_source = media.queue_id + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time.time() + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + async def play_announcement( + self, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """Handle (provider native) playback of an announcement on given player.""" + self.logger.info( + "Playing announcement %s on %s", + announcement.uri, + self.display_name, + ) + if volume_level is not None: + self.logger.warning( + "Announcement volume level is not supported for player %s", + self.display_name, + ) + await self.hass.call_service( + domain="media_player", + service="play_media", + service_data={ + "media_content_id": announcement.uri, + "media_content_type": "music", + "announce": True, + }, + target={"entity_id": self.player_id}, + ) + # Wait until the announcement is finished playing + # This is helpful for people who want to play announcements in a sequence + media_info = await async_parse_tags(announcement.uri, require_duration=True) + duration = media_info.duration or 5 + await asyncio.sleep(duration) + self.logger.debug( + "Playing announcement on %s completed", + self.display_name, + ) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """ + Handle SET_MEMBERS command on the player. + + Group or ungroup the given child player(s) to/from this player. + Will only be called if the PlayerFeature.SET_MEMBERS is supported. + + :param player_ids_to_add: List of player_id's to add to the group. + :param player_ids_to_remove: List of player_id's to remove from the group. + """ + for player_id_to_remove in player_ids_to_remove or []: + await self.hass.call_service( + domain="media_player", + service="unjoin", + target={"entity_id": player_id_to_remove}, + ) + if player_ids_to_add: + await self.hass.call_service( + domain="media_player", + service="join", + service_data={"group_members": player_ids_to_add}, + target={"entity_id": self.player_id}, + ) + + def update_from_compressed_state(self, state: CompressedState) -> None: + """Handle updating the player with updated info in a HA CompressedState.""" + if "s" in state: + self._attr_playback_state = StateMap.get(state["s"], PlaybackState.IDLE) + self._attr_available = state["s"] not in UNAVAILABLE_STATES + if PlayerFeature.POWER in self.supported_features: + self._attr_powered = state["s"] not in OFF_STATES + if "a" in state: + self._update_attributes(state["a"]) + self.update_state() + + def _update_attributes(self, attributes: dict[str, Any]) -> None: + """Update Player attributes from HA state attributes.""" + # process optional attributes - these may not be present in all states + for key, value in attributes.items(): + if key == "friendly_name": + self._attr_name = value + elif key == "media_position": + self._attr_elapsed_time = value + elif key == "media_position_updated_at": + self._attr_elapsed_time_last_updated = from_iso_string(value).timestamp() + elif key == "volume_level": + self._attr_volume_level = int(value * 100) + elif key == "is_volume_muted": + self._attr_volume_muted = value + elif key == "group_members": + group_members: list[str] = ( + [ + # ignore integrations that incorrectly set the group members attribute + # (e.g. linkplay) + x + for x in value + if x.startswith("media_player.") + ] + if value + else [] + ) + if group_members and group_members[0] == self.player_id: + # first in the list is the group leader + self._attr_group_members = group_members + elif group_members and group_members[0] != self.player_id: + # this player is not the group leader + self._attr_group_members.clear() + else: + self._attr_group_members.clear() diff --git a/music_assistant/providers/hass_players/provider.py b/music_assistant/providers/hass_players/provider.py new file mode 100644 index 00000000..a48f6dd6 --- /dev/null +++ b/music_assistant/providers/hass_players/provider.py @@ -0,0 +1,173 @@ +""" +Home Assistant PlayerProvider for Music Assistant. + +Allows using media_player entities in HA to be used as players in MA. +Requires the Home Assistant Plugin. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from music_assistant.mass import MusicAssistant +from music_assistant.models.player_provider import PlayerProvider + +from .constants import CONF_PLAYERS +from .helpers import get_esphome_supported_audio_formats, get_hass_media_players +from .player import HomeAssistantPlayer + +if TYPE_CHECKING: + from hass_client.models import CompressedState, EntityStateEvent + from hass_client.models import Device as HassDevice + from hass_client.models import Entity as HassEntity + from hass_client.models import State as HassState + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.providers.hass import HomeAssistantProvider + + +class HomeAssistantPlayerProvider(PlayerProvider): + """Home Assistant PlayerProvider for Music Assistant.""" + + hass_prov: HomeAssistantProvider + on_unload_callbacks: list[callable] | None = None + + def __init__( + self, + mass: MusicAssistant, + manifest: ProviderManifest, + config: ProviderConfig, + hass_prov: HomeAssistantProvider, + ) -> None: + """Initialize MusicProvider.""" + super().__init__(mass, manifest, config) + self.hass_prov = hass_prov + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + await super().loaded_in_mass() + player_ids = cast("list[str]", self.config.get_value(CONF_PLAYERS)) + # prefetch the device- and entity registry + device_registry = {x["id"]: x for x in await self.hass_prov.hass.get_device_registry()} + entity_registry = { + x["entity_id"]: x for x in await self.hass_prov.hass.get_entity_registry() + } + # setup players from hass entities + async for state in get_hass_media_players(self.hass_prov): + if state["entity_id"] not in player_ids: + continue + await self._setup_player(state, entity_registry, device_registry) + # register for entity state updates + self.on_unload_callbacks = [ + await self.hass_prov.hass.subscribe_entities(self._on_entity_state_update, player_ids) + ] + # remove any leftover players (after reconfigure of players) + for player in self.players: + if player.player_id not in player_ids: + self.mass.players.remove(player.player_id) + + async def unload(self, is_removed: bool = False) -> None: + """ + Handle unload/close of the provider. + + Called when provider is deregistered (e.g. MA exiting or config reloading). + is_removed will be set to True when the provider is removed from the configuration. + """ + if self.on_unload_callbacks: + for callback in self.on_unload_callbacks: + callback() + + async def _setup_player( + self, + state: HassState, + entity_registry: dict[str, HassEntity], + device_registry: dict[str, HassDevice], + ) -> None: + """Handle setup of a Player from an hass entity.""" + hass_device: HassDevice | None = None + hass_domain: str | None = None + # collect extra player data + extra_player_data: dict[str, Any] = {} + if entity_registry_entry := entity_registry.get(state["entity_id"]): + hass_device = device_registry.get(entity_registry_entry["device_id"]) + hass_domain = entity_registry_entry["platform"] + extra_player_data["entity_registry_id"] = entity_registry_entry["id"] + extra_player_data["hass_domain"] = hass_domain + extra_player_data["hass_device_id"] = hass_device["id"] if hass_device else None + if hass_domain == "esphome": + # if the player is an ESPHome player, we need to check if it is a V2 player + # as the V2 player has different capabilities and needs different config entries + # The new media player component publishes its supported sample rates but that info + # is not exposed directly by HA, so we fetch it from the diagnostics. + esphome_supported_audio_formats = await get_esphome_supported_audio_formats( + self.hass_prov, entity_registry_entry["config_entry_id"] + ) + extra_player_data["esphome_supported_audio_formats"] = ( + esphome_supported_audio_formats + ) + # collect device info + dev_info: dict[str, Any] = {} + if hass_device: + extra_player_data["hass_device_id"] = hass_device["id"] + if model := hass_device.get("model"): + dev_info["model"] = model + if manufacturer := hass_device.get("manufacturer"): + dev_info["manufacturer"] = manufacturer + if model_id := hass_device.get("model_id"): + dev_info["model_id"] = model_id + if sw_version := hass_device.get("sw_version"): + dev_info["software_version"] = sw_version + if connections := hass_device.get("connections"): + for key, value in connections: + if key == "mac": + dev_info["mac_address"] = value + + # create the player + player = HomeAssistantPlayer( + provider=self, + hass=self.hass_prov.hass, + player_id=state["entity_id"], + hass_state=state, + dev_info=dev_info, + extra_player_data=extra_player_data, + entity_registry=entity_registry, + ) + await self.mass.players.register(player) + + def _on_entity_state_update(self, event: EntityStateEvent) -> None: + """Handle Entity State event.""" + + def update_player_from_state_msg(entity_id: str, state: CompressedState) -> None: + """Handle updating MA player with updated info in a HA CompressedState.""" + player = cast("HomeAssistantPlayer | None", self.mass.players.get(entity_id)) + if player is None: + # edge case - one of our subscribed entities was not available at startup + # and now came available - we should still set it up + player_ids = cast("list[str]", self.config.get_value(CONF_PLAYERS)) + if entity_id not in player_ids: + return # should not happen, but guard just in case + self.mass.create_task(self._late_add_player(entity_id)) + return + player.update_from_compressed_state(state) + + if entity_additions := event.get("a"): + for entity_id, state in entity_additions.items(): + update_player_from_state_msg(entity_id, state) + if entity_changes := event.get("c"): + for entity_id, state_diff in entity_changes.items(): + if "+" not in state_diff: + continue + update_player_from_state_msg(entity_id, state_diff["+"]) + + async def _late_add_player(self, entity_id: str) -> None: + """Handle setup of Player from HA entity that became available after startup.""" + # prefetch the device- and entity registry + device_registry = {x["id"]: x for x in await self.hass_prov.hass.get_device_registry()} + entity_registry = { + x["entity_id"]: x for x in await self.hass_prov.hass.get_entity_registry() + } + async for state in get_hass_media_players(self.hass_prov): + if state["entity_id"] != entity_id: + continue + await self._setup_player(state, entity_registry, device_registry) diff --git a/music_assistant/providers/musicbrainz/__init__.py b/music_assistant/providers/musicbrainz/__init__.py index 215acfdc..8659fa16 100644 --- a/music_assistant/providers/musicbrainz/__init__.py +++ b/music_assistant/providers/musicbrainz/__init__.py @@ -8,7 +8,7 @@ from __future__ import annotations import re from contextlib import suppress from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, cast from mashumaro import DataClassDictMixin from mashumaro.exceptions import MissingField @@ -33,8 +33,6 @@ if TYPE_CHECKING: LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' -_T = TypeVar("_T") - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig @@ -60,17 +58,15 @@ async def get_config_entries( return () # we do not have any config entries (yet) -def replace_hyphens(data: _T) -> _T: - """Change all hyphens to underscores.""" +def replace_hyphens( + data: dict[str, Any] | list[dict[str, Any]] | Any, +) -> dict[str, Any] | list[dict[str, Any]] | Any: + """Change all hyphened keys to underscores.""" if isinstance(data, dict): - new_values = {} - for key, value in data.items(): - new_key = key.replace("-", "_") - new_values[new_key] = replace_hyphens(value) - return cast("_T", new_values) + return {key.replace("-", "_"): replace_hyphens(value) for key, value in data.items()} if isinstance(data, list): - return cast("_T", [replace_hyphens(x) if isinstance(x, dict) else x for x in data]) + return [replace_hyphens(x) for x in data] return data @@ -110,6 +106,14 @@ class MusicBrainzArtist(DataClassDictMixin): aliases: list[MusicBrainzAlias] | None = None tags: list[MusicBrainzTag] | None = None + @classmethod + def from_raw(cls, data: Any) -> MusicBrainzArtist: + """Instantiate object from raw api data.""" + alt_data = replace_hyphens(data) + if TYPE_CHECKING: + alt_data = cast("dict[str, Any]", alt_data) + return MusicBrainzArtist.from_dict(alt_data) + @dataclass class MusicBrainzArtistCredit(DataClassDictMixin): @@ -133,6 +137,14 @@ class MusicBrainzReleaseGroup(DataClassDictMixin): secondary_type_ids: list[str] | None = None artist_credit: list[MusicBrainzArtistCredit] | None = None + @classmethod + def from_raw(cls, data: Any) -> MusicBrainzReleaseGroup: + """Instantiate object from raw api data.""" + alt_data = replace_hyphens(data) + if TYPE_CHECKING: + alt_data = cast("dict[str, Any]", alt_data) + return MusicBrainzReleaseGroup.from_dict(alt_data) + @dataclass class MusicBrainzTrack(DataClassDictMixin): @@ -143,6 +155,14 @@ class MusicBrainzTrack(DataClassDictMixin): title: str length: int | None = None + @classmethod + def from_raw(cls, data: Any) -> MusicBrainzTrack: + """Instantiate object from raw api data.""" + alt_data = replace_hyphens(data) + if TYPE_CHECKING: + alt_data = cast("dict[str, Any]", alt_data) + return MusicBrainzTrack.from_dict(alt_data) + @dataclass class MusicBrainzMedia(DataClassDictMixin): @@ -175,6 +195,14 @@ class MusicBrainzRelease(DataClassDictMixin): disambiguation: str | None = None # version # TODO (if needed): release-events + @classmethod + def from_raw(cls, data: Any) -> MusicBrainzRelease: + """Instantiate object from raw api data.""" + alt_data = replace_hyphens(data) + if TYPE_CHECKING: + alt_data = cast("dict[str, Any]", alt_data) + return MusicBrainzRelease.from_dict(alt_data) + @dataclass class MusicBrainzRecording(DataClassDictMixin): @@ -190,6 +218,14 @@ class MusicBrainzRecording(DataClassDictMixin): tags: list[MusicBrainzTag] | None = None disambiguation: str | None = None # version (e.g. live, karaoke etc.) + @classmethod + def from_raw(cls, data: Any) -> MusicBrainzRecording: + """Instantiate object from raw api data.""" + alt_data = replace_hyphens(data) + if TYPE_CHECKING: + alt_data = cast("dict[str, Any]", alt_data) + return MusicBrainzRecording.from_dict(alt_data) + class MusicbrainzProvider(MetadataProvider): """The Musicbrainz Metadata provider.""" @@ -246,15 +282,11 @@ class MusicbrainzProvider(MetadataProvider): artist_match: MusicBrainzArtist | None = None for artist in item["artist-credit"]: if compare_strings(artist["artist"]["name"], artistname, strict): - artist_match = MusicBrainzArtist.from_dict( - replace_hyphens(artist["artist"]) - ) + artist_match = MusicBrainzArtist.from_raw(artist["artist"]) else: for alias in artist["artist"].get("aliases", []): if compare_strings(alias["name"], artistname, strict): - artist_match = MusicBrainzArtist.from_dict( - replace_hyphens(artist["artist"]) - ) + artist_match = MusicBrainzArtist.from_raw(artist["artist"]) if not artist_match: continue # match album/release @@ -263,15 +295,13 @@ class MusicbrainzProvider(MetadataProvider): if compare_strings(release["title"], albumname, strict) or compare_strings( release["release-group"]["title"], albumname, strict ): - album_match = MusicBrainzReleaseGroup.from_dict( - replace_hyphens(release["release-group"]) - ) + album_match = MusicBrainzReleaseGroup.from_raw(release["release-group"]) break else: continue # if we reach this point, we got a match on recording, # artist and release(group) - recording = MusicBrainzRecording.from_dict(replace_hyphens(item)) + recording = MusicBrainzRecording.from_raw(item) return (artist_match, album_match, recording) return None @@ -286,7 +316,7 @@ class MusicbrainzProvider(MetadataProvider): result["id"] = artist_id # TODO: Parse all the optional data like relations and such try: - return MusicBrainzArtist.from_dict(replace_hyphens(result)) + return MusicBrainzArtist.from_raw(result) except MissingField as err: raise InvalidDataError from err msg = "Invalid MusicBrainz Artist ID provided" @@ -298,7 +328,7 @@ class MusicbrainzProvider(MetadataProvider): if "id" not in result: result["id"] = recording_id try: - return MusicBrainzRecording.from_dict(replace_hyphens(result)) + return MusicBrainzRecording.from_raw(result) except MissingField as err: raise InvalidDataError from err msg = "Invalid MusicBrainz recording ID provided" @@ -311,7 +341,7 @@ class MusicbrainzProvider(MetadataProvider): if "id" not in result: result["id"] = album_id try: - return MusicBrainzRelease.from_dict(replace_hyphens(result)) + return MusicBrainzRelease.from_raw(result) except MissingField as err: raise InvalidDataError from err msg = "Invalid MusicBrainz Album ID provided" @@ -324,7 +354,7 @@ class MusicbrainzProvider(MetadataProvider): if "id" not in result: result["id"] = releasegroup_id try: - return MusicBrainzReleaseGroup.from_dict(replace_hyphens(result)) + return MusicBrainzReleaseGroup.from_raw(result) except MissingField as err: raise InvalidDataError from err msg = "Invalid MusicBrainz ReleaseGroup ID provided" @@ -394,7 +424,7 @@ class MusicbrainzProvider(MetadataProvider): for relation in result.get("relations", []): if not (artist := relation.get("artist")): continue - return MusicBrainzArtist.from_dict(replace_hyphens(artist)) + return MusicBrainzArtist.from_raw(artist) return None @use_cache(86400 * 30) diff --git a/music_assistant/providers/musiccast/__init__.py b/music_assistant/providers/musiccast/__init__.py index 1f227b0b..cd5407f3 100644 --- a/music_assistant/providers/musiccast/__init__.py +++ b/music_assistant/providers/musiccast/__init__.py @@ -1,85 +1,23 @@ """MusicCast for MusicAssistant.""" -from __future__ import annotations - -import asyncio -import logging -import time -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, cast - -from aiohttp.client_exceptions import ( - ClientError, - ServerDisconnectedError, -) -from aiomusiccast.exceptions import MusicCastGroupException -from aiomusiccast.musiccast_device import MusicCastDevice -from aiomusiccast.pyamaha import MusicCastConnectionException -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption -from music_assistant_models.enums import ( - ConfigEntryType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, -) -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia, PlayerSource -from zeroconf import ServiceStateChange - -from music_assistant.constants import VERBOSE_LOG_LEVEL -from music_assistant.models.player_provider import PlayerProvider -from music_assistant.providers.musiccast.avt_helpers import ( - avt_get_media_info, - avt_next, - avt_pause, - avt_play, - avt_previous, - avt_set_url, - avt_stop, - search_xml, -) -from music_assistant.providers.sonos.helpers import get_primary_ip_address - -from .constants import ( - CONF_PLAYER_SWITCH_SOURCE_NON_NET, - CONF_PLAYER_TURN_OFF_ON_LEAVE, - MC_CONTROL_SOURCE_IDS, - MC_DEVICE_INFO_ENDPOINT, - MC_DEVICE_UPNP_ENDPOINT, - MC_DEVICE_UPNP_PORT, - MC_NETUSB_SOURCE_IDS, - MC_PASSIVE_SOURCE_IDS, - MC_POLL_INTERVAL, - MC_SOURCE_MAIN_SYNC, - MC_SOURCE_MC_LINK, - PLAYER_CONFIG_ENTRIES, - PLAYER_ZONE_SPLITTER, -) -from .musiccast import ( - MusicCastController, - MusicCastPhysicalDevice, - MusicCastPlayerState, - MusicCastZoneDevice, +from music_assistant_models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, ) +from music_assistant_models.provider import ProviderManifest -if TYPE_CHECKING: - from music_assistant_models.config_entries import ( - ConfigValueType, - ProviderConfig, - ) - from music_assistant_models.provider import ProviderManifest - from zeroconf.asyncio import AsyncServiceInfo +from music_assistant.mass import MusicAssistant +from music_assistant.models import ProviderInstanceType - from music_assistant.mass import MusicAssistant - from music_assistant.models import ProviderInstanceType +from .provider import MusicCastProvider async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - return MusicCast(mass, manifest, config) + return MusicCastProvider(mass, manifest, config) async def get_config_entries( @@ -97,767 +35,3 @@ async def get_config_entries( """ # ruff: noqa: ARG001 return () - - -@dataclass(kw_only=True) -class MusicCastPlayer: - """MusicCastPlayer. - - Helper class to store MA player alongside physical device. - """ - - device_id: str # device_id without ZONE_SPLITTER zone - player_main: Player | None = None # mass player - player_zone2: Player | None = None # mass player - # I can only test up to zone 2 - player_zone3: Player | None = None # mass player - player_zone4: Player | None = None # mass player - - # log allowed sources for a device with multiple sources once. see "_handle_zone_grouping" - _log_allowed_sources: bool = True - - physical_device: MusicCastPhysicalDevice - - def get_player(self, zone: str) -> Player | None: - """Get Player by zone name.""" - match zone: - case "main": - return self.player_main - case "zone2": - return self.player_zone2 - case "zone3": - return self.player_zone3 - case "zone4": - return self.player_zone4 - raise RuntimeError(f"Zone {zone} is unknown.") - - def get_all_players(self) -> list[Player]: - """Get all players.""" - assert self.player_main is not None # we always have main - players = [self.player_main] - if self.player_zone2 is not None: - players.append(self.player_zone2) - if self.player_zone3 is not None: - players.append(self.player_zone3) - if self.player_zone4 is not None: - players.append(self.player_zone4) - return players - - -@dataclass(kw_only=True) -class UpnpUpdateHelper: - """UpnpUpdateHelper. - - See _update_player_attributes. - """ - - last_poll: float # time.time - controlled_by_mass: bool - current_uri: str | None - - -class MusicCast(PlayerProvider): - """MusicCast.""" - - musiccast_players: dict[str, MusicCastPlayer] = {} - - # poll upnp playback information, but not too often. see "_update_player_attributes" - # player_id: UpnpUpdateHelper - upnp_update_helper: dict[str, UpnpUpdateHelper] = {} - - # str here is the device id, NOT the player_id - update_player_locks: dict[str, asyncio.Lock] = {} - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.SYNC_PLAYERS} - - async def handle_async_init(self) -> None: - """Async init.""" - self.mc_controller = MusicCastController(logger=self.logger) - # aiomusiccast logs all fetch requests after udp message as debug. - # same approach as in upnp - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("aiomusiccast").setLevel(logging.DEBUG) - else: - logging.getLogger("aiomusiccast").setLevel(self.logger.level + 10) - - async def unload(self, is_removed: bool = False) -> None: - """Call on unload.""" - for mc_player in self.musiccast_players.values(): - mc_player.physical_device.remove() - - async def get_player_config_entries( - self, - player_id: str, - ) -> tuple[ConfigEntry, ...]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - zone_entries: tuple[ConfigEntry, ...] = () - if zone_player := self._get_zone_player(player_id): - if len(zone_player.physical_device.zone_devices) > 1: - mass_player = self.mass.players.get(player_id) - assert mass_player is not None # for type checking - source_options: list[ConfigValueOption] = [] - allowed_sources = self._get_allowed_sources_zone_switch(zone_player) - for ( - source_id, - source_name, - ) in zone_player.source_mapping.items(): - if source_id in allowed_sources: - source_options.append(ConfigValueOption(title=source_name, value=source_id)) - if len(source_options) == 0: - # this should never happen - self.logger.error( - "The player %s has multiple zones, but lacks a non-net source to switch to." - " Please report this on github or discord.", - mass_player.display_name or mass_player.name, - ) - zone_entries = () - else: - zone_entries = ( - ConfigEntry( - key=CONF_PLAYER_SWITCH_SOURCE_NON_NET, - label="Switch to this non-net source when leaving a group.", - type=ConfigEntryType.STRING, - options=source_options, - default_value=source_options[0].value, - description="The zone will switch to this source when leaving a group." - " It must be an input which doesn't require network connectivity.", - ), - ConfigEntry( - key=CONF_PLAYER_TURN_OFF_ON_LEAVE, - type=ConfigEntryType.BOOLEAN, - label="Turn off the zone when it leaves a group.", - default_value=False, - description="Turn off the zone when it leaves a group.", - ), - ) - - return base_entries + zone_entries + PLAYER_CONFIG_ENTRIES - - def _get_zone_player(self, player_id: str) -> MusicCastZoneDevice | None: - """Get music cast zone entity based on player id.""" - device_id, zone = player_id.split(PLAYER_ZONE_SPLITTER) - mc_player = self.musiccast_players.get(device_id) - if mc_player is None: - return None - return mc_player.physical_device.zone_devices.get(zone) - - async def _set_player_unavailable(self, player_id: str) -> None: - """Set a player unavailable, and remove it from the MC group. - - Update all clients. - """ - device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER) - mc_player = self.musiccast_players.get(device_id) - if mc_player is None: - return - mc_player.physical_device.remove() - for player in mc_player.get_all_players(): - # disable zones as well. - player.available = False - await self.mass.players.register_or_update(player) - - async def _cmd_run( - self, player_id: str, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any - ) -> None: - """Help function for all player cmds.""" - try: - await fun(*args) - except MusicCastConnectionException: - await self._set_player_unavailable(player_id) - self.logger.debug("Player became unavailable.") - except MusicCastGroupException: - # can happen, user shall try again. - ... - - def _get_player_id_from_mc_zone_player(self, zone_player: MusicCastZoneDevice) -> str: - device_id = zone_player.physical_device.device.data.device_id - assert device_id is not None - return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}" - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - if zone_player := self._get_zone_player(player_id): - upnp_update_helper = self.upnp_update_helper.get(player_id) - if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass: - await avt_stop(self.mass.http_session, zone_player.physical_device) - else: - await self._cmd_run(player_id, zone_player.stop) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - if zone_player := self._get_zone_player(player_id): - upnp_update_helper = self.upnp_update_helper.get(player_id) - if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass: - await avt_play(self.mass.http_session, zone_player.physical_device) - else: - await self._cmd_run(player_id, zone_player.play) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - if zone_player := self._get_zone_player(player_id): - upnp_update_helper = self.upnp_update_helper.get(player_id) - if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass: - await avt_pause(self.mass.http_session, zone_player.physical_device) - else: - await self._cmd_run(player_id, zone_player.pause) - - async def cmd_next(self, player_id: str) -> None: - """Send NEXT.""" - if zone_player := self._get_zone_player(player_id): - upnp_update_helper = self.upnp_update_helper.get(player_id) - if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass: - await avt_next(self.mass.http_session, zone_player.physical_device) - else: - await self._cmd_run(player_id, zone_player.next_track) - - async def cmd_previous(self, player_id: str) -> None: - """Send PREVIOUS.""" - if zone_player := self._get_zone_player(player_id): - upnp_update_helper = self.upnp_update_helper.get(player_id) - if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass: - await avt_previous(self.mass.http_session, zone_player.physical_device) - else: - await self._cmd_run(player_id, zone_player.previous_track) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - if zone_player := self._get_zone_player(player_id): - await self._cmd_run(player_id, zone_player.volume_set, volume_level) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - if zone_player := self._get_zone_player(player_id): - await self._cmd_run(player_id, zone_player.volume_mute, muted) - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Send POWER command to given player.""" - if zone_player := self._get_zone_player(player_id): - if powered: - await self._cmd_run(player_id, zone_player.turn_on) - else: - await self._cmd_run(player_id, zone_player.turn_off) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player.""" - await self.cmd_group_many(target_player=target_player, child_player_ids=[player_id]) - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player.""" - if zone_player := self._get_zone_player(player_id): - if zone_player.zone_name.startswith("zone"): - # We are are zone. - # We do not leave an MC group, but just change our source. - await self._handle_zone_grouping(zone_player) - return - await self._cmd_run(player_id, zone_player.unjoin_player) - - def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]: - """Return non net sources for a zone player.""" - assert zone_player.zone_data is not None, "zone data missing" - _input_sources: set[str] = set(zone_player.zone_data.input_list) - _net_sources = set(MC_NETUSB_SOURCE_IDS) - _net_sources.add(MC_SOURCE_MC_LINK) # mc grouping source - _net_sources.add(MC_SOURCE_MAIN_SYNC) # main zone sync - return _input_sources.difference(_net_sources) - - async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None: - """Handle zone grouping. - - If a device has multiple zones, only a single zone can be net controlled. - If another zone wants to join the group, the current net zone has to switch - its input to a non-net one and optionally turn off. - """ - player_id = self._get_player_id_from_mc_zone_player(zone_player) - assert player_id is not None # for TYPE_CHECKING - _source = str( - await self.mass.config.get_player_config_value( - player_id, CONF_PLAYER_SWITCH_SOURCE_NON_NET - ) - ) - # verify that this source actually exists and is non net - _allowed_sources = self._get_allowed_sources_zone_switch(zone_player) - if _source not in _allowed_sources: - mass_player = self.mass.players.get(player_id) - assert mass_player is not None - msg = ( - "The switch source you specified for " - f"{mass_player.display_name or mass_player.name}" - " is not allowed. " - f"The source must be any of: {', '.join(sorted(_allowed_sources))} " - "Will use the first available source." - ) - self.logger.error(msg) - _source = _allowed_sources.pop() - - await self._cmd_run(player_id, zone_player.select_source, _source) - _turn_off = bool( - await self.mass.config.get_player_config_value(player_id, CONF_PLAYER_TURN_OFF_ON_LEAVE) - ) - if _turn_off: - await asyncio.sleep(2) - await self._cmd_run(player_id, zone_player.turn_off) - - async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None: - """Create temporary sync group by joining given players to target player.""" - device_id, zone_server = target_player.split(PLAYER_ZONE_SPLITTER) - server = self._get_zone_player(target_player) - if server is None: - return - children: set[MusicCastZoneDevice] = set() - children_zones: list[MusicCastZoneDevice] = [] - for child_id in child_player_ids: - if child := self._get_zone_player(child_id): - _other_zone_mc: MusicCastZoneDevice | None = None - for x in child.other_zones: - if x.is_netusb: - _other_zone_mc = x - if _other_zone_mc and _other_zone_mc != child: - # of the same device, we use main_sync as input - if _other_zone_mc.zone_name == "main": - children_zones.append(child) - else: - self.logger.warning( - "It is impossible to join as a normal zone to another zone of the same " - "device. Only joining to main is possible. Please refer to the docs." - ) - else: - children.add(child) - - for child in children_zones: - child_player_id = self._get_player_id_from_mc_zone_player(child) - if child.state == MusicCastPlayerState.OFF: - await self._cmd_run(child_player_id, child.turn_on) - await self.select_source(child_player_id, MC_SOURCE_MAIN_SYNC) - if not children: - return - - await self._cmd_run(target_player, server.join_players, list(children)) - - async def cmd_ungroup_member(self, player_id: str, target_player: str) -> None: - """Handle UNGROUP command for given player.""" - await self.cmd_ungroup(player_id) - - async def select_source(self, player_id: str, source: str) -> None: - """Handle SELECT SOURCE command on given player.""" - if zone_player := self._get_zone_player(player_id): - await self._cmd_run(player_id, zone_player.select_source, source) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - if zone_player := self._get_zone_player(player_id): - if len(zone_player.physical_device.zone_devices) > 1: - # zone handling - # only a single zone may have netusb capability - for zone_name, dev in zone_player.physical_device.zone_devices.items(): - if zone_name == zone_player.zone_name: - continue - if dev.is_netusb: - await self._handle_zone_grouping(dev) - device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER) - lock = self.update_player_locks.get(device_id) - assert lock is not None # for type checking - async with lock: - # just in case - if zone_player.source_id != "server": - await self.select_source(player_id, "server") - await avt_set_url( - self.mass.http_session, zone_player.physical_device, player_media=media - ) - await avt_play(self.mass.http_session, zone_player.physical_device) - - self.upnp_update_helper[player_id] = UpnpUpdateHelper( - last_poll=time.time(), - controlled_by_mass=True, - current_uri=media.uri, - ) - - if ma_player := self.mass.players.get(player_id): - ma_player.current_media = media - await self.mass.players.register_or_update(ma_player) - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Enqueue next.""" - if zone_player := self._get_zone_player(player_id): - await avt_set_url( - self.mass.http_session, - zone_player.physical_device, - player_media=media, - enqueue=True, - ) - - async def poll_player(self, player_id: str, fetch: bool = True) -> None: - """Poll player for state updates, only main zone is polled.""" - # we only poll for main, as we get zones alongside - device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER) - mc_player = self.musiccast_players.get(device_id) - if mc_player is None: - return - - lock = self.update_player_locks.get(device_id) - assert lock is not None # for type checking - if lock.locked(): - # we are called roughly every 1s when playing on udp callback, so just discard. - return - async with lock: - if fetch: # non udp "explicit polling case" - try: - await mc_player.physical_device.fetch() - except (MusicCastConnectionException, MusicCastGroupException): - await self._set_player_unavailable(player_id) - return - except ServerDisconnectedError: - return - - for player in mc_player.get_all_players(): - _, zone = player.player_id.split(PLAYER_ZONE_SPLITTER) - zone_device = mc_player.physical_device.zone_devices.get(zone) - if zone_device is None: - continue - await self._update_player_attributes(player, zone_device) - player.available = True - await self.mass.players.register_or_update(player) - - async def on_mdns_service_state_change( - self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None - ) -> None: - """Discovery via mdns.""" - if state_change == ServiceStateChange.Removed: - # Wait for connection to fail, same as sonos. - return - if info is None: - return - device_ip = get_primary_ip_address(info) - if device_ip is None: - return - try: - device_info = await self.mass.http_session.get( - f"http://{device_ip}/{MC_DEVICE_INFO_ENDPOINT}", raise_for_status=True - ) - except ClientError: - # typical Errors are - # ClientResponseError -> raise_for_status - # ClientConnectorError -> unable to connect/ not existing/ timeout - # but we can use the base exception class, as we only check - # if the device is suitable - return - device_info_json = await device_info.json() - device_id = device_info_json.get("device_id") - if device_id is None: - return - description_url = f"http://{device_ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_ENDPOINT}" - - _check = await self.mass.http_session.get(description_url) - if _check.status == 404: - self.logger.debug("Missing description url for Yamaha device at %s", device_ip) - return - await self._device_discovered( - device_id=device_id, device_ip=device_ip, description_url=description_url - ) - - async def _device_discovered( - self, device_id: str, device_ip: str, description_url: str - ) -> None: - """Handle discovered MusicCast player.""" - # verify that this is a MusicCast player - check: bool = await MusicCastDevice.check_yamaha_ssdp( - description_url, self.mass.http_session - ) - if not check: - return - - mc_player_known = self.musiccast_players.get(device_id) - if ( - mc_player_known is not None - and mc_player_known.player_main is not None - and ( - mc_player_known.physical_device.device.device.upnp_description == description_url - and mc_player_known.player_main.available - ) - ): - # nothing to do, device is already connected - return - else: - # new or updated player detected - physical_device = MusicCastPhysicalDevice( - device=MusicCastDevice( - client=self.mass.http_session, - ip=device_ip, - upnp_description=description_url, - ), - controller=self.mc_controller, - ) - self.update_player_locks[device_id] = asyncio.Lock() - success = await physical_device.async_init() # fetch + polling - if not success: - self.logger.debug( - "Had trouble setting up device at %s. Will be retried on next discovery.", - device_ip, - ) - return - physical_device.register_callback(self._non_async_udp_callback) - await self._register_player(physical_device, device_id) - - async def _register_player( - self, physical_device: MusicCastPhysicalDevice, device_id: str - ) -> None: - """Register player including zones.""" - device_info = DeviceInfo( - manufacturer="Yamaha Corporation", - model=physical_device.device.data.model_name or "unknown model", - software_version=physical_device.device.data.system_version or "unknown version", - ) - - def get_player(zone_name: str, player_name: str) -> Player: - # player features - # TODO: There is seek in the upnp desc - # http://{ip}:49154/AVTransport/desc.xml - supported_features: set[PlayerFeature] = { - PlayerFeature.VOLUME_SET, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, - PlayerFeature.POWER, - PlayerFeature.SELECT_SOURCE, - PlayerFeature.SET_MEMBERS, - PlayerFeature.NEXT_PREVIOUS, - PlayerFeature.ENQUEUE, - PlayerFeature.GAPLESS_PLAYBACK, - } - - return Player( - player_id=f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_name}", - provider=self.instance_id, - type=PlayerType.PLAYER, - name=player_name, - available=True, - device_info=device_info, - needs_poll=zone_name == "main", - poll_interval=MC_POLL_INTERVAL, - supported_features=supported_features, - ) - - main_device = physical_device.zone_devices.get("main") - if ( - main_device is None - or main_device.zone_data is None - or main_device.zone_data.name is None - ): - return - musiccast_player = MusicCastPlayer( - device_id=device_id, - physical_device=physical_device, - ) - - for zone_name, zone_device in physical_device.zone_devices.items(): - if zone_device.zone_data is None or zone_device.zone_data.name is None: - continue - player = get_player(zone_name, zone_device.zone_data.name) - setattr(musiccast_player, f"player_{zone_device.zone_name}", player) - await self._update_player_attributes(player, zone_device) - await self.mass.players.register_or_update(player) - - if musiccast_player.player_zone2 is not None and musiccast_player._log_allowed_sources: - musiccast_player._log_allowed_sources = False - player_main = musiccast_player.player_main - assert player_main is not None - self.logger.info( - f"The player {player_main.display_name or player_main.name} has multiple zones. " - "Please use the player config to configure a non-net source for grouping. " - ) - - self.musiccast_players[device_id] = musiccast_player - - async def _update_player_attributes(self, player: Player, device: MusicCastZoneDevice) -> None: - # ruff: noqa: PLR0915 - zone_data = device.zone_data - if zone_data is None: - return - - player.name = zone_data.name or "UNKNOWN NAME" - player.powered = zone_data.power == "on" - - # NOTE: aiomusiccast does not type hint the volume variables, and they may - # be none, and not only integers - _current_volume = cast("int | None", zone_data.current_volume) - _max_volume = cast("int | None", zone_data.max_volume) - _min_volume = cast("int | None", zone_data.min_volume) - if _current_volume is None: - player.volume_level = None - else: - _min_volume = 0 if _min_volume is None else _min_volume - _max_volume = 100 if _max_volume is None else _max_volume - if _min_volume == _max_volume: - _max_volume += 1 - player.volume_level = int(_current_volume / (_max_volume - _min_volume) * 100) - player.volume_muted = zone_data.mute - - # STATE - - match device.state: - case MusicCastPlayerState.PAUSED: - player.state = PlayerState.PAUSED - case MusicCastPlayerState.PLAYING: - player.state = PlayerState.PLAYING - case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF: - player.state = PlayerState.IDLE - player.elapsed_time = device.media_position - player.elapsed_time_last_updated = device.media_position_updated_at - - # SOURCES - source_list: list[PlayerSource] = [] - for source_id, source_name in device.source_mapping.items(): - control = source_id in MC_CONTROL_SOURCE_IDS - passive = source_id in MC_PASSIVE_SOURCE_IDS - source_list.append( - PlayerSource( - id=source_id, - name=source_name, - passive=passive, - can_play_pause=control, - can_seek=False, - can_next_previous=control, - ) - ) - player.source_list.set(source_list) - - # UPDATE UPNP HELPER - update_helper = self.upnp_update_helper.get(player.player_id) - now = time.time() - if update_helper is None or now - update_helper.last_poll > 5: - # Let's not do this too often - # Note: The devices always return the last UPnP xmls, even if - # currently another source/ playback method is used - try: - _xml_media_info = await avt_get_media_info( - self.mass.http_session, device.physical_device - ) - except ServerDisconnectedError: - return - _player_current_url = search_xml(_xml_media_info, "CurrentURI") - - # controlled by mass is only True, if we are directly controlled - # i.e. we are not a group member. - # the device's source id is server, if controlled by upnp, but also, if the internal - # dlna function of the device are used. As a fallback, we then - # use the item's title. This can only fail, if our current and next item - # has the same name as the external. - controlled_by_mass = False - if _player_current_url is not None: - controlled_by_mass = ( - player.player_id in _player_current_url - and self.mass.streams.base_url in _player_current_url - and device.source_id == "server" - ) - - update_helper = UpnpUpdateHelper( - last_poll=now, - controlled_by_mass=controlled_by_mass, - current_uri=_player_current_url, - ) - - self.upnp_update_helper[player.player_id] = update_helper - - # UPDATE PLAYBACK INFORMATION - # Note to self: - # player.current_media tells queue controller what is playing - # and player.set_current_media is the helper function - # do not access the queue controller to gain playback information here - if update_helper.current_uri is not None and update_helper.controlled_by_mass: - player.set_current_media(uri=update_helper.current_uri, clear_all=True) - elif device.is_client: - _server = device.group_server - _server_id = self._get_player_id_from_mc_zone_player(_server) - _server_update_helper = self.upnp_update_helper.get(_server_id) - if ( - _server_update_helper is not None - and _server_update_helper.current_uri is not None - and _server_update_helper.controlled_by_mass - ): - player.set_current_media( - uri=_server_update_helper.current_uri, - ) - else: - player.set_current_media( - uri=f"{_server_id}_{_server.source_id}", - title=_server.media_title, - artist=_server.media_artist, - album=_server.media_album_name, - image_url=_server.media_image_url, - ) - else: - player.set_current_media( - uri=f"{player.player_id}_{device.source_id}", - title=device.media_title, - artist=device.media_artist, - album=device.media_album_name, - image_url=device.media_image_url, - ) - - # SOURCE - player.active_source = None # means the player controller will figure it out - if not device.is_client and not update_helper.controlled_by_mass: - player.active_source = device.source_id - elif device.is_client: - _server = device.group_server - _server_id = self._get_player_id_from_mc_zone_player(_server) - if _server_update_helper := self.upnp_update_helper.get(_server_id): - player.active_source = ( - device.source_id if not _server_update_helper.controlled_by_mass else None - ) - - # GROUPING - # A zone cannot be synced to another zone or main of the same device. - # Additionally, a zone can only be synced, if main is currently not using any netusb - # function. - # For a Zone which will be synced to main, grouping emits a "main_sync" instead - # of a mc link. The other way round, we log a warning. - player.can_group_with = {self.instance_id} - - if len(device.musiccast_group) == 1: - if device.musiccast_group[0] == device: - # we are in a group with ourselves. - player.group_childs.clear() - player.synced_to = None - player.active_group = None - - elif not device.is_client and not device.is_server: - player.group_childs.clear() - player.synced_to = None - player.active_group = None - - elif device.is_client: - _synced_to_id = self._get_player_id_from_mc_zone_player(device.group_server) - player.group_childs.clear() - player.synced_to = _synced_to_id - player.active_group = _synced_to_id - - elif device.is_server: - player.group_childs.set( - [self._get_player_id_from_mc_zone_player(x) for x in device.musiccast_group] - ) - player.synced_to = None - player.active_group = None - - def _non_async_udp_callback(self, mc_physical_device: MusicCastPhysicalDevice) -> None: - """Update callback. - - This is called if there are new UDP updates. Unfortunately, aiomusiccast - only allows a sync callback, so we schedule an async task. - """ - mc_player: MusicCastPlayer | None = None - for mc_player in self.musiccast_players.values(): - if mc_player.physical_device == mc_physical_device: - break - assert mc_player is not None # for type checking - if mc_player.player_main is None: - return - main_player_id = mc_player.player_main.player_id - # disable another fetch, these attributes were set via UDP - self.mass.loop.create_task(self.poll_player(main_player_id, False)) diff --git a/music_assistant/providers/musiccast/avt_helpers.py b/music_assistant/providers/musiccast/avt_helpers.py index c71602da..5c633261 100644 --- a/music_assistant/providers/musiccast/avt_helpers.py +++ b/music_assistant/providers/musiccast/avt_helpers.py @@ -1,7 +1,6 @@ """Helpers to make an UPnP request.""" import aiohttp -from music_assistant_models.player import PlayerMedia from music_assistant.helpers.upnp import ( get_xml_soap_media_info, @@ -14,6 +13,7 @@ from music_assistant.helpers.upnp import ( get_xml_soap_stop, get_xml_soap_transport_info, ) +from music_assistant.models.player import PlayerMedia from music_assistant.providers.musiccast.constants import ( MC_DEVICE_UPNP_CTRL_ENDPOINT, MC_DEVICE_UPNP_PORT, diff --git a/music_assistant/providers/musiccast/constants.py b/music_assistant/providers/musiccast/constants.py index 01d99985..b85930ce 100644 --- a/music_assistant/providers/musiccast/constants.py +++ b/music_assistant/providers/musiccast/constants.py @@ -10,13 +10,13 @@ from music_assistant.constants import ( # Constants for players # both the http profile and icy didn't matter for me testing it. -PLAYER_CONFIG_ENTRIES = ( +PLAYER_CONFIG_ENTRIES = [ CONF_ENTRY_OUTPUT_CODEC, CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24), -) +] # player id is {device_id}{ZONE_SPLITTER}{zone_name} PLAYER_ZONE_SPLITTER = "___" # must be url ok diff --git a/music_assistant/providers/musiccast/musiccast.py b/music_assistant/providers/musiccast/musiccast.py index ac0a9134..c7da26d1 100644 --- a/music_assistant/providers/musiccast/musiccast.py +++ b/music_assistant/providers/musiccast/musiccast.py @@ -134,11 +134,10 @@ class MusicCastZoneDevice: return None @property - def media_position_updated_at(self) -> float | None: + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid.""" if self.is_netusb: - stamp: datetime = cast("datetime", self.device.data.netusb_play_time_updated) - return stamp.timestamp() + return cast("datetime", self.device.data.netusb_play_time_updated) return None diff --git a/music_assistant/providers/musiccast/player.py b/music_assistant/providers/musiccast/player.py new file mode 100644 index 00000000..1a06c857 --- /dev/null +++ b/music_assistant/providers/musiccast/player.py @@ -0,0 +1,614 @@ +"""MusicCastPlayer.""" + +import asyncio +import time +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from aiohttp import ServerDisconnectedError +from aiomusiccast.exceptions import MusicCastGroupException +from aiomusiccast.pyamaha import MusicCastConnectionException +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature +from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource + +from music_assistant.models.player import Player +from music_assistant.providers.musiccast.avt_helpers import ( + avt_get_media_info, + avt_next, + avt_pause, + avt_play, + avt_previous, + avt_set_url, + avt_stop, + search_xml, +) +from music_assistant.providers.musiccast.constants import ( + CONF_PLAYER_SWITCH_SOURCE_NON_NET, + CONF_PLAYER_TURN_OFF_ON_LEAVE, + MC_CONTROL_SOURCE_IDS, + MC_NETUSB_SOURCE_IDS, + MC_PASSIVE_SOURCE_IDS, + MC_POLL_INTERVAL, + MC_SOURCE_MAIN_SYNC, + MC_SOURCE_MC_LINK, + PLAYER_CONFIG_ENTRIES, + PLAYER_ZONE_SPLITTER, +) +from music_assistant.providers.musiccast.musiccast import ( + MusicCastPhysicalDevice, + MusicCastPlayerState, + MusicCastZoneDevice, +) + +if TYPE_CHECKING: + from .provider import MusicCastProvider + + +@dataclass(kw_only=True) +class UpnpUpdateHelper: + """UpnpUpdateHelper. + + See _update_player_attributes. + """ + + last_poll: float # time.time + controlled_by_mass: bool + current_uri: str | None + + +class MusicCastPlayer(Player): + """MusicCastPlayer in Music Assistant.""" + + def __init__( + self, + provider: "MusicCastProvider", + player_id: str, + physical_device: MusicCastPhysicalDevice, + zone_device: MusicCastZoneDevice, + ) -> None: + """Init MC Player. + + Keep reference to physical and zone device. + """ + super().__init__(provider, player_id) + self.physical_device = physical_device + self.zone_device = zone_device + + # make this a property and update during normal state updates? + # refers to being controlled by upnp. + self.update_lock = asyncio.Lock() + self.upnp_update_helper: UpnpUpdateHelper | None = None + + async def setup(self) -> None: + """Set up player in Music Assistant.""" + self.set_static_attributes() + + def set_static_attributes(self) -> None: + """Set static properties.""" + self._attr_supported_features = { + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PAUSE, + PlayerFeature.POWER, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.SET_MEMBERS, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.ENQUEUE, + PlayerFeature.GAPLESS_PLAYBACK, + } + + self._attr_device_info = DeviceInfo( + manufacturer="Yamaha Corporation", + model=self.physical_device.device.data.model_name or "unknown model", + software_version=(self.physical_device.device.data.system_version or "unknown version"), + ) + + # polling + self._attr_needs_poll = True + self._attr_poll_interval = MC_POLL_INTERVAL + + # default MC name + if self.zone_device.zone_data is not None: + self._attr_name = self.zone_device.zone_data.name + + # group + self._attr_can_group_with = {self.provider.instance_id} + + self._attr_available = True + + # SOURCES + for source_id, source_name in self.zone_device.source_mapping.items(): + control = source_id in MC_CONTROL_SOURCE_IDS + passive = source_id in MC_PASSIVE_SOURCE_IDS + self._attr_source_list.append( + PlayerSource( + id=source_id, + name=source_name, + passive=passive, + can_play_pause=control, + can_seek=False, + can_next_previous=control, + ) + ) + + async def set_dynamic_attributes(self) -> None: + """Update Player attributes.""" + # ruff: noqa: PLR0915 + self._attr_available = True + + zone_data = self.zone_device.zone_data + if zone_data is None: + return + + self._attr_powered = zone_data.power == "on" + + # NOTE: aiomusiccast does not type hint the volume variables, and they may + # be none, and not only integers + _current_volume = cast("int | None", zone_data.current_volume) + _max_volume = cast("int | None", zone_data.max_volume) + _min_volume = cast("int | None", zone_data.min_volume) + if _current_volume is None: + self._attr_volume_level = None + else: + _min_volume = 0 if _min_volume is None else _min_volume + _max_volume = 100 if _max_volume is None else _max_volume + if _min_volume == _max_volume: + _max_volume += 1 + self._attr_volume_level = int(_current_volume / (_max_volume - _min_volume) * 100) + self._attr_volume_muted = zone_data.mute + + # STATE + + match self.zone_device.state: + case MusicCastPlayerState.PAUSED: + self._attr_playback_state = PlaybackState.PAUSED + case MusicCastPlayerState.PLAYING: + self._attr_playback_state = PlaybackState.PLAYING + case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF: + self._attr_playback_state = PlaybackState.IDLE + self._attr_elapsed_time = self.zone_device.media_position + if self.zone_device.media_position_updated_at is not None: + self._attr_elapsed_time_last_updated = ( + self.zone_device.media_position_updated_at.timestamp() + ) + else: + self._attr_elapsed_time_last_updated = None + + # UPDATE UPNP HELPER + now = time.time() + if self.upnp_update_helper is None or now - self.upnp_update_helper.last_poll > 5: + # Let's not do this too often + # Note: The devices always return the last UPnP xmls, even if + # currently another source/ playback method is used + try: + _xml_media_info = await avt_get_media_info( + self.mass.http_session, self.physical_device + ) + except ServerDisconnectedError: + return + _player_current_url = search_xml(_xml_media_info, "CurrentURI") + + # controlled by mass is only True, if we are directly controlled + # i.e. we are not a group member. + # the device's source id is server, if controlled by upnp, but also, if the internal + # dlna function of the device are used. As a fallback, we then + # use the item's title. This can only fail, if our current and next item + # has the same name as the external. + controlled_by_mass = False + if _player_current_url is not None: + controlled_by_mass = ( + self.player_id in _player_current_url + and self.mass.streams.base_url in _player_current_url + and self.zone_device.source_id == "server" + ) + + self.upnp_update_helper = UpnpUpdateHelper( + last_poll=now, + controlled_by_mass=controlled_by_mass, + current_uri=_player_current_url, + ) + + # UPDATE PLAYBACK INFORMATION + # Note to self: + # player.current_media tells queue controller what is playing + # and player.set_current_media is the helper function + # do not access the queue controller to gain playback information here + if ( + self.upnp_update_helper.current_uri is not None + and self.upnp_update_helper.controlled_by_mass + ): + self.set_current_media(uri=self.upnp_update_helper.current_uri, clear_all=True) + elif self.zone_device.is_client: + _server = self.zone_device.group_server + _server_id = self._get_player_id_from_zone_device(_server) + _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id)) + _server_update_helper: None | UpnpUpdateHelper = None + if _server_player is not None: + _server_update_helper = _server_player.upnp_update_helper + if ( + _server_update_helper is not None + and _server_update_helper.current_uri is not None + and _server_update_helper.controlled_by_mass + ): + self.set_current_media(uri=_server_update_helper.current_uri, clear_all=True) + else: + self.set_current_media( + uri=f"{_server_id}_{_server.source_id}", + title=_server.media_title, + artist=_server.media_artist, + album=_server.media_album_name, + image_url=_server.media_image_url, + ) + else: + self.set_current_media( + uri=f"{self.player_id}_{self.zone_device.source_id}", + title=self.zone_device.media_title, + artist=self.zone_device.media_artist, + album=self.zone_device.media_album_name, + image_url=self.zone_device.media_image_url, + ) + + # SOURCE + self._attr_active_source = self.player_id + if not self.zone_device.is_client and not self.upnp_update_helper.controlled_by_mass: + self._attr_active_source = self.zone_device.source_id + elif self.zone_device.is_client: + _server = self.zone_device.group_server + _server_id = self._get_player_id_from_zone_device(_server) + _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id)) + if _server_player is not None and _server_player.upnp_update_helper is not None: + self._attr_active_source = ( + self.zone_device.source_id + if not _server_player.upnp_update_helper.controlled_by_mass + else None + ) + + # GROUPING + # A zone cannot be synced to another zone or main of the same device. + # Additionally, a zone can only be synced, if main is currently not using any netusb + # function. + # For a Zone which will be synced to main, grouping emits a "main_sync" instead + # of a mc link. The other way round, we log a warning. + if len(self.zone_device.musiccast_group) == 1: + if self.zone_device.musiccast_group[0] == self.zone_device: + # we are in a group with ourselves. + self._attr_group_members.clear() + + elif not self.zone_device.is_client and not self.zone_device.is_server: + self._attr_group_members.clear() + + elif self.zone_device.is_client: + _synced_to_id = self._get_player_id_from_zone_device(self.zone_device.group_server) + self._attr_group_members.clear() + + elif self.zone_device.is_server: + self._attr_group_members = [ + self._get_player_id_from_zone_device(x) for x in self.zone_device.musiccast_group + ] + + self.update_state() + + @property + def synced_to(self) -> str | None: + """ + Return the id of the player this player is synced to (sync leader). + + If this player is not synced to another player (or is the sync leader itself), + this should return None. + """ + if self.zone_device.is_client: + # we are a client, so synced to a server + return self._get_player_id_from_zone_device(self.zone_device.group_server) + return None + + async def _cmd_run(self, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None: + """Help function for all player cmds.""" + try: + await fun(*args) + except MusicCastConnectionException: + # should go to provider here. + await self._set_player_unavailable() + except MusicCastGroupException: + # can happen, user shall try again. + ... + + async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None: + """Handle zone grouping. + + If a device has multiple zones, only a single zone can be net controlled. + If another zone wants to join the group, the current net zone has to switch + its input to a non-net one and optionally turn off. + + This methods targets another zone of this players physical device! + """ + # this is not this player's id + player_id = self._get_player_id_from_zone_device(zone_player) + assert player_id is not None # for TYPE_CHECKING + _source = str( + await self.mass.config.get_player_config_value( + player_id, CONF_PLAYER_SWITCH_SOURCE_NON_NET + ) + ) + # verify that this source actually exists and is non net + _allowed_sources = self._get_allowed_sources_zone_switch(zone_player) + mass_player = self.mass.players.get(player_id) + assert mass_player is not None + if _source not in _allowed_sources: + msg = ( + "The switch source you specified for " + f"{mass_player.display_name or mass_player.name}" + " is not allowed. " + f"The source must be any of: {', '.join(sorted(_allowed_sources))} " + "Will use the first available source." + ) + self.logger.error(msg) + _source = _allowed_sources.pop() + + await mass_player.select_source(_source) + _turn_off = bool( + await self.mass.config.get_player_config_value(player_id, CONF_PLAYER_TURN_OFF_ON_LEAVE) + ) + if _turn_off: + await asyncio.sleep(2) + await mass_player.power(powered=False) + + def _get_player_id_from_zone_device(self, zone_player: MusicCastZoneDevice) -> str: + device_id = zone_player.physical_device.device.data.device_id + assert device_id is not None + return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}" + + def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]: + """Return non net sources for a zone player.""" + assert zone_player.zone_data is not None, "zone data missing" + _input_sources: set[str] = set(zone_player.zone_data.input_list) + _net_sources = set(MC_NETUSB_SOURCE_IDS) + _net_sources.add(MC_SOURCE_MC_LINK) # mc grouping source + _net_sources.add(MC_SOURCE_MAIN_SYNC) # main zone sync + return _input_sources.difference(_net_sources) + + async def _set_player_unavailable(self) -> None: + """Set this player and associated zone players unavailable. + + Only called from a main zone player. + """ + assert self.zone_device.zone_name == "main", "Call only from main player!" + self.logger.debug("Player %s became unavailable.", self.display_name) + + if TYPE_CHECKING: + assert isinstance(self.provider, MusicCastProvider) + + # disable polling + self.physical_device.remove() + + async with self.update_lock: + self._attr_available = False + self.update_state() + + # set other zone unavailable + for zone_device in self.zone_device.other_zones: + if zone_device_player := self.mass.players.get( + self._get_player_id_from_zone_device(zone_device) + ): + assert isinstance(zone_device_player, MusicCastPlayer) # for type checking + async with zone_device_player.update_lock: + zone_device_player._attr_available = False + zone_device_player.update_state() + + async def poll(self) -> None: + """Poll player.""" + if self.update_lock.locked(): + # udp updates come in roughly every second when playing, so discard + return + if self.zone_device.zone_name != "main": + # we only poll main, which polls the whole device + return + async with self.update_lock: + # explicit polling on main + try: + await self.physical_device.fetch() + except (MusicCastConnectionException, MusicCastGroupException): + await self._set_player_unavailable() + return + except ServerDisconnectedError: + return + await self.set_dynamic_attributes() + + def _non_async_udp_callback(self, physical_device: MusicCastPhysicalDevice) -> None: + """Call on UDP updates.""" + self.mass.loop.create_task(self._async_udp_callback()) + + async def _async_udp_callback(self) -> None: + async with self.update_lock: + await self.set_dynamic_attributes() + + async def power(self, powered: bool) -> None: + """Power command.""" + if powered: + await self._cmd_run(self.zone_device.turn_on) + else: + await self._cmd_run(self.zone_device.turn_off) + + async def volume_set(self, volume_level: int) -> None: + """Volume set command.""" + await self._cmd_run(self.zone_device.volume_set, volume_level) + + async def volume_mute(self, muted: bool) -> None: + """Volume mute command.""" + await self._cmd_run(self.zone_device.volume_mute, muted) + + async def play(self) -> None: + """Play command.""" + if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass: + await avt_play(self.mass.http_session, self.physical_device) + else: + await self._cmd_run(self.zone_device.play) + + async def stop(self) -> None: + """Stop command.""" + if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass: + await avt_stop(self.mass.http_session, self.physical_device) + else: + await self._cmd_run(self.zone_device.stop) + + async def pause(self) -> None: + """Pause command.""" + if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass: + await avt_pause(self.mass.http_session, self.physical_device) + else: + await self._cmd_run(self.zone_device.pause) + + async def next_track(self) -> None: + """Next command.""" + if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass: + await avt_next(self.mass.http_session, self.physical_device) + else: + await self._cmd_run(self.zone_device.next_track) + + async def previous_track(self) -> None: + """Previous command.""" + if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass: + await avt_previous(self.mass.http_session, self.physical_device) + else: + await self._cmd_run(self.zone_device.previous_track) + + async def play_media(self, media: PlayerMedia) -> None: + """Play media command.""" + if len(self.physical_device.zone_devices) > 1: + # zone handling + # only a single zone may have netusb capability + for zone_name, dev in self.physical_device.zone_devices.items(): + if zone_name == self.zone_device.zone_name: + continue + if dev.is_netusb: + await self._handle_zone_grouping(dev) + async with self.update_lock: + # just in case + if self.zone_device.source_id != "server": + await self.select_source("server") + await avt_set_url(self.mass.http_session, self.physical_device, player_media=media) + await avt_play(self.mass.http_session, self.physical_device) + + self.upnp_update_helper = UpnpUpdateHelper( + last_poll=time.time(), + controlled_by_mass=True, + current_uri=media.uri, + ) + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Enqueue next command.""" + await avt_set_url( + self.mass.http_session, + self.physical_device, + player_media=media, + enqueue=True, + ) + + async def select_source(self, source: str) -> None: + """Select source command.""" + await self._cmd_run(self.zone_device.select_source, source) + + async def ungroup(self) -> None: + """Ungroup command.""" + if self.zone_device.zone_name.startswith("zone"): + # We are are zone. + # We do not leave an MC group, but just change our source. + await self._handle_zone_grouping(self.zone_device) + return + await self._cmd_run(self.zone_device.unjoin_player) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Set multiple members. + + If we are a server, this is called. + We can ignore removed devices, these are handled via ungroup individually. + """ + children: set[str] = set() # set[ma_player_id] + children_zones: list[str] = [] # list[ma_player_id] + player_ids_to_add = [] if player_ids_to_add is None else player_ids_to_add + for child_id in player_ids_to_add: + if child_player := self.mass.players.get(child_id): + assert isinstance(child_player, MusicCastPlayer) # for type checking + _other_zone_mc: MusicCastZoneDevice | None = None + for x in child_player.zone_device.other_zones: + if x.is_netusb: + _other_zone_mc = x + if _other_zone_mc and _other_zone_mc != child_player.zone_device: + # of the same device, we use main_sync as input + if _other_zone_mc.zone_name == "main": + children_zones.append(child_id) + else: + self.logger.warning( + "It is impossible to join as a normal zone to another zone of the same " + "device. Only joining to main is possible. Please refer to the docs." + ) + else: + children.add(child_id) + + for child_id in children_zones: + child_player = self.mass.players.get(child_id) + if TYPE_CHECKING: + child_player = cast("MusicCastPlayer", child_player) + if child_player.zone_device.state == MusicCastPlayerState.OFF: + await child_player.power(powered=True) + await child_player.select_source(MC_SOURCE_MAIN_SYNC) + if not children: + return + + child_player_zone_devices: list[MusicCastZoneDevice] = [] + for child_id in children: + child_player = self.mass.players.get(child_id) + if TYPE_CHECKING: + child_player = cast("MusicCastPlayer", child_player) + child_player_zone_devices.append(child_player.zone_device) + + await self._cmd_run(self.zone_device.join_players, child_player_zone_devices) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Get player config entries.""" + base_entries = await super().get_config_entries() + + zone_entries: list[ConfigEntry] = [] + if len(self.physical_device.zone_devices) > 1: + source_options: list[ConfigValueOption] = [] + allowed_sources = self._get_allowed_sources_zone_switch(self.zone_device) + for ( + source_id, + source_name, + ) in self.zone_device.source_mapping.items(): + if source_id in allowed_sources: + source_options.append(ConfigValueOption(title=source_name, value=source_id)) + if len(source_options) == 0: + # this should never happen + self.logger.error( + "The player %s has multiple zones, but lacks a non-net source to switch to." + " Please report this on github or discord.", + self.display_name or self.name, + ) + zone_entries = [] + else: + zone_entries = [ + ConfigEntry( + key=CONF_PLAYER_SWITCH_SOURCE_NON_NET, + label="Switch to this non-net source when leaving a group.", + type=ConfigEntryType.STRING, + options=source_options, + default_value=source_options[0].value, + description="The zone will switch to this source when leaving a group." + " It must be an input which doesn't require network connectivity.", + ), + ConfigEntry( + key=CONF_PLAYER_TURN_OFF_ON_LEAVE, + type=ConfigEntryType.BOOLEAN, + label="Turn off the zone when it leaves a group.", + default_value=False, + description="Turn off the zone when it leaves a group.", + ), + ] + + return base_entries + zone_entries + PLAYER_CONFIG_ENTRIES diff --git a/music_assistant/providers/musiccast/provider.py b/music_assistant/providers/musiccast/provider.py new file mode 100644 index 00000000..ccf9fe8b --- /dev/null +++ b/music_assistant/providers/musiccast/provider.py @@ -0,0 +1,248 @@ +"""MusicCast Provider.""" + +import asyncio +import logging +from dataclasses import dataclass + +from aiohttp.client_exceptions import ClientError +from aiomusiccast.musiccast_device import MusicCastDevice +from music_assistant_models.config_entries import ProviderConfig +from music_assistant_models.enums import ProviderFeature +from music_assistant_models.provider import ProviderManifest +from zeroconf import ServiceStateChange +from zeroconf.asyncio import AsyncServiceInfo + +from music_assistant.constants import VERBOSE_LOG_LEVEL +from music_assistant.mass import MusicAssistant +from music_assistant.models.player_provider import PlayerProvider +from music_assistant.providers.musiccast.constants import ( + MC_DEVICE_INFO_ENDPOINT, + MC_DEVICE_UPNP_ENDPOINT, + MC_DEVICE_UPNP_PORT, + PLAYER_ZONE_SPLITTER, +) +from music_assistant.providers.sonos.helpers import get_primary_ip_address + +from .musiccast import ( + MusicCastController, + MusicCastPhysicalDevice, + MusicCastZoneDevice, +) +from .player import MusicCastPlayer, UpnpUpdateHelper + + +@dataclass(kw_only=True) +class MusicCastPlayerHelper: + """MusicCastPlayerHelper. + + Helper class to store MA player alongside physical device. + """ + + device_id: str # device_id without ZONE_SPLITTER zone + player_main: MusicCastPlayer | None = None # mass player + player_zone2: MusicCastPlayer | None = None # mass player + # I can only test up to zone 2 + player_zone3: MusicCastPlayer | None = None # mass player + player_zone4: MusicCastPlayer | None = None # mass player + + # log allowed sources for a device with multiple sources once. see "_handle_zone_grouping" + _log_allowed_sources: bool = True + + physical_device: MusicCastPhysicalDevice + + def get_player(self, zone: str) -> MusicCastPlayer | None: + """Get Player by zone name.""" + match zone: + case "main": + return self.player_main + case "zone2": + return self.player_zone2 + case "zone3": + return self.player_zone3 + case "zone4": + return self.player_zone4 + raise RuntimeError(f"Zone {zone} is unknown.") + + def get_all_players(self) -> list[MusicCastPlayer]: + """Get all players.""" + assert self.player_main is not None # we always have main + players = [self.player_main] + if self.player_zone2 is not None: + players.append(self.player_zone2) + if self.player_zone3 is not None: + players.append(self.player_zone3) + if self.player_zone4 is not None: + players.append(self.player_zone4) + return players + + +class MusicCastProvider(PlayerProvider): + """MusicCast Player Provider.""" + + # poll upnp playback information, but not too often. see "_update_player_attributes" + # player_id: UpnpUpdateHelper + upnp_update_helper: dict[str, UpnpUpdateHelper] = {} + + # str here is the device id, NOT the player_id + update_player_locks: dict[str, asyncio.Lock] = {} + + def __init__( + self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig + ) -> None: + """Init.""" + super().__init__(mass, manifest, config) + # str is device_id here: + self.musiccast_player_helpers: dict[str, MusicCastPlayerHelper] = {} + + async def unload(self, is_removed: bool = False) -> None: + """Call on unload.""" + for mc_player in self.mass.players.all(provider_filter=self.lookup_key): + assert isinstance(mc_player, MusicCastPlayer) # for type checking + mc_player.physical_device.remove() + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.SYNC_PLAYERS} + + async def handle_async_init(self) -> None: + """Async init.""" + self.mc_controller = MusicCastController(logger=self.logger) + # aiomusiccast logs all fetch requests after udp message as debug. + # same approach as in upnp + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("aiomusiccast").setLevel(logging.DEBUG) + else: + logging.getLogger("aiomusiccast").setLevel(self.logger.level + 10) + + async def on_mdns_service_state_change( + self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None + ) -> None: + """Discovery via mdns.""" + if state_change == ServiceStateChange.Removed: + # Wait for connection to fail, same as sonos. + return + if info is None: + return + device_ip = get_primary_ip_address(info) + if device_ip is None: + return + try: + device_info = await self.mass.http_session.get( + f"http://{device_ip}/{MC_DEVICE_INFO_ENDPOINT}", raise_for_status=True + ) + except ClientError: + # typical Errors are + # ClientResponseError -> raise_for_status + # ClientConnectorError -> unable to connect/ not existing/ timeout + # but we can use the base exception class, as we only check + # if the device is suitable + return + device_info_json = await device_info.json() + device_id = device_info_json.get("device_id") + if device_id is None: + return + description_url = f"http://{device_ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_ENDPOINT}" + + _check = await self.mass.http_session.get(description_url) + if _check.status == 404: + self.logger.debug("Missing description url for Yamaha device at %s", device_ip) + return + await self._device_discovered( + device_id=device_id, device_ip=device_ip, description_url=description_url + ) + + async def _device_discovered( + self, device_id: str, device_ip: str, description_url: str + ) -> None: + """Handle discovered MusicCast player.""" + # verify that this is a MusicCast player + check: bool = await MusicCastDevice.check_yamaha_ssdp( + description_url, self.mass.http_session + ) + if not check: + return + + if self.mass.players.get(device_id) is not None: + return + mc_player_known = self.musiccast_player_helpers.get(device_id) + if mc_player_known is not None and ( + mc_player_known.player_main is not None + and mc_player_known.physical_device.device.device.upnp_description == description_url + and mc_player_known.player_main.available + ): + # nothing to do, device is already connected + return + else: + # new or updated player detected + physical_device = MusicCastPhysicalDevice( + device=MusicCastDevice( + client=self.mass.http_session, + ip=device_ip, + upnp_description=description_url, + ), + controller=self.mc_controller, + ) + self.update_player_locks[device_id] = asyncio.Lock() + success = await physical_device.async_init() # fetch + polling + if not success: + self.logger.debug( + "Had trouble setting up device at %s. Will be retried on next discovery.", + device_ip, + ) + return + await self._register_player(physical_device, device_id) + + async def _register_player( + self, physical_device: MusicCastPhysicalDevice, device_id: str + ) -> None: + """Register player including zones.""" + + # player features + # NOTE: There is seek in the upnp desc + # http://{ip}:49154/AVTransport/desc.xml + # however, it appears not to work as it should, so we remain at MA's own + # seek implementation + def get_player(zone_name: str, zone_device: MusicCastZoneDevice) -> MusicCastPlayer: + return MusicCastPlayer( + provider=self, + player_id=f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_name}", + physical_device=physical_device, + zone_device=zone_device, + ) + + main_device = physical_device.zone_devices.get("main") + if ( + main_device is None + or main_device.zone_data is None + or main_device.zone_data.name is None + ): + return + + musiccast_player_helper = MusicCastPlayerHelper( + device_id=device_id, + physical_device=physical_device, + ) + + for zone_name, zone_device in physical_device.zone_devices.items(): + if zone_device.zone_data is None or zone_device.zone_data.name is None: + continue + player = get_player(zone_name, zone_device=zone_device) + await player.setup() + await self.mass.players.register_or_update(player) + physical_device.register_callback(player._non_async_udp_callback) + setattr(musiccast_player_helper, f"player_{zone_device.zone_name}", player) + + if ( + musiccast_player_helper.player_zone2 is not None + and musiccast_player_helper._log_allowed_sources + ): + musiccast_player_helper._log_allowed_sources = False + player_main = musiccast_player_helper.player_main + assert player_main is not None + self.logger.info( + f"The player {player_main.display_name or player_main.name} has multiple zones. " + "Please use the player config to configure a non-net source for grouping. " + ) + + self.musiccast_player_helpers[device_id] = musiccast_player_helper diff --git a/music_assistant/providers/opensubsonic/sonic_provider.py b/music_assistant/providers/opensubsonic/sonic_provider.py index 95d309db..64bd7546 100644 --- a/music_assistant/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/providers/opensubsonic/sonic_provider.py @@ -599,10 +599,7 @@ class OpenSonicProvider(MusicProvider): msg = f"Item {item_id} not found" raise MediaNotFoundError(msg) from e - if item.transcoded_content_type: - mime_type = item.transcoded_content_type - else: - mime_type = item.content_type + mime_type = item.transcoded_content_type or item.content_type self.logger.debug( "Fetching stream details for id %s '%s' with format '%s'", diff --git a/music_assistant/providers/player_group/__init__.py b/music_assistant/providers/player_group/__init__.py deleted file mode 100644 index ed26ad69..00000000 --- a/music_assistant/providers/player_group/__init__.py +++ /dev/null @@ -1,991 +0,0 @@ -""" -Sync Group Player provider. - -This is more like a "virtual" player provider, -allowing the user to create 'presets' of players to sync together (of the same type). -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from contextlib import suppress -from time import time -from typing import TYPE_CHECKING, Final, cast - -import shortuuid -from aiohttp import web -from music_assistant_models.config_entries import ( - ConfigEntry, - ConfigValueOption, - ConfigValueType, - PlayerConfig, -) -from music_assistant_models.constants import PLAYER_CONTROL_NONE -from music_assistant_models.enums import ( - ConfigEntryType, - ContentType, - EventType, - MediaType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, -) -from music_assistant_models.errors import ( - InvalidDataError, - PlayerUnavailableError, - ProviderUnavailableError, - UnsupportedFeaturedException, -) -from music_assistant_models.media_items import AudioFormat, UniqueList -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia - -from music_assistant.constants import ( - CONF_CROSSFADE, - CONF_CROSSFADE_DURATION, - CONF_ENABLE_ICY_METADATA, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_FLOW_MODE, - CONF_GROUP_MEMBERS, - CONF_HTTP_PROFILE, - CONF_OUTPUT_CODEC, - CONF_SAMPLE_RATES, - DEFAULT_PCM_FORMAT, - create_sample_rates_config_entry, -) -from music_assistant.controllers.streams import DEFAULT_STREAM_HEADERS -from music_assistant.helpers.audio import get_player_filter_params -from music_assistant.helpers.ffmpeg import get_ffmpeg_stream -from music_assistant.helpers.util import TaskManager -from music_assistant.models.player_provider import PlayerProvider - -from .ugp_stream import UGPStream - -if TYPE_CHECKING: - from collections.abc import Iterable - - from music_assistant_models.config_entries import ProviderConfig - from music_assistant_models.event import MassEvent - from music_assistant_models.provider import ProviderManifest - - from music_assistant import MusicAssistant - from music_assistant.models import ProviderInstanceType - - -UGP_FORMAT = AudioFormat( - content_type=DEFAULT_PCM_FORMAT.content_type, - sample_rate=DEFAULT_PCM_FORMAT.sample_rate, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, -) - -# ruff: noqa: ARG002 - -UNIVERSAL_PREFIX: Final[str] = "ugp_" -SYNCGROUP_PREFIX: Final[str] = "syncgroup_" -GROUP_TYPE_UNIVERSAL: Final[str] = "universal" -CONF_GROUP_TYPE: Final[str] = "group_type" -CONF_ENTRY_GROUP_TYPE = ConfigEntry( - key=CONF_GROUP_TYPE, - type=ConfigEntryType.STRING, - label="Group type", - default_value="universal", - hidden=True, - required=True, -) -CONF_ENTRY_GROUP_MEMBERS = ConfigEntry( - key=CONF_GROUP_MEMBERS, - type=ConfigEntryType.STRING, - multi_value=True, - label="Group members", - default_value=[], - description="Select all players you want to be part of this group", - required=False, # otherwise dynamic members won't work (which allows empty members list) -) -CONF_ENTRY_SAMPLE_RATES_UGP = create_sample_rates_config_entry( - max_sample_rate=96000, max_bit_depth=24, hidden=True -) -CONFIG_ENTRY_UGP_NOTE = ConfigEntry( - key="ugp_note", - type=ConfigEntryType.LABEL, - label="Please note that although the Universal Group " - "allows you to group any player, it will not enable audio sync " - "between players of different ecosystems. It is advised to always use native " - "player groups or sync groups when available for your player type(s) and use " - "the Universal Group only to group players of different ecosystems/protocols.", - required=False, -) -CONFIG_ENTRY_DYNAMIC_MEMBERS = ConfigEntry( - key="dynamic_members", - type=ConfigEntryType.BOOLEAN, - label="Enable dynamic members", - description="Allow members to (temporary) join/leave the group dynamically, " - "so the group more or less behaves the same like manually syncing players together, " - "with the main difference being that the groupplayer will hold the queue.", - default_value=False, - required=False, -) - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - return PlayerGroupProvider(mass, manifest, config) - - -async def get_config_entries( - mass: MusicAssistant, # noqa: ARG001 - instance_id: str | None = None, # noqa: ARG001 - action: str | None = None, # noqa: ARG001 - values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 -) -> 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. - """ - # nothing to configure (for now) - return () - - -class PlayerGroupProvider(PlayerProvider): - """Base/builtin provider for creating (permanent) player groups.""" - - def __init__( - self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig - ) -> None: - """Initialize MusicProvider.""" - super().__init__(mass, manifest, config) - self.ugp_streams: dict[str, UGPStream] = {} - self._on_unload: list[Callable[[], None]] = [ - self.mass.register_api_command("player_group/create", self.create_group), - ] - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.REMOVE_PLAYER} - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - await super().loaded_in_mass() - # register all existing group players - await self._register_all_players() - # listen for player added events so we can catch late joiners - # (because a group depends on its childs to be available) - self._on_unload.append( - self.mass.subscribe(self._on_mass_player_added_event, EventType.PLAYER_ADDED) - ) - - async def unload(self, is_removed: bool = False) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - """ - # power off all group players at unload - for group_player in self.players: - if group_player.powered: - await self.cmd_power(group_player.player_id, False) - for unload_cb in self._on_unload: - unload_cb() - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - # default entries for player groups - base_entries = ( - *await super().get_player_config_entries(player_id), - CONF_ENTRY_GROUP_TYPE, - CONF_ENTRY_GROUP_MEMBERS, - CONFIG_ENTRY_DYNAMIC_MEMBERS, - ) - # group type is static and can not be changed. we just grab the existing, stored value - group_type: str = self.mass.config.get_raw_player_config_value( - player_id, CONF_GROUP_TYPE, GROUP_TYPE_UNIVERSAL - ) - # handle config entries for universal group players - if group_type == GROUP_TYPE_UNIVERSAL: - group_members = CONF_ENTRY_GROUP_MEMBERS - group_members.options = tuple( - ConfigValueOption(x.display_name, x.player_id) - for x in self.mass.players.all(True, False) - if not x.player_id.startswith(UNIVERSAL_PREFIX) - ) - return ( - *base_entries, - group_members, - CONFIG_ENTRY_UGP_NOTE, - CONF_ENTRY_SAMPLE_RATES_UGP, - CONF_ENTRY_FLOW_MODE_ENFORCED, - ) - # handle config entries for syncgroup players - group_members = CONF_ENTRY_GROUP_MEMBERS - if player_prov := self.mass.get_provider(group_type): - group_members.options = tuple( - ConfigValueOption(x.display_name, x.player_id) for x in player_prov.players - ) - - # grab additional details from one of the provider's players - if not (player_provider := self.mass.get_provider(group_type)): - return base_entries # guard - if TYPE_CHECKING: - player_provider = cast("PlayerProvider", player_provider) - assert player_provider.instance_id != self.instance_id - if not (child_player := next((x for x in player_provider.players), None)): - return base_entries # guard - - # combine base group entries with (base) player entries for this player type - allowed_conf_entries = ( - CONF_HTTP_PROFILE, - CONF_ENABLE_ICY_METADATA, - CONF_CROSSFADE, - CONF_CROSSFADE_DURATION, - CONF_OUTPUT_CODEC, - CONF_FLOW_MODE, - CONF_SAMPLE_RATES, - ) - child_config_entries = await player_provider.get_player_config_entries( - child_player.player_id - ) - return ( - *base_entries, - group_members, - *(entry for entry in child_config_entries if entry.key in allowed_conf_entries), - ) - - async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: - """Call (by config manager) when the configuration of a player changes.""" - members = config.get_value(CONF_GROUP_MEMBERS) - if f"values/{CONF_GROUP_MEMBERS}" in changed_keys: - # ensure we filter invalid members - members = self._filter_members(config.get_value(CONF_GROUP_TYPE), members) - if group_player := self.mass.players.get(config.player_id): - group_player.group_childs.set(members) - if group_player.powered: - # power on group player (which will also resync) if needed - await self.cmd_power(group_player.player_id, True) - if f"values/{CONFIG_ENTRY_DYNAMIC_MEMBERS.key}" in changed_keys: - # dynamic members feature changed - if group_player := self.mass.players.get(config.player_id): - if PlayerFeature.SET_MEMBERS in group_player.supported_features: - group_player.supported_features.remove(PlayerFeature.SET_MEMBERS) - else: - group_player.supported_features.add(PlayerFeature.SET_MEMBERS) - if not members and not config.get_value(CONFIG_ENTRY_DYNAMIC_MEMBERS.key): - raise InvalidDataError("Group player must have at least one member") - await super().on_player_config_change(config, changed_keys) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - group_player = self.mass.players.get(player_id) - # syncgroup: forward command to sync leader - if player_id.startswith(SYNCGROUP_PREFIX): - if sync_leader := self._get_sync_leader(group_player): - if player_provider := self.mass.get_provider(sync_leader.provider): - await player_provider.cmd_stop(sync_leader.player_id) - return - # ugp: forward command to all members - async with TaskManager(self.mass) as tg: - for member in self.mass.players.iter_group_members(group_player, active_only=True): - if player_provider := self.mass.get_provider(member.provider): - tg.create_task(player_provider.cmd_stop(member.player_id)) - # abort the stream session - if (stream := self.ugp_streams.pop(player_id, None)) and not stream.done: - await stream.stop() - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - group_player = self.mass.players.get(player_id) - if not player_id.startswith(SYNCGROUP_PREFIX): - # this shouldn't happen, but just in case - raise UnsupportedFeaturedException - # forward command to sync leader - if sync_leader := self._get_sync_leader(group_player): - if player_provider := self.mass.get_provider(sync_leader.provider): - await player_provider.cmd_play(sync_leader.player_id) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - group_player = self.mass.players.get(player_id) - if not player_id.startswith(SYNCGROUP_PREFIX): - # this shouldn't happen, but just in case - raise UnsupportedFeaturedException - # forward command to sync leader - if sync_leader := self._get_sync_leader(group_player): - if player_provider := self.mass.get_provider(sync_leader.provider): - await player_provider.cmd_pause(sync_leader.player_id) - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Handle POWER command to group player.""" - group_player = self.mass.players.get(player_id, raise_unavailable=True) - if TYPE_CHECKING: - group_player = cast("Player", group_player) - - # always stop at power off - if not powered and group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED): - await self.cmd_stop(group_player.player_id) - - if powered and player_id.startswith(SYNCGROUP_PREFIX): - await self._form_syncgroup(group_player) - - if powered: - # handle TURN_ON of the group player by turning on all members - for member in self.mass.players.iter_group_members( - group_player, only_powered=False, active_only=False - ): - player_provider = self.mass.get_provider(member.provider) - assert player_provider # for typing - if ( - member.state in (PlayerState.PLAYING, PlayerState.PAUSED) - and member.active_source != group_player.active_source - ): - # stop playing existing content on member if we start the group player - await player_provider.cmd_stop(member.player_id) - if member.active_group not in ( - None, - group_player.player_id, - member.player_id, - ): - # collision: child player is part of multiple groups - # and another group already active ! - # solve this by powering off the other group - await self.mass.players.cmd_power(member.active_group, False) - await asyncio.sleep(1) - if not member.powered and member.power_control != PLAYER_CONTROL_NONE: - member.active_group = None # needed to prevent race conditions - await self.mass.players.cmd_power(member.player_id, True) - # set active source to group player if the group (is going to be) powered - member.active_group = group_player.player_id - member.active_source = group_player.active_source - else: - # handle TURN_OFF of the group player by turning off all members - # optimistically set the group state to prevent race conditions - group_player.powered = False - for member in self.mass.players.iter_group_members( - group_player, only_powered=True, active_only=True - ): - # reset active group on player when the group is turned off - member.active_group = None - member.active_source = None - if member.synced_to: - # always ungroup first - await self.mass.players.cmd_ungroup(member.player_id) - # handle TURN_OFF of the group player by turning off all members - if member.powered and member.power_control != PLAYER_CONTROL_NONE: - await self.mass.players.cmd_power(member.player_id, False) - - # optimistically set the group state - group_player.powered = powered - self.mass.players.update(group_player.player_id) - if not powered: - # reset the original group members when powered off - group_player.group_childs.set( - self.mass.config.get_raw_player_config_value(player_id, CONF_GROUP_MEMBERS, []) - ) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - # group volume is already handled in the player manager - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - group_player = self.mass.players.get(player_id) - # power on (which will also resync) if needed - await self.cmd_power(player_id, True) - - # handle play_media for sync group - if player_id.startswith(SYNCGROUP_PREFIX): - # simply forward the command to the sync leader - sync_leader = self._get_sync_leader(group_player) - player_provider = self.mass.get_provider(sync_leader.provider) - assert player_provider # for typing - await player_provider.play_media( - sync_leader.player_id, - media=media, - ) - return - - # handle play_media for UGP group - if (existing := self.ugp_streams.pop(player_id, None)) and not existing.done: - # stop any existing stream first - await existing.stop() - - # select audio source - if media.media_type == MediaType.ANNOUNCEMENT: - # special case: stream announcement - audio_source = self.mass.streams.get_announcement_stream( - media.custom_data["url"], - output_format=UGP_FORMAT, - use_pre_announce=media.custom_data["use_pre_announce"], - ) - elif media.media_type == MediaType.PLUGIN_SOURCE: - # special case: plugin source stream - audio_source = self.mass.streams.get_plugin_source_stream( - plugin_source_id=media.custom_data["source_id"], - output_format=UGP_FORMAT, - player_id=media.custom_data["player_id"], - ) - elif media.queue_id and media.queue_item_id: - # regular queue stream request - audio_source = self.mass.streams.get_queue_flow_stream( - queue=self.mass.player_queues.get(media.queue_id), - start_queue_item=self.mass.player_queues.get_item( - media.queue_id, media.queue_item_id - ), - pcm_format=UGP_FORMAT, - ) - else: - # assume url or some other direct path - # NOTE: this will fail if its an uri not playable by ffmpeg - audio_source = get_ffmpeg_stream( - audio_input=media.uri, - input_format=AudioFormat(ContentType.try_parse(media.uri)), - output_format=UGP_FORMAT, - ) - - # start the stream task - self.ugp_streams[player_id] = UGPStream( - audio_source=audio_source, audio_format=UGP_FORMAT, base_pcm_format=UGP_FORMAT - ) - base_url = f"{self.mass.streams.base_url}/ugp/{player_id}.flac" - - # set the state optimistically - group_player.current_media = media - group_player.elapsed_time = 0 - group_player.elapsed_time_last_updated = time() - 1 - group_player.state = PlayerState.PLAYING - self.mass.players.update(player_id) - - # forward to downstream play_media commands - async with TaskManager(self.mass) as tg: - for member in self.mass.players.iter_group_members( - group_player, only_powered=True, active_only=True - ): - player_provider = self.mass.get_provider(member.provider) - assert player_provider # for typing - tg.create_task( - player_provider.play_media( - member.player_id, - media=PlayerMedia( - uri=f"{base_url}?player_id={member.player_id}", - media_type=MediaType.FLOW_STREAM, - title=group_player.display_name, - queue_id=group_player.player_id, - ), - ) - ) - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of a next media item on the player.""" - group_player = self.mass.players.get(player_id, True) - if not player_id.startswith(SYNCGROUP_PREFIX): - # this shouldn't happen, but just in case - raise UnsupportedFeaturedException("Command is not supported for UGP players") - if sync_leader := self._get_sync_leader(group_player): - await self.mass.players.enqueue_next_media( - sync_leader.player_id, - media=media, - ) - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates. - - This is called by the Player Manager; - if 'needs_poll' is set to True in the player object. - """ - if group_player := self.mass.players.get(player_id): - self._update_attributes(group_player) - if group_player.powered: - await self._ungroup_subgroups_if_found(group_player) - - async def create_group( - self, group_type: str, name: str, members: list[str], dynamic: bool = False - ) -> Player: - """Create new Group Player.""" - # perform basic checks - if group_type == GROUP_TYPE_UNIVERSAL: - prefix = UNIVERSAL_PREFIX - else: - prefix = SYNCGROUP_PREFIX - if (player_prov := self.mass.get_provider(group_type)) is None: - msg = f"Provider {group_type} is not available!" - raise ProviderUnavailableError(msg) - if ProviderFeature.SYNC_PLAYERS not in player_prov.supported_features: - msg = f"Provider {player_prov.name} does not support creating groups" - raise UnsupportedFeaturedException(msg) - group_type = player_prov.instance_id # just in case only domain was sent - - new_group_id = f"{prefix}{shortuuid.random(8).lower()}" - # cleanup list, just in case the frontend sends some garbage - members = self._filter_members(group_type, members) - # create default config with the user chosen name - self.mass.config.create_default_player_config( - new_group_id, - self.instance_id, - name=name, - enabled=True, - values={ - CONF_GROUP_MEMBERS: members, - CONF_GROUP_TYPE: group_type, - CONFIG_ENTRY_DYNAMIC_MEMBERS.key: dynamic, - }, - ) - return await self._register_group_player( - group_player_id=new_group_id, group_type=group_type, name=name, members=members - ) - - async def remove_player(self, player_id: str) -> None: - """Remove a group player.""" - if not (group_player := self.mass.players.get(player_id)): - return - if group_player.powered: - # edge case: the group player is powered and being removed - # make sure to turn it off first (which will also ungroup a syncgroup) - await self.cmd_power(player_id, False) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the sync leader. - """ - group_player = self.mass.players.get(target_player, raise_unavailable=True) - if TYPE_CHECKING: - group_player = cast("Player", group_player) - dynamic_members_enabled = self.mass.config.get_raw_player_config_value( - group_player.player_id, - CONFIG_ENTRY_DYNAMIC_MEMBERS.key, - CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value, - ) - group_type = self.mass.config.get_raw_player_config_value( - group_player.player_id, CONF_ENTRY_GROUP_TYPE.key, CONF_ENTRY_GROUP_TYPE.default_value - ) - if not dynamic_members_enabled: - raise UnsupportedFeaturedException( - f"Adjusting group members is not allowed for group {group_player.display_name}" - ) - child_player = self.mass.players.get(player_id, raise_unavailable=True) - if TYPE_CHECKING: - group_player = cast("Player", group_player) - if child_player.active_group and child_player.active_group != group_player.player_id: - raise InvalidDataError( - f"Player {child_player.display_name} already has another group active" - ) - group_player.group_childs.append(player_id) - - # Ensure that all player are just in this group and not in any other group - await self._ungroup_subgroups_if_found(group_player) - - # handle resync/resume if group player was already playing - if group_player.state == PlayerState.PLAYING and group_type == GROUP_TYPE_UNIVERSAL: - child_player_provider = self.mass.players.get_player_provider(player_id) - base_url = f"{self.mass.streams.base_url}/ugp/{group_player.player_id}.flac" - await child_player_provider.play_media( - player_id, - media=PlayerMedia( - uri=f"{base_url}?player_id={player_id}", - media_type=MediaType.FLOW_STREAM, - title=group_player.display_name, - queue_id=group_player.player_id, - ), - ) - elif group_player.powered and group_type != GROUP_TYPE_UNIVERSAL: - # power on group player (which will also resync) if needed - await self.cmd_power(target_player, True) - - async def cmd_ungroup_member(self, player_id: str, target_player: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player(id) from the given (master) player/sync group. - - - player_id: player_id of the (child) player to ungroup from the group. - - target_player: player_id of the group player. - """ - group_player = self.mass.players.get(target_player, raise_unavailable=True) - child_player = self.mass.players.get(player_id, raise_unavailable=True) - if TYPE_CHECKING: - group_player = cast("Player", group_player) - child_player = cast("Player", child_player) - dynamic_members_enabled = self.mass.config.get_raw_player_config_value( - group_player.player_id, - CONFIG_ENTRY_DYNAMIC_MEMBERS.key, - CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value, - ) - if not dynamic_members_enabled: - raise UnsupportedFeaturedException( - f"Adjusting group members is not allowed for group {group_player.display_name}" - ) - group_type = self.mass.config.get_raw_player_config_value( - group_player.player_id, CONF_ENTRY_GROUP_TYPE.key, CONF_ENTRY_GROUP_TYPE.default_value - ) - was_playing = child_player.state == PlayerState.PLAYING - is_sync_leader = len(child_player.group_childs) > 0 - group_player.group_childs.remove(player_id) - child_player.active_group = None - child_player.active_source = None - player_provider = self.mass.players.get_player_provider(child_player.player_id) - if group_type == GROUP_TYPE_UNIVERSAL: - if was_playing: - # stop playing the child player that was unjoined from the UGP - await player_provider.cmd_stop(child_player.player_id) - self._update_attributes(group_player) - return - # handle sync group - if child_player.group_childs: - # this is the sync leader, unsync all its childs! - # NOTE that some players/providers might support this in a less intrusive way - # but for now we just ungroup all childs to keep things universal - self.logger.info("Detected ungroup of sync leader, ungrouping all childs") - async with TaskManager(self.mass) as tg: - for sync_child_id in child_player.group_childs: - if sync_child_id == child_player.player_id: - continue - tg.create_task(player_provider.cmd_ungroup(sync_child_id)) - await player_provider.cmd_stop(child_player.player_id) - else: - # this is a regular member, just ungroup itself - await player_provider.cmd_ungroup(child_player.player_id) - - if is_sync_leader and was_playing and group_player.powered: - # ungrouping the sync leader stops the group so we need to resume - self.logger.info("Resuming group after ungrouping of sync leader") - task_id = f"resync_group_{group_player.player_id}" - self.mass.call_later( - 2, self.mass.players.cmd_play(group_player.player_id), task_id=task_id - ) - - async def _register_all_players(self) -> None: - """Register all (virtual/fake) group players in the Player controller.""" - player_configs = await self.mass.config.get_player_configs( - self.instance_id, include_values=True - ) - for player_config in player_configs: - if self.mass.players.get(player_config.player_id): - continue # already registered - members = player_config.get_value(CONF_GROUP_MEMBERS) - group_type = player_config.get_value(CONF_GROUP_TYPE) - with suppress(PlayerUnavailableError): - await self._register_group_player( - player_config.player_id, - group_type, - player_config.name or player_config.default_name, - members, - ) - - async def _register_group_player( - self, group_player_id: str, group_type: str, name: str, members: Iterable[str] - ) -> Player: - """Register a syncgroup player.""" - player_features = { - PlayerFeature.POWER, - PlayerFeature.VOLUME_SET, - } - - if not (self.mass.players.get(x) for x in members): - raise PlayerUnavailableError("One or more members are not available!") - - if group_type == GROUP_TYPE_UNIVERSAL: - model_name = "Universal Group" - manufacturer = self.name - # register dynamic route for the ugp stream - self._on_unload.append( - self.mass.streams.register_dynamic_route( - f"/ugp/{group_player_id}.flac", self._serve_ugp_stream - ) - ) - self._on_unload.append( - self.mass.streams.register_dynamic_route( - f"/ugp/{group_player_id}.mp3", self._serve_ugp_stream - ) - ) - can_group_with = { - # allow grouping with all providers, except the playergroup provider itself - x.instance_id - for x in self.mass.players.providers - if x.instance_id != self.instance_id - } - player_features.add(PlayerFeature.MULTI_DEVICE_DSP) - elif player_provider := self.mass.get_provider(group_type): - # grab additional details from one of the provider's players - if TYPE_CHECKING: - player_provider = cast("PlayerProvider", player_provider) - model_name = "Sync Group" - manufacturer = self.mass.get_provider(group_type).name - can_group_with = {player_provider.instance_id} - for feature in ( - PlayerFeature.PAUSE, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE, - PlayerFeature.MULTI_DEVICE_DSP, - PlayerFeature.GAPLESS_PLAYBACK, - PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE, - ): - if all(feature in x.supported_features for x in player_provider.players): - player_features.add(feature) - else: - raise PlayerUnavailableError(f"Provider for syncgroup {group_type} is not available!") - - if self.mass.config.get_raw_player_config_value( - group_player_id, - CONFIG_ENTRY_DYNAMIC_MEMBERS.key, - CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value, - ): - player_features.add(PlayerFeature.SET_MEMBERS) - - player = Player( - player_id=group_player_id, - provider=self.instance_id, - type=PlayerType.GROUP, - name=name, - available=True, - # group players are always powered off by default at init/startup - powered=False, - device_info=DeviceInfo(model=model_name, manufacturer=manufacturer), - supported_features=player_features, - active_source=group_player_id, - needs_poll=True, - poll_interval=30, - can_group_with=can_group_with, - group_childs=UniqueList(members), - ) - - await self.mass.players.register_or_update(player) - self._update_attributes(player) - return player - - def _get_sync_leader(self, group_player: Player) -> Player: - """Get the active sync leader player for the syncgroup.""" - for child_player in self.mass.players.iter_group_members( - group_player, only_powered=False, only_playing=False, active_only=False - ): - # the syncleader is always the first player in the group - return child_player - raise RuntimeError("No players available in syncgroup") - - async def _form_syncgroup(self, group_player: Player) -> None: - """Form syncgroup by sync all (possible) members.""" - sync_leader = await self._select_sync_leader(group_player) - # ensure the sync leader is first in the list - group_player.group_childs.set( - [ - sync_leader.player_id, - *[x for x in group_player.group_childs if x != sync_leader.player_id], - ] - ) - members_to_sync: list[str] = [] - for member in self.mass.players.iter_group_members(group_player, active_only=False): - if member.synced_to and member.synced_to != sync_leader.player_id: - # ungroup first - await self.mass.players.cmd_ungroup(member.player_id) - if sync_leader.player_id == member.player_id: - # skip sync leader - continue - if ( - member.synced_to == sync_leader.player_id - and member.player_id in sync_leader.group_childs - ): - # already synced - continue - members_to_sync.append(member.player_id) - if members_to_sync: - await self.mass.players.cmd_group_many(sync_leader.player_id, members_to_sync) - - async def _select_sync_leader(self, group_player: Player) -> Player: - """Select the active sync leader player for a syncgroup.""" - # prefer the first player that already has sync childs - for prefer_sync_leader in (True, False): - for child_player in self.mass.players.iter_group_members(group_player): - if prefer_sync_leader and child_player.synced_to: - continue - if child_player.active_group not in ( - None, - group_player.player_id, - child_player.player_id, - ): - # this should not happen (because its already handled in the power on logic), - # but guard it just in case bad things happen - continue - return child_player - raise RuntimeError("No players available to form syncgroup") - - async def _on_mass_player_added_event(self, event: MassEvent) -> None: - """Handle player added event from player controller.""" - await self._register_all_players() - - def _update_attributes(self, player: Player) -> None: - """Update attributes of a player.""" - group_type = self.mass.config.get_raw_player_config_value( - player.player_id, CONF_ENTRY_GROUP_TYPE.key, CONF_ENTRY_GROUP_TYPE.default_value - ) - # grab current media and state from one of the active players - for child_player in self.mass.players.iter_group_members( - player, active_only=True, only_playing=True - ): - if child_player.synced_to: - # ignore child players - continue - if child_player.active_source not in (None, player.active_source): - # this should not happen but guard just in case - continue - player.state = child_player.state - if child_player.current_media: - player.current_media = child_player.current_media - player.elapsed_time = child_player.elapsed_time - player.elapsed_time_last_updated = child_player.elapsed_time_last_updated - break - else: - player.state = PlayerState.IDLE - if group_type == GROUP_TYPE_UNIVERSAL: - can_group_with = { - # allow grouping with all providers, except the playergroup provider itself - x.instance_id - for x in self.mass.players.providers - if x.instance_id != self.instance_id - } - elif sync_player_provider := self.mass.get_provider(group_type): - can_group_with = {sync_player_provider.instance_id} - else: - can_group_with = {} - player.can_group_with = can_group_with - self.mass.players.update(player.player_id) - - async def _ungroup_subgroups_if_found(self, player: Player) -> None: - """Verify that no player is part of a separate group.""" - group_type = self.mass.config.get_raw_player_config_value( - player.player_id, CONF_ENTRY_GROUP_TYPE.key, CONF_ENTRY_GROUP_TYPE.default_value - ) - if group_type != GROUP_TYPE_UNIVERSAL: - return - - changed = False - # Verify that no player is part of a separate group - for child_player_id in player.group_childs: - child_player = self.mass.players.get(child_player_id) - if child_player is None: - continue - if PlayerFeature.SET_MEMBERS not in child_player.supported_features: - continue - if child_player.group_childs: - # This is a leader in another group - player_provider = self.mass.players.get_player_provider(child_player_id) - for sync_child_id in child_player.group_childs: - if sync_child_id == child_player_id: - continue - await player_provider.cmd_ungroup(sync_child_id) - changed = True - if child_player.synced_to: - # This is a member of another group - await self.cmd_ungroup_member(child_player.player_id, child_player.synced_to) - changed = True - if changed and player.state == PlayerState.PLAYING: - # Restart playback to ensure all members play the same content - await self.mass.player_queues.resume(player.player_id, False) - - async def _serve_ugp_stream(self, request: web.Request) -> web.Response: - """Serve the UGP (multi-client) flow stream audio to a player.""" - ugp_player_id = request.path.rsplit(".")[0].rsplit("/")[-1] - child_player_id = request.query.get("player_id") # optional! - output_format_str = request.path.rsplit(".")[-1] - - if child_player_id and (child_player := self.mass.players.get(child_player_id)): - # Use the preferred output format of the child player - output_format = await self.mass.streams.get_output_format( - output_format_str=output_format_str, - player=child_player, - content_sample_rate=UGP_FORMAT.sample_rate, - content_bit_depth=UGP_FORMAT.bit_depth, - ) - elif output_format_str == "flac": - output_format = AudioFormat(content_type=ContentType.FLAC) - else: - output_format = AudioFormat(content_type=ContentType.MP3) - - if not (ugp_player := self.mass.players.get(ugp_player_id)): - raise web.HTTPNotFound(reason=f"Unknown UGP player: {ugp_player_id}") - - if not (stream := self.ugp_streams.get(ugp_player_id, None)) or stream.done: - raise web.HTTPNotFound(body=f"There is no active UGP stream for {ugp_player_id}!") - - http_profile: str = await self.mass.config.get_player_config_value( - child_player_id, CONF_HTTP_PROFILE - ) - headers = { - **DEFAULT_STREAM_HEADERS, - "Content-Type": f"audio/{output_format_str}", - "Accept-Ranges": "none", - "Cache-Control": "no-cache", - "Connection": "close", - } - - resp = web.StreamResponse(status=200, reason="OK", headers=headers) - if http_profile == "forced_content_length": - resp.content_length = 4294967296 - elif http_profile == "chunked": - resp.enable_chunked_encoding() - - await resp.prepare(request) - - # return early if this is not a GET request - if request.method != "GET": - return resp - - # all checks passed, start streaming! - self.logger.debug( - "Start serving UGP flow audio stream for UGP-player %s to %s", - ugp_player.display_name, - child_player_id or request.remote, - ) - - # Generate filter params for the player specific DSP settings - filter_params = None - if child_player_id: - filter_params = get_player_filter_params( - self.mass, child_player_id, stream.input_format, output_format - ) - - async for chunk in stream.get_stream( - output_format, - filter_params=filter_params, - ): - try: - await resp.write(chunk) - except (ConnectionError, ConnectionResetError): - break - - return resp - - def _filter_members(self, group_type: str, members: list[str]) -> list[str]: - """Filter out members that are not valid players.""" - if group_type != GROUP_TYPE_UNIVERSAL: - player_provider = self.mass.get_provider(group_type) - return [ - x - for x in members - if (player := self.mass.players.get(x)) - and player.provider == player_provider.instance_id - ] - # cleanup members - filter out impossible choices - syncgroup_childs: list[str] = [] - for member in members: - if not member.startswith(SYNCGROUP_PREFIX): - continue - if syncgroup := self.mass.players.get(member): - syncgroup_childs.extend(syncgroup.group_childs) - # we filter out other UGP players and syncgroup childs - # if their parent is already in the list - return [ - x - for x in members - if self.mass.players.get(x) - and x not in syncgroup_childs - and not x.startswith(UNIVERSAL_PREFIX) - ] diff --git a/music_assistant/providers/player_group/manifest.json b/music_assistant/providers/player_group/manifest.json deleted file mode 100644 index 5e971f18..00000000 --- a/music_assistant/providers/player_group/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "type": "player", - "domain": "player_group", - "stage": "beta", - "name": "Playergroup", - "description": "Create (permanent) groups of your favorite players. \nSupports both syncgroups (to group speakers of the same ecocystem to play in sync) and universal groups to group speakers of different ecosystems to play the same audio (but not in sync).", - "codeowners": ["@music-assistant"], - "requirements": [], - "documentation": "https://music-assistant.io/faq/groups/", - "multi_instance": false, - "builtin": true, - "allow_disable": false, - "icon": "speaker-multiple" -} diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index 742d7981..c674c5c1 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -1,117 +1,37 @@ """Snapcast Player provider for Music Assistant.""" -from __future__ import annotations - -import asyncio -import logging -import pathlib -import random -import re -import socket -import time -import urllib.parse -from contextlib import suppress -from enum import StrEnum -from typing import TYPE_CHECKING, cast - -from bidict import bidict -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType -from music_assistant_models.enums import ( - ConfigEntryType, - ContentType, - MediaType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, +from music_assistant_models.config_entries import ( + ConfigEntry, + ConfigValueOption, + ConfigValueType, + ProviderConfig, ) +from music_assistant_models.enums import ConfigEntryType from music_assistant_models.errors import SetupFailedError -from music_assistant_models.media_items import AudioFormat -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia -from snapcast.control import create_server -from zeroconf import NonUniqueNameException -from zeroconf.asyncio import AsyncServiceInfo - -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - DEFAULT_PCM_FORMAT, - create_sample_rates_config_entry, +from music_assistant_models.provider import ProviderManifest + +from music_assistant.helpers.process import check_output +from music_assistant.mass import MusicAssistant +from music_assistant.models import ProviderInstanceType +from music_assistant.providers.snapcast.constants import ( + CONF_CATEGORY_ADVANCED, + CONF_CATEGORY_BUILT_IN, + CONF_CATEGORY_GENERIC, + CONF_HELP_LINK, + CONF_SERVER_BUFFER_SIZE, + CONF_SERVER_CHUNK_MS, + CONF_SERVER_CONTROL_PORT, + CONF_SERVER_HOST, + CONF_SERVER_INITIAL_VOLUME, + CONF_SERVER_SEND_AUDIO_TO_MUTED, + CONF_SERVER_TRANSPORT_CODEC, + CONF_STREAM_IDLE_THRESHOLD, + CONF_USE_EXTERNAL_SERVER, + DEFAULT_SNAPSERVER_IP, + DEFAULT_SNAPSERVER_PORT, + DEFAULT_SNAPSTREAM_IDLE_THRESHOLD, ) -from music_assistant.helpers.audio import FFMpeg, get_ffmpeg_stream, get_player_filter_params -from music_assistant.helpers.compare import create_safe_string -from music_assistant.helpers.process import AsyncProcess, check_output -from music_assistant.helpers.util import get_ip_pton -from music_assistant.models.player_provider import PlayerProvider - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig - from music_assistant_models.provider import ProviderManifest - from snapcast.control.client import Snapclient - from snapcast.control.group import Snapgroup - from snapcast.control.server import Snapserver - from snapcast.control.stream import Snapstream - - from music_assistant import MusicAssistant - from music_assistant.models import ProviderInstanceType - from music_assistant.providers.player_group import PlayerGroupProvider - -CONF_SERVER_HOST = "snapcast_server_host" -CONF_SERVER_CONTROL_PORT = "snapcast_server_control_port" -CONF_USE_EXTERNAL_SERVER = "snapcast_use_external_server" -CONF_SERVER_BUFFER_SIZE = "snapcast_server_built_in_buffer_size" -CONF_SERVER_CHUNK_MS = "snapcast_server_built_in_chunk_ms" -CONF_SERVER_INITIAL_VOLUME = "snapcast_server_built_in_initial_volume" -CONF_SERVER_TRANSPORT_CODEC = "snapcast_server_built_in_codec" -CONF_SERVER_SEND_AUDIO_TO_MUTED = "snapcast_server_built_in_send_muted" -CONF_STREAM_IDLE_THRESHOLD = "snapcast_stream_idle_threshold" - - -CONF_CATEGORY_GENERIC = "generic" -CONF_CATEGORY_ADVANCED = "advanced" -CONF_CATEGORY_BUILT_IN = "Built-in Snapserver Settings" - -CONF_HELP_LINK = ( - "https://raw.githubusercontent.com/badaix/snapcast/refs/heads/master/server/etc/snapserver.conf" -) - -# snapcast has fixed sample rate/bit depth so make this config entry static and hidden -CONF_ENTRY_SAMPLE_RATES_SNAPCAST = create_sample_rates_config_entry( - supported_sample_rates=[48000], supported_bit_depths=[16], hidden=True -) - -DEFAULT_SNAPSERVER_IP = "127.0.0.1" -DEFAULT_SNAPSERVER_PORT = 1705 -DEFAULT_SNAPSTREAM_IDLE_THRESHOLD = 60000 - -MASS_STREAM_PREFIX = "Music Assistant - " -MASS_ANNOUNCEMENT_POSTFIX = " (announcement)" -SNAPWEB_DIR = pathlib.Path(__file__).parent.resolve().joinpath("snapweb") -CONTROL_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("control.py") - -DEFAULT_SNAPCAST_FORMAT = AudioFormat( - content_type=ContentType.PCM_S16LE, - sample_rate=48000, - # TODO: we can also use 32 bits here - bit_depth=16, - channels=2, -) - -DEFAULT_SNAPCAST_PCM_FORMAT = AudioFormat( - # the format that is used as intermediate pcm stream, - # we prefer F32 here to account for volume normalization - content_type=ContentType.PCM_F32LE, - sample_rate=48000, - bit_depth=16, - channels=2, -) - - -class SnapCastStreamType(StrEnum): - """Enum for Snapcast Stream Type.""" - - MUSIC = "MUSIC" - ANNOUNCEMENT = "ANNOUNCEMENT" +from music_assistant.providers.snapcast.provider import SnapCastProvider async def setup( @@ -265,655 +185,3 @@ async def get_config_entries( category=CONF_CATEGORY_ADVANCED, ), ) - - -class SnapCastProvider(PlayerProvider): - """Player provider for Snapcast based players.""" - - _snapserver: Snapserver - _snapcast_server_host: str - _snapcast_server_control_port: int - _stream_tasks: dict[str, asyncio.Task] - _use_builtin_server: bool - _snapserver_runner: asyncio.Task | None - _snapserver_started: asyncio.Event | None - _ids_map: bidict # ma_id / snapclient_id - _stop_called: bool - - def _get_snapclient_id(self, player_id: str) -> str: - search_dict = self._ids_map - return search_dict.get(player_id) - - def _get_ma_id(self, snap_client_id: str) -> str: - search_dict = self._ids_map.inverse - return search_dict.get(snap_client_id) - - def _generate_and_register_id(self, snap_client_id) -> str: - search_dict = self._ids_map.inverse - if snap_client_id not in search_dict: - new_id = "ma_" + str(re.sub(r"\W+", "", snap_client_id)) - self._ids_map[new_id] = snap_client_id - return new_id - else: - return self._get_ma_id(snap_client_id) - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.SYNC_PLAYERS, ProviderFeature.REMOVE_PLAYER} - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - # set snapcast logging - logging.getLogger("snapcast").setLevel(self.logger.level) - self._use_builtin_server = not self.config.get_value(CONF_USE_EXTERNAL_SERVER) - self._stop_called = False - if self._use_builtin_server: - self._snapcast_server_host = "127.0.0.1" - self._snapcast_server_control_port = DEFAULT_SNAPSERVER_PORT - self._snapcast_server_buffer_size = self.config.get_value(CONF_SERVER_BUFFER_SIZE) - self._snapcast_server_chunk_ms = self.config.get_value(CONF_SERVER_CHUNK_MS) - self._snapcast_server_initial_volume = self.config.get_value(CONF_SERVER_INITIAL_VOLUME) - self._snapcast_server_send_to_muted = self.config.get_value( - CONF_SERVER_SEND_AUDIO_TO_MUTED - ) - self._snapcast_server_transport_codec = self.config.get_value( - CONF_SERVER_TRANSPORT_CODEC - ) - else: - self._snapcast_server_host = self.config.get_value(CONF_SERVER_HOST) - self._snapcast_server_control_port = self.config.get_value(CONF_SERVER_CONTROL_PORT) - self._snapcast_stream_idle_threshold = self.config.get_value(CONF_STREAM_IDLE_THRESHOLD) - self._stream_tasks = {} - self._ids_map = bidict({}) - - if self._use_builtin_server: - await self._start_builtin_server() - else: - self._snapserver_runner = None - self._snapserver_started = None - try: - self._snapserver = await create_server( - self.mass.loop, - self._snapcast_server_host, - port=self._snapcast_server_control_port, - reconnect=True, - ) - self._snapserver.set_on_update_callback(self._handle_update) - self.logger.info( - "Started connection to Snapserver %s", - f"{self._snapcast_server_host}:{self._snapcast_server_control_port}", - ) - # register callback for when the connection gets lost to the snapserver - self._snapserver.set_on_disconnect_callback(self._handle_disconnect) - - except OSError as err: - msg = "Unable to start the Snapserver connection ?" - raise SetupFailedError(msg) from err - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - await super().loaded_in_mass() - # initial load of players - self._handle_update() - - async def unload(self, is_removed: bool = False) -> None: - """Handle close/cleanup of the provider.""" - self._stop_called = True - for snap_client_id in self._snapserver.clients: - player_id = self._get_ma_id(snap_client_id) - if not (player := self.mass.players.get(player_id, raise_unavailable=False)): - continue - if player.state != PlayerState.PLAYING: - continue - await self.cmd_stop(player_id) - self._snapserver.stop() - await self._stop_builtin_server() - - def _handle_update(self) -> None: - """Process Snapcast init Player/Group and set callback .""" - for snap_client in self._snapserver.clients: - if not snap_client.identifier: - self.logger.warning( - "Detected Snapclient %s without identifier, skipping", snap_client.friendly_name - ) - continue - self._handle_player_init(snap_client) - snap_client.set_callback(self._handle_player_update) - for snap_client in self._snapserver.clients: - self._handle_player_update(snap_client) - for snap_group in self._snapserver.groups: - snap_group.set_callback(self._handle_group_update) - - def _handle_group_update(self, snap_group: Snapgroup) -> None: - """Process Snapcast group callback.""" - for snap_client in self._snapserver.clients: - self._handle_player_update(snap_client) - - def _handle_player_init(self, snap_client: Snapclient) -> None: - """Process Snapcast add to Player controller.""" - player_id = self._generate_and_register_id(snap_client.identifier) - player = self.mass.players.get(player_id, raise_unavailable=False) - if not player: - snap_client = cast( - "Snapclient", self._snapserver.client(self._get_snapclient_id(player_id)) - ) - player = Player( - player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=snap_client.friendly_name, - available=snap_client.connected, - device_info=DeviceInfo( - model=snap_client._client.get("host").get("os"), - ip_address=snap_client._client.get("host").get("ip"), - manufacturer=snap_client._client.get("host").get("arch"), - ), - supported_features={ - PlayerFeature.SET_MEMBERS, - PlayerFeature.VOLUME_SET, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PLAY_ANNOUNCEMENT, - }, - synced_to=self._synced_to(player_id), - can_group_with={self.instance_id}, - ) - asyncio.run_coroutine_threadsafe( - self.mass.players.register_or_update(player), loop=self.mass.loop - ) - - def _handle_player_update(self, snap_client: Snapclient) -> None: - """Process Snapcast update to Player controller.""" - player_id = self._get_ma_id(snap_client.identifier) - player = self.mass.players.get(player_id) - if not player: - return - player.name = snap_client.friendly_name - player.volume_level = snap_client.volume - player.volume_muted = snap_client.muted - player.available = snap_client.connected - player.synced_to = self._synced_to(player_id) - - # Note: when the active stream is a MASS stream the active_source is __not__ updated at all. - # So it doesn't matter whether a MASS stream is for music or announcements. - if stream := self._get_active_snapstream(player_id): - if stream.identifier == "default": - player.active_source = None - elif not stream.identifier.startswith(MASS_STREAM_PREFIX): - # unknown source - player.active_source = stream.identifier - else: - player.active_source = None - - self._group_childs(player_id) - self.mass.players.update(player_id) - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - return ( - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_SAMPLE_RATES_SNAPCAST, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - ) - - async def remove_player(self, player_id: str) -> None: - """Remove the client from the snapserver when it is deleted.""" - success, error_msg = await self._snapserver.delete_client( - self._get_snapclient_id(player_id) - ) - if success: - self.logger.debug("Snapclient removed %s", player_id) - else: - self.logger.warning("Unable to remove snapclient %s: %s", player_id, error_msg) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - snap_client_id = self._get_snapclient_id(player_id) - await self._snapserver.client(snap_client_id).set_volume(volume_level) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - # update the state first to avoid race conditions, if an active play_announcement - # finishes the player.state should be IDLE. - player = self.mass.players.get(player_id, raise_unavailable=False) - player.state = PlayerState.IDLE - player.current_media = None - player.active_source = None - self._set_childs_state(player_id) - self.mass.players.update(player_id) - - # we change the active stream only if music was playing - if not player.announcement_in_progress: - await self._get_snapgroup(player_id).set_stream("default") - - # but we always delete the music stream (whether it was active or not) - await self._delete_stream(self._get_stream_name(player_id, SnapCastStreamType.MUSIC)) - - if stream_task := self._stream_tasks.pop(player_id, None): - if not stream_task.done(): - stream_task.cancel() - with suppress(asyncio.CancelledError): - await stream_task - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send MUTE command to given player.""" - ma_player = self.mass.players.get(player_id, raise_unavailable=False) - snap_client_id = self._get_snapclient_id(player_id) - snapclient = self._snapserver.client(snap_client_id) - # Using optimistic value because the library does not return the response from the api - await snapclient.set_muted(muted) - ma_player.volume_muted = snapclient.muted - self.mass.players.update(player_id) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Sync Snapcast player.""" - group = self._get_snapgroup(target_player) - mass_target_player = self.mass.players.get(target_player) - if self._get_snapclient_id(player_id) not in group.clients: - await group.add_client(self._get_snapclient_id(player_id)) - mass_player = self.mass.players.get(player_id) - mass_player.synced_to = target_player - mass_target_player.group_childs.append(player_id) - self.mass.players.update(player_id) - self.mass.players.update(target_player) - - async def cmd_ungroup(self, player_id: str) -> None: - """Ungroup Snapcast player.""" - mass_player = self.mass.players.get(player_id) - if mass_player.synced_to is None: - for mass_child_id in list(mass_player.group_childs): - if mass_child_id != player_id: - await self.cmd_ungroup(mass_child_id) - return - mass_sync_master_player = self.mass.players.get(mass_player.synced_to) - mass_sync_master_player.group_childs.remove(player_id) - mass_player.synced_to = None - snap_client_id = self._get_snapclient_id(player_id) - group = self._get_snapgroup(player_id) - await group.remove_client(snap_client_id) - # assign default/empty stream to the player - await self._get_snapgroup(player_id).set_stream("default") - await self.cmd_stop(player_id=player_id) - # make sure that the player manager gets an update - self.mass.players.update(player_id, skip_forward=True) - self.mass.players.update(mass_player.synced_to, skip_forward=True) - - async def play_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on given player.""" - player = self.mass.players.get(player_id) - if player.synced_to: - msg = "A synced player cannot receive play commands directly" - raise RuntimeError(msg) - - # stop any existing streamtasks first - if stream_task := self._stream_tasks.pop(player_id, None): - if not stream_task.done(): - stream_task.cancel() - with suppress(asyncio.CancelledError): - await stream_task - - # get stream or create new one - stream_name = self._get_stream_name(player_id, SnapCastStreamType.MUSIC) - stream = await self._get_or_create_stream(stream_name, media.queue_id or player_id) - - # if no announcement is playing we activate the stream now, otherwise it - # will be activated by play_announcement when the announcement is over. - if not player.announcement_in_progress: - snap_group = self._get_snapgroup(player_id) - await snap_group.set_stream(stream.identifier) - - player.current_media = media - player.active_source = media.queue_id - - # select audio source - if media.media_type == MediaType.PLUGIN_SOURCE: - # special case: plugin source stream - input_format = DEFAULT_SNAPCAST_FORMAT - audio_source = self.mass.streams.get_plugin_source_stream( - plugin_source_id=media.custom_data["provider"], - output_format=DEFAULT_SNAPCAST_FORMAT, - player_id=player_id, - ) - elif media.queue_id.startswith("ugp_"): - # special case: UGP stream - ugp_provider: PlayerGroupProvider = self.mass.get_provider("player_group") - ugp_stream = ugp_provider.ugp_streams[media.queue_id] - input_format = ugp_stream.base_pcm_format - audio_source = ugp_stream.subscribe_raw() - elif media.queue_id and media.queue_item_id: - # regular queue (flow) stream request - input_format = DEFAULT_SNAPCAST_PCM_FORMAT - audio_source = self.mass.streams.get_queue_flow_stream( - queue=self.mass.player_queues.get(media.queue_id), - start_queue_item=self.mass.player_queues.get_item( - media.queue_id, media.queue_item_id - ), - pcm_format=DEFAULT_PCM_FORMAT, - ) - else: - # assume url or some other direct path - # NOTE: this will fail if its an uri not playable by ffmpeg - input_format = DEFAULT_SNAPCAST_FORMAT - audio_source = get_ffmpeg_stream( - audio_input=media.uri, - input_format=AudioFormat(ContentType.try_parse(media.uri)), - output_format=DEFAULT_SNAPCAST_FORMAT, - ) - - async def _streamer() -> None: - stream_path = self._get_stream_path(stream) - self.logger.debug("Start streaming to %s", stream_path) - async with FFMpeg( - audio_input=audio_source, - input_format=input_format, - output_format=DEFAULT_SNAPCAST_FORMAT, - filter_params=get_player_filter_params( - self.mass, player_id, input_format, DEFAULT_SNAPCAST_FORMAT - ), - audio_output=stream_path, - extra_input_args=["-y", "-re"], - ) as ffmpeg_proc: - player.state = PlayerState.PLAYING - player.current_media = media - player.elapsed_time = 0 - player.elapsed_time_last_updated = time.time() - self.mass.players.update(player_id) - self._set_childs_state(player_id) - await ffmpeg_proc.wait() - - self.logger.debug("Finished streaming to %s", stream_path) - # we need to wait a bit for the stream status to become idle - # to ensure that all snapclients have consumed the audio - while stream.status != "idle": - await asyncio.sleep(0.25) - player.state = PlayerState.IDLE - player.elapsed_time = time.time() - player.elapsed_time_last_updated - self.mass.players.update(player_id) - self._set_childs_state(player_id) - - # start streaming the queue (pcm) audio in a background task - self._stream_tasks[player_id] = self.mass.create_task(_streamer()) - - async def play_announcement( - self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None - ) -> None: - """Handle (provider native) playback of an announcement on given player.""" - # get stream or create new one - stream_name = self._get_stream_name(player_id, SnapCastStreamType.ANNOUNCEMENT) - stream = await self._get_or_create_stream(stream_name, None) - - # always activate the stream (announcements have priority over music) - snap_group = self._get_snapgroup(player_id) - await snap_group.set_stream(stream.identifier) - - # Unfortunately snapcast sets a volume per client (not per stream), so we need a way to - # set the announcement volume without affecting the music volume. - # We go for the simplest solution: save the previous volume, change it, restore later - # (with the downside that the change will be visible in the UI) - player = self.mass.players.get(player_id) - orig_volume_level = player.volume_level # Note: might be None - - if volume_level is not None: - await self.cmd_volume_set(player_id, volume_level) - - input_format = DEFAULT_SNAPCAST_FORMAT - audio_source = self.mass.streams.get_announcement_stream( - announcement.custom_data["url"], - output_format=DEFAULT_SNAPCAST_FORMAT, - use_pre_announce=announcement.custom_data["use_pre_announce"], - ) - - # stream the audio, wait for it to finish (play_announcement should return after the - # announcement is over to avoid simultaneous announcements). - # - # Note: -probesize 8096 is needed to start playing the pre-announce before the TTS - # data arrive (they arrive late, see get_announcement_stream). - # - stream_path = self._get_stream_path(stream) - self.logger.debug("Start announcement streaming to %s", stream_path) - async with FFMpeg( - audio_input=audio_source, - input_format=input_format, - output_format=DEFAULT_SNAPCAST_FORMAT, - filter_params=get_player_filter_params( - self.mass, player_id, input_format, DEFAULT_SNAPCAST_FORMAT - ), - audio_output=stream_path, - extra_input_args=["-y", "-re", "-probesize", "8096"], - ) as ffmpeg_proc: - await ffmpeg_proc.wait() - - self.logger.debug("Finished announcement streaming to %s", stream_path) - # we need to wait a bit for the stream status to become idle - # to ensure that all snapclients have consumed the audio - while stream.status != "idle": - await asyncio.sleep(0.25) - - # delete the announcement stream - await self._delete_stream(stream_name) - - # restore volume, if we changed it above and it's still the same we set - # (the user did not change it himself while the announcement was playing) - if player.volume_level == volume_level and None not in [volume_level, orig_volume_level]: - await self.cmd_volume_set(player_id, orig_volume_level) - - # and restore the group to either the default or the music stream - if player.state == PlayerState.IDLE: - new_stream_name = "default" - else: - new_stream_name = self._get_stream_name(player_id, SnapCastStreamType.MUSIC) - await self._get_snapgroup(player_id).set_stream(new_stream_name) - - def _get_stream_name(self, player_id: str, stream_type: SnapCastStreamType) -> str: - """Return the name of the stream for the given player. - - Each player can have up to two concurrent streams, for music and announcements. - - The stream name depends only on player_id (not queue_id) for two reasones: - 1. Avoid issues when the same queue_id is simultaneously used by two players - (eg in universal groups). - 2. Easily identify which stream belongs to which player, for instance to be able to - delete a music stream even when it is not active due to an announcement. - """ - player = self.mass.players.get(player_id) - safe_name = create_safe_string(player.display_name, replace_space=True) - stream_name = f"{MASS_STREAM_PREFIX}{safe_name}" - if stream_type == SnapCastStreamType.ANNOUNCEMENT: - stream_name += MASS_ANNOUNCEMENT_POSTFIX - return stream_name - - def _get_stream_path(self, stream: Snapstream) -> str: - stream_path = stream.path or f"tcp://{stream._stream['uri']['host']}" - return stream_path.replace("0.0.0.0", self._snapcast_server_host) - - async def _delete_stream(self, stream_name: str) -> None: - if stream := self._get_snapstream(stream_name): - with suppress(TypeError, KeyError, AttributeError): - await self._snapserver.stream_remove_stream(stream.identifier) - - def _get_snapgroup(self, player_id: str) -> Snapgroup: - """Get snapcast group for given player_id.""" - snap_client_id = self._get_snapclient_id(player_id) - client: Snapclient = self._snapserver.client(snap_client_id) - return client.group - - def _get_snapstream(self, stream_name: str) -> Snapstream | None: - """Get a stream by name.""" - with suppress(KeyError): - return self._snapserver.stream(stream_name) - return None - - def _get_active_snapstream(self, player_id: str) -> Snapstream | None: - """Get active stream for given player_id.""" - if group := self._get_snapgroup(player_id): - return self._get_snapstream(group.stream) - return None - - def _synced_to(self, player_id: str) -> str | None: - """Return player_id of the player this player is synced to.""" - snap_group: Snapgroup = self._get_snapgroup(player_id) - master_id: str = self._get_ma_id(snap_group.clients[0]) - - if len(snap_group.clients) < 2 or player_id == master_id: - return None - return master_id - - def _group_childs(self, player_id: str) -> set[str]: - """Return player_ids of the players synced to this player.""" - mass_player = self.mass.players.get(player_id, raise_unavailable=False) - snap_group = self._get_snapgroup(player_id) - mass_player.group_childs.clear() - if mass_player.synced_to is not None: - return - mass_player.group_childs.append(player_id) - { - mass_player.group_childs.append(self._get_ma_id(snap_client_id)) - for snap_client_id in snap_group.clients - if self._get_ma_id(snap_client_id) != player_id - and self._snapserver.client(snap_client_id).connected - } - - async def _get_or_create_stream(self, stream_name: str, queue_id: str | None) -> Snapstream: - """Create new stream on snapcast server (or return existing one).""" - # prefer to reuse existing stream if possible - if stream := self._get_snapstream(stream_name): - return stream - - # The control script is used only for music streams in the builtin server - # (queue_id is None only for announcement streams). - if self._use_builtin_server and queue_id: - extra_args = ( - f"&controlscript={urllib.parse.quote_plus(str(CONTROL_SCRIPT))}" - f"&controlscriptparams=--queueid={urllib.parse.quote_plus(queue_id)}%20" - f"--api-port={self.mass.webserver.publish_port}%20" - f"--streamserver-ip={self.mass.streams.publish_ip}%20" - f"--streamserver-port={self.mass.streams.publish_port}" - ) - else: - extra_args = "" - - attempts = 50 - while attempts: - attempts -= 1 - # pick a random port - port = random.randint(4953, 4953 + 200) - result = await self._snapserver.stream_add_stream( - # NOTE: setting the sampleformat to something else - # (like 24 bits bit depth) does not seem to work at all! - f"tcp://0.0.0.0:{port}?sampleformat=48000:16:2" - f"&idle_threshold={self._snapcast_stream_idle_threshold}" - f"{extra_args}&name={stream_name}" - ) - if "id" not in result: - # if the port is already taken, the result will be an error - self.logger.warning(result) - continue - return self._snapserver.stream(result["id"]) - msg = "Unable to create stream - No free port found?" - raise RuntimeError(msg) - - def _set_childs_state(self, player_id: str) -> None: - """Set the state of the child`s of the player.""" - mass_player = self.mass.players.get(player_id) - for child_player_id in mass_player.group_childs: - if child_player_id == player_id: - continue - mass_child_player = self.mass.players.get(child_player_id) - mass_child_player.state = mass_player.state - self.mass.players.update(child_player_id) - - async def _builtin_server_runner(self) -> None: - """Start running the builtin snapserver.""" - if self._snapserver_started.is_set(): - raise RuntimeError("Snapserver is already started!") - logger = self.logger.getChild("snapserver") - logger.info("Starting builtin Snapserver...") - # register the snapcast mdns services - for name, port in ( - ("-http", 1780), - ("-jsonrpc", 1705), - ("-stream", 1704), - ("-tcp", 1705), - ("", 1704), - ): - zeroconf_type = f"_snapcast{name}._tcp.local." - try: - info = AsyncServiceInfo( - zeroconf_type, - name=f"Snapcast.{zeroconf_type}", - properties={"is_mass": "true"}, - addresses=[await get_ip_pton(self.mass.streams.publish_ip)], - port=port, - server=f"{socket.gethostname()}.local", - ) - attr_name = f"zc_service_set{name}" - if getattr(self, attr_name, None): - await self.mass.aiozc.async_update_service(info) - else: - await self.mass.aiozc.async_register_service(info, strict=False) - setattr(self, attr_name, True) - except NonUniqueNameException: - self.logger.debug( - "Could not register mdns record for %s as its already in use", - zeroconf_type, - ) - except Exception as err: - self.logger.exception( - "Could not register mdns record for %s: %s", zeroconf_type, str(err) - ) - - args = [ - "snapserver", - # config settings taken from - # https://raw.githubusercontent.com/badaix/snapcast/86cd4b2b63e750a72e0dfe6a46d47caf01426c8d/server/etc/snapserver.conf - f"--server.datadir={self.mass.storage_path}", - "--http.enabled=true", - "--http.port=1780", - f"--http.doc_root={SNAPWEB_DIR}", - "--tcp.enabled=true", - f"--tcp.port={self._snapcast_server_control_port}", - "--stream.sampleformat=48000:16:2", - f"--stream.buffer={self._snapcast_server_buffer_size}", - f"--stream.chunk_ms={self._snapcast_server_chunk_ms}", - f"--stream.codec={self._snapcast_server_transport_codec}", - f"--stream.send_to_muted={str(self._snapcast_server_send_to_muted).lower()}", - f"--streaming_client.initial_volume={self._snapcast_server_initial_volume}", - ] - async with AsyncProcess(args, stdout=True, name="snapserver") as snapserver_proc: - # keep reading from stdout until exit - async for data in snapserver_proc.iter_any(): - data = data.decode().strip() # noqa: PLW2901 - for line in data.split("\n"): - logger.debug(line) - if "(Snapserver) Version 0." in line: - # delay init a small bit to prevent race conditions - # where we try to connect too soon - self.mass.loop.call_later(2, self._snapserver_started.set) - - async def _stop_builtin_server(self) -> None: - """Stop the built-in Snapserver.""" - self.logger.info("Stopping, built-in Snapserver") - if self._snapserver_runner and not self._snapserver_runner.done(): - self._snapserver_runner.cancel() - self._snapserver_started.clear() - - async def _start_builtin_server(self) -> None: - """Start the built-in Snapserver.""" - if self._use_builtin_server: - self._snapserver_started = asyncio.Event() - self._snapserver_runner = self.mass.create_task(self._builtin_server_runner()) - await asyncio.wait_for(self._snapserver_started.wait(), 10) - - def _handle_disconnect(self, exc: Exception) -> None: - """Handle disconnect callback from snapserver.""" - if self._stop_called or self.mass.closing: - # we're instructed to stop/exit, so no need to restart the connection - return - self.logger.info( - "Connection to SnapServer lost, reason: %s. Reloading provider in 5 seconds.", - str(exc), - ) - # schedule a reload of the provider - self.mass.call_later(5, self.mass.load_provider, self.instance_id, allow_retry=True) diff --git a/music_assistant/providers/snapcast/constants.py b/music_assistant/providers/snapcast/constants.py new file mode 100644 index 00000000..ad58fd41 --- /dev/null +++ b/music_assistant/providers/snapcast/constants.py @@ -0,0 +1,66 @@ +"""Constants for snapcast provider.""" + +import pathlib +from enum import StrEnum + +from music_assistant_models.enums import ContentType +from music_assistant_models.media_items.audio_format import AudioFormat + +from music_assistant.constants import create_sample_rates_config_entry + +CONF_SERVER_HOST = "snapcast_server_host" +CONF_SERVER_CONTROL_PORT = "snapcast_server_control_port" +CONF_USE_EXTERNAL_SERVER = "snapcast_use_external_server" +CONF_SERVER_BUFFER_SIZE = "snapcast_server_built_in_buffer_size" +CONF_SERVER_CHUNK_MS = "snapcast_server_built_in_chunk_ms" +CONF_SERVER_INITIAL_VOLUME = "snapcast_server_built_in_initial_volume" +CONF_SERVER_TRANSPORT_CODEC = "snapcast_server_built_in_codec" +CONF_SERVER_SEND_AUDIO_TO_MUTED = "snapcast_server_built_in_send_muted" +CONF_STREAM_IDLE_THRESHOLD = "snapcast_stream_idle_threshold" + + +CONF_CATEGORY_GENERIC = "generic" +CONF_CATEGORY_ADVANCED = "advanced" +CONF_CATEGORY_BUILT_IN = "Built-in Snapserver Settings" + +CONF_HELP_LINK = ( + "https://raw.githubusercontent.com/badaix/snapcast/refs/heads/master/server/etc/snapserver.conf" +) + +# snapcast has fixed sample rate/bit depth so make this config entry static and hidden +CONF_ENTRY_SAMPLE_RATES_SNAPCAST = create_sample_rates_config_entry( + supported_sample_rates=[48000], supported_bit_depths=[16], hidden=True +) + +DEFAULT_SNAPSERVER_IP = "127.0.0.1" +DEFAULT_SNAPSERVER_PORT = 1705 +DEFAULT_SNAPSTREAM_IDLE_THRESHOLD = 60000 + +MASS_STREAM_PREFIX = "Music Assistant - " +MASS_ANNOUNCEMENT_POSTFIX = " (announcement)" +SNAPWEB_DIR = pathlib.Path(__file__).parent.resolve().joinpath("snapweb") +CONTROL_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("control.py") + +DEFAULT_SNAPCAST_FORMAT = AudioFormat( + content_type=ContentType.PCM_S16LE, + sample_rate=48000, + # TODO: we can also use 32 bits here + bit_depth=16, + channels=2, +) + +DEFAULT_SNAPCAST_PCM_FORMAT = AudioFormat( + # the format that is used as intermediate pcm stream, + # we prefer F32 here to account for volume normalization + content_type=ContentType.PCM_F32LE, + sample_rate=48000, + bit_depth=16, + channels=2, +) + + +class SnapCastStreamType(StrEnum): + """Enum for Snapcast Stream Type.""" + + MUSIC = "MUSIC" + ANNOUNCEMENT = "ANNOUNCEMENT" diff --git a/music_assistant/providers/snapcast/control.py b/music_assistant/providers/snapcast/control.py index ca620b60..9dec33ae 100755 --- a/music_assistant/providers/snapcast/control.py +++ b/music_assistant/providers/snapcast/control.py @@ -115,7 +115,7 @@ class MusicAssistantControl: elif cmd == "SetProperty": properties = request["params"] logger.debug(f"SetProperty: {property}") - if "shuffle" in property: + if "shuffle" in properties: self.send_request( "player_queues/shuffle", queue_id=queue_id, @@ -201,8 +201,8 @@ class MusicAssistantControl: "rate": 1.0, "position": mass_queue_details["elapsed_time"], } + image_url: str | None = None if current_queue_item and (media_item := current_queue_item.get("media_item")): - image_url: str | None = None if image_path := current_queue_item.get("image", {}).get("path"): image_path_encoded = urllib.parse.quote_plus(image_path) image_url = ( @@ -269,7 +269,7 @@ class MusicAssistantControl: logger.info("Snapcast RPC websocket closed") def send_request( - self, command: str, callback: MessageCallback | None = None, **args: dict[str, Any] + self, command: str, callback: MessageCallback | None = None, **args: str | float ) -> None: """Send request to Music Assistant.""" msg_id = shortuuid.random(10) @@ -288,8 +288,9 @@ if __name__ == "__main__": # Parse command line queue_id = None api_port = None - streamserver_ip = None - streamserver_port = None + streamserver_ip: str | None = None + streamserver_port: str | None = None + stream_id: str | None = None for arg in sys.argv: if arg.startswith("--stream="): stream_id = arg.split("=")[1] @@ -321,6 +322,8 @@ if __name__ == "__main__": "Initializing for stream_id %s, queue_id %s and api_port %s", stream_id, queue_id, api_port ) + assert streamserver_ip is not None # for type checking + assert streamserver_port is not None ctrl = MusicAssistantControl(queue_id, streamserver_ip, int(streamserver_port), int(api_port)) # keep listening for messages on stdin and forward them diff --git a/music_assistant/providers/snapcast/player.py b/music_assistant/providers/snapcast/player.py new file mode 100644 index 00000000..b4a1de5e --- /dev/null +++ b/music_assistant/providers/snapcast/player.py @@ -0,0 +1,488 @@ +"""Snapcast Player.""" + +import asyncio +import random +import time +import urllib.parse +from contextlib import suppress +from typing import TYPE_CHECKING, cast + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ContentType, MediaType, PlaybackState, PlayerFeature +from music_assistant_models.media_items.audio_format import AudioFormat +from music_assistant_models.player import DeviceInfo, PlayerMedia +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup +from snapcast.control.stream import Snapstream + +from music_assistant.constants import ( + ATTR_ANNOUNCEMENT_IN_PROGRESS, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_OUTPUT_CODEC_HIDDEN, + DEFAULT_PCM_FORMAT, +) +from music_assistant.helpers.audio import get_player_filter_params +from music_assistant.helpers.compare import create_safe_string +from music_assistant.helpers.ffmpeg import FFMpeg, get_ffmpeg_stream +from music_assistant.models.player import Player +from music_assistant.providers.snapcast.constants import ( + CONF_ENTRY_SAMPLE_RATES_SNAPCAST, + CONTROL_SCRIPT, + DEFAULT_SNAPCAST_FORMAT, + DEFAULT_SNAPCAST_PCM_FORMAT, + MASS_ANNOUNCEMENT_POSTFIX, + MASS_STREAM_PREFIX, + SnapCastStreamType, +) +from music_assistant.providers.universal_group.constants import UGP_PREFIX +from music_assistant.providers.universal_group.player import UniversalGroupPlayer + +if TYPE_CHECKING: + from music_assistant.providers.snapcast.provider import SnapCastProvider + + +class SnapCastPlayer(Player): + """SnapCastPlayer.""" + + def __init__( + self, + provider: "SnapCastProvider", + player_id: str, + snap_client: Snapclient, + snap_client_id: str, + ) -> None: + """Init.""" + self.provider: SnapCastProvider + self.snap_client = snap_client + self.snap_client_id = snap_client_id + super().__init__(provider, player_id) + self._stream_task: asyncio.Task | None = None + + @property + def synced_to(self) -> str | None: + """ + Return the id of the player this player is synced to (sync leader). + + If this player is not synced to another player (or is the sync leader itself), + this should return None. + """ + snap_group = self._get_snapgroup() + assert snap_group is not None # for type checking + master_id: str = self.provider._get_ma_id(snap_group.clients[0]) + if len(snap_group.clients) < 2 or self.player_id == master_id: + return None + return master_id + + def setup(self) -> None: + """Set up player.""" + self._attr_name = self.snap_client.friendly_name + self._attr_available = self.snap_client.connected + self._attr_device_info = DeviceInfo( + model=self.snap_client._client.get("host").get("os"), + ip_address=self.snap_client._client.get("host").get("ip"), + manufacturer=self.snap_client._client.get("host").get("arch"), + ) + self._attr_supported_features = { + PlayerFeature.SET_MEMBERS, + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PLAY_ANNOUNCEMENT, + } + self._attr_can_group_with = {self.provider.instance_id} + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + await self.snap_client.set_volume(volume_level) + + async def stop(self) -> None: + """Send STOP command to given player.""" + # update the state first to avoid race conditions, if an active play_announcement + # finishes the player.state should be IDLE. + self._attr_playback_state = PlaybackState.IDLE + self._attr_current_media = None + self._attr_active_source = None + self._set_childs_state() + + self.update_state() + + # we change the active stream only if music was playing + if not self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS): + snapgroup = self._get_snapgroup() + assert snapgroup is not None # for type checking + await snapgroup.set_stream("default") + + # but we always delete the music stream (whether it was active or not) + await self._delete_stream(self._get_stream_name(SnapCastStreamType.MUSIC)) + + if self._stream_task is not None: + if not self._stream_task.done(): + self._stream_task.cancel() + with suppress(asyncio.CancelledError): + await self._stream_task + + async def volume_mute(self, muted: bool) -> None: + """Send MUTE command to given player.""" + # Using optimistic value because the library does not return the response from the api + await self.snap_client.set_muted(muted) + self._attr_volume_muted = muted + self.update_state() + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + group = self._get_snapgroup() + assert group is not None # for type checking + # handle client additions + for player_id in player_ids_to_add or []: + if player_id not in group.clients: + snapcast_id = self.provider._get_snapclient_id(player_id) + await group.add_client(snapcast_id) + self._attr_group_members.append(player_id) + # handle client removals + for player_id in player_ids_to_remove or []: + if player_id in group.clients: + snapcast_id = self.provider._get_snapclient_id(player_id) + await group.remove_client(snapcast_id) + self._attr_group_members.remove(player_id) + self.update_state() + + async def ungroup(self) -> None: + """Ungroup.""" + if self.synced_to is None: + for mass_child_id in list(self.group_members): + if mass_child_id != self.player_id: + if child_player := self.mass.players.get(mass_child_id): + await child_player.ungroup() + return + mass_sync_master_player = self.mass.players.get(self.synced_to) + assert mass_sync_master_player is not None # for type checking + mass_sync_master_player._attr_group_members.remove(self.player_id) + group = self._get_snapgroup() + assert group is not None # for type checking + await group.remove_client(self.snap_client_id) + # assign default/empty stream to the player + await group.set_stream("default") + await self.stop() + # make sure that the player manager gets an update + self.update_state() + mass_sync_master_player.update_state() + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + # ruff: noqa: PLR0915 + if self.synced_to: + msg = "A synced player cannot receive play commands directly" + raise RuntimeError(msg) + + # stop any existing streamtasks first + if self._stream_task is not None: + if not self._stream_task.done(): + self._stream_task.cancel() + with suppress(asyncio.CancelledError): + await self._stream_task + + # get stream or create new one + stream_name = self._get_stream_name(SnapCastStreamType.MUSIC) + stream = await self._get_or_create_stream(stream_name, media.queue_id or self.player_id) + + # if no announcement is playing we activate the stream now, otherwise it + # will be activated by play_announcement when the announcement is over. + if not self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS): + snap_group = self._get_snapgroup() + assert snap_group is not None # for type checking + await snap_group.set_stream(stream.identifier) + + self._attr_current_media = media + self._attr_active_source = media.queue_id + + # select audio source + if media.media_type == MediaType.PLUGIN_SOURCE: + # special case: plugin source stream + input_format = DEFAULT_SNAPCAST_FORMAT + assert media.custom_data is not None # for type checking + audio_source = self.mass.streams.get_plugin_source_stream( + plugin_source_id=media.custom_data["provider"], + output_format=DEFAULT_SNAPCAST_FORMAT, + player_id=self.player_id, + ) + elif media.queue_id and media.queue_id.startswith(UGP_PREFIX): + # special case: UGP stream + ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.queue_id)) + ugp_stream = ugp_player.stream + assert ugp_stream is not None # for type checker + input_format = ugp_stream.base_pcm_format + audio_source = ugp_stream.subscribe_raw() + elif media.queue_id and media.queue_item_id: + # regular queue (flow) stream request + input_format = DEFAULT_SNAPCAST_PCM_FORMAT + queue = self.mass.player_queues.get(media.queue_id) + start_queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id) + assert queue is not None # for type checking + assert start_queue_item is not None # for type checking + audio_source = self.mass.streams.get_queue_flow_stream( + queue=queue, + start_queue_item=start_queue_item, + pcm_format=DEFAULT_PCM_FORMAT, + ) + else: + # assume url or some other direct path + # NOTE: this will fail if its an uri not playable by ffmpeg + input_format = DEFAULT_SNAPCAST_FORMAT + audio_source = get_ffmpeg_stream( + audio_input=media.uri, + input_format=AudioFormat(content_type=ContentType.try_parse(media.uri)), + output_format=DEFAULT_SNAPCAST_FORMAT, + ) + + async def _streamer() -> None: + stream_path = self._get_stream_path(stream) + self.logger.debug("Start streaming to %s", stream_path) + async with FFMpeg( + audio_input=audio_source, + input_format=input_format, + output_format=DEFAULT_SNAPCAST_FORMAT, + filter_params=get_player_filter_params( + self.mass, self.player_id, input_format, DEFAULT_SNAPCAST_FORMAT + ), + audio_output=stream_path, + extra_input_args=["-y", "-re"], + ) as ffmpeg_proc: + self._attr_playback_state = PlaybackState.PLAYING + self._attr_current_media = media + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time.time() + self.update_state() + + self._set_childs_state() + await ffmpeg_proc.wait() + + self.logger.debug("Finished streaming to %s", stream_path) + # we need to wait a bit for the stream status to become idle + # to ensure that all snapclients have consumed the audio + while stream.status != "idle": + await asyncio.sleep(0.25) + self._attr_playback_state = PlaybackState.IDLE + self._attr_elapsed_time = time.time() - self._attr_elapsed_time_last_updated + self.update_state() + self._set_childs_state() + + # start streaming the queue (pcm) audio in a background task + self._stream_task = self.mass.create_task(_streamer()) + + async def play_announcement( + self, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """Handle (provider native) playback of an announcement on given player.""" + # get stream or create new one + stream_name = self._get_stream_name(SnapCastStreamType.ANNOUNCEMENT) + stream = await self._get_or_create_stream(stream_name, None) + + # always activate the stream (announcements have priority over music) + snap_group = self._get_snapgroup() + assert snap_group is not None # for type checking + await snap_group.set_stream(stream.identifier) + + # Unfortunately snapcast sets a volume per client (not per stream), so we need a way to + # set the announcement volume without affecting the music volume. + # We go for the simplest solution: save the previous volume, change it, restore later + # (with the downside that the change will be visible in the UI) + orig_volume_level = self.volume_level # Note: might be None + + if volume_level is not None: + await self.volume_set(volume_level) + + input_format = DEFAULT_SNAPCAST_FORMAT + assert announcement.custom_data is not None # for type checking + audio_source = self.mass.streams.get_announcement_stream( + announcement.custom_data["url"], + output_format=DEFAULT_SNAPCAST_FORMAT, + use_pre_announce=announcement.custom_data["use_pre_announce"], + ) + + # stream the audio, wait for it to finish (play_announcement should return after the + # announcement is over to avoid simultaneous announcements). + # + # Note: -probesize 8096 is needed to start playing the pre-announce before the TTS + # data arrive (they arrive late, see get_announcement_stream). + # + stream_path = self._get_stream_path(stream) + self.logger.debug("Start announcement streaming to %s", stream_path) + async with FFMpeg( + audio_input=audio_source, + input_format=input_format, + output_format=DEFAULT_SNAPCAST_FORMAT, + filter_params=get_player_filter_params( + self.mass, self.player_id, input_format, DEFAULT_SNAPCAST_FORMAT + ), + audio_output=stream_path, + extra_input_args=["-y", "-re", "-probesize", "8096"], + ) as ffmpeg_proc: + await ffmpeg_proc.wait() + + self.logger.debug("Finished announcement streaming to %s", stream_path) + # we need to wait a bit for the stream status to become idle + # to ensure that all snapclients have consumed the audio + while stream.status != "idle": + await asyncio.sleep(0.25) + + # delete the announcement stream + await self._delete_stream(stream_name) + + # restore volume, if we changed it above and it's still the same we set + # (the user did not change it himself while the announcement was playing) + if self.volume_level == volume_level and orig_volume_level is not None: + await self.volume_set(orig_volume_level) + + # and restore the group to either the default or the music stream + if self.playback_state == PlaybackState.IDLE: + new_stream_name = "default" + else: + new_stream_name = self._get_stream_name(SnapCastStreamType.MUSIC) + group = self._get_snapgroup() + assert group is not None # for type checking + await group.set_stream(new_stream_name) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Player config.""" + base_entries = await super().get_config_entries() + return [ + *base_entries, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_ENTRY_SAMPLE_RATES_SNAPCAST, + CONF_ENTRY_OUTPUT_CODEC_HIDDEN, + ] + + def _handle_player_update(self, snap_client: Snapclient) -> None: + """Process Snapcast update to Player controller. + + This is a callback function + """ + self._attr_name = self.snap_client.friendly_name + self._attr_volume_level = self.snap_client.volume + self._attr_volume_muted = self.snap_client.muted + self._attr_available = self.snap_client.connected + + # Note: when the active stream is a MASS stream the active_source is __not__ updated at all. + # So it doesn't matter whether a MASS stream is for music or announcements. + if stream := self._get_active_snapstream(): + if stream.identifier == "default": + self._attr_active_source = None + elif not stream.identifier.startswith(MASS_STREAM_PREFIX): + # unknown source + self._attr_active_source = stream.identifier + else: + self._attr_active_source = None + + self._group_childs() + + self.update_state() + + def _get_stream_name(self, stream_type: SnapCastStreamType) -> str: + """Return the name of the stream for the given player. + + Each player can have up to two concurrent streams, for music and announcements. + + The stream name depends only on player_id (not queue_id) for two reasones: + 1. Avoid issues when the same queue_id is simultaneously used by two players + (eg in universal groups). + 2. Easily identify which stream belongs to which player, for instance to be able to + delete a music stream even when it is not active due to an announcement. + """ + safe_name = create_safe_string(self.display_name, replace_space=True) + stream_name = f"{MASS_STREAM_PREFIX}{safe_name}" + if stream_type == SnapCastStreamType.ANNOUNCEMENT: + stream_name += MASS_ANNOUNCEMENT_POSTFIX + return stream_name + + async def _get_or_create_stream(self, stream_name: str, queue_id: str | None) -> Snapstream: + """Create new stream on snapcast server (or return existing one).""" + # prefer to reuse existing stream if possible + if stream := self._get_snapstream(stream_name): + return stream + + # The control script is used only for music streams in the builtin server + # (queue_id is None only for announcement streams). + if self.provider._use_builtin_server and queue_id: + extra_args = ( + f"&controlscript={urllib.parse.quote_plus(str(CONTROL_SCRIPT))}" + f"&controlscriptparams=--queueid={urllib.parse.quote_plus(queue_id)}%20" + f"--api-port={self.mass.webserver.publish_port}%20" + f"--streamserver-ip={self.mass.streams.publish_ip}%20" + f"--streamserver-port={self.mass.streams.publish_port}" + ) + extra_args = "" + else: + extra_args = "" + + attempts = 50 + while attempts: + attempts -= 1 + # pick a random port + port = random.randint(4953, 4953 + 200) + result = await self.provider._snapserver.stream_add_stream( + # NOTE: setting the sampleformat to something else + # (like 24 bits bit depth) does not seem to work at all! + f"tcp://0.0.0.0:{port}?sampleformat=48000:16:2" + f"&idle_threshold={self.provider._snapcast_stream_idle_threshold}" + f"{extra_args}&name={stream_name}" + ) + if "id" not in result: + # if the port is already taken, the result will be an error + self.logger.warning(result) + continue + return self.provider._snapserver.stream(result["id"]) + msg = "Unable to create stream - No free port found?" + raise RuntimeError(msg) + + def _get_snapstream(self, stream_name: str) -> Snapstream | None: + """Get a stream by name.""" + with suppress(KeyError): + return self.provider._snapserver.stream(stream_name) + return None + + def _get_stream_path(self, stream: Snapstream) -> str: + stream_path = stream.path or f"tcp://{stream._stream['uri']['host']}" + return stream_path.replace("0.0.0.0", self.provider._snapcast_server_host) + + async def _delete_stream(self, stream_name: str) -> None: + if stream := self._get_snapstream(stream_name): + with suppress(TypeError, KeyError, AttributeError): + await self.provider._snapserver.stream_remove_stream(stream.identifier) + + def _get_snapgroup(self) -> Snapgroup | None: + """Get snapcast group for given player_id.""" + return cast("Snapgroup | None", self.snap_client.group) + + def _set_childs_state(self) -> None: + """Set the state of the child`s of the player.""" + for child_player_id in self.group_members: + if child_player_id == self.player_id: + continue + if mass_child_player := self.mass.players.get(child_player_id): + mass_child_player._attr_playback_state = self.playback_state + mass_child_player.update_state() + + def _get_active_snapstream(self) -> Snapstream | None: + """Get active stream for given player_id.""" + if group := self._get_snapgroup(): + return self._get_snapstream(group.stream) + return None + + def _group_childs(self) -> None: + """Return player_ids of the players synced to this player.""" + snap_group = self._get_snapgroup() + assert snap_group is not None # for type checking + self._attr_group_members.clear() + if self.synced_to is not None: + return + self._attr_group_members.append(self.player_id) + { + self._attr_group_members.append(self.provider._get_ma_id(snap_client_id)) + for snap_client_id in snap_group.clients + if self.provider._get_ma_id(snap_client_id) != self.player_id + and self.provider._snapserver.client(snap_client_id).connected + } + self.update_state() diff --git a/music_assistant/providers/snapcast/provider.py b/music_assistant/providers/snapcast/provider.py new file mode 100644 index 00000000..c1c14137 --- /dev/null +++ b/music_assistant/providers/snapcast/provider.py @@ -0,0 +1,295 @@ +"""SnapCastProvider.""" + +import asyncio +import logging +import re +import socket +from typing import cast + +from bidict import bidict +from music_assistant_models.enums import PlaybackState, ProviderFeature +from music_assistant_models.errors import SetupFailedError +from snapcast.control import create_server +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup +from snapcast.control.server import Snapserver +from zeroconf import NonUniqueNameException +from zeroconf.asyncio import AsyncServiceInfo + +from music_assistant.helpers.process import AsyncProcess +from music_assistant.helpers.util import get_ip_pton +from music_assistant.models.player_provider import PlayerProvider +from music_assistant.providers.snapcast.constants import ( + CONF_SERVER_BUFFER_SIZE, + CONF_SERVER_CHUNK_MS, + CONF_SERVER_CONTROL_PORT, + CONF_SERVER_HOST, + CONF_SERVER_INITIAL_VOLUME, + CONF_SERVER_SEND_AUDIO_TO_MUTED, + CONF_SERVER_TRANSPORT_CODEC, + CONF_STREAM_IDLE_THRESHOLD, + CONF_USE_EXTERNAL_SERVER, + DEFAULT_SNAPSERVER_PORT, + SNAPWEB_DIR, +) +from music_assistant.providers.snapcast.player import SnapCastPlayer + + +class SnapCastProvider(PlayerProvider): + """SnapCastProvider.""" + + _snapserver: Snapserver + _snapserver_runner: asyncio.Task | None + _snapserver_started: asyncio.Event | None + _snapcast_server_host: str + _snapcast_server_control_port: int + _ids_map: bidict[str, str] # ma_id / snapclient_id + _use_builtin_server: bool + _stop_called: bool + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.SYNC_PLAYERS, ProviderFeature.REMOVE_PLAYER} + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + # set snapcast logging + logging.getLogger("snapcast").setLevel(self.logger.level) + self._use_builtin_server = not self.config.get_value(CONF_USE_EXTERNAL_SERVER) + self._stop_called = False + if self._use_builtin_server: + self._snapcast_server_host = "127.0.0.1" + self._snapcast_server_control_port = DEFAULT_SNAPSERVER_PORT + self._snapcast_server_buffer_size = self.config.get_value(CONF_SERVER_BUFFER_SIZE) + self._snapcast_server_chunk_ms = self.config.get_value(CONF_SERVER_CHUNK_MS) + self._snapcast_server_initial_volume = self.config.get_value(CONF_SERVER_INITIAL_VOLUME) + self._snapcast_server_send_to_muted = self.config.get_value( + CONF_SERVER_SEND_AUDIO_TO_MUTED + ) + self._snapcast_server_transport_codec = self.config.get_value( + CONF_SERVER_TRANSPORT_CODEC + ) + else: + self._snapcast_server_host = str(self.config.get_value(CONF_SERVER_HOST)) + self._snapcast_server_control_port = int( + str(self.config.get_value(CONF_SERVER_CONTROL_PORT)) + ) + self._snapcast_stream_idle_threshold = self.config.get_value(CONF_STREAM_IDLE_THRESHOLD) + self._stream_tasks = {} + self._ids_map = bidict({}) + + if self._use_builtin_server: + await self._start_builtin_server() + else: + self._snapserver_runner = None + self._snapserver_started = None + try: + self._snapserver = await create_server( + self.mass.loop, + self._snapcast_server_host, + port=self._snapcast_server_control_port, + reconnect=True, + ) + self._snapserver.set_on_update_callback(self._handle_update) + self.logger.info( + "Started connection to Snapserver %s", + f"{self._snapcast_server_host}:{self._snapcast_server_control_port}", + ) + # register callback for when the connection gets lost to the snapserver + self._snapserver.set_on_disconnect_callback(self._handle_disconnect) + + except OSError as err: + msg = "Unable to start the Snapserver connection ?" + raise SetupFailedError(msg) from err + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + await super().loaded_in_mass() + # initial load of players + self._handle_update() + + async def unload(self, is_removed: bool = False) -> None: + """Handle close/cleanup of the provider.""" + self._stop_called = True + for snap_client in self._snapserver.clients: + player_id = self._get_ma_id(snap_client.identifier) + if not (player := self.mass.players.get(player_id, raise_unavailable=False)): + continue + if player.playback_state != PlaybackState.PLAYING: + continue + await player.stop() + self._snapserver.stop() + await self._stop_builtin_server() + + async def _start_builtin_server(self) -> None: + """Start the built-in Snapserver.""" + if self._use_builtin_server: + self._snapserver_started = asyncio.Event() + self._snapserver_runner = self.mass.create_task(self._builtin_server_runner()) + await asyncio.wait_for(self._snapserver_started.wait(), 10) + + async def _stop_builtin_server(self) -> None: + """Stop the built-in Snapserver.""" + self.logger.info("Stopping, built-in Snapserver") + if self._snapserver_runner and not self._snapserver_runner.done(): + self._snapserver_runner.cancel() + if self._snapserver_started is not None: + self._snapserver_started.clear() + + async def _builtin_server_runner(self) -> None: + """Start running the builtin snapserver.""" + assert self._snapserver_started is not None # for type checking + if self._snapserver_started.is_set(): + raise RuntimeError("Snapserver is already started!") + logger = self.logger.getChild("snapserver") + logger.info("Starting builtin Snapserver...") + # register the snapcast mdns services + for name, port in ( + ("-http", 1780), + ("-jsonrpc", 1705), + ("-stream", 1704), + ("-tcp", 1705), + ("", 1704), + ): + zeroconf_type = f"_snapcast{name}._tcp.local." + try: + info = AsyncServiceInfo( + zeroconf_type, + name=f"Snapcast.{zeroconf_type}", + properties={"is_mass": "true"}, + addresses=[await get_ip_pton(str(self.mass.streams.publish_ip))], + port=port, + server=f"{socket.gethostname()}.local", + ) + attr_name = f"zc_service_set{name}" + if getattr(self, attr_name, None): + await self.mass.aiozc.async_update_service(info) + else: + await self.mass.aiozc.async_register_service(info, strict=False) + setattr(self, attr_name, True) + except NonUniqueNameException: + self.logger.debug( + "Could not register mdns record for %s as its already in use", + zeroconf_type, + ) + except Exception as err: + self.logger.exception( + "Could not register mdns record for %s: %s", zeroconf_type, str(err) + ) + + args = [ + "snapserver", + # config settings taken from + # https://raw.githubusercontent.com/badaix/snapcast/86cd4b2b63e750a72e0dfe6a46d47caf01426c8d/server/etc/snapserver.conf + f"--server.datadir={self.mass.storage_path}", + "--http.enabled=true", + "--http.port=1780", + f"--http.doc_root={SNAPWEB_DIR}", + "--tcp.enabled=true", + f"--tcp.port={self._snapcast_server_control_port}", + "--stream.sampleformat=48000:16:2", + f"--stream.buffer={self._snapcast_server_buffer_size}", + f"--stream.chunk_ms={self._snapcast_server_chunk_ms}", + f"--stream.codec={self._snapcast_server_transport_codec}", + f"--stream.send_to_muted={str(self._snapcast_server_send_to_muted).lower()}", + f"--streaming_client.initial_volume={self._snapcast_server_initial_volume}", + ] + async with AsyncProcess(args, stdout=True, name="snapserver") as snapserver_proc: + # keep reading from stdout until exit + async for data in snapserver_proc.iter_any(): + data = data.decode().strip() # noqa: PLW2901 + for line in data.split("\n"): + logger.debug(line) + if "(Snapserver) Version 0." in line: + # delay init a small bit to prevent race conditions + # where we try to connect too soon + self.mass.loop.call_later(2, self._snapserver_started.set) + + def _get_ma_id(self, snap_client_id: str) -> str: + search_dict = self._ids_map.inverse + ma_id = search_dict.get(snap_client_id) + assert ma_id is not None # for type checking + return ma_id + + def _get_snapclient_id(self, player_id: str) -> str: + search_dict = self._ids_map + snap_id = search_dict.get(player_id) + assert snap_id is not None # for type checking + return snap_id + + def _generate_and_register_id(self, snap_client_id) -> str: + search_dict = self._ids_map.inverse + if snap_client_id not in search_dict: + new_id = "ma_" + str(re.sub(r"\W+", "", snap_client_id)) + self._ids_map[new_id] = snap_client_id + return new_id + else: + return self._get_ma_id(snap_client_id) + + def _handle_player_init(self, snap_client: Snapclient) -> None: + """Process Snapcast add to Player controller.""" + player_id = self._generate_and_register_id(snap_client.identifier) + player = self.mass.players.get(player_id, raise_unavailable=False) + if not player: + snap_client = cast( + "Snapclient", self._snapserver.client(self._get_snapclient_id(player_id)) + ) + player = SnapCastPlayer( + provider=self, + player_id=player_id, + snap_client=snap_client, + snap_client_id=self._get_snapclient_id(player_id), + ) + player.setup() + asyncio.run_coroutine_threadsafe( + self.mass.players.register_or_update(player), loop=self.mass.loop + ) + + def _handle_update(self) -> None: + """Process Snapcast init Player/Group and set callback .""" + for snap_client in self._snapserver.clients: + if not snap_client.identifier: + self.logger.warning( + "Detected Snapclient %s without identifier, skipping", snap_client.friendly_name + ) + continue + self._handle_player_init(snap_client) + if ma_player := self.mass.players.get(self._get_ma_id(snap_client.identifier)): + assert isinstance(ma_player, SnapCastPlayer) # for type checking + snap_client.set_callback(ma_player._handle_player_update) + for snap_client in self._snapserver.clients: + if ma_player := self.mass.players.get(self._get_ma_id(snap_client.identifier)): + assert isinstance(ma_player, SnapCastPlayer) # for type checking + snap_client.set_callback(ma_player._handle_player_update) + for snap_group in self._snapserver.groups: + snap_group.set_callback(self._handle_group_update) + + def _handle_group_update(self, snap_group: Snapgroup) -> None: + """Process Snapcast group callback.""" + for snap_client in self._snapserver.clients: + if ma_player := self.mass.players.get(self._get_ma_id(snap_client.identifier)): + assert isinstance(ma_player, SnapCastPlayer) # for type checking + snap_client.set_callback(ma_player._handle_player_update) + + def _handle_disconnect(self, exc: Exception) -> None: + """Handle disconnect callback from snapserver.""" + if self._stop_called or self.mass.closing: + # we're instructed to stop/exit, so no need to restart the connection + return + self.logger.info( + "Connection to SnapServer lost, reason: %s. Reloading provider in 5 seconds.", + str(exc), + ) + # schedule a reload of the provider + self.mass.call_later(5, self.mass.load_provider, self.instance_id, allow_retry=True) + + async def remove_player(self, player_id: str) -> None: + """Remove the client from the snapserver when it is deleted.""" + success, error_msg = await self._snapserver.delete_client( + self._get_snapclient_id(player_id) + ) + if success: + self.logger.debug("Snapclient removed %s", player_id) + else: + self.logger.warning("Unable to remove snapclient %s: %s", player_id, error_msg) diff --git a/music_assistant/providers/sonos/const.py b/music_assistant/providers/sonos/const.py index fd3e91bb..375f34bf 100644 --- a/music_assistant/providers/sonos/const.py +++ b/music_assistant/providers/sonos/const.py @@ -3,14 +3,15 @@ from __future__ import annotations from aiosonos.api.models import PlayBackState as SonosPlayBackState -from music_assistant_models.enums import PlayerFeature, PlayerState -from music_assistant_models.player import PlayerSource +from music_assistant_models.enums import PlaybackState, PlayerFeature + +from music_assistant.models.player import PlayerSource PLAYBACK_STATE_MAP = { - SonosPlayBackState.PLAYBACK_STATE_BUFFERING: PlayerState.PLAYING, - SonosPlayBackState.PLAYBACK_STATE_IDLE: PlayerState.IDLE, - SonosPlayBackState.PLAYBACK_STATE_PAUSED: PlayerState.PAUSED, - SonosPlayBackState.PLAYBACK_STATE_PLAYING: PlayerState.PLAYING, + SonosPlayBackState.PLAYBACK_STATE_BUFFERING: PlaybackState.PLAYING, + SonosPlayBackState.PLAYBACK_STATE_IDLE: PlaybackState.IDLE, + SonosPlayBackState.PLAYBACK_STATE_PAUSED: PlaybackState.PAUSED, + SonosPlayBackState.PLAYBACK_STATE_PLAYING: PlaybackState.PLAYING, } PLAYER_FEATURES_BASE = { diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index f93e9a03..9a2081c7 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -11,28 +11,38 @@ from __future__ import annotations import asyncio import time -from collections.abc import Callable from typing import TYPE_CHECKING -from aiohttp.client_exceptions import ClientConnectorError +from aiohttp import ClientConnectorError from aiosonos.api.models import ContainerType, MusicService, SonosCapability from aiosonos.client import SonosLocalApiClient from aiosonos.const import EventType as SonosEventType from aiosonos.const import SonosEvent from aiosonos.exceptions import ConnectionFailed, FailedCommand +from music_assistant_models.config_entries import ConfigEntry from music_assistant_models.enums import ( + ConfigEntryType, EventType, + MediaType, + PlaybackState, PlayerFeature, - PlayerState, - PlayerType, RepeatMode, ) -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia - -from .const import ( +from music_assistant_models.errors import PlayerCommandFailed +from music_assistant_models.player import PlayerMedia + +from music_assistant.constants import ( + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, + CONF_ENTRY_OUTPUT_CODEC, + create_sample_rates_config_entry, +) +from music_assistant.helpers.tags import async_parse_tags +from music_assistant.helpers.upnp import get_xml_soap_set_url +from music_assistant.models.player import Player +from music_assistant.providers.sonos.const import ( CONF_AIRPLAY_MODE, PLAYBACK_STATE_MAP, - PLAYER_FEATURES_BASE, PLAYER_SOURCE_MAP, SOURCE_AIRPLAY, SOURCE_LINE_IN, @@ -40,6 +50,7 @@ from .const import ( SOURCE_SPOTIFY, SOURCE_TV, ) +from music_assistant.providers.universal_group.constants import UGP_PREFIX if TYPE_CHECKING: from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo @@ -47,8 +58,18 @@ if TYPE_CHECKING: from .provider import SonosPlayerProvider +SUPPORTED_FEATURES = { + PlayerFeature.PAUSE, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.SEEK, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.ENQUEUE, + PlayerFeature.SET_MEMBERS, +} + -class SonosPlayer: +class SonosPlayer(Player): """Holds the details of the (discovered) Sonosplayer.""" def __init__( @@ -56,25 +77,17 @@ class SonosPlayer: prov: SonosPlayerProvider, player_id: str, discovery_info: SonosDiscoveryInfo, - ip_address: str, ) -> None: """Initialize the SonosPlayer.""" - self.prov = prov - self.mass = prov.mass - self.player_id = player_id + super().__init__(prov, player_id) self.discovery_info = discovery_info - self.ip_address = ip_address - self.logger = prov.logger.getChild(player_id) self.connected: bool = False - self.client = SonosLocalApiClient(self.ip_address, self.mass.http_session) - self.mass_player: Player | None = None self._listen_task: asyncio.Task | None = None # Sonos speakers can optionally have airplay (most S2 speakers do) # and this airplay player can also be a player within MA. # We can do some smart stuff if we link them together where possible. # The player we can just guess from the sonos player id (mac address). self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}" - self._on_cleanup_callbacks: list[Callable[[], None]] = [] @property def airplay_mode_enabled(self) -> bool: @@ -90,64 +103,60 @@ class SonosPlayer: self.airplay_mode_enabled and self.client.player.is_coordinator and (airplay_player := self.get_linked_airplay_player(False)) - and airplay_player.state in (PlayerState.PLAYING, PlayerState.PAUSED) + and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) ) - def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None: - """Return the linked airplay player if available/enabled.""" - if enabled_only and not self.airplay_mode_enabled: - return None - if not (airplay_player := self.mass.players.get(self.airplay_player_id)): - return None - if not airplay_player.available: + @property + def synced_to(self) -> str | None: + """ + Return the id of the player this player is synced to (sync leader). + + If this player is not synced to another player (or is the sync leader itself), + this should return None. + """ + if self.client.player.is_coordinator: return None - return airplay_player + if self.client.player.group: + return self.client.player.group.coordinator_id + return None async def setup(self) -> None: """Handle setup of the player.""" # connect the player first so we can fail early + self.client = SonosLocalApiClient(self.device_info.ip_address, self.mass.http_session) await self._connect(False) # collect supported features - supported_features = set(PLAYER_FEATURES_BASE) + _supported_features = SUPPORTED_FEATURES.copy() if SonosCapability.AUDIO_CLIP in self.discovery_info["device"]["capabilities"]: - supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT) + _supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT) if not self.client.player.has_fixed_volume: - supported_features.add(PlayerFeature.VOLUME_SET) - supported_features.add(PlayerFeature.VOLUME_MUTE) + _supported_features.add(PlayerFeature.VOLUME_SET) + _supported_features.add(PlayerFeature.VOLUME_MUTE) if not self.get_linked_airplay_player(False): - supported_features.add(PlayerFeature.NEXT_PREVIOUS) - - # instantiate the MA player - self.mass_player = mass_player = Player( - player_id=self.player_id, - provider=self.prov.instance_id, - type=PlayerType.PLAYER, - name=self.discovery_info["device"]["name"] - or self.discovery_info["device"]["modelDisplayName"], - available=True, - device_info=DeviceInfo( - model=self.discovery_info["device"]["modelDisplayName"], - manufacturer=self.prov.manifest.name, - ip_address=self.ip_address, - ), - supported_features=supported_features, - # NOTE: strictly taken we can have multiple sonos households - # but for now we assume we only have one - can_group_with={self.prov.instance_id}, + _supported_features.add(PlayerFeature.NEXT_PREVIOUS) + self._attr_supported_features = _supported_features + + self._attr_name = ( + self.discovery_info["device"]["name"] + or self.discovery_info["device"]["modelDisplayName"] ) + self._attr_device_info.model = self.discovery_info["device"]["modelDisplayName"] + self._attr_device_info.manufacturer = self._provider.manifest.name + self._attr_can_group_with = {self._provider.instance_id} + if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]: - mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN]) + self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN]) if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]: - mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_TV]) + self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_TV]) if SonosCapability.AIRPLAY in self.discovery_info["device"]["capabilities"]: - mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_AIRPLAY]) + self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_AIRPLAY]) self.update_attributes() - await self.mass.players.register_or_update(mass_player) + await self.mass.players.register_or_update(self) # register callback for state changed - self._on_cleanup_callbacks.append( + self._on_unload_callbacks.append( self.client.subscribe( self.on_player_event, ( @@ -157,7 +166,7 @@ class SonosPlayer: ) ) # register callback for airplay player state changes - self._on_cleanup_callbacks.append( + self._on_unload_callbacks.append( self.mass.subscribe( self._on_airplay_player_event, (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED), @@ -167,70 +176,149 @@ class SonosPlayer: # register callback for playerqueue state changes # note we don't filter on the player_id here because we also need to catch # events from group players - self._on_cleanup_callbacks.append( + self._on_unload_callbacks.append( self.mass.subscribe( self._on_mass_queue_items_event, EventType.QUEUE_ITEMS_UPDATED, ) ) - self._on_cleanup_callbacks.append( + self._on_unload_callbacks.append( self.mass.subscribe( self._on_mass_queue_event, (EventType.QUEUE_UPDATED, EventType.QUEUE_ITEMS_UPDATED), ) ) - async def unload(self, is_removed: bool = False) -> None: - """Unload the player (disconnect + cleanup).""" - await self._disconnect() - self.mass.players.remove(self.player_id, False) - for callback in self._on_cleanup_callbacks: - callback() + async def get_config_entries( + self, + ) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + base_entries = [ + *await super().get_config_entries(), + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, + create_sample_rates_config_entry( + # set safe max bit depth to 16 bits because the older Sonos players + # do not support 24 bit playback (e.g. Play:1) + max_sample_rate=48000, + max_bit_depth=24, + safe_max_bit_depth=16, + hidden=False, + ), + ] + return [ + *base_entries, + ConfigEntry( + key="airplay_detected", + type=ConfigEntryType.BOOLEAN, + label="airplay_detected", + hidden=True, + required=False, + default_value=self.get_linked_airplay_player(False) is not None, + ), + ConfigEntry( + key=CONF_AIRPLAY_MODE, + type=ConfigEntryType.BOOLEAN, + label="Enable AirPlay mode", + description="Almost all newer Sonos speakers have AirPlay support. " + "If you have the AirPlay provider enabled in Music Assistant, " + "your Sonos speaker will also be detected as a AirPlay speaker, meaning " + "you can group them with other AirPlay speakers.\n\n" + "By default, Music Assistant uses the Sonos protocol for playback but with this " + "feature enabled, it will use the AirPlay protocol instead by redirecting " + "the playback related commands to the linked AirPlay player in Music Assistant, " + "allowing you to mix and match Sonos speakers with AirPlay speakers. \n\n" + "NOTE: You need to have the AirPlay provider enabled as well as " + "the AirPlay version of this player.", + required=False, + default_value=False, + depends_on="airplay_detected", + hidden=SonosCapability.AIRPLAY not in self.discovery_info["device"]["capabilities"], + ), + ] + + def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None: + """Return the linked airplay player if available/enabled.""" + if enabled_only and not self.airplay_mode_enabled: + return None + if not (airplay_player := self.mass.players.get(self.airplay_player_id)): + return None + if not airplay_player.available: + return None + return airplay_player + + async def volume_set(self, volume_level: int) -> None: + """ + Handle VOLUME_SET command on the player. - def reconnect(self, delay: float = 1) -> None: - """Reconnect the player.""" - # use a task_id to prevent multiple reconnects - task_id = f"sonos_reconnect_{self.player_id}" - self.mass.call_later(delay, self._connect, delay, task_id=task_id) + Will only be called if the PlayerFeature.VOLUME_SET is supported. - async def cmd_stop(self) -> None: - """Send STOP command to given player.""" + :param volume_level: volume level (0..100) to set on the player. + """ + await self.client.player.set_volume(volume_level) + # sync volume level with airplay player + if airplay_player := self.get_linked_airplay_player(False): + if airplay_player.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED): + airplay_player._attr_volume_level = volume_level + + async def volume_mute(self, muted: bool) -> None: + """ + Handle VOLUME MUTE command on the player. + + Will only be called if the PlayerFeature.VOLUME_MUTE is supported. + + :param muted: bool if player should be muted. + """ + await self.client.player.set_volume(muted=muted) + + async def play(self) -> None: + """Handle PLAY command on the player.""" if self.client.player.is_passive: self.logger.debug("Ignore STOP command: Player is synced to another player.") return - if (airplay := self.get_linked_airplay_player(True)) and self.airplay_mode_active: + if airplay_player := self.get_linked_airplay_player(True): # linked airplay player is active, redirect the command - self.logger.debug("Redirecting STOP command to linked airplay player.") - if player_provider := self.mass.get_provider(airplay.provider): - await player_provider.cmd_stop(airplay.player_id) - return - await self.client.player.group.stop() + self.logger.debug("Redirecting PLAY command to linked airplay player.") + await airplay_player.play() + else: + await self.client.player.group.play() - async def cmd_play(self) -> None: - """Send PLAY command to given player.""" + async def stop(self) -> None: + """Handle STOP command on the player.""" if self.client.player.is_passive: self.logger.debug("Ignore STOP command: Player is synced to another player.") return - if airplay := self.get_linked_airplay_player(True): + if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active: # linked airplay player is active, redirect the command - self.logger.debug("Redirecting PLAY command to linked airplay player.") - if player_provider := self.mass.get_provider(airplay.provider): - await player_provider.cmd_play(airplay.player_id) - return - await self.client.player.group.play() + self.logger.debug("Redirecting STOP command to linked airplay player.") + await airplay_player.stop() + else: + await self.client.player.group.stop() + self._attr_playback_state = PlaybackState.IDLE + self.update_state() + + async def pause(self) -> None: + """ + Handle PAUSE command on the player. + + Will only be called if the player reports PlayerFeature.PAUSE is supported. + """ + + def _update_state() -> None: + self._attr_playback_state = PlaybackState.PAUSED + self.update_state() - async def cmd_pause(self) -> None: - """Send PAUSE command to given player.""" if self.client.player.is_passive: self.logger.debug("Ignore STOP command: Player is synced to another player.") return - if (airplay := self.get_linked_airplay_player(True)) and self.airplay_mode_active: + if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active: # linked airplay player is active, redirect the command self.logger.debug("Redirecting PAUSE command to linked airplay player.") - if player_provider := self.mass.get_provider(airplay.provider): - await player_provider.cmd_pause(airplay.player_id) + await airplay_player.pause() + _update_state() return - active_source = self.mass_player.active_source + active_source = self._attr_active_source if self.mass.player_queues.get(active_source): # Sonos seems to be bugged when playing our queue tracks and we send pause, # it can't resume the current track and simply aborts/skips it @@ -238,57 +326,239 @@ class SonosPlayer: # https://github.com/music-assistant/support/issues/3758 # TODO: revisit this later once we implemented support for range requests # as I have the feeling the pause issue is related to seek support (=range requests) - await self.cmd_stop() + await self.stop() + _update_state() return if not self.client.player.group.playback_actions.can_pause: - await self.cmd_stop() + await self.stop() + _update_state() return await self.client.player.group.pause() + _update_state() + + async def next_track(self) -> None: + """ + Handle NEXT_TRACK command on the player. + + Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS + is supported and the player is not currently playing a MA queue. + """ + await self.client.player.group.skip_to_next_track() + + async def previous_track(self) -> None: + """ + Handle PREVIOUS_TRACK command on the player. + + Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS + is supported and the player is not currently playing a MA queue. + """ + await self.client.player.group.skip_to_previous_track() + + async def seek(self, position: int) -> None: + """ + Handle SEEK command on the player. + + Seek to a specific position in the current track. + Will only be called if the player reports PlayerFeature.SEEK is + supported and the player is NOT currently playing a MA queue. + + :param position: The position to seek to, in seconds. + """ + await self.client.player.group.seek(position) + + async def play_media( + self, + media: PlayerMedia, + ) -> None: + """ + Handle PLAY MEDIA command on given player. - async def cmd_seek(self, position: int) -> None: - """Handle SEEK command for given player. + This is called by the Player controller to start playing Media on the player, + which can be a MA queue item/stream or a native source. + The provider's own implementation should work out how to handle this request. - - position: position in seconds to seek to in the current playing item. + :param media: Details of the item that needs to be played on the player. """ + + def _update_state() -> None: + self._attr_current_media = media + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + if self.client.player.is_passive: - self.logger.debug("Ignore STOP command: Player is synced to another player.") + # this should be already handled by the player manager, but just in case... + msg = ( + f"Player {self.display_name} can not " + "accept play_media command, it is synced to another player." + ) + raise PlayerCommandFailed(msg) + # for now always reset the active session + self.client.player.group.active_session_id = None + if airplay_player := self.get_linked_airplay_player(True): + # airplay mode is enabled, redirect the command + self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.") + await self._play_media_airplay(airplay_player, media) + _update_state() return - await self.client.player.group.seek(position) - async def cmd_volume_set(self, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - await self.client.player.set_volume(volume_level) - # sync volume level with airplay player - if airplay := self.get_linked_airplay_player(False): - if airplay.state not in (PlayerState.PLAYING, PlayerState.PAUSED): - airplay.volume_level = volume_level + if media.media_type in ( + MediaType.PLUGIN_SOURCE, + MediaType.FLOW_STREAM, + ) or media.queue_id.startswith(UGP_PREFIX): + # flow stream or plugin source playback + # always use the legacy (UPNP) playback method for this + await self._play_media_legacy(media) + _update_state() + return - async def cmd_volume_mute(self, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - await self.client.player.set_volume(muted=muted) + if media.queue_id: + # Regular Queue item playback + # create a sonos cloud queue and load it + cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/" + mass_queue = self.mass.player_queues.get(media.queue_id) + await self.client.player.group.play_cloud_queue( + cloud_queue_url, + http_authorization=media.queue_id, + item_id=media.queue_item_id, + queue_version=str(int(mass_queue.items_last_updated)), + ) + self.mass.call_later(5, self.sync_play_modes, media.queue_id) + _update_state() + return + + # All other playback types + # play a single uri/url + # note that this most probably will only work for (long running) radio streams + # enforce mp3 here because Sonos really does not support FLAC streams without duration + media.uri = media.uri.replace(".flac", ".mp3") + await self.client.player.group.play_stream_url( + media.uri, {"name": media.title, "type": "track"} + ) + _update_state() async def select_source(self, source: str) -> None: - """Handle SELECT SOURCE command on given player.""" + """ + Handle SELECT SOURCE command on the player. + + Will only be called if the PlayerFeature.SELECT_SOURCE is supported. + + :param source: The source(id) to select, as defined in the source_list. + """ if source == SOURCE_LINE_IN: await self.client.player.group.load_line_in(play_on_completion=True) elif source == SOURCE_TV: await self.client.player.load_home_theater_playback() else: # unsupported source - try to clear the queue/player - await self.cmd_stop() + await self.stop() + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """ + Handle enqueuing of the next (queue) item on the player. + + Called when player reports it started buffering a queue item + and when the queue items updated. + + A PlayerProvider implementation is in itself responsible for handling this + so that the queue items keep playing until its empty or the player stopped. + + Will only be called if the player reports PlayerFeature.ENQUEUE is + supported and the player is currently playing a MA queue. + + This will NOT be called if the end of the queue is reached (and repeat disabled). + This will NOT be called if the player is using flow mode to playback the queue. + + :param media: Details of the item that needs to be enqueued on the player. + """ + if session_id := self.client.player.group.active_session_id: + await self.client.api.playback_session.refresh_cloud_queue(session_id) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """ + Handle SET_MEMBERS command on the player. + + Group or ungroup the given child player(s) to/from this player. + Will only be called if the PlayerFeature.SET_MEMBERS is supported. + + :param player_ids_to_add: List of player_id's to add to the group. + :param player_ids_to_remove: List of player_id's to remove from the group. + """ + if airplay_player := self.get_linked_airplay_player(False): + # if airplay mode is enabled, we could possibly receive child player id's that are + # not Sonos players, but AirPlay players. We redirect those. + airplay_child_ids = [x for x in player_ids_to_add or [] if x.startswith("ap")] + player_ids_to_add = [x for x in player_ids_to_add if x not in airplay_child_ids] + if airplay_child_ids: + if ( + airplay_player.active_source != self._attr_active_source + and airplay_player.playback_state == PlaybackState.PLAYING + ): + # edge case player is not playing a MA queue - fail this request + raise PlayerCommandFailed("Player is not playing a Music Assistant queue.") + await self.mass.players.cmd_group_many(airplay_player.player_id, airplay_child_ids) + if player_ids_to_add: + await self.client.player.group.modify_group_members( + player_ids_to_add=player_ids_to_add, player_ids_to_remove=[] + ) + + async def ungroup(self) -> None: + """ + Handle UNGROUP command on the player. + + Remove the player from any (sync)groups it currently is grouped to. + If this player is the sync leader (or group player), + all child's will be ungrouped and the group dissolved. + + Will only be called if the PlayerFeature.SET_MEMBERS is supported. + """ + await self.client.player.leave_group() + + async def play_announcement( + self, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """ + Handle (native) playback of an announcement on the player. + + Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported. + + :param announcement: Details of the announcement that needs to be played on the player. + :param volume_level: The volume level to play the announcement at (0..100). + If not set, the player should use the current volume level. + """ + self.logger.debug( + "Playing announcement %s on %s", + announcement.uri, + self.display_name, + ) + await self.client.player.play_audio_clip( + announcement.uri, volume_level, name="Announcement" + ) + # Wait until the announcement is finished playing + # This is helpful for people who want to play announcements in a sequence + # yeah we can also setup a subscription on the sonos player for this, but this is easier + media_info = await async_parse_tags(announcement.uri, require_duration=True) + duration = media_info.duration or 10 + await asyncio.sleep(duration) + + def on_player_event(self, event: SonosEvent | None) -> None: + """Handle incoming event from player.""" + self.update_attributes() + self.update_state() def update_attributes(self) -> None: # noqa: PLR0915 """Update the player attributes.""" - if not self.mass_player: - return - self.mass_player.available = self.connected + self._attr_available = self.connected if not self.connected: return if self.client.player.has_fixed_volume: - self.mass_player.volume_level = 100 + self._attr_volume_level = 100 else: - self.mass_player.volume_level = self.client.player.volume_level or 0 - self.mass_player.volume_muted = self.client.player.volume_muted + self._attr_volume_level = self.client.player.volume_level or 0 + self._attr_volume_muted = self.client.player.volume_muted group_parent = None airplay_player = self.get_linked_airplay_player(False) @@ -296,97 +566,94 @@ class SonosPlayer: # player is group coordinator active_group = self.client.player.group if len(self.client.player.group_members) > 1: - self.mass_player.group_childs.set(self.client.player.group_members) + self._attr_group_members = self.client.player.group_members else: - self.mass_player.group_childs.clear() + self._attr_group_members.clear() # append airplay child's to group childs if self.airplay_mode_enabled and airplay_player: airplay_childs = [ - x for x in airplay_player.group_childs if x != airplay_player.player_id + x for x in airplay_player._attr_group_members if x != airplay_player.player_id ] - self.mass_player.group_childs.extend(airplay_childs) - airplay_prov = self.mass.get_provider(airplay_player.provider) - self.mass_player.can_group_with.update( + self._attr_group_members.extend(airplay_childs) + airplay_prov = airplay_player.provider + self._attr_can_group_with.update( x.player_id for x in airplay_prov.players if x.player_id != airplay_player.player_id ) else: - self.mass_player.can_group_with = {self.prov.instance_id} - self.mass_player.synced_to = None + self._attr_can_group_with = {self._provider.instance_id} else: # player is group child (synced to another player) - group_parent = self.prov.sonos_players.get(self.client.player.group.coordinator_id) + group_parent: SonosPlayer = self.mass.players.get( + self.client.player.group.coordinator_id + ) if not group_parent or not group_parent.client or not group_parent.client.player: # handle race condition where the group parent is not yet discovered return active_group = group_parent.client.player.group - self.mass_player.group_childs.clear() - self.mass_player.synced_to = active_group.coordinator_id - self.mass_player.active_source = active_group.coordinator_id + self._attr_group_members.clear() # map playback state - self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state] - self.mass_player.elapsed_time = active_group.position + self._playback_state = PLAYBACK_STATE_MAP[active_group.playback_state] + self._attr_elapsed_time = active_group.position # figure out the active source based on the container container_type = active_group.container_type active_service = active_group.active_service container = active_group.playback_metadata.get("container") if container_type == ContainerType.LINEIN: - self.mass_player.active_source = SOURCE_LINE_IN + self._attr_active_source = SOURCE_LINE_IN elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF): - self.mass_player.active_source = SOURCE_TV + self._attr_active_source = SOURCE_TV elif container_type == ContainerType.AIRPLAY: # check if the MA airplay player is active - if airplay_player and airplay_player.state in ( - PlayerState.PLAYING, - PlayerState.PAUSED, + if airplay_player and airplay_player.playback_state in ( + PlaybackState.PLAYING, + PlaybackState.PAUSED, ): - self.mass_player.state = airplay_player.state - self.mass_player.active_source = airplay_player.active_source - self.mass_player.elapsed_time = airplay_player.elapsed_time - self.mass_player.elapsed_time_last_updated = ( - airplay_player.elapsed_time_last_updated - ) - self.mass_player.current_media = airplay_player.current_media + self._attr_playback_state = airplay_player.playback_state + self._attr_active_source = airplay_player.active_source + self._attr_elapsed_time = airplay_player.elapsed_time + self._attr_elapsed_time_last_updated = airplay_player.elapsed_time_last_updated + self._attr_current_media = airplay_player.current_media # return early as we dont need further info return else: - self.mass_player.active_source = SOURCE_AIRPLAY + self._attr_active_source = SOURCE_AIRPLAY elif container_type == ContainerType.STATION: - self.mass_player.active_source = SOURCE_RADIO + self._attr_active_source = SOURCE_RADIO # add radio to source list if not yet there - if SOURCE_RADIO not in [x.id for x in self.mass_player.source_list]: - self.mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO]) + if SOURCE_RADIO not in [x.id for x in self._attr_source_list]: + self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_RADIO]) elif active_service == MusicService.SPOTIFY: - self.mass_player.active_source = SOURCE_SPOTIFY + self._attr_active_source = SOURCE_SPOTIFY # add spotify to source list if not yet there - if SOURCE_SPOTIFY not in [x.id for x in self.mass_player.source_list]: - self.mass_player.source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY]) + if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]: + self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY]) elif active_service == MusicService.MUSIC_ASSISTANT: if self.client.player.is_coordinator: - self.mass_player.active_source = self.mass_player.player_id + self._attr_active_source = self._player_id elif object_id := container.get("id", {}).get("objectId"): - self.mass_player.active_source = object_id.split(":")[-1] + self._attr_active_source = object_id.split(":")[-1] else: - self.mass_player.active_source = None + self._attr_active_source = None # its playing some service we did not yet map elif container and container.get("service", {}).get("name"): - self.mass_player.active_source = container["service"]["name"] + self._attr_active_source = container["service"]["name"] elif container and container.get("name"): - self.mass_player.active_source = container["name"] + self._attr_active_source = container["name"] elif active_service: - self.mass_player.active_source = active_service + self._attr_active_source = active_service elif container_type: - self.mass_player.active_source = container_type + self._attr_active_source = container_type else: # the player has nothing loaded at all (empty queue and no service active) - self.mass_player.active_source = None + self._attr_active_source = None # parse current media - self.mass_player.elapsed_time = self.client.player.group.position - self.mass_player.elapsed_time_last_updated = time.time() + self._attr_elapsed_time = self.client.player.group.position + self._attr_elapsed_time_last_updated = time.time() current_media = None if (current_item := active_group.playback_metadata.get("currentItem")) and ( (track := current_item.get("track")) and track.get("name") @@ -403,7 +670,7 @@ class SonosPlayer: image_url=track_image_url, ) if active_service == MusicService.MUSIC_ASSISTANT: - current_media.queue_id = self.mass_player.active_source + current_media.queue_id = self._attr_active_source current_media.queue_item_id = current_item["id"] # radio stream info if container and container.get("name") and active_group.playback_metadata.get("streamInfo"): @@ -427,7 +694,15 @@ class SonosPlayer: if not current_media.uri: current_media.uri = container["id"]["objectId"] - self.mass_player.current_media = current_media + self._attr_current_media = current_media + + def update_elapsed_time(self, elapsed_time: float | None = None) -> None: + """Update the elapsed time of the current media.""" + if elapsed_time is not None: + self._attr_elapsed_time = elapsed_time + last_updated = time.time() + self._attr_elapsed_time_last_updated = last_updated + self.update_state() async def _connect(self, retry_on_fail: int = 0) -> None: """Connect to the Sonos player.""" @@ -440,7 +715,7 @@ class SonosPlayer: self.logger.warning("Failed to connect to Sonos player: %s", err) if not retry_on_fail or not self.mass_player: raise - self.mass_player.available = False + self._attr_available = False self.mass.players.update(self.player_id) self.reconnect(min(retry_on_fail + 30, 3600)) return @@ -461,13 +736,19 @@ class SonosPlayer: # this should simply try to reconnect once and if that fails # we rely on mdns to pick it up again later await self._disconnect() - self.mass_player.available = False + self._attr_available = False self.mass.players.update(self.player_id) self.reconnect(5) self._listen_task = self.mass.create_task(_listener()) await init_ready.wait() + def reconnect(self, delay: float = 1) -> None: + """Reconnect the player.""" + # use a task_id to prevent multiple reconnects + task_id = f"sonos_reconnect_{self.player_id}" + self.mass.call_later(delay, self._connect, delay, task_id=task_id) + async def _disconnect(self) -> None: """Disconnect the client and cleanup.""" self.connected = False @@ -477,11 +758,6 @@ class SonosPlayer: await self.client.disconnect() self.logger.debug("Disconnected from player API") - def on_player_event(self, event: SonosEvent | None) -> None: - """Handle incoming event from player.""" - self.update_attributes() - self.mass.players.update(self.player_id) - def _on_airplay_player_event(self, event: MassEvent) -> None: """Handle incoming event from linked airplay player.""" if not self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE): @@ -489,25 +765,25 @@ class SonosPlayer: if event.object_id != self.airplay_player_id: return self.update_attributes() - self.mass.players.update(self.player_id) + self.update_state() async def _on_mass_queue_items_event(self, event: MassEvent) -> None: """Handle incoming event from linked MA playerqueue.""" # If the queue items changed and we have an active sonos queue, # we need to inform the sonos queue to refresh the items. - if self.mass_player.active_source != event.object_id: + if self._attr_active_source != event.object_id: return if not self.connected: return queue = self.mass.player_queues.get(event.object_id) - if not queue or queue.state not in (PlayerState.PLAYING, PlayerState.PAUSED): + if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED): return if session_id := self.client.player.group.active_session_id: await self.client.api.playback_session.refresh_cloud_queue(session_id) async def _on_mass_queue_event(self, event: MassEvent) -> None: """Handle incoming event from linked MA playerqueue.""" - if self.mass_player.active_source != event.object_id: + if self._attr_active_source != event.object_id: return if not self.connected: return @@ -524,7 +800,7 @@ class SonosPlayer: async def sync_play_modes(self, queue_id: str) -> None: """Sync the play modes between MA and Sonos.""" queue = self.mass.player_queues.get(queue_id) - if not queue or queue.state not in (PlayerState.PLAYING, PlayerState.PAUSED): + if not queue or queue.state not in (PlaybackState.PLAYING, PlaybackState.PAUSED): return repeat_single_enabled = queue.repeat_mode == RepeatMode.ONE repeat_all_enabled = queue.repeat_mode == RepeatMode.ALL @@ -542,3 +818,55 @@ class SonosPlayer: if "groupCoordinatorChanged" not in str(err): # this may happen at race conditions raise + + async def _play_media_airplay( + self, + airplay_player: Player, + media: PlayerMedia, + ) -> None: + """Handle PLAY MEDIA using the legacy upnp api.""" + player_id = self.player_id + if ( + airplay_player.playback_state == PlaybackState.PLAYING + and airplay_player.active_source == media.queue_id + ): + # if the airplay player is already playing, + # the stream will be reused so no need to do the whole grouping thing below + await self.mass.players.play_media(airplay_player.player_id, media) + return + + # Sonos has an annoying bug (for years already, and they dont seem to care), + # where it looses its sync childs when airplay playback is (re)started. + # Try to handle it here with this workaround. + group_childs = [x for x in self.client.player.group.player_ids if x != player_id] + if group_childs: + await self.mass.players.cmd_ungroup_many(group_childs) + await self.mass.players.play_media(airplay_player.player_id, media) + if group_childs: + # ensure master player is first in the list + group_childs = [self.player_id, *group_childs] + await asyncio.sleep(5) + await self.client.player.group.set_group_members(group_childs) + + async def _play_media_legacy( + self, + media: PlayerMedia, + ) -> None: + """Handle PLAY MEDIA using the legacy upnp api.""" + xml_data, soap_action = get_xml_soap_set_url(media) + player_ip = self.device_info.ip_address + async with self.mass.http_session.post( + f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control", + headers={ + "SOAPACTION": soap_action, + "Content-Type": "text/xml; charset=utf-8", + "Connection": "close", + }, + data=xml_data, + ) as resp: + if resp.status != 200: + raise PlayerCommandFailed( + f"Failed to send command to Sonos player: {resp.status} {resp.reason}" + ) + await self.cmd_play(self.player_id) + return diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index 0c19a85d..08348141 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -7,38 +7,27 @@ https://github.com/music-assistant/aiosonos from __future__ import annotations -import asyncio -import time from typing import TYPE_CHECKING, Any from aiohttp import web from aiohttp.client_exceptions import ClientError from aiosonos.api.models import SonosCapability from aiosonos.utils import get_discovery_info -from music_assistant_models.config_entries import ConfigEntry, PlayerConfig -from music_assistant_models.enums import ConfigEntryType, MediaType, PlayerState, ProviderFeature -from music_assistant_models.errors import PlayerCommandFailed -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.enums import PlaybackState, ProviderFeature from zeroconf import ServiceStateChange from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, - CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, CONF_ENTRY_MANUAL_DISCOVERY_IPS, - CONF_ENTRY_OUTPUT_CODEC, MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL, - create_sample_rates_config_entry, ) -from music_assistant.helpers.tags import async_parse_tags -from music_assistant.helpers.upnp import get_xml_soap_set_next_url, get_xml_soap_set_url from music_assistant.models.player_provider import PlayerProvider -from .const import CONF_AIRPLAY_MODE from .helpers import get_primary_ip_address from .player import SonosPlayer if TYPE_CHECKING: + from music_assistant_models.config_entries import PlayerConfig from music_assistant_models.queue_item import QueueItem from zeroconf.asyncio import AsyncServiceInfo @@ -46,8 +35,6 @@ if TYPE_CHECKING: class SonosPlayerProvider(PlayerProvider): """Sonos Player provider.""" - sonos_players: dict[str, SonosPlayer] - @property def supported_features(self) -> set[ProviderFeature]: """Return the features supported by this Provider.""" @@ -55,7 +42,6 @@ class SonosPlayerProvider(PlayerProvider): async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" - self.sonos_players: dict[str, SonosPlayer] = {} self.mass.streams.register_dynamic_route( "/sonos_queue/v2.3/itemWindow", self._handle_sonos_queue_itemwindow ) @@ -84,16 +70,12 @@ class SonosPlayerProvider(PlayerProvider): ) continue player_id = discovery_info["device"]["id"] - self.sonos_players[player_id] = sonos_player = SonosPlayer( - self, player_id, discovery_info=discovery_info, ip_address=ip_address - ) + sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info) + sonos_player.device_info.ip_address = ip_address await sonos_player.setup() async def unload(self, is_removed: bool = False) -> None: """Handle close/cleanup of the provider.""" - # disconnect all players - await asyncio.gather(*(player.unload() for player in self.sonos_players.values())) - self.sonos_players = None self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/itemWindow") self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/version") self.mass.streams.unregister_dynamic_route("/sonos_queue/v2.3/context") @@ -113,274 +95,46 @@ class SonosPlayerProvider(PlayerProvider): name = name.split("@", 1)[1] if "@" in name else name player_id = info.decoded_properties["uuid"] # handle update for existing device - if sonos_player := self.sonos_players.get(player_id): - if mass_player := sonos_player.mass_player: - cur_address = get_primary_ip_address(info) - if cur_address and cur_address != sonos_player.ip_address: - sonos_player.logger.debug( - "Address updated from %s to %s", sonos_player.ip_address, cur_address - ) - sonos_player.ip_address = cur_address - mass_player.device_info = DeviceInfo( - model=mass_player.device_info.model, - manufacturer=mass_player.device_info.manufacturer, - ip_address=str(cur_address), - ) - if not sonos_player.connected: - self.logger.debug("Player back online: %s", mass_player.display_name) - sonos_player.client.player_ip = cur_address - # schedule reconnect - sonos_player.reconnect() - self.mass.players.update(player_id) + if sonos_player := self.mass.players.get(player_id): + assert isinstance(sonos_player, SonosPlayer), ( + "Player ID already exists but is not a SonosPlayer" + ) + # if mass_player := sonos_player.mass_player: + cur_address = get_primary_ip_address(info) + if cur_address and cur_address != sonos_player.device_info.ip_address: + sonos_player.logger.debug( + "Address updated from %s to %s", + sonos_player.device_info.ip_address, + cur_address, + ) + sonos_player.device_info.ip_address = cur_address + if not sonos_player.connected: + self.logger.debug("Player back online: %s", sonos_player.display_name) + sonos_player.client.player_ip = cur_address + # schedule reconnect + sonos_player.reconnect() + self.mass.players.trigger_player_update(player_id) return # handle new player setup in a delayed task because mdns announcements # can arrive in (duplicated) bursts task_id = f"setup_sonos_{player_id}" self.mass.call_later(5, self._setup_player, player_id, name, info, task_id=task_id) - async def get_player_config_entries( - self, - player_id: str, - ) -> tuple[ConfigEntry, ...]: - """Return Config Entries for the given player.""" - base_entries = ( - *await super().get_player_config_entries(player_id), - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, - CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, - create_sample_rates_config_entry( - # set safe max bit depth to 16 bits because the older Sonos players - # do not support 24 bit playback (e.g. Play:1) - max_sample_rate=48000, - max_bit_depth=24, - safe_max_bit_depth=16, - hidden=False, - ), - ) - if not (sonos_player := self.sonos_players.get(player_id)): - # most probably the player is not yet discovered - return base_entries - return ( - *base_entries, - ConfigEntry( - key="airplay_detected", - type=ConfigEntryType.BOOLEAN, - label="airplay_detected", - hidden=True, - required=False, - default_value=sonos_player.get_linked_airplay_player(False) is not None, - ), - ConfigEntry( - key=CONF_AIRPLAY_MODE, - type=ConfigEntryType.BOOLEAN, - label="Enable AirPlay mode", - description="Almost all newer Sonos speakers have AirPlay support. " - "If you have the AirPlay provider enabled in Music Assistant, " - "your Sonos speaker will also be detected as a AirPlay speaker, meaning " - "you can group them with other AirPlay speakers.\n\n" - "By default, Music Assistant uses the Sonos protocol for playback but with this " - "feature enabled, it will use the AirPlay protocol instead by redirecting " - "the playback related commands to the linked AirPlay player in Music Assistant, " - "allowing you to mix and match Sonos speakers with AirPlay speakers. \n\n" - "NOTE: You need to have the AirPlay provider enabled as well as " - "the AirPlay version of this player.", - required=False, - default_value=False, - depends_on="airplay_detected", - hidden=SonosCapability.AIRPLAY - not in sonos_player.discovery_info["device"]["capabilities"], - ), - ) - async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: """Call (by config manager) when the configuration of a player changes.""" await super().on_player_config_change(config, changed_keys) if "values/airplay_mode" in changed_keys and ( - (sonos_player := self.sonos_players.get(config.player_id)) + (sonos_player := self.mass.players.get(config.player_id)) and (airplay_player := sonos_player.get_linked_airplay_player(False)) - and airplay_player.state in (PlayerState.PLAYING, PlayerState.PAUSED) + and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) ): # edge case: we switched from airplay mode to sonos mode (or vice versa) # we need to make sure that playback gets stopped on the airplay player - if airplay_prov := self.mass.get_provider(airplay_player.provider): - airplay_player.active_source = None - await airplay_prov.cmd_stop(airplay_player.player_id) - airplay_player.active_source = None - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.cmd_stop() - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.cmd_play() - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.cmd_pause() - - async def cmd_seek(self, player_id: str, position: int) -> None: - """Handle SEEK command for given player. - - - player_id: player_id of the player to handle the command. - - position: position in seconds to seek to in the current playing item. - """ - if sonos_player := self.sonos_players[player_id]: - await sonos_player.cmd_seek(position) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.cmd_volume_set(volume_level) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.cmd_volume_mute(muted) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the syncgroup master or group player. - """ - await self.cmd_group_many(target_player, [player_id]) - - async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None: - """Create temporary sync group by joining given players to target player.""" - sonos_player = self.sonos_players[target_player] - if airplay_player := sonos_player.get_linked_airplay_player(False): - # if airplay mode is enabled, we could possibly receive child player id's that are - # not Sonos players, but AirPlay players. We redirect those. - airplay_child_ids = [x for x in child_player_ids if x.startswith("ap")] - child_player_ids = [x for x in child_player_ids if x not in airplay_child_ids] - if airplay_child_ids: - if ( - airplay_player.active_source != sonos_player.mass_player.active_source - and airplay_player.state == PlayerState.PLAYING - ): - # edge case player is not playing a MA queue - fail this request - raise PlayerCommandFailed("Player is not playing a Music Assistant queue.") - await self.mass.players.cmd_group_many(airplay_player.player_id, airplay_child_ids) - if child_player_ids: - await sonos_player.client.player.group.modify_group_members( - player_ids_to_add=child_player_ids, player_ids_to_remove=[] - ) - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - sonos_player = self.sonos_players[player_id] - await sonos_player.client.player.leave_group() - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - sonos_player = self.sonos_players[player_id] - mass_player = self.mass.players.get(player_id) - if sonos_player.client.player.is_passive: - # this should be already handled by the player manager, but just in case... - msg = ( - f"Player {mass_player.display_name} can not " - "accept play_media command, it is synced to another player." - ) - raise PlayerCommandFailed(msg) - # for now always reset the active session - sonos_player.client.player.group.active_session_id = None - if airplay_player := sonos_player.get_linked_airplay_player(True): - # airplay mode is enabled, redirect the command - self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.") - await self._play_media_airplay(sonos_player, airplay_player, media) - return - - if media.media_type in ( - MediaType.PLUGIN_SOURCE, - MediaType.FLOW_STREAM, - ) or media.queue_id.startswith("ugp_"): - # flow stream or plugin source playback - # always use the legacy (UPNP) playback method for this - await self._play_media_legacy(sonos_player, media) - return - - if media.queue_id: - # Regular Queue item playback - # create a sonos cloud queue and load it - cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/" - mass_queue = self.mass.player_queues.get(media.queue_id) - await sonos_player.client.player.group.play_cloud_queue( - cloud_queue_url, - http_authorization=media.queue_id, - item_id=media.queue_item_id, - queue_version=str(int(mass_queue.items_last_updated)), - ) - self.mass.call_later(5, sonos_player.sync_play_modes, media.queue_id) - return - - # All other playback types - # play a single uri/url - # note that this most probably will only work for (long running) radio streams - # enforce mp3 here because Sonos really does not support FLAC streams without duration - media.uri = media.uri.replace(".flac", ".mp3") - await sonos_player.client.player.group.play_stream_url( - media.uri, {"name": media.title, "type": "track"} - ) - - async def cmd_next(self, player_id: str) -> None: - """Handle NEXT TRACK command for given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.client.player.group.skip_to_next_track() - - async def cmd_previous(self, player_id: str) -> None: - """Handle PREVIOUS TRACK command for given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.client.player.group.skip_to_previous_track() - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of the next queue item on the player.""" - sonos_player = self.sonos_players[player_id] - if session_id := sonos_player.client.player.group.active_session_id: - await sonos_player.client.api.playback_session.refresh_cloud_queue(session_id) - - async def play_announcement( - self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None - ) -> None: - """Handle (provider native) playback of an announcement on given player.""" - sonos_player = self.sonos_players[player_id] - self.logger.debug( - "Playing announcement %s on %s", - announcement.uri, - sonos_player.mass_player.display_name, - ) - await sonos_player.client.player.play_audio_clip( - announcement.uri, volume_level, name="Announcement" - ) - # Wait until the announcement is finished playing - # This is helpful for people who want to play announcements in a sequence - # yeah we can also setup a subscription on the sonos player for this, but this is easier - media_info = await async_parse_tags(announcement.uri, require_duration=True) - duration = media_info.duration or 10 - await asyncio.sleep(duration) - - async def select_source(self, player_id: str, source: str) -> None: - """Handle SELECT SOURCE command on given player.""" - if sonos_player := self.sonos_players[player_id]: - await sonos_player.select_source(source) + await airplay_player.stop() async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None: """Handle setup of a new player that is discovered using mdns.""" - assert player_id not in self.sonos_players + assert not self.mass.players.get(player_id) address = get_primary_ip_address(info) if address is None: return @@ -400,14 +154,13 @@ class SonosPlayerProvider(PlayerProvider): ) return self.logger.debug("Discovered Sonos device %s on %s", name, address) - self.sonos_players[player_id] = sonos_player = SonosPlayer( - self, player_id, discovery_info=discovery_info, ip_address=address - ) + sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info) + sonos_player.device_info.ip_address = address await sonos_player.setup() - # trigger update on all existing players to update the group status - for _player in self.sonos_players.values(): - if _player.player_id != player_id: - _player.on_player_event(None) + # # trigger update on all existing players to update the group status + # for _player in self.sonos_players.values(): + # if _player.player_id != player_id: + # _player.on_player_event(None) async def _handle_sonos_queue_itemwindow(self, request: web.Request) -> web.Response: """ @@ -458,7 +211,7 @@ class SonosPlayerProvider(PlayerProvider): self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Version request: %s", request.query) sonos_playback_id = request.headers["X-Sonos-Playback-Id"] sonos_player_id = sonos_playback_id.split(":")[0] - if not (self.sonos_players.get(sonos_player_id)): + if not (self.mass.players.get(sonos_player_id)): return web.Response(status=501) mass_queue = self.mass.player_queues.get_active_queue(sonos_player_id) context_version = request.query.get("contextVersion") or "1" @@ -477,7 +230,7 @@ class SonosPlayerProvider(PlayerProvider): sonos_player_id = sonos_playback_id.split(":")[0] if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)): return web.Response(status=501) - if not (self.sonos_players.get(sonos_player_id)): + if not (self.mass.players.get(sonos_player_id)): return web.Response(status=501) result = { "contextVersion": "1", @@ -525,7 +278,7 @@ class SonosPlayerProvider(PlayerProvider): sonos_player_id = sonos_playback_id.split(":")[0] if not (mass_player := self.mass.players.get(sonos_player_id)): return web.Response(status=501) - if not (self.sonos_players.get(sonos_player_id)): + if not (self.mass.players.get(sonos_player_id)): return web.Response(status=501) for item in json_body["items"]: if item["type"] != "update": @@ -533,8 +286,7 @@ class SonosPlayerProvider(PlayerProvider): if "positionMillis" not in item: continue if mass_player.current_media and mass_player.current_media.queue_item_id == item["id"]: - mass_player.elapsed_time = item["positionMillis"] / 1000 - mass_player.elapsed_time_last_updated = time.time() + mass_player.update_elapsed_time(item["positionMillis"] / 1000) break return web.Response(status=204) @@ -595,84 +347,3 @@ class SonosPlayerProvider(PlayerProvider): else None, }, } - - async def _play_media_legacy( - self, - sonos_player: SonosPlayer, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA using the legacy upnp api.""" - xml_data, soap_action = get_xml_soap_set_url(media) - player_ip = sonos_player.mass_player.device_info.ip_address - async with self.mass.http_session.post( - f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control", - headers={ - "SOAPACTION": soap_action, - "Content-Type": "text/xml; charset=utf-8", - "Connection": "close", - }, - data=xml_data, - ) as resp: - if resp.status != 200: - raise PlayerCommandFailed( - f"Failed to send command to Sonos player: {resp.status} {resp.reason}" - ) - await self.cmd_play(sonos_player.player_id) - return - - async def _enqueue_next_media_legacy( - self, sonos_player: SonosPlayer, media: PlayerMedia - ) -> None: - """Handle enqueuing of the next queue item using the legacy unpnp api.""" - xml_data, soap_action = get_xml_soap_set_next_url(media) - player_ip = sonos_player.mass_player.device_info.ip_address - async with self.mass.http_session.post( - f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control", - headers={ - "SOAPACTION": soap_action, - "Content-Type": "text/xml; charset=utf-8", - "Connection": "close", - }, - data=xml_data, - ) as resp: - if resp.status != 200: - raise PlayerCommandFailed( - f"Failed to send command to Sonos player: {resp.status} {resp.reason}" - ) - - # disable crossfade mode if needed - # crossfading is handled by our streams controller - if sonos_player.client.player.group.play_modes.crossfade: - await sonos_player.client.player.group.set_play_modes(crossfade=False) - - async def _play_media_airplay( - self, - sonos_player: SonosPlayer, - airplay_player: Player, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA using the legacy upnp api.""" - player_id = sonos_player.player_id - mass_player = self.mass.players.get(player_id) - mass_player.active_source = airplay_player.active_source - if ( - airplay_player.state == PlayerState.PLAYING - and airplay_player.active_source == media.queue_id - ): - # if the airplay player is already playing, - # the stream will be reused so no need to do the whole grouping thing below - await self.mass.players.play_media(airplay_player.player_id, media) - return - - # Sonos has an annoying bug (for years already, and they dont seem to care), - # where it looses its sync childs when airplay playback is (re)started. - # Try to handle it here with this workaround. - group_childs = [x for x in sonos_player.client.player.group.player_ids if x != player_id] - if group_childs: - await self.mass.players.cmd_ungroup_many(group_childs) - await self.mass.players.play_media(airplay_player.player_id, media) - if group_childs: - # ensure master player is first in the list - group_childs = [sonos_player.player_id, *group_childs] - await asyncio.sleep(5) - await sonos_player.client.player.group.set_group_members(group_childs) diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 2df049c4..0c96581c 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -9,39 +9,14 @@ integration for Sonos. from __future__ import annotations -import asyncio -import logging -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from music_assistant_models.config_entries import ConfigEntry, ConfigValueType -from music_assistant_models.enums import ( - ConfigEntryType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, -) -from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia -from requests.exceptions import RequestException -from soco import SoCo, events_asyncio, zonegroupstate -from soco import config as soco_config -from soco.discovery import discover, scan_network +from music_assistant_models.enums import ConfigEntryType -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, - CONF_ENTRY_HTTP_PROFILE_DEFAULT_1, - CONF_ENTRY_MANUAL_DISCOVERY_IPS, - CONF_ENTRY_OUTPUT_CODEC, - VERBOSE_LOG_LEVEL, - create_sample_rates_config_entry, -) -from music_assistant.helpers.upnp import create_didl_metadata -from music_assistant.models.player_provider import PlayerProvider +from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS -from .player import SonosPlayer +from .provider import SonosPlayerProvider if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig @@ -51,46 +26,18 @@ if TYPE_CHECKING: from music_assistant.models import ProviderInstanceType -PLAYER_FEATURES = { - PlayerFeature.SET_MEMBERS, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, - PlayerFeature.ENQUEUE, - PlayerFeature.GAPLESS_PLAYBACK, - PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE, -} - -CONF_NETWORK_SCAN = "network_scan" -CONF_HOUSEHOLD_ID = "household_id" -SUBSCRIPTION_TIMEOUT = 1200 -ZGS_SUBSCRIPTION_TIMEOUT = 2 - -CONF_ENTRY_SAMPLE_RATES = create_sample_rates_config_entry( - max_sample_rate=48000, max_bit_depth=16, hidden=True -) - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - soco_config.EVENTS_MODULE = events_asyncio - zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT - prov = SonosPlayerProvider(mass, manifest, config) - # set-up soco logging - if prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("soco").setLevel(logging.DEBUG) - else: - logging.getLogger("soco").setLevel(prov.logger.level + 10) - await prov.handle_async_init() - return prov + return SonosPlayerProvider(mass, manifest, config) async def get_config_entries( - mass: MusicAssistant, - instance_id: str | None = None, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 ) -> tuple[ConfigEntry, ...]: """ Return Config entries to setup this provider. @@ -99,395 +46,14 @@ async def get_config_entries( action: [optional] action key called from config entries UI. values: the (intermediate) raw values for config entries sent with the action. """ - # ruff: noqa: ARG001 - household_ids = await discover_household_ids(mass) return ( + CONF_ENTRY_MANUAL_DISCOVERY_IPS, ConfigEntry( - key=CONF_NETWORK_SCAN, - type=ConfigEntryType.BOOLEAN, - label="Enable network scan for discovery", - default_value=False, - description="Enable network scan for discovery of players. \n" - "Can be used if (some of) your players are not automatically discovered.\n" - "Should normally not be needed", - ), - ConfigEntry( - key=CONF_HOUSEHOLD_ID, - type=ConfigEntryType.STRING, - label="Household ID", - default_value=household_ids[0] if household_ids else None, - description="Household ID for the Sonos (S1) system. Will be auto detected if empty.", - category="advanced", - required=False, + key="discovery_timeout", + type=ConfigEntryType.INTEGER, + label="Discovery timeout (seconds)", + description="Timeout for discovering Sonos players on the network", + default_value=30, + range=(10, 120), ), - CONF_ENTRY_MANUAL_DISCOVERY_IPS, ) - - -@dataclass -class UnjoinData: - """Class to track data necessary for unjoin coalescing.""" - - players: list[SonosPlayer] - event: asyncio.Event = field(default_factory=asyncio.Event) - - -class SonosPlayerProvider(PlayerProvider): - """Sonos Player provider.""" - - _discovery_running: bool = False - _discovery_reschedule_timer: asyncio.TimerHandle | None = None - - def __init__(self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig): - """Handle initialization of the provider.""" - super().__init__(mass, manifest, config) - self.sonosplayers: OrderedDict[str, SonosPlayer] = OrderedDict() - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.SYNC_PLAYERS} - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - self.topology_condition = asyncio.Condition() - self.boot_counts: dict[str, int] = {} - self.mdns_names: dict[str, str] = {} - self.unjoin_data: dict[str, UnjoinData] = {} - self._discovery_running = False - self.hosts_in_error: dict[str, bool] = {} - self.discovery_lock = asyncio.Lock() - self.creation_lock = asyncio.Lock() - self._known_invisible: set[SoCo] = set() - - async def unload(self, is_removed: bool = False) -> None: - """Handle close/cleanup of the provider.""" - if self._discovery_reschedule_timer: - self._discovery_reschedule_timer.cancel() - self._discovery_reschedule_timer = None - # await any in-progress discovery - while self._discovery_running: - await asyncio.sleep(0.5) - await asyncio.gather(*(player.offline() for player in self.sonosplayers.values())) - if events_asyncio.event_listener: - await events_asyncio.event_listener.async_stop() - self.sonosplayers = OrderedDict() - - async def get_player_config_entries( - self, - player_id: str, - ) -> tuple[ConfigEntry, ...]: - """Return Config Entries for the given player.""" - base_entries = await super().get_player_config_entries(player_id) - return ( - *base_entries, - CONF_ENTRY_SAMPLE_RATES, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, - CONF_ENTRY_HTTP_PROFILE_DEFAULT_1, - ) - - def is_device_invisible(self, ip_address: str) -> bool: - """Check if device at provided IP is known to be invisible.""" - return any(x for x in self._known_invisible if x.ip_address == ip_address) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - sonos_player = self.sonosplayers[player_id] - if sonos_player.sync_coordinator: - self.logger.debug( - "Ignore STOP command for %s: Player is synced to another player.", - player_id, - ) - return - await asyncio.to_thread(sonos_player.soco.stop) - self.mass.call_later(2, sonos_player.poll_speaker) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - sonos_player = self.sonosplayers[player_id] - if sonos_player.sync_coordinator: - self.logger.debug( - "Ignore PLAY command for %s: Player is synced to another player.", - player_id, - ) - return - await asyncio.to_thread(sonos_player.soco.play) - sonos_player.mass_player.poll_interval = 5 - self.mass.call_later(2, sonos_player.poll_speaker) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - sonos_player = self.sonosplayers[player_id] - if sonos_player.sync_coordinator: - self.logger.debug( - "Ignore PLAY command for %s: Player is synced to another player.", - player_id, - ) - return - if "Pause" not in sonos_player.soco.available_actions: - # pause not possible - await self.cmd_stop(player_id) - return - await asyncio.to_thread(sonos_player.soco.pause) - self.mass.call_later(2, sonos_player.poll_speaker) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - sonos_player = self.sonosplayers[player_id] - - def set_volume_level(player_id: str, volume_level: int) -> None: - sonos_player.soco.volume = volume_level - - await asyncio.to_thread(set_volume_level, player_id, volume_level) - self.mass.call_later(2, sonos_player.poll_speaker) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - - def set_volume_mute(player_id: str, muted: bool) -> None: - sonos_player = self.sonosplayers[player_id] - sonos_player.soco.mute = muted - - await asyncio.to_thread(set_volume_mute, player_id, muted) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player. - - Join/add the given player(id) to the given (master) player/sync group. - - - player_id: player_id of the player to handle the command. - - target_player: player_id of the syncgroup master or group player. - """ - sonos_player = self.sonosplayers[player_id] - sonos_master_player = self.sonosplayers[target_player] - await sonos_master_player.join([sonos_player]) - self.mass.call_later(2, sonos_player.poll_speaker) - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - sonos_player = self.sonosplayers[player_id] - await sonos_player.unjoin() - self.mass.call_later(2, sonos_player.poll_speaker) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - sonos_player = self.sonosplayers[player_id] - mass_player = self.mass.players.get(player_id) - assert mass_player - if sonos_player.sync_coordinator: - # this should be already handled by the player manager, but just in case... - msg = ( - f"Player {mass_player.display_name} can not " - "accept play_media command, it is synced to another player." - ) - raise PlayerCommandFailed(msg) - didl_metadata = create_didl_metadata(media) - await asyncio.to_thread(sonos_player.soco.play_uri, media.uri, meta=didl_metadata) - self.mass.call_later(2, sonos_player.poll_speaker) - sonos_player.mass_player.poll_interval = 5 - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of the next queue item on the player.""" - sonos_player = self.sonosplayers[player_id] - didl_metadata = create_didl_metadata(media) - - # disable crossfade mode if needed - # crossfading is handled by our streams controller - if sonos_player.crossfade: - - def set_crossfade() -> None: - try: - sonos_player.soco.cross_fade = False - sonos_player.crossfade = False - except Exception as err: - self.logger.warning( - "Unable to set crossfade for player %s: %s", sonos_player.zone_name, err - ) - - await asyncio.to_thread(set_crossfade) - - try: - await asyncio.to_thread( - sonos_player.soco.avTransport.SetNextAVTransportURI, - [("InstanceID", 0), ("NextURI", media.uri), ("NextURIMetaData", didl_metadata)], - timeout=60, - ) - except Exception as err: - self.logger.warning( - "Unable to enqueue next track on player: %s: %s", sonos_player.zone_name, err - ) - - async def poll_player(self, player_id: str) -> None: - """Poll player for state updates.""" - if player_id not in self.sonosplayers: - return - sonos_player = self.sonosplayers[player_id] - # dynamically change the poll interval - if sonos_player.mass_player.state == PlayerState.PLAYING: - sonos_player.mass_player.poll_interval = 5 - else: - sonos_player.mass_player.poll_interval = 30 - try: - # the check_poll logic will work out what endpoints need polling now - # based on when we last received info from the device - if needs_poll := await sonos_player.check_poll(): - await sonos_player.poll_speaker() - # always update the attributes - sonos_player.update_player(signal_update=needs_poll) - except ConnectionResetError as err: - raise PlayerUnavailableError from err - - async def discover_players(self) -> None: - """Discover Sonos players on the network.""" - if self._discovery_running: - return - - # Handle config option for manual IP's - manual_ip_config = cast( - "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key) - ) - for ip_address in manual_ip_config: - try: - player = SoCo(ip_address) - self._add_player(player) - except RequestException as err: - # player is offline - self.logger.debug("Failed to add SonosPlayer %s: %s", player, err) - except Exception as err: - self.logger.warning( - "Failed to add SonosPlayer %s: %s", - player, - err, - exc_info=err if self.logger.isEnabledFor(10) else None, - ) - - allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN) - if not (household_id := self.config.get_value(CONF_HOUSEHOLD_ID)): - household_id = "Sonos" - - def do_discover() -> None: - """Run discovery and add players in executor thread.""" - self._discovery_running = True - try: - self.logger.debug("Sonos discovery started...") - discovered_devices: set[SoCo] = ( - discover( - timeout=30, household_id=household_id, allow_network_scan=allow_network_scan - ) - or set() - ) - - # process new players - for soco in discovered_devices: - try: - self._add_player(soco) - except RequestException as err: - # player is offline - self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err) - except Exception as err: - self.logger.warning( - "Failed to add SonosPlayer %s: %s", - soco, - err, - exc_info=err if self.logger.isEnabledFor(10) else None, - ) - finally: - self._discovery_running = False - - await asyncio.to_thread(do_discover) - - def reschedule() -> None: - self._discovery_reschedule_timer = None - self.mass.create_task(self.discover_players()) - - # reschedule self once finished - self._discovery_reschedule_timer = self.mass.loop.call_later(1800, reschedule) - - def _add_player(self, soco: SoCo) -> None: - """Add discovered Sonos player.""" - player_id = soco.uid - # check if existing player changed IP - if existing := self.sonosplayers.get(player_id): - if existing.soco.ip_address != soco.ip_address: - existing.update_ip(soco.ip_address) - return - if not soco.is_visible: - return - enabled = self.mass.config.get_raw_player_config_value(player_id, "enabled", True) - if not enabled: - self.logger.debug("Ignoring disabled player: %s", player_id) - return - - speaker_info = soco.get_speaker_info(True, timeout=7) - if soco.uid not in self.boot_counts: - self.boot_counts[soco.uid] = soco.boot_seqnum - self.logger.debug("Adding new player: %s", speaker_info) - if not (mass_player := self.mass.players.get(soco.uid)): - mass_player = Player( - player_id=soco.uid, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=soco.player_name, - available=True, - supported_features=PLAYER_FEATURES, - device_info=DeviceInfo( - model=speaker_info["model_name"], - ip_address=soco.ip_address, - manufacturer="SONOS", - ), - needs_poll=True, - poll_interval=30, - can_group_with={self.instance_id}, - ) - self.sonosplayers[player_id] = sonos_player = SonosPlayer( - self, - soco=soco, - mass_player=mass_player, - ) - if not soco.fixed_volume: - mass_player.supported_features = { - *mass_player.supported_features, - PlayerFeature.VOLUME_SET, - } - asyncio.run_coroutine_threadsafe( - self.mass.players.register_or_update(sonos_player.mass_player), loop=self.mass.loop - ) - - -async def discover_household_ids(mass: MusicAssistant, prefer_s1: bool = True) -> list[str]: - """Discover the HouseHold ID of S1 speaker(s) the network.""" - if cache := await mass.cache.get("sonos_household_ids"): - return cast("list[str]", cache) - household_ids: list[str] = [] - - def get_all_sonos_ips() -> set[SoCo]: - """Run full network discovery and return IP's of all devices found on the network.""" - discovered_zones: set[SoCo] | None - if discovered_zones := scan_network(multi_household=True): - return {zone.ip_address for zone in discovered_zones} - return set() - - all_sonos_ips = await asyncio.to_thread(get_all_sonos_ips) - for ip_address in all_sonos_ips: - async with mass.http_session.get(f"http://{ip_address}:1400/status/zp") as resp: - if resp.status == 200: - data = await resp.text() - if prefer_s1 and "2" in data: - continue - if "HouseholdControlID" in data: - household_id = data.split("")[1].split( - "" - )[0] - household_ids.append(household_id) - await mass.cache.set("sonos_household_ids", household_ids, 3600) - return household_ids diff --git a/music_assistant/providers/sonos_s1/player.py b/music_assistant/providers/sonos_s1/player.py index db73e251..2a070cd6 100644 --- a/music_assistant/providers/sonos_s1/player.py +++ b/music_assistant/providers/sonos_s1/player.py @@ -8,16 +8,14 @@ integration for Sonos. from __future__ import annotations import asyncio -import contextlib import datetime import logging import time -from collections.abc import Callable, Coroutine -from typing import TYPE_CHECKING, Any +from collections.abc import Callable +from typing import TYPE_CHECKING -from music_assistant_models.enums import PlayerFeature, PlayerState +from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType from music_assistant_models.errors import PlayerCommandFailed -from music_assistant_models.player import DeviceInfo, Player from soco import SoCoException from soco.core import ( MUSIC_SRC_AIRPLAY, @@ -27,18 +25,24 @@ from soco.core import ( MUSIC_SRC_TV, SoCo, ) -from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer -from music_assistant.constants import VERBOSE_LOG_LEVEL -from music_assistant.helpers.datetime import utc +from music_assistant.constants import ( + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + CONF_ENTRY_HTTP_PROFILE_DEFAULT_1, + CONF_ENTRY_OUTPUT_CODEC, + create_sample_rates_config_entry, +) +from music_assistant.helpers.upnp import create_didl_metadata +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia -from .helpers import SonosUpdateError, soco_error +from .helpers import soco_error if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry from soco.events_base import Event as SonosEvent from soco.events_base import SubscriptionBase - from . import SonosPlayerProvider + from .provider import SonosPlayerProvider CALLBACK_TYPE = Callable[[], None] LOGGER = logging.getLogger(__name__) @@ -48,63 +52,56 @@ PLAYER_FEATURES = ( PlayerFeature.VOLUME_MUTE, PlayerFeature.VOLUME_SET, ) -DURATION_SECONDS = "duration_in_s" -POSITION_SECONDS = "position_in_s" -SUBSCRIPTION_TIMEOUT = 1200 -ZGS_SUBSCRIPTION_TIMEOUT = 2 -AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) -AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 -SONOS_STATE_PLAYING = "PLAYING" -SONOS_STATE_TRANSITIONING = "TRANSITIONING" -NEVER_TIME = -1200.0 -RESUB_COOLDOWN_SECONDS = 10.0 -SUBSCRIPTION_SERVICES = { - # "alarmClock", - "avTransport", - # "contentDirectory", - "deviceProperties", - "renderingControl", - "zoneGroupTopology", + +SOURCES_MAP = { + MUSIC_SRC_LINE_IN: "Line-in", + MUSIC_SRC_TV: "TV", + MUSIC_SRC_RADIO: "Radio", + MUSIC_SRC_SPOTIFY_CONNECT: "Spotify", + MUSIC_SRC_AIRPLAY: "AirPlay", } -SUPPORTED_VANISH_REASONS = ("powered off", "sleeping", "switch to bluetooth", "upgrade") -UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"] -LINEIN_SOURCES = (MUSIC_SRC_TV, MUSIC_SRC_LINE_IN) -SOURCE_AIRPLAY = "AirPlay" -SOURCE_LINEIN = "Line-in" -SOURCE_SPOTIFY_CONNECT = "Spotify Connect" -SOURCE_TV = "TV" -SOURCE_MAPPING = { - MUSIC_SRC_AIRPLAY: SOURCE_AIRPLAY, - MUSIC_SRC_TV: SOURCE_TV, - MUSIC_SRC_LINE_IN: SOURCE_LINEIN, - MUSIC_SRC_SPOTIFY_CONNECT: SOURCE_SPOTIFY_CONNECT, + +PLAYBACK_STATE_MAP = { + "PLAYING": PlaybackState.PLAYING, + "PAUSED_PLAYBACK": PlaybackState.PAUSED, + "STOPPED": PlaybackState.IDLE, + "TRANSITIONING": PlaybackState.PLAYING, } +NEVER_TIME = 0 + class SonosSubscriptionsFailed(PlayerCommandFailed): """Subscription creation failed.""" -class SonosPlayer: - """Wrapper around Sonos/SoCo with some additional attributes.""" +class SonosPlayer(Player): + """Sonos Player implementation for S1 speakers.""" def __init__( self, - sonos_prov: SonosPlayerProvider, + provider: SonosPlayerProvider, soco: SoCo, - mass_player: Player, ) -> None: """Initialize SonosPlayer instance.""" - self.sonos_prov = sonos_prov - self.mass = sonos_prov.mass - self.player_id = soco.uid + super().__init__(provider, soco.uid) self.soco = soco - self.logger = sonos_prov.logger self.household_id: str = soco.household_id self.subscriptions: list[SubscriptionBase] = [] - self.mass_player: Player = mass_player - self.available: bool = True - # cached attributes + + # Set player attributes + self._attr_type = PlayerType.PLAYER + self._attr_supported_features = set(PLAYER_FEATURES) + self._attr_name = soco.player_name + self._attr_device_info = DeviceInfo( + model=soco.speaker_info["model_name"], + manufacturer="Sonos", + ip_address=soco.ip_address, + ) + self._attr_available = True + self._attr_can_group_with = {provider.instance_id} + + # Cached attributes self.crossfade: bool = False self.play_mode: str | None = None self.playback_status: str | None = None @@ -119,690 +116,217 @@ class SonosPlayer: self.loudness: bool = False self.bass: int = 0 self.treble: int = 0 + # Subscriptions and events self._subscriptions: list[SubscriptionBase] = [] self._subscription_lock: asyncio.Lock | None = None self._last_activity: float = NEVER_TIME self._resub_cooldown_expires_at: float | None = None + # Grouping self.sync_coordinator: SonosPlayer | None = None - self.group_members: list[SonosPlayer] = [self] - self.group_members_ids: list[str] = [] - self._group_members_missing: set[str] = set() - - def __hash__(self) -> int: - """Return a hash of self.""" - return hash(self.player_id) - - @property - def zone_name(self) -> str: - """Return zone name.""" - if self.mass_player: - return self.mass_player.display_name - return str(self.soco.speaker_info["zone_name"]) - - @property - def subscription_address(self) -> str: - """Return the current subscription callback address.""" - assert len(self._subscriptions) > 0 - addr, port = self._subscriptions[0].event_listener.address - return ":".join([addr, str(port)]) - - @property - def missing_subscriptions(self) -> set[str]: - """Return a list of missing service subscriptions.""" - subscribed_services = {sub.service.service_type for sub in self._subscriptions} - return SUBSCRIPTION_SERVICES - subscribed_services - - @property - def should_poll(self) -> bool: - """Return if this player should be polled/pinged.""" - if not self.available: - return True - return (time.monotonic() - self._last_activity) > self.mass_player.poll_interval - - def setup(self) -> None: - """Run initial setup of the speaker (NOT async friendly).""" - if self.soco.is_coordinator: - self.crossfade = self.soco.cross_fade - self.mass_player.volume_level = self.soco.volume - self.mass_player.volume_muted = self.soco.mute - self.loudness = self.soco.loudness - self.bass = self.soco.bass - self.treble = self.soco.treble - self.update_groups() - if not self.sync_coordinator: - self.poll_media() - - asyncio.run_coroutine_threadsafe(self.subscribe(), self.mass.loop) - - async def offline(self) -> None: - """Handle removal of speaker when unavailable.""" - if not self.available: - return - - if self._resub_cooldown_expires_at is None and not self.mass.closing: - self._resub_cooldown_expires_at = time.monotonic() + RESUB_COOLDOWN_SECONDS - self.logger.debug("Starting resubscription cooldown for %s", self.zone_name) - - self.available = False - self.mass_player.available = False - self.mass.players.update(self.player_id) - self._share_link_plugin = None - - await self.unsubscribe() - - def log_subscription_result(self, result: Any, event: str, level: int = logging.DEBUG) -> None: - """Log a message if a subscription action (create/renew/stop) results in an exception.""" - if not isinstance(result, Exception): - return - - if isinstance(result, asyncio.exceptions.TimeoutError): - message = "Request timed out" - exc_info = None - else: - message = str(result) - exc_info = result if not str(result) else None - - self.logger.log( - level, - "%s failed for %s: %s", - event, - self.zone_name, - message, - exc_info=exc_info if self.logger.isEnabledFor(10) else None, - ) - - async def subscribe(self) -> None: - """Initiate event subscriptions under an async lock.""" - if not self._subscription_lock: - self._subscription_lock = asyncio.Lock() - - async with self._subscription_lock: - try: - # Create event subscriptions. - subscriptions = [ - self._subscribe_target(getattr(self.soco, service), self._handle_event) - for service in self.missing_subscriptions - ] - if not subscriptions: - return - self.logger.log(VERBOSE_LOG_LEVEL, "Creating subscriptions for %s", self.zone_name) - results = await asyncio.gather(*subscriptions, return_exceptions=True) - for result in results: - self.log_subscription_result(result, "Creating subscription", logging.WARNING) - if any(isinstance(result, Exception) for result in results): - raise SonosSubscriptionsFailed - except SonosSubscriptionsFailed: - self.logger.warning("Creating subscriptions failed for %s", self.zone_name) - assert self._subscription_lock is not None - async with self._subscription_lock: - await self.offline() - - async def unsubscribe(self) -> None: - """Cancel all subscriptions.""" - if not self._subscriptions: - return - self.logger.log(VERBOSE_LOG_LEVEL, "Unsubscribing from events for %s", self.zone_name) - results = await asyncio.gather( - *(subscription.unsubscribe() for subscription in self._subscriptions), - return_exceptions=True, - ) - for result in results: - self.log_subscription_result(result, "Unsubscribe") - self._subscriptions = [] - - async def check_poll(self) -> bool: - """Validate availability of the speaker based on recent activity.""" - if not self.should_poll: - return False - self.logger.log(VERBOSE_LOG_LEVEL, "Polling player for availability...") - try: - await asyncio.to_thread(self.ping) - self._speaker_activity("ping") - except SonosUpdateError: - if not self.available: - return False # already offline - self.logger.warning( - "No recent activity and cannot reach %s, marking unavailable", - self.zone_name, + # self.group_members: list[SonosPlayer] = [self] + + async def setup(self) -> None: + """Set up the player.""" + await self.mass.players.register_or_update(self) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + return [ + *await super().get_config_entries(), + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + CONF_ENTRY_HTTP_PROFILE_DEFAULT_1, + CONF_ENTRY_OUTPUT_CODEC, + create_sample_rates_config_entry( + supported_sample_rates=[44100, 48000], + supported_bit_depths=[16], + hidden=False, + ), + ] + + async def stop(self) -> None: + """Send STOP command to the player.""" + if self.sync_coordinator: + self.logger.debug( + "Ignore STOP command for %s: Player is synced to another player.", + self.player_id, ) - await self.offline() - return True - - def update_ip(self, ip_address: str) -> None: - """Handle updated IP of a Sonos player (NOT async friendly).""" - if self.available: - return - self.logger.debug( - "Player IP-address changed from %s to %s", self.soco.ip_address, ip_address - ) - try: - self.ping() - except SonosUpdateError: return - self.soco.ip_address = ip_address - self.setup() - self.mass_player.device_info = DeviceInfo( - model=self.mass_player.device_info.model, - ip_address=ip_address, - manufacturer=self.mass_player.device_info.manufacturer, - ) - self.update_player() - - @soco_error() - def ping(self) -> None: - """Test device availability. Failure will raise SonosUpdateError.""" - self.soco.renderingControl.GetVolume([("InstanceID", 0), ("Channel", "Master")], timeout=1) - - async def join( - self, - members: list[SonosPlayer], - ) -> None: - """Sync given players/speakers with this player.""" - async with self.sonos_prov.topology_condition: - group: list[SonosPlayer] = await asyncio.to_thread(self._join, members) - await self.wait_for_groups([group]) - - async def unjoin(self) -> None: - """Unjoin player from all/any groups.""" - async with self.sonos_prov.topology_condition: - await asyncio.to_thread(self._unjoin) - await self.wait_for_groups([[self]]) - - def update_player(self, signal_update: bool = True) -> None: - """Update Sonos Player.""" - self._update_attributes() - if signal_update: - # send update to the player manager right away only if we are triggered from an event - # when we're just updating from a manual poll, the player manager - # will detect changes to the player object itself - self.mass.loop.call_soon_threadsafe(self.sonos_prov.mass.players.update, self.player_id) - - async def poll_speaker(self) -> None: - """Poll the speaker for updates.""" - - def _poll() -> None: - """Poll the speaker for updates (NOT async friendly).""" - self.update_groups() - self.poll_media() - self.mass_player.volume_level = self.soco.volume - self.mass_player.volume_muted = self.soco.mute - - await asyncio.to_thread(_poll) - - @soco_error() - def poll_media(self) -> None: - """Poll information about currently playing media.""" - transport_info = self.soco.get_current_transport_info() - new_status = transport_info["current_transport_state"] - - if new_status == SONOS_STATE_TRANSITIONING: - return - - update_position = new_status != self.playback_status - self.playback_status = new_status - self.play_mode = self.soco.play_mode - self._set_basic_track_info(update_position=update_position) - self.update_player() - - async def _subscribe_target( - self, target: SubscriptionBase, sub_callback: Callable[[SonosEvent], None] - ) -> None: - """Create a Sonos subscription for given target.""" - subscription = await target.subscribe( - auto_renew=True, requested_timeout=SUBSCRIPTION_TIMEOUT - ) + await asyncio.to_thread(self.soco.stop) + self.mass.call_later(2, self.poll_speaker) - def on_renew_failed(exception: Exception) -> None: - """Handle a failed subscription renewal callback.""" - self.mass.create_task(self._renew_failed(exception)) - - subscription.callback = sub_callback - subscription.auto_renew_fail = on_renew_failed - self._subscriptions.append(subscription) - - async def _renew_failed(self, exception: Exception) -> None: - """Mark the speaker as offline after a subscription renewal failure. - - This is to reset the state to allow a future clean subscription attempt. - """ - if not self.available: - return - - self.log_subscription_result(exception, "Subscription renewal", logging.WARNING) - await self.offline() - - def _handle_event(self, event: SonosEvent) -> None: - """Handle SonosEvent callback.""" - service_type: str = event.service.service_type - self._speaker_activity(f"{service_type} subscription") - - if service_type == "DeviceProperties": - self.update_player() - return - if service_type == "AVTransport": - self._handle_avtransport_event(event) - return - if service_type == "RenderingControl": - self._handle_rendering_control_event(event) - return - if service_type == "ZoneGroupTopology": - self._handle_zone_group_topology_event(event) - return - - def _handle_avtransport_event(self, event: SonosEvent) -> None: - """Update information about currently playing media from an event.""" - # NOTE: The new coordinator can be provided in a media update event but - # before the ZoneGroupState updates. If this happens the playback - # state will be incorrect and should be ignored. Switching to the - # new coordinator will use its media. The regrouping process will - # be completed during the next ZoneGroupState update. - av_transport_uri = event.variables.get("av_transport_uri", "") - current_track_uri = event.variables.get("current_track_uri", "") - if av_transport_uri == current_track_uri and av_transport_uri.startswith("x-rincon:"): - new_coordinator_uid = av_transport_uri.split(":")[-1] - if new_coordinator_speaker := self.sonos_prov.sonosplayers.get(new_coordinator_uid): - self.logger.log( - 5, - "Media update coordinator (%s) received for %s", - new_coordinator_speaker.zone_name, - self.zone_name, - ) - self.sync_coordinator = new_coordinator_speaker - else: - self.logger.debug( - "Media update coordinator (%s) for %s not yet available", - new_coordinator_uid, - self.zone_name, - ) + async def play(self) -> None: + """Send PLAY command to the player.""" + if self.sync_coordinator: + self.logger.debug( + "Ignore PLAY command for %s: Player is synced to another player.", + self.player_id, + ) return + await asyncio.to_thread(self.soco.play) + self._attr_poll_interval = 5 + self.mass.call_later(2, self.poll_speaker) - if crossfade := event.variables.get("current_crossfade_mode"): - self.crossfade = bool(int(crossfade)) - - # Missing transport_state indicates a transient error - if (new_status := event.variables.get("transport_state")) is None: + async def pause(self) -> None: + """Send PAUSE command to the player.""" + if self.sync_coordinator: + self.logger.debug( + "Ignore PAUSE command for %s: Player is synced to another player.", + self.player_id, + ) return - - # Ignore transitions, we should get the target state soon - if new_status == SONOS_STATE_TRANSITIONING: + if "Pause" not in self.soco.available_actions: + # pause not possible + await self.stop() return + await asyncio.to_thread(self.soco.pause) + self.mass.call_later(2, self.poll_speaker) - evars = event.variables - new_status = evars["transport_state"] - state_changed = new_status != self.playback_status - - self.play_mode = evars["current_play_mode"] - self.playback_status = new_status - - track_uri = evars["enqueued_transport_uri"] or evars["current_track_uri"] - audio_source = self.soco.music_source_from_uri(track_uri) + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to the player.""" - self._set_basic_track_info(update_position=state_changed) + def set_volume_level(volume_level: int) -> None: + self.soco.volume = volume_level - if (ct_md := evars["current_track_meta_data"]) and not self.image_url: - if album_art_uri := getattr(ct_md, "album_art_uri", None): - # TODO: handle library mess here - self.image_url = album_art_uri + await asyncio.to_thread(set_volume_level, volume_level) + self.mass.call_later(2, self.poll_speaker) - et_uri_md = evars["enqueued_transport_uri_meta_data"] - if isinstance(et_uri_md, DidlPlaylistContainer): - self.playlist_name = et_uri_md.title + async def volume_mute(self, muted: bool) -> None: + """Send VOLUME MUTE command to the player.""" - if queue_size := evars.get("number_of_tracks", 0): - self.queue_size = int(queue_size) + def set_volume_mute(muted: bool) -> None: + self.soco.mute = muted - if audio_source == MUSIC_SRC_RADIO: - if et_uri_md: - self.channel = et_uri_md.title + await asyncio.to_thread(set_volume_mute, muted) + self.mass.call_later(2, self.poll_speaker) - # Extra guards for S1 compatibility - if ct_md and hasattr(ct_md, "radio_show") and ct_md.radio_show: - radio_show = ct_md.radio_show.split(",")[0] - self.channel = " • ".join(filter(None, [self.channel, radio_show])) + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on the player.""" + if self.sync_coordinator: + # this should be already handled by the player manager, but just in case... + msg = ( + f"Player {self.display_name} can not " + "accept play_media command, it is synced to another player." + ) + raise PlayerCommandFailed(msg) - if isinstance(et_uri_md, DidlAudioBroadcast): - self.title = self.title or self.channel + didl_metadata = create_didl_metadata(media) + await asyncio.to_thread(self.soco.play_uri, media.uri, meta=didl_metadata) + self.mass.call_later(2, self.poll_speaker) + self._attr_poll_interval = 5 - self.update_player() + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing next media item.""" + if self.sync_coordinator: + # this should be already handled by the player manager, but just in case... + msg = ( + f"Player {self.display_name} can not " + "accept enqueue command, it is synced to another player." + ) + raise PlayerCommandFailed(msg) - def _handle_rendering_control_event(self, event: SonosEvent) -> None: - """Update information about currently volume settings.""" - variables = event.variables + didl_metadata = create_didl_metadata(media) - if "volume" in variables: - volume = variables["volume"] - self.mass_player.volume_level = int(volume["Master"]) + # Disable crossfade mode if needed + # crossfading is handled by our streams controller + if self.crossfade: - if mute := variables.get("mute"): - self.mass_player.volume_muted = mute["Master"] == "1" + def set_crossfade() -> None: + try: + self.soco.cross_fade = False + except SoCoException as err: + self.logger.debug("Error setting crossfade: %s", err) - self.update_player() + await asyncio.to_thread(set_crossfade) - def _handle_zone_group_topology_event(self, event: SonosEvent) -> None: - """Handle callback for topology change event.""" - if "zone_player_uui_ds_in_group" not in event.variables: - return - asyncio.run_coroutine_threadsafe(self.create_update_groups_coro(event), self.mass.loop) + def add_to_queue() -> None: + self.soco.add_uri_to_queue(media.uri, didl_metadata) - async def _rebooted(self) -> None: - """Handle a detected speaker reboot.""" - self.logger.debug("%s rebooted, reconnecting", self.zone_name) - await self.offline() - self._speaker_activity("reboot") + await asyncio.to_thread(add_to_queue) + self.mass.call_later(2, self.poll_speaker) - def update_groups(self) -> None: - """Update group topology when polling.""" - asyncio.run_coroutine_threadsafe(self.create_update_groups_coro(), self.mass.loop) + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + # TODO: Implement Sonos S1 grouping logic + # This would involve calling SoCo grouping methods - def update_group_for_uid(self, uid: str) -> None: - """Update group topology if uid is missing.""" - if uid not in self._group_members_missing: - return - missing_zone = self.sonos_prov.sonosplayers[uid].zone_name - self.logger.debug("%s was missing, adding to %s group", missing_zone, self.zone_name) - self.update_groups() - - def create_update_groups_coro( - self, event: SonosEvent | None = None - ) -> Coroutine[Any, Any, None]: - """Handle callback for topology change event.""" - - def _get_soco_group() -> list[str]: - """Ask SoCo cache for existing topology.""" - coordinator_uid = self.soco.uid - joined_uids = [] - with contextlib.suppress(OSError, SoCoException): - if self.soco.group and self.soco.group.coordinator: - coordinator_uid = self.soco.group.coordinator.uid - joined_uids = [ - p.uid - for p in self.soco.group.members - if p.uid != coordinator_uid and p.is_visible - ] - - return [coordinator_uid, *joined_uids] - - async def _extract_group(event: SonosEvent | None) -> list[str]: - """Extract group layout from a topology event.""" - group = event and event.zone_player_uui_ds_in_group - if group: - assert isinstance(group, str) - return group.split(",") - return await asyncio.to_thread(_get_soco_group) - - def _regroup(group: list[str]) -> None: - """Rebuild internal group layout (async safe).""" - if group == [self.soco.uid] and self.group_members == [self] and self.group_members_ids: - # Skip updating existing single speakers in polling mode - return - - group_members = [] - group_members_ids = [] - - for uid in group: - speaker = self.sonos_prov.sonosplayers.get(uid) - if speaker: - self._group_members_missing.discard(uid) - group_members.append(speaker) - group_members_ids.append(uid) - else: - self._group_members_missing.add(uid) - self.logger.debug( - "%s group member unavailable (%s), will try again", - self.zone_name, - uid, - ) - return - - if self.group_members_ids == group_members_ids: - # Useful in polling mode for speakers with stereo pairs or surrounds - # as those "invisible" speakers will bypass the single speaker check - return - - self.sync_coordinator = None - self.group_members = group_members - self.group_members_ids = group_members_ids - self.mass.loop.call_soon_threadsafe(self.mass.players.update, self.player_id) - - for joined_uid in group[1:]: - joined_speaker = self.sonos_prov.sonosplayers.get(joined_uid) - if joined_speaker: - joined_speaker.sync_coordinator = self - joined_speaker.group_members = group_members - joined_speaker.group_members_ids = group_members_ids - joined_speaker.update_player() - - self.logger.debug("Regrouped %s: %s", self.zone_name, self.group_members_ids) - self.update_player() - - async def _handle_group_event(event: SonosEvent | None) -> None: - """Get async lock and handle event.""" - async with self.sonos_prov.topology_condition: - group = await _extract_group(event) - if self.soco.uid == group[0]: - _regroup(group) - self.sonos_prov.topology_condition.notify_all() - - return _handle_group_event(event) - - async def wait_for_groups(self, groups: list[list[SonosPlayer]]) -> None: - """Wait until all groups are present, or timeout.""" - - def _test_groups(groups: list[list[SonosPlayer]]) -> bool: - """Return whether all groups exist now.""" - for group in groups: - coordinator = group[0] - - # Test that coordinator is coordinating - current_group = coordinator.group_members - if coordinator != current_group[0]: - return False - - # Test that joined members match - if set(group[1:]) != set(current_group[1:]): - return False - - return True + async def poll(self) -> None: + """Poll player for state updates.""" + self.poll_speaker() + def poll_speaker(self) -> None: + """Poll speaker for state updates.""" try: - async with asyncio.timeout(5): - while not _test_groups(groups): - await self.sonos_prov.topology_condition.wait() - except TimeoutError: - self.logger.warning("Timeout waiting for target groups %s", groups) - - any_speaker = next(iter(self.sonos_prov.sonosplayers.values())) - any_speaker.soco.zone_group_state.clear_cache() - - def _update_attributes(self) -> None: - """Update attributes of the MA Player from SoCo state.""" - # generic attributes (player_info) - self.mass_player.available = self.available - - if not self.available: - self.mass_player.state = PlayerState.IDLE - self.mass_player.synced_to = None - self.mass_player.group_childs.clear() - return + # Update speaker state from SoCo + self._update_speaker_state() + except Exception as err: + self.logger.debug("Error polling speaker: %s", err) - # transport info (playback state) - self.mass_player.state = _convert_state(self.playback_status) + def _update_speaker_state(self) -> None: + """Update speaker state from SoCo.""" + try: + # Get current transport info + transport_info = self.soco.get_current_transport_info() + self.playback_status = transport_info.get("current_transport_state") + + # Update playback state + if self.playback_status: + self._attr_playback_state = PLAYBACK_STATE_MAP.get( + self.playback_status, PlaybackState.IDLE + ) - # media info (track info) - self.mass_player.current_item_id = self.uri - if self.uri and self.mass.streams.base_url in self.uri and self.player_id in self.uri: - self.mass_player.active_source = self.player_id - else: - self.mass_player.active_source = self.source_name - if self.position is not None and self.position_updated_at is not None: - self.mass_player.elapsed_time = self.position - self.mass_player.elapsed_time_last_updated = self.position_updated_at.timestamp() + # Get volume info + self._attr_volume_level = self.soco.volume + self._attr_volume_muted = self.soco.mute + + # Get current track info + track_info = self.soco.get_current_track_info() + if track_info: + self._attr_current_media = PlayerMedia( + uri=track_info.get("uri", ""), + title=track_info.get("title"), + artist=track_info.get("artist"), + album=track_info.get("album"), + image_url=track_info.get("album_art"), + ) + self.position = int(track_info.get("position", "0").split(":")[0]) * 60 + int( + track_info.get("position", "0").split(":")[1] + ) - # zone topology (syncing/grouping) details - if self.sync_coordinator: - # player is synced to another player - self.mass_player.synced_to = self.sync_coordinator.player_id - self.mass_player.group_childs.clear() - self.mass_player.active_source = self.sync_coordinator.mass_player.active_source - elif len(self.group_members_ids) > 1: - # this player is the sync leader in a group - self.mass_player.synced_to = None - self.mass_player.group_childs.extend(self.group_members_ids) - else: - # standalone player, not synced - self.mass_player.synced_to = None - self.mass_player.group_childs.clear() - - def _set_basic_track_info(self, update_position: bool = False) -> None: - """Query the speaker to update media metadata and position info.""" - self.channel = None - self.duration = None - self.image_url = None - self.source_name = None - self.title = None - self.uri = None + # Update other attributes + self._attr_name = self.soco.player_name + self.crossfade = self.soco.cross_fade - try: - track_info = self._poll_track_info() - except SonosUpdateError as err: - self.logger.warning("Fetching track info failed: %s", err) - return - if not track_info["uri"]: - return - self.uri = track_info["uri"] - - audio_source = self.soco.music_source_from_uri(self.uri) - if source := SOURCE_MAPPING.get(audio_source): - self.source_name = source - if audio_source in LINEIN_SOURCES: - self.position = None - self.position_updated_at = None - self.title = source - return - - self.artist = track_info.get("artist") - self.album_name = track_info.get("album") - self.title = track_info.get("title") - self.image_url = track_info.get("album_art") - - playlist_position = int(track_info.get("playlist_position", -1)) - if playlist_position > 0: - self.queue_position = playlist_position - - self._update_media_position(track_info, force_update=update_position) - - def _update_media_position( - self, position_info: dict[str, int], force_update: bool = False - ) -> None: - """Update state when playing music tracks.""" - duration = position_info.get(DURATION_SECONDS) - current_position = position_info.get(POSITION_SECONDS) + self.update_state() - if not (duration or current_position): - self.position = None - self.position_updated_at = None - return + except Exception as err: + self.logger.debug("Error updating speaker state: %s", err) - should_update = force_update - self.duration = duration - - # player started reporting position? - if current_position is not None and self.position is None: - should_update = True - - # position jumped? - if current_position is not None and self.position is not None: - if self.playback_status == SONOS_STATE_PLAYING: - assert self.position_updated_at is not None - time_delta = utc() - self.position_updated_at - time_diff = time_delta.total_seconds() - else: - time_diff = 0 - - calculated_position = self.position + time_diff - - if abs(calculated_position - current_position) > 1.5: - should_update = True - - if current_position is None: - self.position = None - self.position_updated_at = None - elif should_update: - self.position = current_position - self.position_updated_at = utc() - - def _speaker_activity(self, source: str) -> None: - """Track the last activity on this speaker, set availability and resubscribe.""" - if self._resub_cooldown_expires_at: - if time.monotonic() < self._resub_cooldown_expires_at: - self.logger.debug( - "Activity on %s from %s while in cooldown, ignoring", - self.zone_name, - source, - ) - return - self._resub_cooldown_expires_at = None + @property + def is_coordinator(self) -> bool: + """Return True if this player is the group coordinator.""" + return self.sync_coordinator is None - self.logger.log(VERBOSE_LOG_LEVEL, "Activity on %s from %s", self.zone_name, source) - self._last_activity = time.monotonic() - was_available = self.available - self.available = True - if not was_available: - self.update_player() - self.mass.loop.call_soon_threadsafe(self.mass.create_task, self.subscribe()) + def schedule_poll(self, interval: float = 2.0) -> None: + """Schedule a poll update.""" + self.mass.call_later(interval, self.poll_speaker) @soco_error() - def _join(self, members: list[SonosPlayer]) -> list[SonosPlayer]: - if self.sync_coordinator: - self._unjoin() - group = [self] - else: - group = self.group_members.copy() - - for player in members: - if player.soco.uid != self.soco.uid and player not in group: - player.soco.join(self.soco) - player.sync_coordinator = self - group.append(player) - - return group + def join(self, target_player: SonosPlayer) -> None: + """Join this player to another player's group.""" + self.soco.join(target_player.soco) @soco_error() - def _unjoin(self) -> None: - if self.group_members == [self]: - return + def unjoin(self) -> None: + """Remove this player from its group.""" self.soco.unjoin() - self.sync_coordinator = None - @soco_error() - def _poll_track_info(self) -> dict[str, Any]: - """Poll the speaker for current track info. - - Add converted position values (NOT async fiendly). - """ - track_info: dict[str, Any] = self.soco.get_current_track_info() - track_info[DURATION_SECONDS] = _timespan_secs(track_info.get("duration")) - track_info[POSITION_SECONDS] = _timespan_secs(track_info.get("position")) - return track_info - - -def _convert_state(sonos_state: str | None) -> PlayerState: - """Convert Sonos state to PlayerState.""" - if sonos_state == "PLAYING": - return PlayerState.PLAYING - if sonos_state == "TRANSITIONING": - return PlayerState.PLAYING - if sonos_state == "PAUSED_PLAYBACK": - return PlayerState.PAUSED - return PlayerState.IDLE - - -def _timespan_secs(timespan: str | None) -> int | None: - """Parse a time-span into number of seconds.""" - if timespan in ("", "NOT_IMPLEMENTED"): - return None - if timespan is None: - return None - return int(sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))))) + def speaker_activity(self, event: SonosEvent) -> None: + """Handle speaker activity from Sonos events.""" + self._last_activity = time.time() + self.schedule_poll() diff --git a/music_assistant/providers/sonos_s1/provider.py b/music_assistant/providers/sonos_s1/provider.py new file mode 100644 index 00000000..56768149 --- /dev/null +++ b/music_assistant/providers/sonos_s1/provider.py @@ -0,0 +1,124 @@ +"""Sonos S1 Player Provider implementation.""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from dataclasses import dataclass +from typing import Any + +from music_assistant_models.enums import ProviderFeature +from soco import SoCo +from soco import config as soco_config +from soco.discovery import discover, scan_network + +from music_assistant.constants import VERBOSE_LOG_LEVEL +from music_assistant.models.player_provider import PlayerProvider + +from .player import SonosPlayer + + +@dataclass +class DiscoveredPlayer: + """Discovered Sonos player info.""" + + soco: SoCo + sonos_player: SonosPlayer | None = None + + +class SonosPlayerProvider(PlayerProvider): + """Sonos S1 Player Provider for legacy Sonos speakers.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the provider.""" + super().__init__(*args, **kwargs) + self.sonosplayers: dict[str, SonosPlayer] = {} + self._discovered_players: dict[str, DiscoveredPlayer] = {} + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.SYNC_PLAYERS} + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + # Set up SoCo logging + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("soco").setLevel(logging.DEBUG) + else: + logging.getLogger("soco").setLevel(self.logger.level + 10) + + # Disable SoCo cache to prevent stale data + soco_config.CACHE_ENABLED = False + + # Start discovery + await self.discover_players() + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + # Clean up subscriptions and connections + for sonos_player in self.sonosplayers.values(): + if hasattr(sonos_player, "subscriptions"): + for subscription in sonos_player.subscriptions: + with suppress(Exception): + subscription.unsubscribe() + + async def discover_players(self) -> None: + """Discover Sonos players on the network.""" + try: + # Discover players using SoCo + discovered = await asyncio.to_thread(discover) + if not discovered: + # Try manual discovery + discovered = await asyncio.to_thread(scan_network) + + for soco in discovered: + await self._setup_player(soco) + + except Exception as err: + self.logger.error("Error discovering Sonos players: %s", err) + + async def _setup_player(self, soco: SoCo) -> None: + """Set up a discovered Sonos player.""" + player_id = soco.uid + + if player_id in self.sonosplayers: + return + + try: + # Create SonosPlayer instance + sonos_player = SonosPlayer(self, soco) + self.sonosplayers[player_id] = sonos_player + + # Create discovery info + discovered_player = DiscoveredPlayer( + soco=soco, + sonos_player=sonos_player, + ) + self._discovered_players[player_id] = discovered_player + + # Register with Music Assistant + await sonos_player.setup() + + # Set up event subscriptions + await self._setup_subscriptions(sonos_player) + + except Exception as err: + self.logger.error("Error setting up Sonos player %s: %s", player_id, err) + + async def _setup_subscriptions(self, sonos_player: SonosPlayer) -> None: + """Set up event subscriptions for a Sonos player.""" + try: + # Set up event subscriptions + # This would involve subscribing to SoCo events for state changes + pass + except Exception as err: + self.logger.debug( + "Error setting up subscriptions for %s: %s", sonos_player.player_id, err + ) + + async def poll_player(self, player_id: str) -> None: + """Poll player for state updates.""" + if sonos_player := self.sonosplayers.get(player_id): + await sonos_player.poll() diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index 76e44c8b..2ef15b3e 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -9,6 +9,7 @@ import time from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlencode +import pkce from music_assistant_models.config_entries import ConfigEntry, ConfigValueType from music_assistant_models.enums import ( AlbumType, @@ -137,7 +138,6 @@ async def get_config_entries( if action == CONF_ACTION_AUTH: # spotify PKCE auth flow # https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow - import pkce code_verifier, code_challenge = pkce.generate_pkce_pair() async with AuthenticationHelper(mass, cast("str", values["session_id"])) as auth_helper: diff --git a/music_assistant/providers/spotify/helpers.py b/music_assistant/providers/spotify/helpers.py index 8b6c4e78..2ad04f82 100644 --- a/music_assistant/providers/spotify/helpers.py +++ b/music_assistant/providers/spotify/helpers.py @@ -11,7 +11,6 @@ from music_assistant.helpers.process import check_output async def get_librespot_binary() -> str: """Find the correct librespot binary belonging to the platform.""" - # ruff: noqa: SIM102 async def check_librespot(librespot_path: str) -> str | None: try: returncode, output = await check_output(librespot_path, "--version") diff --git a/music_assistant/providers/spotify_connect/__init__.py b/music_assistant/providers/spotify_connect/__init__.py index f6c806ec..97f73c1d 100644 --- a/music_assistant/providers/spotify_connect/__init__.py +++ b/music_assistant/providers/spotify_connect/__init__.py @@ -27,10 +27,10 @@ from music_assistant_models.enums import ( ) from music_assistant_models.errors import UnsupportedFeaturedException from music_assistant_models.media_items import AudioFormat -from music_assistant_models.player import PlayerMedia from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW from music_assistant.helpers.process import AsyncProcess, check_output +from music_assistant.models.player import PlayerMedia from music_assistant.models.plugin import PluginProvider, PluginSource from music_assistant.providers.spotify.helpers import get_librespot_binary diff --git a/music_assistant/providers/squeezelite/__init__.py b/music_assistant/providers/squeezelite/__init__.py index e80537c5..0eb21913 100644 --- a/music_assistant/providers/squeezelite/__init__.py +++ b/music_assistant/providers/squeezelite/__init__.py @@ -2,64 +2,22 @@ from __future__ import annotations -import asyncio -import logging -import statistics -import time -from collections import deque -from collections.abc import Iterator -from dataclasses import dataclass from typing import TYPE_CHECKING -from aiohttp import web -from aioslimproto.client import PlayerState as SlimPlayerState -from aioslimproto.client import SlimClient -from aioslimproto.models import EventType as SlimEventType -from aioslimproto.models import Preset as SlimPreset -from aioslimproto.models import VisualisationType as SlimVisualisationType -from aioslimproto.server import SlimServer -from music_assistant_models.config_entries import ( - ConfigEntry, - ConfigValueOption, - ConfigValueType, - PlayerConfig, -) -from music_assistant_models.enums import ( - ConfigEntryType, - ContentType, - MediaType, - PlayerFeature, - PlayerState, - PlayerType, - ProviderFeature, - RepeatMode, -) -from music_assistant_models.errors import MusicAssistantError, SetupFailedError -from music_assistant_models.media_items import AudioFormat -from music_assistant_models.player import DeviceInfo, Player, PlayerMedia +from music_assistant_models.config_entries import ConfigEntry, ConfigValueType +from music_assistant_models.enums import ConfigEntryType -from music_assistant.constants import ( - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_HTTP_PROFILE_FORCED_2, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_SYNC_ADJUST, - CONF_PORT, - CONF_SYNC_ADJUST, - DEFAULT_PCM_FORMAT, - VERBOSE_LOG_LEVEL, - create_sample_rates_config_entry, -) -from music_assistant.helpers.audio import get_ffmpeg_stream, get_player_filter_params -from music_assistant.helpers.util import TaskManager -from music_assistant.models.player_provider import PlayerProvider -from music_assistant.providers.player_group import PlayerGroupProvider +from music_assistant.constants import CONF_PORT -from .multi_client_stream import MultiClientStream +from .constants import ( + CONF_CLI_JSON_PORT, + CONF_CLI_TELNET_PORT, + CONF_DISCOVERY, + DEFAULT_SLIMPROTO_PORT, +) +from .provider import SqueezelitePlayerProvider if TYPE_CHECKING: - from aioslimproto.models import SlimEvent from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest @@ -67,76 +25,11 @@ if TYPE_CHECKING: from music_assistant.models import ProviderInstanceType -CACHE_KEY_PREV_STATE = "slimproto_prev_state" - - -STATE_MAP = { - SlimPlayerState.BUFFERING: PlayerState.PLAYING, - SlimPlayerState.BUFFER_READY: PlayerState.PLAYING, - SlimPlayerState.PAUSED: PlayerState.PAUSED, - SlimPlayerState.PLAYING: PlayerState.PLAYING, - SlimPlayerState.STOPPED: PlayerState.IDLE, -} -REPEATMODE_MAP = {RepeatMode.OFF: 0, RepeatMode.ONE: 1, RepeatMode.ALL: 2} - -# sync constants -MIN_DEVIATION_ADJUST = 8 # 5 milliseconds -MIN_REQ_PLAYPOINTS = 8 # we need at least 8 measurements -DEVIATION_JUMP_IGNORE = 500 # ignore a sudden unrealistic jump -MAX_SKIP_AHEAD_MS = 800 # 0.8 seconds - - -@dataclass -class SyncPlayPoint: - """Simple structure to describe a Sync Playpoint.""" - - timestamp: float - sync_master: str - diff: int - - -CONF_CLI_TELNET_PORT = "cli_telnet_port" -CONF_CLI_JSON_PORT = "cli_json_port" -CONF_DISCOVERY = "discovery" -CONF_DISPLAY = "display" -CONF_VISUALIZATION = "visualization" - -DEFAULT_PLAYER_VOLUME = 20 -DEFAULT_SLIMPROTO_PORT = 3483 -DEFAULT_VISUALIZATION = SlimVisualisationType.NONE - - -CONF_ENTRY_DISPLAY = ConfigEntry( - key=CONF_DISPLAY, - type=ConfigEntryType.BOOLEAN, - default_value=False, - required=False, - label="Enable display support", - description="Enable/disable native display support on squeezebox or squeezelite32 hardware.", - category="advanced", -) -CONF_ENTRY_VISUALIZATION = ConfigEntry( - key=CONF_VISUALIZATION, - type=ConfigEntryType.STRING, - default_value=DEFAULT_VISUALIZATION, - options=[ - ConfigValueOption(title=x.name.replace("_", " ").title(), value=x.value) - for x in SlimVisualisationType - ], - required=False, - label="Visualization type", - description="The type of visualization to show on the display " - "during playback if the device supports this.", - category="advanced", - depends_on=CONF_DISPLAY, -) - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - return SlimprotoProvider(mass, manifest, config) + return SqueezelitePlayerProvider(mass, manifest, config) async def get_config_entries( @@ -205,764 +98,5 @@ async def get_config_entries( "The default is 3483 and using a different port is not supported by " "hardware squeezebox players. Only adjust this port if you want to " "use other slimproto based servers side by side with (squeezelite) software players.", - category="advanced", ), ) - - -class SlimprotoProvider(PlayerProvider): - """Base/builtin provider for players using the SLIM protocol (aka slimproto).""" - - slimproto: SlimServer - _sync_playpoints: dict[str, deque[SyncPlayPoint]] - _do_not_resync_before: dict[str, float] - _multi_streams: dict[str, MultiClientStream] - - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return {ProviderFeature.SYNC_PLAYERS} - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - self._sync_playpoints = {} - self._do_not_resync_before = {} - self._multi_streams = {} - control_port = self.config.get_value(CONF_PORT) - telnet_port = self.config.get_value(CONF_CLI_TELNET_PORT) - json_port = self.config.get_value(CONF_CLI_JSON_PORT) - # silence aioslimproto logger a bit - if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): - logging.getLogger("aioslimproto").setLevel(logging.DEBUG) - else: - logging.getLogger("aioslimproto").setLevel(self.logger.level + 10) - self.slimproto = SlimServer( - cli_port=telnet_port or None, - cli_port_json=json_port or None, - ip_address=self.mass.streams.publish_ip, - name="Music Assistant", - control_port=control_port, - ) - # start slimproto socket server - try: - await self.slimproto.start() - except OSError as err: - raise SetupFailedError( - "Unable to start the Slimproto server - " - "is one of the required TCP ports already taken ?" - ) from err - - async def loaded_in_mass(self) -> None: - """Call after the provider has been loaded.""" - await super().loaded_in_mass() - self.slimproto.subscribe(self._client_callback) - self.mass.streams.register_dynamic_route( - "/slimproto/multi", self._serve_multi_client_stream - ) - # it seems that WiiM devices do not use the json rpc port that is broadcasted - # in the discovery info but instead they just assume that the jsonrpc endpoint - # lives on the same server as stream URL. So we need to provide a jsonrpc.js - # endpoint that just redirects to the jsonrpc handler within the slimproto package. - self.mass.streams.register_dynamic_route( - "/jsonrpc.js", self.slimproto.cli._handle_jsonrpc_client - ) - - async def unload(self, is_removed: bool = False) -> None: - """Handle close/cleanup of the provider.""" - self.mass.streams.unregister_dynamic_route("/slimproto/multi") - self.mass.streams.unregister_dynamic_route("/jsonrpc.js") - await self.slimproto.stop() - - async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - base_entries = await super().get_player_config_entries(player_id) - if slimclient := self.slimproto.get_player(player_id): - max_sample_rate = int(slimclient.max_sample_rate) - else: - # player not (yet) connected? use default - max_sample_rate = 48000 - # create preset entries (for players that support it) - preset_entries = () - presets = [] - async for playlist in self.mass.music.playlists.iter_library_items(True): - presets.append(ConfigValueOption(playlist.name, playlist.uri)) - async for radio in self.mass.music.radio.iter_library_items(True): - presets.append(ConfigValueOption(radio.name, radio.uri)) - preset_count = 10 - preset_entries = tuple( - ConfigEntry( - key=f"preset_{index}", - type=ConfigEntryType.STRING, - options=presets, - label=f"Preset {index}", - description="Assign a playable item to the player's preset. " - "Only supported on real squeezebox hardware or jive(lite) based emulators.", - category="presets", - required=False, - ) - for index in range(1, preset_count + 1) - ) - return ( - base_entries - + preset_entries - + ( - CONF_ENTRY_DEPRECATED_EQ_BASS, - CONF_ENTRY_DEPRECATED_EQ_MID, - CONF_ENTRY_DEPRECATED_EQ_TREBLE, - CONF_ENTRY_OUTPUT_CODEC, - CONF_ENTRY_SYNC_ADJUST, - CONF_ENTRY_DISPLAY, - CONF_ENTRY_VISUALIZATION, - CONF_ENTRY_HTTP_PROFILE_FORCED_2, - create_sample_rates_config_entry( - max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24 - ), - ) - ) - - async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: - """Call (by config manager) when the configuration of a player changes.""" - if slimplayer := self.slimproto.get_player(config.player_id): - await self._set_preset_items(slimplayer) - await self._set_display(slimplayer) - await super().on_player_config_change(config, changed_keys) - - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player.""" - # forward command to player and any connected sync members - async with TaskManager(self.mass) as tg: - for slimplayer in self._get_sync_clients(player_id): - tg.create_task(slimplayer.stop()) - - async def cmd_play(self, player_id: str) -> None: - """Send PLAY command to given player.""" - # forward command to player and any connected sync members - async with TaskManager(self.mass) as tg: - for slimplayer in self._get_sync_clients(player_id): - tg.create_task(slimplayer.play()) - - async def play_media( - self, - player_id: str, - media: PlayerMedia, - ) -> None: - """Handle PLAY MEDIA on given player.""" - player = self.mass.players.get(player_id) - if player.synced_to: - msg = "A synced player cannot receive play commands directly" - raise RuntimeError(msg) - - if not player.group_childs: - slimplayer = self.slimproto.get_player(player_id) - # simple, single-player playback - await self._handle_play_url( - slimplayer, - url=media.uri, - media=media, - send_flush=True, - auto_play=False, - ) - return - - # this is a syncgroup, we need to handle this with a multi client stream - master_audio_format = AudioFormat( - content_type=DEFAULT_PCM_FORMAT.content_type, - sample_rate=DEFAULT_PCM_FORMAT.sample_rate, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, - ) - if media.media_type == MediaType.ANNOUNCEMENT: - # special case: stream announcement - audio_source = self.mass.streams.get_announcement_stream( - media.custom_data["url"], - output_format=master_audio_format, - use_pre_announce=media.custom_data["use_pre_announce"], - ) - elif media.media_type == MediaType.PLUGIN_SOURCE: - # special case: plugin source stream - audio_source = self.mass.streams.get_plugin_source_stream( - plugin_source_id=media.custom_data["source_id"], - output_format=master_audio_format, - # need to pass player_id from the PlayerMedia object - # because this could have been a group - player_id=media.custom_data["player_id"], - ) - elif media.queue_id.startswith("ugp_"): - # special case: UGP stream - ugp_provider: PlayerGroupProvider = self.mass.get_provider("player_group") - ugp_stream = ugp_provider.ugp_streams[media.queue_id] - # Filter is later applied in MultiClientStream - audio_source = ugp_stream.get_stream(master_audio_format, filter_params=None) - elif media.queue_id and media.queue_item_id: - # regular queue stream request - audio_source = self.mass.streams.get_queue_flow_stream( - queue=self.mass.player_queues.get(media.queue_id), - start_queue_item=self.mass.player_queues.get_item( - media.queue_id, media.queue_item_id - ), - pcm_format=master_audio_format, - ) - else: - # assume url or some other direct path - # NOTE: this will fail if its an uri not playable by ffmpeg - audio_source = get_ffmpeg_stream( - audio_input=media.uri, - input_format=AudioFormat(ContentType.try_parse(media.uri)), - output_format=master_audio_format, - ) - # start the stream task - self._multi_streams[player_id] = stream = MultiClientStream( - audio_source=audio_source, audio_format=master_audio_format - ) - base_url = f"{self.mass.streams.base_url}/slimproto/multi?player_id={player_id}&fmt=flac" - - # forward to downstream play_media commands - async with TaskManager(self.mass) as tg: - for slimplayer in self._get_sync_clients(player_id): - url = f"{base_url}&child_player_id={slimplayer.player_id}" - stream.expected_clients += 1 - tg.create_task( - self._handle_play_url( - slimplayer, - url=url, - media=media, - send_flush=True, - auto_play=False, - ) - ) - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle enqueuing of the next queue item on the player.""" - if not (slimplayer := self.slimproto.get_player(player_id)): - return - await self._handle_play_url( - slimplayer, - url=media.uri, - media=media, - enqueue=True, - send_flush=False, - auto_play=True, - ) - - async def _handle_play_url( - self, - slimplayer: SlimClient, - url: str, - media: PlayerMedia, - enqueue: bool = False, - send_flush: bool = True, - auto_play: bool = False, - ) -> None: - """Handle playback of an url on slimproto player(s).""" - metadata = { - "item_id": media.uri, - "title": media.title, - "album": media.album, - "artist": media.artist, - "image_url": media.image_url, - "duration": media.duration, - "queue_id": media.queue_id, - "queue_item_id": media.queue_item_id, - } - if queue := self.mass.player_queues.get(media.queue_id): - slimplayer.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode] - slimplayer.extra_data["playlist shuffle"] = int(queue.shuffle_enabled) - await slimplayer.play_url( - url=url, - mime_type=f"audio/{url.split('.')[-1].split('?')[0]}", - metadata=metadata, - enqueue=enqueue, - send_flush=send_flush, - # if autoplay=False playback will not start automatically - # instead 'buffer ready' will be called when the buffer is full - # to coordinate a start of multiple synced players - autostart=auto_play, - ) - # if queue is set to single track repeat, - # immediately set this track as the next - # this prevents race conditions with super short audio clips (on single repeat) - # https://github.com/music-assistant/hass-music-assistant/issues/2059 - if queue and queue.repeat_mode == RepeatMode.ONE: - self.mass.call_later( - 0.2, - slimplayer.play_url( - url=url, - mime_type=f"audio/{url.split('.')[-1].split('?')[0]}", - metadata=metadata, - enqueue=True, - send_flush=False, - autostart=True, - ), - ) - - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player.""" - # forward command to player and any connected sync members - async with TaskManager(self.mass) as tg: - for slimplayer in self._get_sync_clients(player_id): - tg.create_task(slimplayer.pause()) - - async def cmd_power(self, player_id: str, powered: bool) -> None: - """Send POWER command to given player.""" - if slimplayer := self.slimproto.get_player(player_id): - await slimplayer.power(powered) - # store last state in cache - await self.mass.cache.set( - player_id, (powered, slimplayer.volume_level), base_key=CACHE_KEY_PREV_STATE - ) - - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - if slimplayer := self.slimproto.get_player(player_id): - await slimplayer.volume_set(volume_level) - # store last state in cache - await self.mass.cache.set( - player_id, (slimplayer.powered, volume_level), base_key=CACHE_KEY_PREV_STATE - ) - - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - if slimplayer := self.slimproto.get_player(player_id): - await slimplayer.mute(muted) - - async def cmd_group(self, player_id: str, target_player: str) -> None: - """Handle GROUP command for given player.""" - child_player = self.mass.players.get(player_id) - assert child_player # guard - parent_player = self.mass.players.get(target_player) - assert parent_player # guard - if parent_player.synced_to: - raise RuntimeError("Parent player is already synced!") - if child_player.synced_to and child_player.synced_to != target_player: - raise RuntimeError("Player is already synced to another player") - # always make sure that the parent player is part of the sync group - parent_player.group_childs.append(parent_player.player_id) - parent_player.group_childs.append(child_player.player_id) - child_player.synced_to = parent_player.player_id - # check if we should (re)start or join a stream session - # TODO: support late joining of a client into an existing stream session - # so it doesn't need to be restarted anymore. - active_queue = self.mass.player_queues.get_active_queue(parent_player.player_id) - if active_queue.state == PlayerState.PLAYING: - # playback needs to be restarted to form a new multi client stream session - # this could potentially be called by multiple players at the exact same time - # so we debounce the resync a bit here with a timer - self.mass.call_later( - 1, - self.mass.player_queues.resume, - active_queue.queue_id, - fade_in=False, - task_id=f"resume_{active_queue.queue_id}", - ) - else: - # make sure that the player manager gets an update - self.mass.players.update(child_player.player_id, skip_forward=True) - self.mass.players.update(parent_player.player_id, skip_forward=True) - - async def cmd_ungroup(self, player_id: str) -> None: - """Handle UNGROUP command for given player. - - Remove the given player from any (sync)groups it currently is grouped to. - - - player_id: player_id of the player to handle the command. - """ - player = self.mass.players.get(player_id, raise_unavailable=True) - if player.synced_to: - group_leader = self.mass.players.get(player.synced_to, raise_unavailable=True) - if player_id in group_leader.group_childs: - group_leader.group_childs.remove(player_id) - player.synced_to = None - if slimclient := self.slimproto.get_player(player_id): - await slimclient.stop() - # make sure that the player manager gets an update - self.mass.players.update(player.player_id, skip_forward=True) - self.mass.players.update(group_leader.player_id, skip_forward=True) - - def _client_callback( - self, - event: SlimEvent, - ) -> None: - if self.mass.closing: - return - - if event.type == SlimEventType.PLAYER_DISCONNECTED: - if mass_player := self.mass.players.get(event.player_id): - mass_player.available = False - self.mass.players.update(mass_player.player_id) - return - - if not (slimplayer := self.slimproto.get_player(event.player_id)): - return - - if event.type == SlimEventType.PLAYER_CONNECTED: - self.mass.create_task(self._handle_connected(slimplayer)) - return - - if event.type == SlimEventType.PLAYER_BUFFER_READY: - self.mass.create_task(self._handle_buffer_ready(slimplayer)) - return - - if event.type == SlimEventType.PLAYER_HEARTBEAT: - self._handle_player_heartbeat(slimplayer) - return - - if event.type in (SlimEventType.PLAYER_BTN_EVENT, SlimEventType.PLAYER_CLI_EVENT): - self.mass.create_task(self._handle_player_cli_event(slimplayer, event)) - return - - # forward player update to MA player controller - self.mass.create_task(self._handle_player_update(slimplayer)) - - async def _handle_player_update(self, slimplayer: SlimClient) -> None: - """Process SlimClient update/add to Player controller.""" - player_id = slimplayer.player_id - player = self.mass.players.get(player_id, raise_unavailable=False) - if not player: - # player does not yet exist, create it - player = Player( - player_id=player_id, - provider=self.instance_id, - type=PlayerType.PLAYER, - name=slimplayer.name, - available=True, - powered=slimplayer.powered, - device_info=DeviceInfo( - model=slimplayer.device_model, - ip_address=slimplayer.device_address, - manufacturer=slimplayer.device_type, - ), - supported_features={ - PlayerFeature.POWER, - PlayerFeature.SET_MEMBERS, - PlayerFeature.MULTI_DEVICE_DSP, - PlayerFeature.VOLUME_SET, - PlayerFeature.PAUSE, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE, - PlayerFeature.GAPLESS_PLAYBACK, - }, - can_group_with={self.instance_id}, - ) - await self.mass.players.register_or_update(player) - - # update player state on player events - player.available = True - if slimplayer.current_media and (metadata := slimplayer.current_media.metadata): - player.current_media = PlayerMedia( - uri=metadata.get("item_id"), - title=metadata.get("title"), - album=metadata.get("album"), - artist=metadata.get("artist"), - image_url=metadata.get("image_url"), - duration=metadata.get("duration"), - queue_id=metadata.get("queue_id"), - queue_item_id=metadata.get("queue_item_id"), - ) - else: - player.current_media = None - player.active_source = player.player_id - player.name = slimplayer.name - player.powered = slimplayer.powered - player.state = STATE_MAP[slimplayer.state] - player.volume_level = slimplayer.volume_level - player.volume_muted = slimplayer.muted - self.mass.players.update(player_id) - - def _handle_player_heartbeat(self, slimplayer: SlimClient) -> None: - """Process SlimClient elapsed_time update.""" - if slimplayer.state == SlimPlayerState.STOPPED: - # ignore server heartbeats when stopped - return - - # elapsed time change on the player will be auto picked up - # by the player manager. - if not (player := self.mass.players.get(slimplayer.player_id)): - # race condition?! - return - player.elapsed_time = slimplayer.elapsed_seconds - player.elapsed_time_last_updated = time.time() - - # handle sync - if player.synced_to: - self._handle_client_sync(slimplayer) - - async def _handle_player_cli_event(self, slimplayer: SlimClient, event: SlimEvent) -> None: - """Process CLI Event.""" - if not event.data: - return - queue = self.mass.player_queues.get_active_queue(slimplayer.player_id) - if event.data.startswith("button preset_") and event.data.endswith(".single"): - preset_id = event.data.split("preset_")[1].split(".")[0] - preset_index = int(preset_id) - 1 - if len(slimplayer.presets) >= preset_index + 1: - preset = slimplayer.presets[preset_index] - await self.mass.player_queues.play_media(queue.queue_id, preset.uri) - elif event.data == "button repeat": - if queue.repeat_mode == RepeatMode.OFF: - repeat_mode = RepeatMode.ONE - elif queue.repeat_mode == RepeatMode.ONE: - repeat_mode = RepeatMode.ALL - else: - repeat_mode = RepeatMode.OFF - self.mass.player_queues.set_repeat(queue.queue_id, repeat_mode) - slimplayer.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode] - slimplayer.signal_update() - elif event.data == "button shuffle": - self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) - slimplayer.extra_data["playlist shuffle"] = int(queue.shuffle_enabled) - slimplayer.signal_update() - elif event.data in ("button jump_fwd", "button fwd"): - await self.mass.player_queues.next(queue.queue_id) - elif event.data in ("button jump_rew", "button rew"): - await self.mass.player_queues.previous(queue.queue_id) - elif event.data.startswith("time "): - # seek request - _, param = event.data.split(" ", 1) - if param.isnumeric(): - await self.mass.player_queues.seek(queue.queue_id, int(param)) - self.logger.debug("CLI Event: %s", event.data) - - def _handle_client_sync(self, slimplayer: SlimClient) -> None: - """Synchronize audio of a sync slimplayer.""" - player = self.mass.players.get(slimplayer.player_id) - sync_master_id = player.synced_to - if not sync_master_id: - # we only correct sync members, not the sync master itself - return - if not (sync_master := self.slimproto.get_player(sync_master_id)): - return # just here as a guard as bad things can happen - - if sync_master.state != SlimPlayerState.PLAYING: - return - if slimplayer.state != SlimPlayerState.PLAYING: - return - if slimplayer.player_id not in self._sync_playpoints: - return - - # we collect a few playpoints of the player to determine - # average lag/drift so we can adjust accordingly - sync_playpoints = self._sync_playpoints[slimplayer.player_id] - - now = time.time() - if now < self._do_not_resync_before[slimplayer.player_id]: - return - - last_playpoint = sync_playpoints[-1] if sync_playpoints else None - if last_playpoint and (now - last_playpoint.timestamp) > 10: - # last playpoint is too old, invalidate - sync_playpoints.clear() - if last_playpoint and last_playpoint.sync_master != sync_master.player_id: - # this should not happen, but just in case - sync_playpoints.clear() - - diff = int( - self._get_corrected_elapsed_milliseconds(sync_master) - - self._get_corrected_elapsed_milliseconds(slimplayer) - ) - - sync_playpoints.append(SyncPlayPoint(now, sync_master.player_id, diff)) - - # ignore unexpected spikes - if ( - sync_playpoints - and abs(statistics.fmean(abs(x.diff) for x in sync_playpoints) - abs(diff)) - > DEVIATION_JUMP_IGNORE - ): - return - - min_req_playpoints = 2 if sync_master.elapsed_seconds < 2 else MIN_REQ_PLAYPOINTS - if len(sync_playpoints) < min_req_playpoints: - return - - # get the average diff - avg_diff = statistics.fmean(x.diff for x in sync_playpoints) - delta = int(abs(avg_diff)) - - if delta < MIN_DEVIATION_ADJUST: - return - - # resync the player by skipping ahead or pause for x amount of (milli)seconds - sync_playpoints.clear() - self._do_not_resync_before[player.player_id] = now + 5 - if avg_diff > MAX_SKIP_AHEAD_MS: - # player lagging behind more than MAX_SKIP_AHEAD_MS, - # we need to correct the sync_master - self.logger.debug("%s resync: pauseFor %sms", sync_master.name, delta) - self.mass.create_task(sync_master.pause_for(delta)) - elif avg_diff > 0: - # handle player lagging behind, fix with skip_ahead - self.logger.debug("%s resync: skipAhead %sms", player.display_name, delta) - self.mass.create_task(slimplayer.skip_over(delta)) - else: - # handle player is drifting too far ahead, use pause_for to adjust - self.logger.debug("%s resync: pauseFor %sms", player.display_name, delta) - self.mass.create_task(slimplayer.pause_for(delta)) - - async def _handle_buffer_ready(self, slimplayer: SlimClient) -> None: - """Handle buffer ready event, player has buffered a (new) track. - - Only used when autoplay=0 for coordinated start of synced players. - """ - player = self.mass.players.get(slimplayer.player_id) - if player.synced_to: - # unpause of sync child is handled by sync master - return - if not player.group_childs: - # not a sync group, continue - await slimplayer.unpause_at(slimplayer.jiffies) - return - count = 0 - while count < 40: - childs_total = 0 - childs_ready = 0 - await asyncio.sleep(0.2) - for sync_child in self._get_sync_clients(player.player_id): - childs_total += 1 - if sync_child.state == SlimPlayerState.BUFFER_READY: - childs_ready += 1 - if childs_total == childs_ready: - break - - # all child's ready (or timeout) - start play - async with TaskManager(self.mass) as tg: - for _client in self._get_sync_clients(player.player_id): - self._sync_playpoints.setdefault( - _client.player_id, deque(maxlen=MIN_REQ_PLAYPOINTS) - ).clear() - # NOTE: Officially you should do an unpause_at based on the player timestamp - # but I did not have any good results with that. - # Instead just start playback on all players and let the sync logic work out - # the delays etc. - self._do_not_resync_before[_client.player_id] = time.time() + 1 - tg.create_task(_client.pause_for(200)) - - async def _handle_connected(self, slimplayer: SlimClient) -> None: - """Handle a slimplayer connected event.""" - player_id = slimplayer.player_id - self.logger.info("Player %s connected", slimplayer.name or player_id) - # set presets and display - await self._set_preset_items(slimplayer) - await self._set_display(slimplayer) - # update all attributes - await self._handle_player_update(slimplayer) - # restore volume and power state - if last_state := await self.mass.cache.get(player_id, base_key=CACHE_KEY_PREV_STATE): - init_power = last_state[0] - init_volume = last_state[1] - else: - init_volume = DEFAULT_PLAYER_VOLUME - init_power = False - await slimplayer.power(init_power) - await slimplayer.stop() - await slimplayer.volume_set(init_volume) - - def _get_sync_clients(self, player_id: str) -> Iterator[SlimClient]: - """Get all sync clients for a player.""" - player = self.mass.players.get(player_id) - # we need to return the player itself too - group_child_ids = {player_id} - group_child_ids.update(player.group_childs) - for child_id in group_child_ids: - if slimplayer := self.slimproto.get_player(child_id): - yield slimplayer - - def _get_corrected_elapsed_milliseconds(self, slimplayer: SlimClient) -> int: - """Return corrected elapsed milliseconds.""" - sync_delay = self.mass.config.get_raw_player_config_value( - slimplayer.player_id, CONF_SYNC_ADJUST, 0 - ) - return slimplayer.elapsed_milliseconds - sync_delay - - async def _set_preset_items(self, slimplayer: SlimClient) -> None: - """Set the presets for a player.""" - preset_items: list[SlimPreset] = [] - for preset_index in range(1, 11): - if preset_conf := self.mass.config.get_raw_player_config_value( - slimplayer.player_id, f"preset_{preset_index}" - ): - try: - media_item = await self.mass.music.get_item_by_uri(preset_conf) - preset_items.append( - SlimPreset( - uri=media_item.uri, - text=media_item.name, - icon=self.mass.metadata.get_image_url(media_item.image), - ) - ) - except MusicAssistantError: - # non-existing media item or some other edge case - preset_items.append( - SlimPreset( - uri=f"preset_{preset_index}", - text=f"ERROR ", - icon="", - ) - ) - else: - break - slimplayer.presets = preset_items - - async def _set_display(self, slimplayer: SlimClient) -> None: - """Set the display config for a player.""" - display_enabled = self.mass.config.get_raw_player_config_value( - slimplayer.player_id, - CONF_ENTRY_DISPLAY.key, - CONF_ENTRY_DISPLAY.default_value, - ) - visualization = self.mass.config.get_raw_player_config_value( - slimplayer.player_id, - CONF_ENTRY_VISUALIZATION.key, - CONF_ENTRY_VISUALIZATION.default_value, - ) - await slimplayer.configure_display( - visualisation=SlimVisualisationType(visualization), disabled=not display_enabled - ) - - async def _serve_multi_client_stream(self, request: web.Request) -> web.Response: - """Serve the multi-client flow stream audio to a player.""" - player_id = request.query.get("player_id") - fmt = request.query.get("fmt") - child_player_id = request.query.get("child_player_id") - - if not self.mass.players.get(player_id): - raise web.HTTPNotFound(reason=f"Unknown player: {player_id}") - - if not (child_player := self.mass.players.get(child_player_id)): - raise web.HTTPNotFound(reason=f"Unknown player: {child_player_id}") - - if not (stream := self._multi_streams.get(player_id, None)) or stream.done: - raise web.HTTPNotFound(f"There is no active stream for {player_id}!") - - resp = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": f"audio/{fmt}", - }, - ) - await resp.prepare(request) - - # return early if this is not a GET request - if request.method != "GET": - return resp - - # all checks passed, start streaming! - self.logger.debug( - "Start serving multi-client flow audio stream to %s", - child_player.display_name, - ) - output_format = AudioFormat(content_type=ContentType.try_parse(fmt)) - async for chunk in stream.get_stream( - output_format=output_format, - filter_params=get_player_filter_params( - self.mass, child_player_id, stream.audio_format, output_format - ) - if child_player_id - else None, - ): - try: - await resp.write(chunk) - except (BrokenPipeError, ConnectionResetError, ConnectionError): - # race condition - break - - return resp diff --git a/music_assistant/providers/squeezelite/constants.py b/music_assistant/providers/squeezelite/constants.py new file mode 100644 index 00000000..1c337898 --- /dev/null +++ b/music_assistant/providers/squeezelite/constants.py @@ -0,0 +1,16 @@ +"""Constants for the Squeezelite player provider.""" + +from __future__ import annotations + +from aioslimproto.models import VisualisationType as SlimVisualisationType + +CONF_CLI_TELNET_PORT = "cli_telnet_port" +CONF_CLI_JSON_PORT = "cli_json_port" +CONF_DISCOVERY = "discovery" +CONF_PORT = "port" +DEFAULT_SLIMPROTO_PORT = 3483 +CONF_DISPLAY = "display" +CONF_VISUALIZATION = "visualization" + + +DEFAULT_VISUALIZATION = SlimVisualisationType.NONE diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py new file mode 100644 index 00000000..df123888 --- /dev/null +++ b/music_assistant/providers/squeezelite/player.py @@ -0,0 +1,400 @@ +"""Squeezelite Player implementation.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from aioslimproto.client import PlayerState as SlimPlayerState +from aioslimproto.client import SlimClient +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, PlayerConfig +from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.media_items import AudioFormat + +from music_assistant.constants import ( + CONF_ENTRY_DEPRECATED_EQ_BASS, + CONF_ENTRY_DEPRECATED_EQ_MID, + CONF_ENTRY_DEPRECATED_EQ_TREBLE, + CONF_ENTRY_HTTP_PROFILE_FORCED_2, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_SYNC_ADJUST, + DEFAULT_PCM_FORMAT, + create_sample_rates_config_entry, +) +from music_assistant.helpers.util import TaskManager +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia + +from .constants import ( + CONF_DISPLAY, + CONF_VISUALIZATION, + DEFAULT_VISUALIZATION, + SlimVisualisationType, +) + +if TYPE_CHECKING: + from aioslimproto.models import EventType as SlimEventType + + from .provider import SqueezelitePlayerProvider + + +STATE_MAP = { + SlimPlayerState.BUFFERING: PlaybackState.PLAYING, + SlimPlayerState.BUFFER_READY: PlaybackState.PLAYING, + SlimPlayerState.PAUSED: PlaybackState.PAUSED, + SlimPlayerState.PLAYING: PlaybackState.PLAYING, + SlimPlayerState.STOPPED: PlaybackState.IDLE, +} + +CONF_ENTRY_DISPLAY = ConfigEntry( + key=CONF_DISPLAY, + type=ConfigEntryType.BOOLEAN, + default_value=False, + required=False, + label="Enable display support", + description="Enable/disable native display support on squeezebox or squeezelite32 hardware.", + category="advanced", +) +CONF_ENTRY_VISUALIZATION = ConfigEntry( + key=CONF_VISUALIZATION, + type=ConfigEntryType.STRING, + default_value=DEFAULT_VISUALIZATION, + options=[ + ConfigValueOption(title=x.name.replace("_", " ").title(), value=x.value) + for x in SlimVisualisationType + ], + required=False, + label="Visualization type", + description="The type of visualization to show on the display " + "during playback if the device supports this.", + category="advanced", + depends_on=CONF_DISPLAY, +) + + +class SqueezelitePlayer(Player): + """Squeezelite Player implementation.""" + + _attr_type = PlayerType.PLAYER + + def __init__( + self, + provider: SqueezelitePlayerProvider, + player_id: str, + client: SlimClient, + ) -> None: + """Initialize the Squeezelite Player.""" + super().__init__(provider, player_id) + self.client = client + self.provider: SqueezelitePlayerProvider = provider + + # Set static player attributes + self._attr_supported_features = { + PlayerFeature.POWER, + PlayerFeature.SET_MEMBERS, + PlayerFeature.MULTI_DEVICE_DSP, + PlayerFeature.VOLUME_SET, + PlayerFeature.PAUSE, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.ENQUEUE, + PlayerFeature.GAPLESS_PLAYBACK, + } + self._attr_name = client.name + self._attr_available = True + self._attr_powered = client.powered + self._attr_device_info = DeviceInfo( + model=client.device_model, + ip_address=client.device_address, + manufacturer=client.device_type, + ) + self._attr_can_group_with = {provider.instance_id} + + async def setup(self) -> None: + """Set up the player.""" + await self.mass.players.register_or_update(self) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the player.""" + base_entries = await super().get_config_entries() + max_sample_rate = int(self.client.max_sample_rate) + # create preset entries (for players that support it) + preset_entries = () + presets = [] + async for playlist in self.mass.music.playlists.iter_library_items(True): + presets.append(ConfigValueOption(playlist.name, playlist.uri)) + async for radio in self.mass.music.radio.iter_library_items(True): + presets.append(ConfigValueOption(radio.name, radio.uri)) + preset_count = 10 + preset_entries = tuple( + ConfigEntry( + key=f"preset_{index}", + type=ConfigEntryType.STRING, + options=presets, + label=f"Preset {index}", + description="Assign a playable item to the player's preset. " + "Only supported on real squeezebox hardware or jive(lite) based emulators.", + category="presets", + required=False, + ) + for index in range(1, preset_count + 1) + ) + return ( + base_entries + + preset_entries + + ( + CONF_ENTRY_DEPRECATED_EQ_BASS, + CONF_ENTRY_DEPRECATED_EQ_MID, + CONF_ENTRY_DEPRECATED_EQ_TREBLE, + CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_SYNC_ADJUST, + CONF_ENTRY_DISPLAY, + CONF_ENTRY_VISUALIZATION, + CONF_ENTRY_HTTP_PROFILE_FORCED_2, + create_sample_rates_config_entry( + max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24 + ), + ) + ) + + async def handle_slim_event(self, event: SlimEventType) -> None: + """Handle player update from slimproto server.""" + # Update player state from slim player + self._attr_available = True + self._attr_name = self.client.name + self._attr_powered = self.client.powered + self._attr_playback_state = STATE_MAP[self.client.state] + self._attr_volume_level = self.client.volume_level + self._attr_volume_muted = self.client.muted + self._attr_active_source = self.player_id + + # Update current media if available + if self.client.current_media and (metadata := self.client.current_media.metadata): + self._attr_current_media = PlayerMedia( + uri=metadata.get("item_id"), + title=metadata.get("title"), + album=metadata.get("album"), + artist=metadata.get("artist"), + image_url=metadata.get("image_url"), + duration=metadata.get("duration"), + queue_id=metadata.get("queue_id"), + queue_item_id=metadata.get("queue_item_id"), + ) + else: + self._attr_current_media = None + + self.update_state() + + async def power(self, powered: bool) -> None: + """Handle POWER command on the player.""" + if powered: + await self.client.power_on() + else: + await self.client.power_off() + + async def volume_set(self, volume_level: int) -> None: + """Handle VOLUME_SET command on the player.""" + await self.client.volume_set(volume_level) + + async def volume_mute(self, muted: bool) -> None: + """Handle VOLUME MUTE command on the player.""" + await self.client.volume_mute(muted) + + async def stop(self) -> None: + """Handle STOP command on the player.""" + async with TaskManager(self.mass) as tg: + for client in self.provider._get_sync_clients(self.player_id): + tg.create_task(client.stop()) + + async def play(self) -> None: + """Handle PLAY command on the player.""" + async with TaskManager(self.mass) as tg: + for client in self.provider._get_sync_clients(self.player_id): + tg.create_task(client.play()) + + async def pause(self) -> None: + """Handle PAUSE command on the player.""" + async with TaskManager(self.mass) as tg: + for client in self.provider._get_sync_clients(self.player_id): + tg.create_task(client.pause()) + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on the player.""" + if self.synced_to: + msg = "A synced player cannot receive play commands directly" + raise RuntimeError(msg) + + if not self.group_members: + # Simple, single-player playback + await self._handle_play_url( + self.client, + url=media.uri, + media=media, + send_flush=True, + auto_play=False, + ) + return + + # This is a syncgroup, we need to handle this with a multi client stream + master_audio_format = AudioFormat( + content_type=DEFAULT_PCM_FORMAT.content_type, + sample_rate=48000, # Default for squeezelite + bit_depth=16, + channels=2, + ) + + # Start multi-client stream for sync group + await self._handle_multi_client_stream(media, master_audio_format) + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing next media item.""" + if self.synced_to: + msg = "A synced player cannot receive enqueue commands directly" + raise RuntimeError(msg) + + # Handle enqueue for single player or sync group + if not self.group_members: + await self._handle_play_url( + self.client, + url=media.uri, + media=media, + send_flush=False, + auto_play=True, + ) + else: + # Handle multi-client enqueue + await self._handle_multi_client_enqueue(media) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + if self.synced_to: + # this should not happen, but guard anyways + raise RuntimeError("Player is synced, cannot set members") + if not player_ids_to_add and not player_ids_to_remove: + # nothing to do + return + + raop_session = self.raop_stream.session if self.raop_stream else None + # handle removals first + if player_ids_to_remove: + if self.player_id in player_ids_to_remove: + # dissolve the entire sync group + if self.raop_stream and self.raop_stream.running: + # stop the stream session if it is running + await self.raop_stream.session.stop() + self._attr_group_members = [] + self.update_state() + return + + for child_player in self._get_sync_clients(): + if child_player.player_id in player_ids_to_remove: + if raop_session: + await raop_session.remove_client(child_player) + self._attr_group_members.remove(child_player.player_id) + + # handle additions + for player_id in player_ids_to_add or []: + if player_id == self.player_id or player_id in self.group_members: + # nothing to do: player is already part of the group + continue + child_player: SqueezelitePlayer | None = self.mass.players.get(player_id) + if not child_player: + # should not happen, but guard against it + continue + if child_player.synced_to and child_player.synced_to != self.player_id: + raise RuntimeError("Player is already synced to another player") + + # ensure the child does not have an existing stream session active + if child_player := self.mass.players.get(player_id): + if ( + child_player.raop_stream + and child_player.raop_stream.running + and child_player.raop_stream.session != raop_session + ): + await child_player.raop_stream.session.remove_client(child_player) + + # add new child to the existing raop session (if any) + self._attr_group_members.append(player_id) + if raop_session: + await raop_session.add_client(child_player) + + # always update the state after modifying group members + self.update_state() + + def set_config(self, config: PlayerConfig) -> None: + """Set/update the player config.""" + super().set_config(config) + self.mass.create_task(self._set_preset_items()) + self.mass.create_task(self._set_display()) + + async def _handle_play_url( + self, + client: SlimClient, + url: str, + media: PlayerMedia, + send_flush: bool = True, + auto_play: bool = True, + ) -> None: + """Handle playing a URL on a client.""" + if send_flush: + await client.flush() + + # Send play command with metadata + metadata = { + "item_id": media.uri, + "title": media.title, + "album": media.album, + "artist": media.artist, + "image_url": media.image_url, + "duration": media.duration, + "queue_id": media.queue_id, + "queue_item_id": media.queue_item_id, + } + + await client.play_url(url, metadata=metadata, auto_play=auto_play) + + def _get_sync_clients(self) -> Iterator[SlimClient]: + """Get all sync clients for a player.""" + yield self.client + for member_id in self.group_members: + yield self.provider.slimproto.get_player(member_id) + + async def _handle_multi_client_stream( + self, media: PlayerMedia, master_audio_format: AudioFormat + ) -> None: + """Handle multi-client stream for sync groups.""" + # This would need implementation of the multi-client streaming logic + # For now, simplified implementation + sync_clients = list(self.provider._get_sync_clients(self.player_id)) + + # Play on all sync clients + async with TaskManager(self.mass) as tg: + for slimclient in sync_clients: + tg.create_task( + self._handle_play_url( + slimclient, + media.uri, + media, + send_flush=True, + auto_play=False, + ) + ) + + async def _handle_multi_client_enqueue(self, media: PlayerMedia) -> None: + """Handle multi-client enqueue for sync groups.""" + sync_clients = list(self.provider._get_sync_clients(self.player_id)) + + # Enqueue on all sync clients + async with TaskManager(self.mass) as tg: + for slimclient in sync_clients: + tg.create_task( + self._handle_play_url( + slimclient, + media.uri, + media, + send_flush=False, + auto_play=True, + ) + ) diff --git a/music_assistant/providers/squeezelite/provider.py b/music_assistant/providers/squeezelite/provider.py new file mode 100644 index 00000000..8f844d9c --- /dev/null +++ b/music_assistant/providers/squeezelite/provider.py @@ -0,0 +1,134 @@ +"""Squeezelite Player Provider implementation.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from aioslimproto.server import SlimServer +from music_assistant_models.enums import ProviderFeature +from music_assistant_models.errors import SetupFailedError + +from music_assistant.constants import CONF_PORT, VERBOSE_LOG_LEVEL +from music_assistant.helpers.util import is_port_in_use +from music_assistant.models.player_provider import PlayerProvider + +from .constants import CONF_CLI_JSON_PORT, CONF_CLI_TELNET_PORT +from .multi_client_stream import MultiClientStream +from .player import SqueezelitePlayer + +if TYPE_CHECKING: + from aioslimproto.client import SlimClient + from aioslimproto.models import EventType as SlimEventType + + +@dataclass +class StreamInfo: + """Dataclass to store stream information.""" + + stream_id: str + players: list[str] + stream_obj: MultiClientStream + + +class SqueezelitePlayerProvider(PlayerProvider): + """Player provider for players using slimproto (like Squeezelite).""" + + def __init__(self, *args, **kwargs) -> None: + """Initialize the provider.""" + super().__init__(*args, **kwargs) + self.slimproto: SlimServer | None = None + self._players: dict[str, SqueezelitePlayer] = {} + self._multi_client_streams: dict[str, StreamInfo] = {} + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.SYNC_PLAYERS} + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + # set-up aioslimproto logging + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("aioslimproto").setLevel(logging.DEBUG) + else: + logging.getLogger("aioslimproto").setLevel(self.logger.level + 10) + # setup slimproto server + control_port = self.config.get_value(CONF_PORT) + if await is_port_in_use(control_port): + msg = f"Port {control_port} is not available" + raise SetupFailedError(msg) + telnet_port = self.config.get_value(CONF_CLI_TELNET_PORT) + if telnet_port is not None and await is_port_in_use(telnet_port): + msg = f"Telnet port {telnet_port} is not available" + raise SetupFailedError(msg) + json_port = self.config.get_value(CONF_CLI_JSON_PORT) + if json_port is not None and await is_port_in_use(json_port): + msg = f"JSON port {json_port} is not available" + raise SetupFailedError(msg) + # silence aioslimproto logger a bit + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("aioslimproto").setLevel(logging.DEBUG) + else: + logging.getLogger("aioslimproto").setLevel(self.logger.level + 10) + self.slimproto = SlimServer( + cli_port=telnet_port or None, + cli_port_json=json_port or None, + ip_address=self.mass.streams.publish_ip, + name="Music Assistant", + control_port=control_port, + ) + # start slimproto socket server + await self.slimproto.start() + + async def loaded_in_mass(self) -> None: + """Call after the provider has been loaded.""" + await super().loaded_in_mass() + self.slimproto.subscribe(self._client_callback) + self.mass.streams.register_dynamic_route( + "/slimproto/multi", self._serve_multi_client_stream + ) + # it seems that WiiM devices do not use the json rpc port that is broadcasted + # in the discovery info but instead they just assume that the jsonrpc endpoint + # lives on the same server as stream URL. So we need to provide a jsonrpc.js + # endpoint that just redirects to the jsonrpc handler within the slimproto package. + self.mass.streams.register_dynamic_route( + "/jsonrpc.js", self.slimproto.cli._handle_jsonrpc_client + ) + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + if self.slimproto: + await self.slimproto.stop() + self.mass.streams.unregister_dynamic_route("/slimproto/multi") + self.mass.streams.unregister_dynamic_route("/jsonrpc.js") + + async def _player_join(self, slimplayer: SlimClient) -> None: + """Handle player joining the slimproto server.""" + player_id = slimplayer.player_id + if player_id in self._players: + return + + self.logger.debug("Player %s joined the server", player_id) + + # Create SqueezelitePlayer instance + player = SqueezelitePlayer(self, player_id, slimplayer) + self._players[player_id] = player + + # Register with Music Assistant + await player.setup() + + async def _player_leave(self, player_id: str) -> None: + """Handle player leaving the slimproto server.""" + self.logger.debug("Player %s left the server", player_id) + + if self._players.pop(player_id, None): + if mass_player := self.mass.players.get(player_id): + mass_player.available = False + self.mass.players.update(player_id) + + async def _player_update(self, player_id: str, event: SlimEventType) -> None: + """Handle player update from slimproto server.""" + if player := self._players.get(player_id): + await player.handle_slim_event(event) diff --git a/music_assistant/providers/tidal/__init__.py b/music_assistant/providers/tidal/__init__.py index 373c5065..5b409728 100644 --- a/music_assistant/providers/tidal/__init__.py +++ b/music_assistant/providers/tidal/__init__.py @@ -345,10 +345,8 @@ class TidalProvider(MusicProvider): # Handle conversion from ISO format to timestamp if needed if isinstance(expires_at, str) and "T" in expires_at: # This looks like an ISO format date - import datetime - try: - dt = datetime.datetime.fromisoformat(expires_at) + dt = datetime.fromisoformat(expires_at) # Convert to timestamp expires_at = dt.timestamp() # Update the config with the numeric value diff --git a/music_assistant/providers/universal_group/__init__.py b/music_assistant/providers/universal_group/__init__.py new file mode 100644 index 00000000..c16a278c --- /dev/null +++ b/music_assistant/providers/universal_group/__init__.py @@ -0,0 +1,52 @@ +""" +Universal Group Player provider. + +Create universal groups to group speakers of different +protocols/ecosystems to play the same audio (but not in sync). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .player import UniversalGroupPlayer +from .provider import UniversalGroupProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant import MusicAssistant + from music_assistant.models import ProviderInstanceType + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return UniversalGroupProvider(mass, manifest, config) + + +async def get_config_entries( + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 +) -> 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. + """ + # nothing to configure (for now) + return () + + +__all__ = ( + "UniversalGroupPlayer", + "UniversalGroupProvider", + "get_config_entries", + "setup", +) diff --git a/music_assistant/providers/universal_group/constants.py b/music_assistant/providers/universal_group/constants.py new file mode 100644 index 00000000..085cf28e --- /dev/null +++ b/music_assistant/providers/universal_group/constants.py @@ -0,0 +1,35 @@ +"""Universal Group Player constants.""" + +from __future__ import annotations + +from typing import Final + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType +from music_assistant_models.media_items import AudioFormat + +from music_assistant.constants import DEFAULT_PCM_FORMAT, create_sample_rates_config_entry + +UGP_PREFIX: Final[str] = "ugp_" + + +CONF_ENTRY_SAMPLE_RATES_UGP = create_sample_rates_config_entry( + max_sample_rate=96000, max_bit_depth=24, hidden=True +) +CONFIG_ENTRY_UGP_NOTE = ConfigEntry( + key="ugp_note", + type=ConfigEntryType.LABEL, + label="Please note that although the Universal Group " + "allows you to group any player, it will not (and can not) enable audio sync " + "between players of different ecosystems. It is advised to always use native " + "player groups or sync groups when available for your player type(s) and use " + "the Universal Group only to group players of different ecosystems/protocols.", + required=False, +) + + +UGP_FORMAT = AudioFormat( + content_type=DEFAULT_PCM_FORMAT.content_type, + sample_rate=DEFAULT_PCM_FORMAT.sample_rate, + bit_depth=DEFAULT_PCM_FORMAT.bit_depth, +) diff --git a/music_assistant/providers/universal_group/manifest.json b/music_assistant/providers/universal_group/manifest.json new file mode 100644 index 00000000..ac9e2963 --- /dev/null +++ b/music_assistant/providers/universal_group/manifest.json @@ -0,0 +1,13 @@ +{ + "type": "player", + "domain": "universal_group", + "stage": "beta", + "name": "Universal Group Player", + "description": "Create universal groups to group speakers of different protocols/ecosystems to play the same audio (but not in sync).", + "codeowners": ["@music-assistant"], + "requirements": [], + "documentation": "https://music-assistant.io/faq/groups/", + "multi_instance": false, + "builtin": false, + "icon": "speaker-multiple" +} diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py new file mode 100644 index 00000000..d0df12c5 --- /dev/null +++ b/music_assistant/providers/universal_group/player.py @@ -0,0 +1,429 @@ +"""Group Player implementation.""" + +from __future__ import annotations + +import asyncio +from time import time +from typing import TYPE_CHECKING, cast + +from aiohttp import web +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + ConfigEntryType, + ContentType, + MediaType, + PlaybackState, + PlayerFeature, +) +from music_assistant_models.errors import UnsupportedFeaturedException +from music_assistant_models.media_items import AudioFormat +from propcache import under_cached_property as cached_property + +from music_assistant.constants import ( + CONF_DYNAMIC_GROUP_MEMBERS, + CONF_ENTRY_FLOW_MODE_ENFORCED, + CONF_GROUP_MEMBERS, + CONF_HTTP_PROFILE, + DEFAULT_STREAM_HEADERS, +) +from music_assistant.helpers.audio import get_player_filter_params +from music_assistant.helpers.ffmpeg import get_ffmpeg_stream +from music_assistant.helpers.util import TaskManager +from music_assistant.models.player import DeviceInfo, GroupPlayer, PlayerMedia +from music_assistant.providers.universal_group.constants import UGP_FORMAT + +from .constants import CONF_ENTRY_SAMPLE_RATES_UGP, CONFIG_ENTRY_UGP_NOTE, UGP_PREFIX +from .ugp_stream import UGPStream + +if TYPE_CHECKING: + from .provider import UniversalGroupProvider + +BASE_FEATURES = {PlayerFeature.POWER, PlayerFeature.VOLUME_SET, PlayerFeature.MULTI_DEVICE_DSP} + + +class UniversalGroupPlayer(GroupPlayer): + """Universal Group Player implementation.""" + + def __init__( + self, + provider: UniversalGroupProvider, + player_id: str, + ) -> None: + """Initialize GroupPlayer instance.""" + super().__init__(provider, player_id) + self.stream: UGPStream | None = None + self._attr_name = self.config.name or f"Universal Group {player_id}" + self._attr_available = True + self._attr_powered = False # group players are always powered off by default + self._attr_active_source = player_id + self._attr_group_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) + self._attr_device_info = DeviceInfo(model="Universal Group", manufacturer=provider.name) + self._attr_supported_features = {*BASE_FEATURES} + # register dynamic route for the ugp stream + self._on_unload_callbacks.append( + self.mass.streams.register_dynamic_route( + f"/ugp/{self.player_id}.flac", self._serve_ugp_stream + ) + ) + self._on_unload_callbacks.append( + self.mass.streams.register_dynamic_route( + f"/ugp/{self.player_id}.mp3", self._serve_ugp_stream + ) + ) + # allow grouping with all providers, except the ugp provider itself + self._attr_can_group_with = { + x.instance_id + for x in self.mass.players.providers + if x.instance_id != self.provider.instance_id + } + self._set_attributes() + + @cached_property + def is_dynamic(self) -> bool: + """Return if the player is a dynamic group player.""" + return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False)) + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + return [ + # default entries for player groups + *await super().get_config_entries(), + # add universal group specific entries + CONFIG_ENTRY_UGP_NOTE, + ConfigEntry( + key=CONF_GROUP_MEMBERS, + type=ConfigEntryType.STRING, + multi_value=True, + label="Group members", + default_value=[], + description="Select all players you want to be part of this group", + required=False, # needed for dynamic members (which allows empty members list) + options=[ + ConfigValueOption(x.display_name, x.player_id) + for x in self.mass.players.all(True, False) + if not x.player_id.startswith(UGP_PREFIX) + ], + ), + ConfigEntry( + key=CONF_DYNAMIC_GROUP_MEMBERS, + type=ConfigEntryType.BOOLEAN, + label="Enable dynamic members", + description="Allow members to (temporary) join/leave the group dynamically.", + default_value=False, + required=False, + ), + CONF_ENTRY_SAMPLE_RATES_UGP, + CONF_ENTRY_FLOW_MODE_ENFORCED, + ] + + async def stop(self) -> None: + """Handle STOP command.""" + async with TaskManager(self.mass) as tg: + for member in self.mass.players.iter_group_members(self, active_only=True): + tg.create_task(member.stop()) + # abort the stream session + if self.stream and not self.stream.done: + await self.stream.stop() + + async def power(self, powered: bool) -> None: + """Handle POWER command to group player.""" + # always stop at power off + if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + await self.stop() + + # optimistically set the group state + prev_power = self._attr_powered + self._attr_powered = powered + self.update_state() + + if powered: + # handle TURN_ON of the group player by turning on all members + for member in self.mass.players.iter_group_members( + self, only_powered=False, active_only=False + ): + if ( + member.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) + and member.active_source != self.active_source + ): + # stop playing existing content on member if we start the group player + await member.stop() + if member.active_group is not None and member.active_group != self.player_id: + # collision: child player is part of multiple groups + # and another group already active ! + # solve this by trying to leave the group first + if ( + other_group := self.mass.players.get(member.active_group) + ) and PlayerFeature.SET_MEMBERS in other_group.supported_features: + await other_group.set_members(player_ids_to_remove=[member.player_id]) + else: + # if the other group does not support SET_MEMBERS, + # we need to power it off to leave the group + await self.mass.players.cmd_power(member.active_group, False) + await asyncio.sleep(1) + await asyncio.sleep(1) + if member.synced_to: + # edge case: the member is part of a syncgroup - ungroup it first + await member.ungroup() + if not member.powered and member.power_control != PLAYER_CONTROL_NONE: + await self.mass.players.cmd_power(member.player_id, True) + elif prev_power: + # handle TURN_OFF of the group player by turning off all members + for member in self.mass.players.iter_group_members( + self, only_powered=True, active_only=True + ): + # handle TURN_OFF of the group player by turning off all members + if member.powered and member.power_control != PLAYER_CONTROL_NONE: + await self.mass.players.cmd_power(member.player_id, False) + + if not powered: + # reset the original group members when powered off + self._attr_group_members = cast( + "list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []) + ) + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + # group volume is already handled in the player manager + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + await self.power(True) + + if self.stream and not self.stream.done: + # stop any existing stream first + await self.stream.stop() + + # select audio source + if media.media_type == MediaType.ANNOUNCEMENT and media.custom_data: + # special case: stream announcement + audio_source = self.mass.streams.get_announcement_stream( + media.custom_data["url"], + output_format=UGP_FORMAT, + use_pre_announce=media.custom_data["use_pre_announce"], + ) + elif media.media_type == MediaType.PLUGIN_SOURCE and media.custom_data: + # special case: plugin source stream + audio_source = self.mass.streams.get_plugin_source_stream( + plugin_source_id=media.custom_data["source_id"], + output_format=UGP_FORMAT, + player_id=media.custom_data["player_id"], + ) + elif media.queue_id and media.queue_item_id: + # regular queue stream request + queue = self.mass.player_queues.get(media.queue_id) + queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id) + if not queue or not queue_item: + # this should not happen, but guard just in case + raise RuntimeError(f"Invalid queue(item): {media.queue_id}, {media.queue_item_id}") + audio_source = self.mass.streams.get_queue_flow_stream( + queue=queue, + start_queue_item=queue_item, + pcm_format=UGP_FORMAT, + ) + else: + # assume url or some other direct path + # NOTE: this will fail if its an uri not playable by ffmpeg + audio_source = get_ffmpeg_stream( + audio_input=media.uri, + input_format=AudioFormat(content_type=ContentType.try_parse(media.uri)), + output_format=UGP_FORMAT, + ) + + # start the stream task + self.stream = UGPStream( + audio_source=audio_source, audio_format=UGP_FORMAT, base_pcm_format=UGP_FORMAT + ) + base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.flac" + + # set the state optimistically + self._attr_current_media = media + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time() - 1 + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + + # forward to downstream play_media commands + async with TaskManager(self.mass) as tg: + for member in self.mass.players.iter_group_members( + self, only_powered=True, active_only=True + ): + tg.create_task( + member.play_media( + PlayerMedia( + uri=f"{base_url}?player_id={member.player_id}", + media_type=MediaType.FLOW_STREAM, + title=self.display_name, + queue_id=self.player_id, + ) + ) + ) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + if not self.is_dynamic: + raise UnsupportedFeaturedException( + f"Group {self.display_name} does not allow dynamically adding/removing members!" + ) + # handle additions + for player_id in player_ids_to_add or []: + if player_id in self._attr_group_members: + continue + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot add {self.display_name} to itself as a member!" + ) + child_player = self.mass.players.get(player_id, True) + assert child_player # for type checking + if child_player.synced_to: + # This is player is part of a syncgroup - ungroup it first + await child_player.ungroup() + self._attr_group_members.append(player_id) + # let the newly add member join the stream if we're playing + if self.stream and not self.stream.done and self.powered: + base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.flac" + await child_player.play_media( + media=PlayerMedia( + uri=f"{base_url}?player_id={player_id}", + media_type=MediaType.FLOW_STREAM, + title=self.display_name, + queue_id=child_player.player_id, + ), + ) + # handle removals + static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) + for player_id in player_ids_to_remove or []: + if player_id not in self._attr_group_members: + continue + if player_id in static_members: + raise UnsupportedFeaturedException( + f"Cannot remove {player_id} from {self.display_name} " + "as it is a static member of this group" + ) + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot remove {self.display_name} from itself as a member!" + ) + self._attr_group_members.remove(player_id) + child_player = self.mass.players.get(player_id, True) + assert child_player is not None # for type checking + if child_player.playback_state in ( + PlaybackState.PLAYING, + PlaybackState.PAUSED, + ): + # if the child player is playing the group stream, stop it + await child_player.stop() + self.update_state() + + async def poll(self) -> None: + """Poll player for state updates.""" + self._set_attributes() + self.update_state() + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + await super().on_unload() + if self.powered: + # edge case: the group player is powered and being unloaded + # make sure to turn it off first (which will also ungroup a syncgroup) + await self.power(False) + + def _set_attributes(self) -> None: + """Set attributes of the group player.""" + if self.is_dynamic and PlayerFeature.SET_MEMBERS not in self.supported_features: + # dynamic group players should support SET_MEMBERS feature + self._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + elif not self.is_dynamic and PlayerFeature.SET_MEMBERS in self.supported_features: + # static group players should not support SET_MEMBERS feature + self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS) + # grab current media and state from one of the active players + for child_player in self.mass.players.iter_group_members(self, active_only=True): + self._attr_available = True + self._attr_playback_state = child_player.playback_state + if child_player.elapsed_time: + self._attr_elapsed_time = child_player.elapsed_time + self._attr_elapsed_time_last_updated = child_player.elapsed_time_last_updated + break + else: + self._attr_playback_state = PlaybackState.IDLE + self._attr_available = False + self.update_state() + + async def _serve_ugp_stream(self, request: web.Request) -> web.StreamResponse: + """Serve the UGP (multi-client) flow stream audio to a player.""" + ugp_player_id = request.path.rsplit(".")[0].rsplit("/")[-1] + child_player_id = request.query.get("player_id") # optional! + output_format_str = request.path.rsplit(".")[-1] + + if child_player_id and (child_player := self.mass.players.get(child_player_id)): + # Use the preferred output format of the child player + output_format = await self.mass.streams.get_output_format( + output_format_str=output_format_str, + player=child_player, + content_sample_rate=UGP_FORMAT.sample_rate, + content_bit_depth=UGP_FORMAT.bit_depth, + ) + http_profile = cast( + "str", + await self.mass.config.get_player_config_value(child_player_id, CONF_HTTP_PROFILE), + ) + elif output_format_str == "flac": + output_format = AudioFormat(content_type=ContentType.FLAC) + else: + output_format = AudioFormat(content_type=ContentType.MP3) + http_profile = "chunked" + + if not (ugp_player := self.mass.players.get(ugp_player_id)): + raise web.HTTPNotFound(reason=f"Unknown UGP player: {ugp_player_id}") + + if not self.stream or self.stream.done: + raise web.HTTPNotFound(body=f"There is no active UGP stream for {ugp_player_id}!") + + headers = { + **DEFAULT_STREAM_HEADERS, + "Content-Type": f"audio/{output_format_str}", + "Accept-Ranges": "none", + "Cache-Control": "no-cache", + "Connection": "close", + } + + resp = web.StreamResponse(status=200, reason="OK", headers=headers) + if http_profile == "forced_content_length": + resp.content_length = 4294967296 + elif http_profile == "chunked": + resp.enable_chunked_encoding() + + await resp.prepare(request) + + # return early if this is not a GET request + if request.method != "GET": + return resp + + # all checks passed, start streaming! + self.logger.debug( + "Start serving UGP flow audio stream for UGP-player %s to %s", + ugp_player.display_name, + child_player_id or request.remote, + ) + + # Generate filter params for the player specific DSP settings + filter_params = None + if child_player_id: + filter_params = get_player_filter_params( + self.mass, child_player_id, self.stream.input_format, output_format + ) + + async for chunk in self.stream.get_stream( + output_format, + filter_params=filter_params, + ): + try: + await resp.write(chunk) + except (ConnectionError, ConnectionResetError): + break + + return resp diff --git a/music_assistant/providers/universal_group/provider.py b/music_assistant/providers/universal_group/provider.py new file mode 100644 index 00000000..d7ea4364 --- /dev/null +++ b/music_assistant/providers/universal_group/provider.py @@ -0,0 +1,70 @@ +"""Universal Player Group Provider implementation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shortuuid +from music_assistant_models.enums import ProviderFeature + +from music_assistant.constants import CONF_DYNAMIC_GROUP_MEMBERS, CONF_GROUP_MEMBERS +from music_assistant.models.player_provider import PlayerProvider + +from .constants import UGP_PREFIX +from .player import UniversalGroupPlayer + +if TYPE_CHECKING: + from music_assistant.models.player import Player + + +class UniversalGroupProvider(PlayerProvider): + """Universal Group Player Provider.""" + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return {ProviderFeature.CREATE_GROUP_PLAYER, ProviderFeature.REMOVE_GROUP_PLAYER} + + async def create_group_player( + self, name: str, members: list[str], dynamic: bool = True + ) -> Player: + """Create new Universal Group Player.""" + # filter out members that are not registered players + # TODO: do we want to filter out groups here to prevent nested groups? + members = [x for x in members if x in [y.player_id for y in self.mass.players]] + # generate a new player_id for the group player + player_id = f"{UGP_PREFIX}{shortuuid.random(8).lower()}" + self.mass.config.create_default_player_config( + player_id=player_id, + provider=self.lookup_key, + name=name, + enabled=True, + values={ + CONF_GROUP_MEMBERS: members, + CONF_DYNAMIC_GROUP_MEMBERS: dynamic, + }, + ) + return await self._register_player(player_id) + + async def remove_group_player(self, player_id: str) -> None: + """ + Remove a group player. + + Only called for providers that support REMOVE_GROUP_PLAYER feature. + + :param player_id: ID of the group player to remove. + """ + # we simply permanently unregister the player and wipe its config + await self.mass.players.unregister(player_id, True) + + async def discover_players(self) -> None: + """Discover players.""" + for player_conf in await self.mass.config.get_player_configs(self.lookup_key): + if player_conf.player_id.startswith(UGP_PREFIX): + await self._register_player(player_conf.player_id) + + async def _register_player(self, player_id: str) -> Player: + """Register a universal group player.""" + group = UniversalGroupPlayer(self, player_id) + await self.mass.players.register_or_update(group) + return group diff --git a/music_assistant/providers/player_group/ugp_stream.py b/music_assistant/providers/universal_group/ugp_stream.py similarity index 92% rename from music_assistant/providers/player_group/ugp_stream.py rename to music_assistant/providers/universal_group/ugp_stream.py index e0c69876..12c02bbe 100644 --- a/music_assistant/providers/player_group/ugp_stream.py +++ b/music_assistant/providers/universal_group/ugp_stream.py @@ -16,11 +16,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from music_assistant_models.media_items import AudioFormat -from music_assistant.helpers.audio import get_ffmpeg_stream +from music_assistant.helpers.ffmpeg import get_ffmpeg_stream from music_assistant.helpers.util import empty_queue -# ruff: noqa: ARG002 - class UGPStream: """ @@ -41,14 +39,14 @@ class UGPStream: self.audio_source = audio_source self.input_format = audio_format self.base_pcm_format = base_pcm_format - self.subscribers: list[Callable[[bytes], Awaitable]] = [] - self._task: asyncio.Task | None = None + self.subscribers: list[Callable[[bytes], Awaitable[None]]] = [] + self._task: asyncio.Task[None] | None = None self._done: asyncio.Event = asyncio.Event() @property def done(self) -> bool: """Return if this stream is already done.""" - return self._done.is_set() and self._task and self._task.done() + return self._done.is_set() and self._task is not None and self._task.done() async def stop(self) -> None: """Stop/cancel the stream.""" @@ -69,7 +67,7 @@ class UGPStream: # start the runner as soon as the (first) client connects if not self._task: self._task = asyncio.create_task(self._runner()) - queue = asyncio.Queue(10) + queue: asyncio.Queue[bytes] = asyncio.Queue(10) try: self.subscribers.append(queue.put) while True: diff --git a/music_assistant/providers/ytmusic/__init__.py b/music_assistant/providers/ytmusic/__init__.py index 3235a67a..05647335 100644 --- a/music_assistant/providers/ytmusic/__init__.py +++ b/music_assistant/providers/ytmusic/__init__.py @@ -132,7 +132,7 @@ SUPPORTED_FEATURES = { # TODO: fix disabled tests -# ruff: noqa: PLW2901, RET504 +# ruff: noqa: PLW2901 async def setup( diff --git a/pyproject.toml b/pyproject.toml index e3262de7..ce608657 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,12 @@ dependencies = [ "ifaddr==0.2.0", "mashumaro==3.16", "music-assistant-frontend==2.15.2", - "music-assistant-models==1.1.47", + "music-assistant-models==1.1.52", "mutagen==1.47.0", "orjson==3.10.18", "pillow==11.3.0", "podcastparser==0.6.10", + "propcache>=0.2.1", "python-slugify==8.0.4", "unidecode==1.4.0", "xmltodict==0.14.2", diff --git a/requirements_all.txt b/requirements_all.txt index 18a1add3..d80afcbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -30,13 +30,14 @@ ifaddr==0.2.0 liblistenbrainz==0.5.6 mashumaro==3.16 music-assistant-frontend==2.15.2 -music-assistant-models==1.1.47 +music-assistant-models==1.1.52 mutagen==1.47.0 orjson==3.10.18 pillow==11.3.0 pkce==1.0.3 plexapi==4.17.0 podcastparser==0.6.10 +propcache>=0.2.1 py-opensonic==7.0.2 pyblu==2.0.1 PyChromecast==14.0.7 diff --git a/scripts/example.py b/scripts/example.py index af7cbe60..1a74240b 100644 --- a/scripts/example.py +++ b/scripts/example.py @@ -7,9 +7,6 @@ from aiorun import run from music_assistant.client.client import MusicAssistantClient -# ruff: noqa: ANN201,PTH102,PTH112,PTH113,PTH118,PTH123,T201 - - logging.basicConfig(level=logging.DEBUG) # Get parsed passed in arguments. diff --git a/scripts/gen_requirements_all.py b/scripts/gen_requirements_all.py index 57c4491f..c5b23a22 100644 --- a/scripts/gen_requirements_all.py +++ b/scripts/gen_requirements_all.py @@ -12,7 +12,7 @@ from pathlib import Path PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$") GIT_REPO_REGEX = re.compile(r"^(git\+https:\/\/[-_\.\w\d\/]+[@-_\.\w\d\/]*)$") -# ruff: noqa: PTH112,PTH113,PTH118,PTH123,T201 +# ruff: noqa: T201 def gather_core_requirements() -> list[str]: diff --git a/scripts/profiler.py b/scripts/profiler.py index c02e9163..5a9e0ff9 100644 --- a/scripts/profiler.py +++ b/scripts/profiler.py @@ -7,7 +7,7 @@ https://www.red-gate.com/simple-talk/development/python/memory-profiling-in-pyth import asyncio import tracemalloc -# ruff: noqa: D103,E501,E741,FBT003,T201,ANN201,ANN202 +# ruff: noqa: D103, E741, T201 # pylint: disable=missing-function-docstring # list to store memory snapshots -- 2.34.1