Skip to content
59 changes: 9 additions & 50 deletions custom_components/mass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@
import os

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_CALL_SERVICE,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
EVENT_STATE_CHANGED,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
Expand Down Expand Up @@ -39,7 +34,7 @@
DOMAIN,
)
from .panel import async_register_panel
from .player_controls import HassPlayerControls
from .player_controls import async_register_player_controls
from .websockets import async_register_websockets

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -96,26 +91,17 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
hass.config_entries.async_setup_platforms(entry, PLATFORMS)

# register hass players with mass
controls = HassPlayerControls(hass, mass, entry.options)
hass.create_task(controls.async_register_player_controls())

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_HOMEASSISTANT_START:
await controls.async_register_player_controls()
elif event.event_type == EVENT_CALL_SERVICE:
await async_intercept_play_media(event, controls)
hass.create_task(async_register_player_controls(hass, mass, entry))

async def on_hass_stop(event: Event):
"""Handle an incoming stop event from Home Assistant."""
await mass.stop()

# setup event listeners, register their unsubscribe in the unload
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_hass_event)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_hass_event)
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(entry.add_update_listener(_update_listener))

# Websocket support and frontend (panel)
async_register_websockets(hass)
Expand Down Expand Up @@ -150,33 +136,6 @@ async def async_remove_entry(hass: HomeAssistantType, entry: ConfigEntry) -> Non
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
entity_id = event.data["service_data"]["entity_id"]
media_content_id = event.data["service_data"]["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: HomeAssistantType, entry: ConfigEntry):
"""Unload a config entry."""
unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Expand Down
23 changes: 16 additions & 7 deletions custom_components/mass/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.service import async_get_current_entity

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will require a newer version of HA so you need to signal that to HACS. Maybe merge it when beta is out ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, leave it here as draft PR until new HA version is released which includes this new helper.

from music_assistant import MusicAssistant
from music_assistant.helpers.images import get_image_url
from music_assistant.models.media_items import Album, Track

from .const import DOMAIN
from .player_controls import async_register_player_control

MEDIA_TYPE_RADIO = "radio"

Expand Down Expand Up @@ -98,13 +100,20 @@ 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.

return PlayMedia(item.identifier, MEDIA_CONTENT_TYPE_FLAC)
# Get entity_id that requested the media from contextvar
# TODO: How to intercept a play request for the 'webbrowser' player
# or at least hide our source for the webbrowser player ?
entity_id = async_get_current_entity()
# create player on the fly (or get existing one)
player = await async_register_player_control(self.hass, mass, entity_id)
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
152 changes: 78 additions & 74 deletions custom_components/mass/player_controls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
"""Support Home Assistant media_player entities to be used as Players for Music Assistant."""
from __future__ import annotations

from typing import Dict

from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
Expand All @@ -18,9 +16,12 @@
SERVICE_PLAY_MEDIA,
SUPPORT_PLAY_MEDIA,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
EVENT_HOMEASSISTANT_START,
EVENT_STATE_CHANGED,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_STOP,
Expand All @@ -37,11 +38,10 @@
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import Event
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.dt import utcnow
from music_assistant import MusicAssistant
from music_assistant.models.player import DeviceInfo, Player, PlayerGroup, PlayerState
Expand Down Expand Up @@ -80,7 +80,7 @@ class HassPlayer(Player):
"""Mapping from Home Assistant Mediaplayer to Music Assistant Player."""

def __init__(
self, hass: HomeAssistantType, entity_id: str, mute_as_power: bool
self, hass: HomeAssistant, entity_id: str, mute_as_power: bool
) -> None:
"""Initialize player."""
self.hass = hass
Expand Down Expand Up @@ -218,9 +218,7 @@ async def volume_set(self, volume_level: int) -> None:
class HassSqueezeboxPlayer(HassPlayer):
"""Representation of Hass player from Squeezebox Local integration."""

def __init__(
self, hass: HomeAssistantType, entity_id: str, squeeze_id: str
) -> None:
def __init__(self, hass: HomeAssistant, entity_id: str, squeeze_id: str) -> None:
"""Initialize player."""
self.squeeze_id = squeeze_id
self.slimserver = hass.data[SLIMPROTO_DOMAIN]
Expand Down Expand Up @@ -251,7 +249,7 @@ def on_squeezebox_event(self, event: Event) -> None:
class HassGroupPlayer(PlayerGroup):
"""Mapping from Home Assistant Grouped Mediaplayer to Music Assistant Player."""

def __init__(self, hass: HomeAssistantType, entity_id: str) -> None:
def __init__(self, hass: HomeAssistant, entity_id: str) -> None:
"""Initialize player."""
self.hass = hass
self.player_id = entity_id
Expand Down Expand Up @@ -311,7 +309,7 @@ def _get_source_entity_id(self, entity_id: str) -> str | None:
class HassCastGroupPlayer(PlayerGroup, HassPlayer):
"""Mapping from Google Cast GroupPlayer to Music Assistant Player."""

def __init__(self, hass: HomeAssistantType, entity_id: str) -> None:
def __init__(self, hass: HomeAssistant, entity_id: str) -> None:
"""Initialize player."""
self.ent_reg = er.async_get(hass)
ent_entry = self.ent_reg.async_get(entity_id)
Expand Down Expand Up @@ -360,77 +358,83 @@ 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 entity as player controls on Music Assistant."""

def __init__(
self, hass: HomeAssistantType, 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:
"""Handle hass state-changed events to update registered PlayerControls."""
entity_id: str = event.data[ATTR_ENTITY_ID]
entity = hass.states.get(entity_id)
if entity is None or entity.attributes is None:
return

if not entity_id.startswith(MP_DOMAIN):
return
if not (entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_PLAY_MEDIA):
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):
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
player = None
# 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(hass, entity_id)
elif ent_entry.platform == SLIMPROTO_DOMAIN:
player = HassSqueezeboxPlayer(hass, entity_id, ent_entry.unique_id)
elif ent_entry.platform == GROUP_DOMAIN:
player = HassGroupPlayer(hass, entity_id)

# handle genric player for all other integrations
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 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 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:
if not entity_id.startswith(MP_DOMAIN):
return

if not (entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_PLAY_MEDIA):
# handle existing source player
if source_player := mass.players.get_player(entity_id, True):
source_player.on_hass_event(event)
return

ent_reg = er.async_get(self.hass)
dev_reg = dr.async_get(self.hass)
player = None
# 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 == GROUP_DOMAIN:
player = HassGroupPlayer(self.hass, entity_id)

# handle genric player for all other integrations
mute_as_power = entity_id in self.config.get(CONF_MUTE_POWER_PLAYERS, [])
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 all current entities
async def register_all(*args, **kwargs):
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)

await register_all()

# register event listeners
entry.async_on_unload(
hass.bus.async_listen(EVENT_STATE_CHANGED, async_hass_state_event)
)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_all)
)