diff --git a/custom_components/mass/__init__.py b/custom_components/mass/__init__.py index fcaa146e..a49af0c2 100644 --- a/custom_components/mass/__init__.py +++ b/custom_components/mass/__init__.py @@ -5,11 +5,7 @@ import os from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - EVENT_CALL_SERVICE, - EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -37,7 +33,7 @@ DOMAIN_EVENT, ) from .panel import async_register_panel -from .player_controls import HassPlayerControls +from .player_controls import async_register_player_controls from .services import register_services from .websockets import async_register_websockets @@ -117,17 +113,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if conf.get(CONF_CREATE_MASS_PLAYERS, True): hass.config_entries.async_setup_platforms(entry, PLATFORMS) - # register hass players with mass - controls = HassPlayerControls(hass, mass, entry.options) + async def on_hass_start(*args, **kwargs): + """Start sync actions when Home Assistant is started.""" + register_services(hass, mass) + # register hass players with mass + await async_register_player_controls(hass, mass, entry) + # start and schedule sync (every 3 hours) + await mass.music.start_sync(schedule=3) - async def handle_hass_event(event: Event): - """Handle an incoming event from Home Assistant.""" - if event.event_type == EVENT_HOMEASSISTANT_STOP: - await mass.stop() - elif event.event_type == EVENT_CALL_SERVICE: - await async_intercept_play_media(event, controls) + async def on_hass_stop(event: Event): + """Handle an incoming stop event from Home Assistant.""" + await mass.stop() - async def handle_mass_event(event: MassEvent): + async def on_mass_event(event: MassEvent): """Handle an incoming event from Music Assistant.""" # forward event to the HA eventbus if hasattr(event.data, "to_dict"): @@ -139,24 +137,17 @@ async def handle_mass_event(event: MassEvent): {"type": event.type.value, "object_id": event.object_id, "data": data}, ) - async def on_start(*args, **kwargs): - """Start sync actions when Home Assistant is started.""" - register_services(hass, mass) - await controls.async_register_player_controls() - await mass.music.start_sync(schedule=3) - # setup event listeners, register their unsubscribe in the unload - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_hass_event) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(async_at_start(hass, on_start)) + entry.async_on_unload(async_at_start(hass, on_hass_start)) entry.async_on_unload(entry.add_update_listener(_update_listener)) entry.async_on_unload( - hass.bus.async_listen(EVENT_STATE_CHANGED, controls.async_hass_state_event) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(hass.bus.async_listen(EVENT_CALL_SERVICE, handle_hass_event)) - entry.async_on_unload(mass.subscribe(handle_mass_event, FORWARD_EVENTS)) + entry.async_on_unload(entry.add_update_listener(_update_listener)) + entry.async_on_unload(mass.subscribe(on_mass_event, FORWARD_EVENTS)) # Websocket support and frontend (panel) async_register_websockets(hass) @@ -191,40 +182,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: os.rename(db_file, db_file_old) -async def async_intercept_play_media( - event: Event, - controls: HassPlayerControls, -): - """Intercept play_media service calls.""" - if event.data["domain"] != "media_player": - return - if event.data["service"] != "play_media": - return - - service_data = event.data.get("service_data") - if not service_data: - return - - entity_id = service_data.get("entity_id") - if not entity_id: - return - - media_content_id = service_data.get("media_content_id", "") - if not media_content_id.startswith(f"media-source://{DOMAIN}/"): - return - - uri = media_content_id.replace(f"media-source://{DOMAIN}/", "") - - # create player on the fly (or get existing one) - # TODO: How to intercept a play request for the 'webbrowser' player ? - player = await controls.async_register_player_control(entity_id, manual=True) - if not player: - return - - # send the mass library uri to the player(queue) - await player.active_queue.play_media(uri) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/mass/media_source.py b/custom_components/mass/media_source.py index 8adb8ac4..2a3ae2a9 100644 --- a/custom_components/mass/media_source.py +++ b/custom_components/mass/media_source.py @@ -30,6 +30,7 @@ from music_assistant.models.media_items import MediaItemType from .const import DOMAIN +from .player_controls import async_register_player_control MEDIA_TYPE_RADIO = "radio" @@ -98,13 +99,23 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: if mass is None: raise Unresolvable("MusicAssistant is not initialized") - # this part is tricky because we need to know which player is requesting the media - # so we can put the request on the correct queue - # for now we have a workaround in place that intercepts the call_service command - # to the media_player and find out the player from there. - # Hacky but it does the job and let's hope for a contextvar in the future. + if item.target_media_player is None: + # TODO: How to intercept a play request for the 'webbrowser' player + # or at least hide our source for the webbrowser player ? + raise Unresolvable("Playback not supported on the device.") - return PlayMedia(item.identifier, MEDIA_CONTENT_TYPE_FLAC) + # get/create mass player instance attached to this entity id + player = await async_register_player_control( + self.hass, mass, item.target_media_player + ) + if not player: + return PlayMedia(item.identifier, MEDIA_TYPE_MUSIC) + + # send the mass library uri to the player(queue) + stream_url = await player.active_queue.play_media(item.identifier, passive=True) + # tell the actual player to play the stream url + content_type = player.active_queue.settings.stream_type.value + return PlayMedia(stream_url, f"audio/{content_type}") async def async_browse_media( self, diff --git a/custom_components/mass/player_controls.py b/custom_components/mass/player_controls.py index 0349ba66..ac86b711 100644 --- a/custom_components/mass/player_controls.py +++ b/custom_components/mass/player_controls.py @@ -1,7 +1,7 @@ """Support Home Assistant media_player entities to be used as Players for Music Assistant.""" from __future__ import annotations -from typing import Dict, Tuple +from typing import Tuple from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -22,9 +22,11 @@ SERVICE_PLAY_MEDIA, SUPPORT_PLAY_MEDIA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + EVENT_STATE_CHANGED, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -441,77 +443,77 @@ def update_attributes(self) -> None: self._attr_group_childs = child_players -class HassPlayerControls: - """Enable Home Assisant entities to be used as Players for MusicAssistant.""" +async def async_register_player_control( + hass: HomeAssistant, mass: MusicAssistant, entity_id: str +) -> HassPlayer | None: + """Register hass media_player entity as player control on Music Assistant.""" - def __init__(self, hass: HomeAssistant, mass: MusicAssistant, config: dict) -> None: - """Initialize class.""" - self.hass = hass - self.mass = mass - self.config = config - self._registered_players: Dict[str, HassPlayer] = {} + # check for existing player first if already registered + if player := mass.players.get_player(entity_id, True): + return player - async def async_hass_state_event(self, event: Event) -> None: + entity = hass.states.get(entity_id) + if entity is None or entity.attributes is None: + return + + if not (entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_PLAY_MEDIA): + return + + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + player = None + # Integration specific player controls + conf_entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + mute_as_power = entity_id in conf_entry.options.get(CONF_MUTE_POWER_PLAYERS, []) + if ent_entry := ent_reg.async_get(entity_id): + if ent_entry.platform == DOMAIN: + # this is already a Music assistant player + return + if ent_entry.platform == CAST_DOMAIN: + if dev_entry := dev_reg.async_get(ent_entry.device_id): + if dev_entry.model == "Google Cast Group": + player = HassCastGroupPlayer(hass, entity_id) + elif ent_entry.platform == SLIMPROTO_DOMAIN: + player = HassSqueezeboxPlayer(hass, entity_id, ent_entry.unique_id) + elif ent_entry.platform == ESPHOME_DOMAIN: + player = ESPHomePlayer(hass, entity_id, mute_as_power) + elif ent_entry.platform == GROUP_DOMAIN: + player = HassGroupPlayer(hass, entity_id) + + # handle genric player for all other integrations + if player is None: + player = HassPlayer(hass, entity_id, mute_as_power) + await mass.players.register_player(player) + return player + + +async def async_register_player_controls( + hass: HomeAssistant, mass: MusicAssistant, entry: ConfigEntry +): + """Register hass entities as player controls on Music Assistant.""" + # allowed_entities not configured = not filter (=all) + allowed_entities = entry.options.get(CONF_PLAYER_ENTITIES) + + async def async_hass_state_event(event: Event) -> None: """Handle hass state-changed events to update registered PlayerControls.""" entity_id: str = event.data[ATTR_ENTITY_ID] if not entity_id.startswith(MP_DOMAIN): return - if entity_id in self._registered_players: - self._registered_players[entity_id].on_hass_event(event) - else: - # entity not (yet) registered - await self.async_register_player_control(entity_id) - - async def async_register_player_controls(self): - """Register hass entities as player controls on Music Assistant.""" - - for entity in self.hass.states.async_all(MEDIA_PLAYER_DOMAIN): - await self.async_register_player_control(entity.entity_id) - - async def async_register_player_control( - self, entity_id: str, manual=False - ) -> HassPlayer | None: - """Register hass entitie as player controls on Music Assistant.""" - allowed_entities = self.config.get(CONF_PLAYER_ENTITIES) - # allowed_entities not configured = not filter (=all) - if not (manual or allowed_entities is None or entity_id in allowed_entities): + # handle existing source player + if source_player := mass.players.get_player(entity_id, True): + source_player.on_hass_event(event) return - - if entity_id in self._registered_players: - return self._registered_players[entity_id] - - entity = self.hass.states.get(entity_id) - if entity is None or entity.attributes is None: - return - - if not (entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_PLAY_MEDIA): - return - - ent_reg = er.async_get(self.hass) - dev_reg = dr.async_get(self.hass) - player = None - mute_as_power = entity_id in self.config.get(CONF_MUTE_POWER_PLAYERS, []) - # Integration specific player controls - if ent_entry := ent_reg.async_get(entity_id): - if ent_entry.platform == DOMAIN: - # this is already a Music assistant player - return - if ent_entry.platform == CAST_DOMAIN: - if dev_entry := dev_reg.async_get(ent_entry.device_id): - if dev_entry.model == "Google Cast Group": - player = HassCastGroupPlayer(self.hass, entity_id) - elif ent_entry.platform == SLIMPROTO_DOMAIN: - player = HassSqueezeboxPlayer(self.hass, entity_id, ent_entry.unique_id) - elif ent_entry.platform == ESPHOME_DOMAIN: - player = ESPHomePlayer(self.hass, entity_id, mute_as_power) - elif ent_entry.platform == GROUP_DOMAIN: - player = HassGroupPlayer(self.hass, entity_id) - - # handle genric player for all other integrations - if player is None: - player = HassPlayer(self.hass, entity_id, mute_as_power) - self._registered_players[entity_id] = player - await self.mass.players.register_player(player) - return player + # entity not (yet) registered + if allowed_entities is None or entity_id in allowed_entities: + await async_register_player_control(hass, mass, entity_id) + + # register event listener + entry.async_on_unload( + hass.bus.async_listen(EVENT_STATE_CHANGED, async_hass_state_event) + ) + # register all current entities + for entity in hass.states.async_all(MEDIA_PLAYER_DOMAIN): + if allowed_entities is None or entity.entity_id in allowed_entities: + await async_register_player_control(hass, mass, entity.entity_id) diff --git a/hacs.json b/hacs.json index 2e3b2a43..7ce6fbc1 100644 --- a/hacs.json +++ b/hacs.json @@ -4,5 +4,5 @@ "zip_release": true, "filename": "mass.zip", "hide_default_branch": true, - "homeassistant": "2022.5.0" + "homeassistant": "2022.6.0" } \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 9c14f0e4..daca4dd8 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -7,5 +7,5 @@ pytest==7.1.2 pytest-cov==3.0.0 pytest-timeout==2.1.0 pre-commit==2.19.0 -homeassistant==2022.5.5 +homeassistant==2022.6.0b5 music-assistant \ No newline at end of file