Skip to content
Merged
79 changes: 18 additions & 61 deletions custom_components/mass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"):
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions custom_components/mass/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
134 changes: 68 additions & 66 deletions custom_components/mass/player_controls.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion hacs.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"zip_release": true,
"filename": "mass.zip",
"hide_default_branch": true,
"homeassistant": "2022.5.0"
"homeassistant": "2022.6.0"
}
2 changes: 1 addition & 1 deletion requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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