"""All constants for Music Assistant."""
-__version__ = "0.0.30"
+__version__ = "0.0.31"
REQUIRED_PYTHON_VER = "3.7"
CONF_USERNAME = "username"
EVENT_STREAM_STARTED = "streaming started"
EVENT_STREAM_ENDED = "streaming ended"
EVENT_CONFIG_CHANGED = "config changed"
-EVENT_PLAYBACK_STARTED = "playback started"
-EVENT_PLAYBACK_STOPPED = "playback stopped"
EVENT_MUSIC_SYNC_STATUS = "music sync status"
EVENT_QUEUE_UPDATED = "queue updated"
EVENT_QUEUE_ITEMS_UPDATED = "queue items updated"
+EVENT_QUEUE_TIME_UPDATED = "queue time updated"
EVENT_SHUTDOWN = "application shutdown"
EVENT_PROVIDER_REGISTERED = "provider registered"
EVENT_PLAYER_CONTROL_REGISTERED = "player control registered"
yield (True, b"")
return
# fire event that streaming has started for this track
+ streamdetails.path = "" # invalidate
self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)
# yield chunks from stdout
# we keep 1 chunk behind to detect end of stream properly
yield (False, prev_chunk)
prev_chunk = chunk
# fire event that streaming has ended
- self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)
- # send task to background to analyse the audio
- if queue_item.media_type == MediaType.Track:
- self.mass.loop.run_in_executor(None, self.__analyze_audio, streamdetails)
+ if not cancelled.is_set():
+ streamdetails.seconds_played = queue_item.duration
+ self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)
+ # send task to background to analyse the audio
+ if queue_item.media_type == MediaType.Track:
+ self.mass.add_job(self.__analyze_audio, streamdetails)
def __get_player_sox_options(
self, player_id: str, streamdetails: StreamDetails
class PlayerState(Enum):
"""Enum for the playstate of a player."""
- Off = "off"
Stopped = "stopped"
Paused = "paused"
Playing = "playing"
name: str = ""
powered: bool = False
elapsed_time: int = 0
- state: PlayerState = PlayerState.Off
+ state: PlayerState = PlayerState.Stopped
available: bool = True
current_uri: str = ""
volume_level: int = 0
from typing import List
from music_assistant.constants import (
- EVENT_PLAYBACK_STARTED,
- EVENT_PLAYBACK_STOPPED,
EVENT_QUEUE_ITEMS_UPDATED,
+ EVENT_QUEUE_TIME_UPDATED,
EVENT_QUEUE_UPDATED,
)
from music_assistant.models.media_types import Track
self._last_item_time = 0
self._last_queue_startindex = 0
self._next_queue_startindex = 0
- self._last_player_state = PlayerState.Stopped
self._last_track = None
# load previous queue settings from disk
self.mass.add_job(self.__async_restore_saved_state())
return
if self.use_queue_stream:
return await self.async_play_index(self.cur_index + 1)
+ await self.mass.player_manager.async_cmd_power_on(self.player_id)
return await self.mass.player_manager.get_player_provider(
self.player_id
).async_cmd_next(self.player_id)
"""Play the previous track in the queue."""
if self.cur_index is None:
return
+ await self.mass.player_manager.async_cmd_power_on(self.player_id)
if self.use_queue_stream:
return await self.async_play_index(self.cur_index - 1)
return await self.mass.player_manager.async_cmd_previous(self.player_id)
async def async_resume(self):
"""Resume previous queue."""
if self.items:
+ await self.mass.player_manager.async_cmd_power_on(self.player_id)
prev_index = self.cur_index
supports_queue = PlayerFeature.QUEUE in self.player.features
if self.use_queue_stream or not supports_queue:
async def async_play_index(self, index):
"""Play item at index X in queue."""
+ await self.mass.player_manager.async_cmd_power_on(self.player_id)
player_prov = self.mass.player_manager.get_player_provider(self.player_id)
supports_queue = PlayerFeature.QUEUE in self.player.features
if not isinstance(index, int):
async def async_load(self, queue_items: List[QueueItem]):
"""Load (overwrite) queue with new items."""
+ await self.mass.player_manager.async_cmd_power_on(self.player_id)
supports_queue = PlayerFeature.QUEUE in self.player.features
for index, item in enumerate(queue_items):
item.sort_index = index
break
# process new index
await self.async_process_queue_update(cur_index, track_time)
- self.mass.signal_event(EVENT_QUEUE_UPDATED, self.to_dict())
async def async_start_queue_stream(self):
"""Call when queue_streamer starts playing the queue stream."""
async def async_process_queue_update(self, new_index, track_time):
"""Compare the queue index to determine if playback changed."""
new_track = self.get_item(new_index)
- if (not self._last_track and new_track) or self._last_track != new_track:
+ self._cur_item_time = track_time
+ self._cur_index = new_index
+ if self._last_track != new_track:
# queue track updated
- # account for track changing state so trigger track change after 1 second
- if self._last_track and self._last_track.streamdetails:
- self._last_track.streamdetails.seconds_played = self._last_item_time
- self.mass.signal_event(
- EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails
- )
- if new_track and new_track.streamdetails:
- self.mass.signal_event(EVENT_PLAYBACK_STARTED, new_track.streamdetails)
- self._last_track = new_track
- if self._last_player_state != self.player.state:
- self._last_player_state = self.player.state
- if self.player.elapsed_time == 0 and self.player.state in [
- PlayerState.Stopped,
- PlayerState.Off,
- ]:
- # player stopped playing
- if self._last_track:
- self.mass.signal_event(
- EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails
- )
+ self._last_track = new_track
+ self.mass.signal_event(EVENT_QUEUE_UPDATED, self.to_dict())
+ if self._last_track:
+ self._last_track.streamdetails = None # invalidate streamdetails
# update vars
- if track_time > 2:
- # account for track changing state so keep this a few seconds behind
+ if self._last_item_time != track_time:
self._last_item_time = track_time
- self._cur_item_time = track_time
- self._cur_index = new_index
+ self.mass.signal_event(
+ EVENT_QUEUE_TIME_UPDATED,
+ {"player_id": self.player_id, "cur_item_time": track_time},
+ )
@staticmethod
def __shuffle_items(queue_items):
)
from music_assistant.models.musicprovider import MusicProvider
from music_assistant.models.provider import ProviderType
-from music_assistant.models.streamdetails import StreamDetails
+from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
from music_assistant.utils import compare_strings, run_periodic
from PIL import Image
param media_item: The MediaItem (track/radio) for which to request the streamdetails for.
param player_id: Optionally provide the player_id which will play this stream.
"""
- if media_item.streamdetails:
- media_item.streamdetails.player_id = player_id
- return media_item.streamdetails # already present, no need to fetch again!
- # always request the full db track as there might be other qualities available
- # except for radio
- if media_item.media_type == MediaType.Radio:
- full_track = media_item
- else:
- full_track = await self.async_get_track(
- media_item.item_id, media_item.provider, lazy=True, refresh=True
+ if media_item.provider == "uri":
+ # special type: a plain uri was added to the queue
+ streamdetails = StreamDetails(
+ type=StreamType.URL,
+ provider="uri",
+ item_id=media_item.item_id,
+ path=media_item.item_id,
+ content_type=ContentType(media_item.item_id.split(".")[-1]),
+ sample_rate=44100,
+ bit_depth=16,
)
- # sort by quality and check track availability
- for prov_media in sorted(
- full_track.provider_ids, key=lambda x: x.quality, reverse=True
- ):
- # get streamdetails from provider
- music_prov = self.mass.get_provider(prov_media.provider)
- if not music_prov:
- continue # provider temporary unavailable ?
+ else:
+ # always request the full db track as there might be other qualities available
+ # except for radio
+ if media_item.media_type == MediaType.Radio:
+ full_track = media_item
+ else:
+ full_track = await self.async_get_track(
+ media_item.item_id, media_item.provider, lazy=True, refresh=True
+ )
+ # sort by quality and check track availability
+ for prov_media in sorted(
+ full_track.provider_ids, key=lambda x: x.quality, reverse=True
+ ):
+ # get streamdetails from provider
+ music_prov = self.mass.get_provider(prov_media.provider)
+ if not music_prov:
+ continue # provider temporary unavailable ?
- streamdetails = await music_prov.async_get_stream_details(
- prov_media.item_id
- )
+ streamdetails = await music_prov.async_get_stream_details(
+ prov_media.item_id
+ )
if streamdetails:
streamdetails.player_id = player_id
from music_assistant.models.player_queue import PlayerQueue, QueueItem, QueueOption
from music_assistant.models.playerprovider import PlayerProvider
from music_assistant.models.provider import ProviderType
-from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
from music_assistant.utils import (
async_iter_items,
callback,
queue_item = QueueItem(
Track(
item_id=uri,
- provider="",
- name="uri",
+ provider="uri",
+ name=uri,
)
)
- queue_item.streamdetails = StreamDetails(
- type=StreamType.URL,
- provider="",
- item_id=uri,
- path=uri,
- content_type=ContentType(uri.split(".")[-1]),
- sample_rate=44100,
- bit_depth=16,
+ # generate uri for this queue item
+ queue_item.uri = "%s/stream/%s/%s" % (
+ self.mass.web.internal_url,
+ player_id,
+ queue_item.queue_item_id,
)
# turn on player
await self.async_cmd_power_on(player_id)
for child_player_id in player.group_childs:
if self._players.get(child_player_id):
await self.async_cmd_power_off(child_player_id)
+ else:
+ # if this was the last powered player in the group, turn off group
+ for parent_player_id in player.group_parents:
+ parent_player = self._players.get(parent_player_id)
+ if not parent_player:
+ continue
+ has_powered_players = False
+ for child_player_id in parent_player.group_childs:
+ if child_player_id == player_id:
+ continue
+ child_player = self._players.get(child_player_id)
+ if child_player and child_player.powered:
+ has_powered_players = True
+ break
+ if not has_powered_players:
+ await self.async_cmd_power_off(parent_player_id)
async def async_cmd_power_toggle(self, player_id: str):
"""
@callback
def __get_player_state(self, player: Player, active_parent: str):
"""Get final/calculated player's state."""
- if not player.available or not player.powered:
- return PlayerState.Off
if active_parent != player.player_id:
# use group state
return self._players[active_parent].state
@property
def state(self) -> PlayerState:
"""Return the state of the player."""
- if not self._powered:
- return PlayerState.Off
if self.media_status is None:
return PlayerState.Stopped
if self.media_status.player_is_playing:
player.current_uri = uri
player.sox = subprocess.Popen(["play", uri])
player.state = PlayerState.Playing
+ player.powered = True
self.mass.add_job(self.mass.player_manager.async_update_player(player))
async def report_progress():
"""Report fake progress while sox is playing."""
player.elapsed_time = 0
- while player.sox and not player.sox.poll():
+ while (
+ player.state == PlayerState.Playing
+ and player.sox
+ and not player.sox.poll()
+ ):
await asyncio.sleep(1)
player.elapsed_time += 1
self.mass.add_job(self.mass.player_manager.async_update_player(player))
import logging
from typing import List
-import slugify as slug
from hass_client import (
EVENT_CONNECTED,
EVENT_STATE_CHANGED,
IS_SUPERVISOR,
HomeAssistant,
)
-from music_assistant.constants import (
- CONF_URL,
- EVENT_PLAYER_ADDED,
- EVENT_PLAYER_CHANGED,
- EVENT_PLAYER_REMOVED,
-)
+from music_assistant.constants import CONF_URL
from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
-from music_assistant.models.media_types import MediaType
-from music_assistant.models.player import Player, PlayerControl, PlayerControlType
+from music_assistant.models.player import PlayerControl, PlayerControlType
from music_assistant.models.provider import Provider
-from music_assistant.utils import callback, run_periodic, try_parse_float
+from music_assistant.utils import callback, try_parse_float
PROV_ID = "homeassistant"
PROV_NAME = "Home Assistant integration"
entry_type=ConfigEntryType.PASSWORD,
description_key="hass_token",
)
-CONFIG_ENTRY_PUBLISH_PLAYERS = ConfigEntry(
- entry_key=CONF_PUBLISH_PLAYERS,
- entry_type=ConfigEntryType.BOOL,
- description_key=CONF_PUBLISH_PLAYERS,
- default_value=True,
-)
-
-# TODO: handle player removals and renames in publishing to hass
async def async_setup(mass):
class HomeAssistantPlugin(Provider):
"""Homeassistant plugin.
- allows publishing of our players to hass
allows using hass entities (like switches, media_players or gui inputs) to be triggered
"""
self._tasks = []
self._tracked_entities = []
self._sources = []
- self._published_players = {}
super().__init__(*args, **kwargs)
@property
entries.append(CONFIG_ENTRY_URL)
entries.append(CONFIG_ENTRY_TOKEN)
entries += [
- CONFIG_ENTRY_PUBLISH_PLAYERS,
ConfigEntry(
entry_key=CONF_POWER_ENTITIES,
entry_type=ConfigEntryType.STRING,
)
# register callbacks
self._hass.register_event_callback(self.__async_hass_event)
- self.mass.add_event_listener(
- self.__async_mass_event,
- [EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED, EVENT_PLAYER_REMOVED],
- )
await self._hass.async_connect()
- self._tasks.append(self.mass.add_job(self.__async_get_sources()))
return True
async def async_on_stop(self):
if self._hass:
await self._hass.async_close()
- async def __async_mass_event(self, event, event_data):
- """Receive event from Music Assistant."""
- if event in [EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED]:
- await self.__async_publish_player(event_data)
- # TODO: player removals
-
async def __async_hass_event(self, event_type, event_data):
"""Receive event from Home Assistant."""
if event_type == EVENT_STATE_CHANGED:
if event_data["entity_id"] in self._tracked_entities:
new_state = event_data["new_state"]
await self.__async_update_player_controls(new_state)
- elif event_type == "call_service" and event_data["domain"] == "media_player":
- await self.__async_handle_player_command(
- event_data["service"], event_data["service_data"]
- )
elif event_type == EVENT_CONNECTED:
# register player controls on connect
self.mass.add_job(self.__async_register_player_controls())
- async def __async_handle_player_command(self, service, service_data):
- """Handle forwarded service call for one of our players."""
- if isinstance(service_data["entity_id"], list):
- # can be a list of entity ids if action fired on multiple items
- entity_ids = service_data["entity_id"]
- else:
- entity_ids = [service_data["entity_id"]]
- for entity_id in entity_ids:
- if entity_id in self._published_players:
- # call is for one of our players so handle it
- player_id = self._published_players[entity_id]
- if not self.mass.player_manager.get_player(player_id):
- return
- if service == "turn_on":
- await self.mass.player_manager.async_cmd_power_on(player_id)
- elif service == "turn_off":
- await self.mass.player_manager.async_cmd_power_off(player_id)
- elif service == "toggle":
- await self.mass.player_manager.async_cmd_power_toggle(player_id)
- elif service == "volume_mute":
- await self.mass.player_manager.async_cmd_volume_mute(
- player_id, service_data["is_volume_muted"]
- )
- elif service == "volume_up":
- await self.mass.player_manager.async_cmd_volume_up(player_id)
- elif service == "volume_down":
- await self.mass.player_manager.async_cmd_volume_down(player_id)
- elif service == "volume_set":
- volume_level = service_data["volume_level"] * 100
- await self.mass.player_manager.async_cmd_volume_set(
- player_id, volume_level
- )
- elif service == "media_play":
- await self.mass.player_manager.async_cmd_play(player_id)
- elif service == "media_pause":
- await self.mass.player_manager.async_cmd_pause(player_id)
- elif service == "media_stop":
- await self.mass.player_manager.async_cmd_stop(player_id)
- elif service == "media_next_track":
- await self.mass.player_manager.async_cmd_next(player_id)
- elif service == "media_play_pause":
- await self.mass.player_manager.async_cmd_play_pause(player_id)
- elif service in ["play_media", "select_source"]:
- return await self.__async_handle_play_media(player_id, service_data)
- else:
- LOGGER.error(
- "%s service is unhandled. Service data: %s",
- service,
- service_data,
- )
-
- async def __async_handle_play_media(self, player_id, service_data):
- """Handle play media request from homeassistant."""
- media_content_id = service_data.get("media_content_id")
- if not media_content_id:
- media_content_id = service_data.get("source")
- queue_opt = "add" if service_data.get("enqueue") else "play"
- if "://" not in media_content_id:
- media_items = []
- for playlist_str in media_content_id.split(","):
- playlist_str = playlist_str.strip()
- playlist = (
- await self.mass.music_manager.async_get_library_playlist_by_name(
- playlist_str
- )
- )
- if playlist:
- media_items.append(playlist)
- else:
- radio = await self.mass.music_manager.async_get_radio_by_name(
- playlist_str
- )
- if radio:
- media_items.append(radio)
- queue_opt = "play"
- return await self.mass.player_manager.async_play_media(
- player_id, media_items, queue_opt
- )
- if "spotify://playlist" in media_content_id:
- # TODO: handle parsing of other uri's here
- playlist = await self.mass.music_manager.async_getplaylist(
- "spotify", media_content_id.split(":")[-1]
- )
- return await self.mass.player_manager.async_play_media(
- player_id, playlist, queue_opt
- )
-
- async def __async_publish_player(self, player: Player):
- """Publish player details to Home Assistant."""
- if not self.mass.config.providers[PROV_ID][CONF_PUBLISH_PLAYERS]:
- return False
- if not player.available:
- return
- player_id = player.player_id
- entity_id = (
- "media_player.mass_" + slug.slugify(player.name, separator="_").lower()
- )
- player_queue = self.mass.player_manager.get_player_queue(player_id)
- cur_item = player_queue.cur_item if player_queue else None
- state_attributes = {
- "supported_features": 196541,
- "friendly_name": player.name,
- "source_list": self._sources,
- "source": "unknown",
- "volume_level": player.volume_level / 100,
- "is_volume_muted": player.muted,
- "media_position_updated_at": player.updated_at.isoformat(),
- "media_duration": cur_item.duration if cur_item else None,
- "media_position": player_queue.cur_item_time if player_queue else None,
- "media_title": cur_item.name if cur_item else None,
- "media_artist": cur_item.artists[0].name
- if cur_item and cur_item.artists
- else None,
- "media_album_name": cur_item.album.name
- if cur_item and cur_item.album
- else None,
- "entity_picture": "",
- "mass_player_id": player_id,
- }
- if cur_item:
- host = self.mass.web.internal_url
- item_type = "radio" if cur_item.media_type == MediaType.Radio else "track"
- # pylint: disable=line-too-long
- img_url = f"{host}/api/{item_type}/{cur_item.item_id}/thumb?provider={cur_item.provider}"
- state_attributes["entity_picture"] = img_url
- self._published_players[entity_id] = player.player_id
- await self._hass.async_set_state(entity_id, player.state, state_attributes)
-
- @run_periodic(600)
- async def __async_get_sources(self):
- """We build a list of all playlists to use as player sources."""
- # pylint: disable=attribute-defined-outside-init
- self._sources = [
- playlist.name
- async for playlist in self.mass.music_manager.async_get_library_playlists()
- ]
- self._sources += [
- playlist.name
- async for playlist in self.mass.music_manager.async_get_library_radios()
- ]
-
@callback
def __get_power_control_entities(self):
"""Return list of entities that can be used as power control."""
from music_assistant.constants import (
CONF_PASSWORD,
CONF_USERNAME,
- EVENT_PLAYBACK_STOPPED,
+ EVENT_STREAM_ENDED,
EVENT_STREAM_STARTED,
)
from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
self.__logged_in = False
self._throttler = Throttler(rate_limit=4, period=1)
self.mass.add_event_listener(self.async_mass_event, EVENT_STREAM_STARTED)
- self.mass.add_event_listener(self.async_mass_event, EVENT_PLAYBACK_STOPPED)
+ self.mass.add_event_listener(self.async_mass_event, EVENT_STREAM_ENDED)
return True
async def async_search(
}
]
await self.__async_post_data("track/reportStreamingStart", data=events)
- elif msg == EVENT_PLAYBACK_STOPPED and msg_details.provider == PROV_ID:
+ elif msg == EVENT_STREAM_ENDED and msg_details.provider == PROV_ID:
# report streaming ended to qobuz
# if msg_details.details < 6:
# return ????????????? TODO