From 53a69a6260c6f7da84b9fc2ae3481470a082d6ff Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Thu, 24 Oct 2019 01:19:04 +0200 Subject: [PATCH] some more refactoring different approach for grouped players first version of sonos support --- mass.py | 1 + music_assistant/__init__.py | 8 +- music_assistant/constants.py | 3 + music_assistant/homeassistant.py | 49 +-- music_assistant/http_streamer.py | 41 +-- music_assistant/models/player.py | 285 ++++++++++-------- music_assistant/models/player_queue.py | 17 +- music_assistant/models/playerstate.py | 10 - music_assistant/musicproviders/qobuz.py | 12 +- music_assistant/player_manager.py | 20 +- music_assistant/playerproviders/chromecast.py | 51 ++-- music_assistant/playerproviders/sonos.py | 240 +++++++++++++++ music_assistant/playerproviders/squeezebox.py | 2 +- music_assistant/utils.py | 15 +- music_assistant/web.py | 19 +- music_assistant/web/components/player.vue.js | 72 +++-- .../web/components/volumecontrol.vue.js | 13 +- requirements.txt | 3 +- 18 files changed, 597 insertions(+), 264 deletions(-) create mode 100644 music_assistant/playerproviders/sonos.py diff --git a/mass.py b/mass.py index ac14d227..76472fb6 100755 --- a/mass.py +++ b/mass.py @@ -61,6 +61,7 @@ def do_update(): cd %s curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip" unzip -q master.zip + pip install -r musicassistant-master/requirements.txt cp -rf musicassistant-master/music_assistant . cp -rf musicassistant-master/mass.py . rm -R musicassistant-master diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index 8cc2b00c..559c74bb 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -14,7 +14,7 @@ import logging from .database import Database from .config import MassConfig -from .utils import run_periodic, LOGGER, try_parse_bool +from .utils import run_periodic, LOGGER, try_parse_bool, serialize_values from .metadata import MetaData from .cache import Cache from .music_manager import MusicManager @@ -63,9 +63,11 @@ class MusicAssistant(): loop.default_exception_handler(context) #LOGGER.exception(f"Caught exception: {context}") - async def signal_event(self, msg, msg_details=None): + async def signal_event(self, msg, msg_details:dict): ''' signal (systemwide) event ''' - LOGGER.debug("Event: %s - %s" %(msg, msg_details)) + if not (msg_details == None or isinstance(msg_details, (str, int, dict))): + msg_details = serialize_values(msg_details) + LOGGER.debug("Event: %s" %(msg)) listeners = list(self.event_listeners.values()) for callback, eventfilter in listeners: if not eventfilter or eventfilter in msg: diff --git a/music_assistant/constants.py b/music_assistant/constants.py index d848e47a..9c438c89 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -16,9 +16,12 @@ CONF_KEY_PLAYERSETTINGS = "player_settings" CONF_KEY_MUSICPROVIDERS = "musicproviders" CONF_KEY_PLAYERPROVIDERS = "playerproviders" +EVENT_PLAYER_ADDED = "player added" +EVENT_PLAYER_REMOVED = "player removed" EVENT_PLAYER_CHANGED = "player changed" 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_HASS_ENTITY_CHANGED = "hass entity changed" diff --git a/music_assistant/homeassistant.py b/music_assistant/homeassistant.py index 8c7ba2cd..6434e875 100644 --- a/music_assistant/homeassistant.py +++ b/music_assistant/homeassistant.py @@ -16,12 +16,11 @@ import slugify as slug import json from .utils import run_periodic, LOGGER, parse_track_title, try_parse_int from .models.media_types import Track -from .constants import CONF_ENABLED, CONF_URL, CONF_TOKEN, EVENT_PLAYER_CHANGED +from .constants import CONF_ENABLED, CONF_URL, CONF_TOKEN, EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED, EVENT_HASS_ENTITY_CHANGED from .cache import use_cache CONF_KEY = 'homeassistant' CONF_PUBLISH_PLAYERS = "publish_players" -EVENT_HASS_CHANGED = "hass entity changed" ### auto detect hassio for auto config #### if os.path.isfile('/data/options.json'): @@ -84,6 +83,7 @@ class HomeAssistant(): loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.mass.event_loop.create_task(self.__hass_websocket()) await self.mass.add_event_listener(self.mass_event, EVENT_PLAYER_CHANGED) + await self.mass.add_event_listener(self.mass_event, EVENT_PLAYER_ADDED) self.mass.event_loop.create_task(self.__get_sources()) async def get_state_async(self, entity_id, attribute='state'): @@ -114,11 +114,11 @@ class HomeAssistant(): if 'state' in state_obj: self._tracked_entities[entity_id] = state_obj self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_HASS_CHANGED, entity_id)) + self.mass.signal_event(EVENT_HASS_ENTITY_CHANGED, state_obj)) async def mass_event(self, msg, msg_details): ''' received event from mass ''' - if msg == EVENT_PLAYER_CHANGED: + if msg in [EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED]: await self.publish_player(msg_details) async def hass_event(self, event_type, event_data): @@ -127,7 +127,7 @@ class HomeAssistant(): if event_data['entity_id'] in self._tracked_entities: self._tracked_entities[event_data['entity_id']] = event_data['new_state'] self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_HASS_CHANGED, event_data['entity_id'])) + self.mass.signal_event(EVENT_HASS_ENTITY_CHANGED, event_data)) elif event_type == 'call_service' and event_data['domain'] == 'media_player': await self.__handle_player_command(event_data['service'], event_data['service_data']) @@ -194,27 +194,40 @@ class HomeAssistant(): track.provider = 'http' return await self.mass.players.play_media(player_id, track, queue_opt) - async def publish_player(self, player): + async def publish_player(self, player_info): ''' publish player details to hass''' if not self.mass.config['base']['homeassistant']['publish_players']: return False - player_id = player.player_id - entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower() - state = player.state if player.powered else 'off' + if not player_info["name"]: + return + # TODO: throttle updates to home assistant ? + player_id = player_info["player_id"] + entity_id = 'media_player.mass_' + slug.slugify(player_info["name"], separator='_').lower() + state = player_info["state"] state_attributes = { "supported_features": 65471, - "friendly_name": player.name, + "friendly_name": player_info["name"], "source_list": self._sources, "source": 'unknown', - "volume_level": player.volume_level/100, - "is_volume_muted": player.muted, - "media_duration": player.cur_item.duration if player.cur_item else 0, - "media_position": player.cur_time, - "media_title": player.cur_item.name if player.cur_item else "", - "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "", - "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "", - "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else "" + "volume_level": player_info["volume_level"]/100, + "is_volume_muted": player_info["muted"], + "media_position_updated_at": player_info["media_position_updated_at"], + "media_duration": None, + "media_position": player_info["cur_time"], + "media_title": None, + "media_artist": None, + "media_album_name": None, + "entity_picture": None } + if state != "off": + player = await self.mass.players.get_player(player_id) + if player.queue.cur_item: + queue_item = await player.queue.by_item_id(player.queue.cur_item) + state_attributes["media_duration"] = queue_item.duration + state_attributes["media_title"] = queue_item.name + state_attributes["media_artist"] = queue_item.artists[0].name + state_attributes["media_album_name"] = queue_item.album.name + state_attributes["entity_picture"] = queue_item.album.metadata.get("image") self._published_players[entity_id] = player_id await self.__set_state(entity_id, state, state_attributes) diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py index d29a2169..c262ef1e 100755 --- a/music_assistant/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -275,6 +275,8 @@ class HTTPStreamer(): sox_proc.terminate() fill_buffer_thread.join() del sox_proc + # run garbage collect manually to avoid too much memory fragmentation + gc.collect() LOGGER.info("streaming of queue for player %s completed" % player.name) async def __get_audio_stream(self, player, queue_item, cancelled, @@ -289,17 +291,20 @@ class HTTPStreamer(): self.mass.event_loop).result() if streamdetails: streamdetails['player_id'] = player.player_id + if not 'item_id' in streamdetails: + streamdetails['item_id'] = prov_media['item_id'] + if not 'provider' in streamdetails: + streamdetails['provider'] = prov_media['provider'] + if not 'quality' in streamdetails: + streamdetails['quality'] = prov_media['quality'] queue_item.streamdetails = streamdetails - queue_item.item_id = prov_media['item_id'] - queue_item.provider = prov_media['provider'] - queue_item.quality = prov_media['quality'] break if not streamdetails: LOGGER.warning(f"no stream details for {queue_item.name}") yield (True, b'') return # get sox effects and resample options - sox_options = await self.__get_player_sox_options(player, queue_item) + sox_options = await self.__get_player_sox_options(player, streamdetails) outputfmt = 'flac -C 0' if resample: outputfmt = 'raw -b 32 -c 2 -e signed-integer' @@ -321,7 +326,7 @@ class HTTPStreamer(): process = subprocess.Popen(args, shell=True, stdout=subprocess.PIPE) # fire event that streaming has started for this track asyncio.run_coroutine_threadsafe( - self.mass.signal_event(EVENT_STREAM_STARTED, queue_item), self.mass.event_loop) + self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails), self.mass.event_loop) # yield chunks from stdout # we keep 1 chunk behind to detect end of stream properly bytes_sent = 0 @@ -344,20 +349,21 @@ class HTTPStreamer(): # fire event that streaming has ended asyncio.run_coroutine_threadsafe( - self.mass.signal_event(EVENT_STREAM_ENDED, queue_item), self.mass.event_loop) + self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails), self.mass.event_loop) # send task to main event loop to analyse the audio - self.mass.event_loop.call_soon_threadsafe( - asyncio.ensure_future, self.__analyze_audio(queue_item)) + if queue_item.media_type == MediaType.Track: + self.mass.event_loop.call_soon_threadsafe( + asyncio.ensure_future, self.__analyze_audio(streamdetails)) # run garbage collect manually to avoid too much memory fragmentation gc.collect() - async def __get_player_sox_options(self, player, queue_item): + async def __get_player_sox_options(self, player, streamdetails): ''' get player specific sox effect options ''' sox_options = [] # volume normalisation gain_correct = asyncio.run_coroutine_threadsafe( self.mass.players.get_gain_correct( - player.player_id, queue_item.item_id, queue_item.provider), + player.player_id, streamdetails["item_id"], streamdetails["provider"]), self.mass.event_loop).result() if gain_correct != 0: sox_options.append('vol %s dB ' % gain_correct) @@ -365,7 +371,7 @@ class HTTPStreamer(): if player.settings['max_sample_rate']: max_sample_rate = try_parse_int(player.settings['max_sample_rate']) if max_sample_rate: - quality = queue_item.quality + quality = streamdetails["quality"] if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000: sox_options.append('rate -v 192000') elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000: @@ -376,19 +382,14 @@ class HTTPStreamer(): sox_options.append(player.settings['sox_options']) return " ".join(sox_options) - async def __analyze_audio(self, queue_item): + async def __analyze_audio(self, streamdetails): ''' analyze track audio, for now we only calculate EBU R128 loudness ''' - if queue_item.media_type != MediaType.Track: - # TODO: calculate loudness average for web radio ? - LOGGER.debug("analyze is only supported for tracks") - return - item_key = '%s%s' %(queue_item.item_id, queue_item.provider) + item_key = '%s%s' %(streamdetails["item_id"], streamdetails["provider"]) if item_key in self.analyze_jobs: return # prevent multiple analyze jobs for same track self.analyze_jobs[item_key] = True - streamdetails = queue_item.streamdetails track_loudness = await self.mass.db.get_track_loudness( - queue_item.item_id, queue_item.provider) + streamdetails["item_id"], streamdetails["provider"]) if track_loudness == None: # only when needed we do the analyze stuff LOGGER.debug('Start analyzing track %s' % item_key) @@ -404,7 +405,7 @@ class HTTPStreamer(): meter = pyloudnorm.Meter(rate) # create BS.1770 meter loudness = meter.integrated_loudness(data) # measure loudness del data - await self.mass.db.set_track_loudness(queue_item.item_id, queue_item.provider, loudness) + await self.mass.db.set_track_loudness(streamdetails["item_id"], streamdetails["provider"], loudness) del audio_data LOGGER.debug("Integrated loudness of track %s is: %s" %(item_key, loudness)) self.analyze_jobs.pop(item_key, None) diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index f7e88e05..29806f31 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -5,7 +5,9 @@ import asyncio from enum import Enum from typing import List import operator -from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float +import time +from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, \ + try_parse_bool, try_parse_float from ..constants import EVENT_PLAYER_CHANGED from ..cache import use_cache from .media_types import Track, MediaType @@ -100,30 +102,35 @@ class Player(): #### Common implementation, should NOT be overrridden ##### - def __init__(self, mass, player_id, prov_id): + def __init__(self, mass, player_id, prov_id, is_group=False): # private attributes self.mass = mass self._player_id = player_id # unique id for this player self._prov_id = prov_id # unique provider id for the player self._name = '' - self._is_group = False - self._state = PlayerState.Stopped + self._state = PlayerState.Stopped + self._group_childs = [] + self._last_group_parent = None self._powered = False self._cur_time = 0 + self._media_position_updated_at = 0 self._cur_uri = '' self._volume_level = 0 self._muted = False - self._group_parent = None self._queue = PlayerQueue(mass, self) self._player_settings = None + self._initialized = False + self._last_event = 0 + self._update_cur_time_task = None # public attributes self.supports_queue = True # has native support for a queue self.supports_gapless = False # has native gapless support self.supports_crossfade = False # has native crossfading support - # if home assistant support is enabled, register state listener - if self.mass.hass.enabled: - self.mass.event_loop.create_task( - self.mass.add_event_listener(self.hass_state_listener, "hass entity changed")) + + + def __del__(self): + if self._update_cur_time_task: + self._update_cur_time_task.cancel() @property def player_id(self): @@ -153,23 +160,62 @@ class Player(): @property def is_group(self): ''' [PROTECTED] is_group property of this player ''' - return self._is_group + return len(self._group_childs) > 0 - @is_group.setter - def is_group(self, is_group:bool): - ''' [PROTECTED] set is_group property of this player ''' - if is_group != self._is_group: - self._is_group = is_group + @property + def group_parents(self): + ''' [PROTECTED] player ids of all groups this player belongs to ''' + player_ids = [] + for item in self.mass.players._players.values(): + if self.player_id in item.group_childs: + player_ids.append(item.player_id) + return player_ids + + @property + def group_childs(self)->list: + ''' + [PROTECTED] + return all child player ids for this group player as list + empty list if this player is not a group player + ''' + return self._group_childs + + @group_childs.setter + def group_childs(self, group_childs:list): + ''' [PROTECTED] set group_childs property of this player ''' + if group_childs != self._group_childs: + self._group_childs = group_childs + self.mass.event_loop.create_task(self.update()) + for child_player_id in group_childs: + self.mass.event_loop.create_task( + self.mass.players.trigger_update(child_player_id)) + + def add_group_child(self, child_player_id): + ''' add player as child to this group player ''' + if not child_player_id in self._group_childs: + self._group_childs.append(child_player_id) self.mass.event_loop.create_task(self.update()) + self.mass.event_loop.create_task( + self.mass.players.trigger_update(child_player_id)) + + def remove_group_child(self, child_player_id): + ''' remove player as child from this group player ''' + if child_player_id in self._group_childs: + self._group_childs.remove(child_player_id) + self.mass.event_loop.create_task(self.update()) + self.mass.event_loop.create_task( + self.mass.players.trigger_update(child_player_id)) @property def state(self): ''' [PROTECTED] state property of this player ''' if not self.powered: return PlayerState.Off - if self.group_parent: - group_player = self.mass.players._players.get(self.group_parent) - if group_player: + # prefer group player state + for group_parent_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_parent_id) + if group_player and group_player.state != PlayerState.Off: + self._last_group_parent = group_parent_id return group_player.state return self._state @@ -178,7 +224,7 @@ class Player(): ''' [PROTECTED] set state property of this player ''' if state != self._state: self._state = state - self.mass.event_loop.create_task(self.update()) + self.mass.event_loop.create_task(self.update(update_queue=True)) @property def powered(self): @@ -205,32 +251,40 @@ class Player(): ''' [PROTECTED] set (real) power state for this player ''' if powered != self._powered: self._powered = powered - self.mass.event_loop.create_task(self.update()) + self.mass.event_loop.create_task(self.update()) @property def cur_time(self): ''' [PROTECTED] cur_time (player's elapsed time) property of this player ''' - # handle group player - if self.group_parent: - group_player = self.mass.players.get_player_sync(self.group_parent) - if group_player: + # prefer group player state + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return group_player.cur_time return self.queue.cur_item_time @cur_time.setter def cur_time(self, cur_time:int): ''' [PROTECTED] set cur_time (player's elapsed time) property of this player ''' + if cur_time == None: + cur_time = 0 if cur_time != self._cur_time: self._cur_time = cur_time - self.mass.event_loop.create_task(self.update()) + self._media_position_updated_at = time.time() + self.mass.event_loop.create_task(self.update(update_queue=True)) + + @property + def media_position_updated_at(self): + ''' [PROTECTED] When was the position of the current playing media valid. ''' + return self._media_position_updated_at @property def cur_uri(self): ''' [PROTECTED] cur_uri (uri loaded in player) property of this player ''' - # handle group player - if self.group_parent: - group_player = self.mass.players.get_player_sync(self.group_parent) - if group_player: + # prefer group player's state + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return group_player.cur_uri return self._cur_uri @@ -239,7 +293,7 @@ class Player(): ''' [PROTECTED] set cur_uri (uri loaded in player) property of this player ''' if cur_uri != self._cur_uri: self._cur_uri = cur_uri - self.mass.event_loop.create_task(self.update()) + self.mass.event_loop.create_task(self.update(update_queue=True)) @property def volume_level(self): @@ -248,8 +302,9 @@ class Player(): if self.is_group: group_volume = 0 active_players = 0 - for child_player in self.group_childs: - if child_player.enabled and child_player.powered: + for child_player_id in self.group_childs: + child_player = self.mass.players._players.get(child_player_id) + if child_player and child_player.enabled and child_player.powered: group_volume += child_player.volume_level active_players += 1 if active_players: @@ -271,6 +326,10 @@ class Player(): if volume_level != self._volume_level: self._volume_level = volume_level self.mass.event_loop.create_task(self.update()) + # trigger update on group player + for group_parent_id in self.group_parents: + self.mass.event_loop.create_task( + self.mass.players.trigger_update(group_parent_id)) @property def muted(self): @@ -285,25 +344,6 @@ class Player(): self._muted = is_muted self.mass.event_loop.create_task(self.update()) - @property - def group_parent(self): - ''' [PROTECTED] group_parent property of this player ''' - return self._group_parent - - @group_parent.setter - def group_parent(self, group_parent:str): - ''' [PROTECTED] set muted property of this player ''' - if group_parent != self._group_parent: - self._group_parent = group_parent - self.mass.event_loop.create_task(self.update()) - - @property - def group_childs(self): - ''' [PROTECTED] return group childs ''' - if not self.is_group: - return [] - return [item for item in self.mass.players.players if item.group_parent == self.player_id] - @property def enabled(self): ''' [PROTECTED] player enabled config setting ''' @@ -312,49 +352,42 @@ class Player(): @property def queue(self): ''' [PROTECTED] player's queue ''' - # handle group player - if self.group_parent: - group_player = self.mass.players.get_player_sync(self.group_parent) - if group_player: + # prefer group player's state + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return group_player.queue return self._queue - @property - def cur_item(self): - ''' current item in the player's queue ''' - return self.queue.cur_item - async def stop(self): ''' [PROTECTED] send stop command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.players.get_player(self.group_parent) - if group_player: + # redirect playback related commands to parent player + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return await group_player.stop() - else: - return await self.cmd_stop() + return await self.cmd_stop() async def play(self): ''' [PROTECTED] send play (unpause) command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.players.get_player(self.group_parent) - if group_player: + # redirect playback related commands to parent player + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return await group_player.play() - elif self.state == PlayerState.Paused: + if self.state == PlayerState.Paused: return await self.cmd_play() elif self.state != PlayerState.Playing: return await self.queue.resume() async def pause(self): ''' [PROTECTED] send pause command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.players.get_player(self.group_parent) - if group_player: + # redirect playback related commands to parent player + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return await group_player.pause() - else: - return await self.cmd_pause() + return await self.cmd_pause() async def play_pause(self): ''' toggle play/pause''' @@ -365,23 +398,21 @@ class Player(): async def next(self): ''' [PROTECTED] send next command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.players.get_player(self.group_parent) - if group_player: + # redirect playback related commands to parent player + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return await group_player.next() - else: - return await self.queue.next() + return await self.queue.next() async def previous(self): ''' [PROTECTED] send previous command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.players.get_player(self.group_parent) - if group_player: + # redirect playback related commands to parent player + for group_id in self.group_parents: + group_player = self.mass.players.get_player_sync(group_id) + if group_player.state != PlayerState.Off: return await group_player.previous() - else: - return await self.queue.previous() + return await self.queue.previous() async def power(self, power): ''' [PROTECTED] send power ON command to player ''' @@ -413,18 +444,18 @@ class Player(): domain = self.settings['hass_power_entity'].split('.')[0] service_data = { 'entity_id': self.settings['hass_power_entity']} await self.mass.hass.call_service(domain, 'turn_on', service_data) + # power on group parent if needed + last_group_player = await self.mass.players.get_player(self._last_group_parent) + if last_group_player: + await last_group_player.power_on() # handle play on power on - if self.settings.get('play_power_on'): + elif self.settings.get('play_power_on'): await self.play() - # handle group power - if self.group_parent: - # player has a group parent, check if it should be turned on - group_player = await self.mass.players.get_player(self.group_parent) - if group_player and not group_player.powered: - return await group_player.power_on() async def power_off(self): - ''' [PROTECTED] send power TOGGLE command to player ''' + ''' [PROTECTED] send power OFF command to player ''' + if self._state in [PlayerState.Playing, PlayerState.Paused]: + await self.stop() await self.cmd_power_off() # handle mute as power if self.settings.get('mute_as_power'): @@ -445,16 +476,20 @@ class Player(): # handle group power if self.is_group: # player is group, turn off all childs - for item in self.group_childs: - if item.powered: - await item.power_off() - elif self.group_parent: - # player has a group parent, check if it should be turned off - group_player = await self.mass.players.get_player(self.group_parent) - if group_player.powered: + for child_player_id in self.group_childs: + child_player = self.mass.players._players.get(child_player_id) + if child_player and child_player.powered: + await child_player.power_off() + # if player has group parent(s), check if it should be turned off + for group_parent_id in self.group_parents: + group_player = await self.mass.players.get_player(group_parent_id) + if group_player.state != PlayerState.Off: needs_power = False - for child_player in group_player.group_childs: - if child_player.player_id != self.player_id and child_player.powered: + for child_player_id in group_player.group_childs: + if child_player_id == self.player_id: + continue + child_player = self.mass.players._players.get(child_player_id) + if child_player and child_player.powered: needs_power = True break if not needs_power: @@ -479,8 +514,9 @@ class Player(): volume_dif_percent = 1+(new_volume/100) else: volume_dif_percent = volume_dif/cur_volume - for child_player in self.group_childs: - if child_player.enabled and child_player.powered: + for child_player_id in self.group_childs: + child_player = self.mass.players._players.get(child_player_id) + if child_player and child_player.enabled and child_player.powered: cur_child_volume = child_player.volume_level new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent) await child_player.volume_set(new_child_volume) @@ -513,17 +549,25 @@ class Player(): ''' [PROTECTED] send mute command to player ''' return await self.cmd_volume_mute(is_muted) - async def update(self): + async def update(self, update_queue=False): ''' [PROTECTED] signal player updated ''' - await self.queue.update() - await self.mass.signal_event(EVENT_PLAYER_CHANGED, self) self.get_player_settings() - - async def hass_state_listener(self, msg, msg_details=None): - ''' called when tracked entities in hass change state ''' - if (msg_details == self.settings.get('hass_power_entity') or - msg_details == self.settings.get('hass_volume_entity')): - await self.update() + if not self._initialized: + return + # update queue state if player state changes + if update_queue: + await self.queue.update() + await self.mass.signal_event(EVENT_PLAYER_CHANGED, self.to_dict()) + if self._state == PlayerState.Playing and not self._update_cur_time_task and (time.time() - self._media_position_updated_at > 2): + self._update_cur_time_task = self.mass.event_loop.create_task(self.__update_cur_time()) + + async def __update_cur_time(self): + ''' background task that keeps updating the current time ''' + while self._state == PlayerState.Playing: + calc_time = self._cur_time + (time.time() - self._media_position_updated_at) + self.cur_time = calc_time + await asyncio.sleep(1) + self._update_cur_time_task = None @property def settings(self): @@ -577,10 +621,13 @@ class Player(): "state": self.state, "powered": self.powered, "cur_time": self.cur_time, + "media_position_updated_at": self.media_position_updated_at, "cur_uri": self.cur_uri, "volume_level": self.volume_level, "muted": self.muted, - "group_parent": self.group_parent, + "group_parents": self.group_parents, + "group_childs": self.group_childs, "enabled": self.enabled, - "cur_item": self.cur_item.__dict__ if self.cur_item else None + "cur_queue_index": self.queue.cur_index, + "cur_queue_item": self.queue.cur_item } \ No newline at end of file diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index ab75dbf4..9093f94b 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -69,13 +69,16 @@ class PlayerQueue(): @property def cur_index(self): ''' match current uri with queue items to determine queue index ''' + if not self._items: + return None return self._cur_index @property def cur_item(self): + ''' return the queue item id of the current item in the queue ''' if self.cur_index == None or not len(self.items) > self.cur_index: return None - return self.items[self.cur_index] + return self.items[self.cur_index].queue_item_id @property def cur_item_time(self): @@ -156,6 +159,8 @@ class PlayerQueue(): async def next(self): ''' request next track in queue ''' + if self.cur_index == None: + return if self.use_queue_stream: return await self.play_index(self.cur_index+1) else: @@ -163,6 +168,8 @@ class PlayerQueue(): async def previous(self): ''' request previous track in queue ''' + if self.cur_index == None: + return if self.use_queue_stream: return await self.play_index(self.cur_index-1) else: @@ -291,13 +298,13 @@ class PlayerQueue(): if (not self._last_track and new_track) or self._last_track != new_track: # queue track updated # account for track changing state so trigger track change after 1 second - if self._last_track: - self._last_track.seconds_played = self._last_item_time + if self._last_track and self._last_track.streamdetails: + self._last_track.streamdetails["seconds_played"] = self._last_item_time self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_PLAYBACK_STOPPED, self._last_track)) + self.mass.signal_event(EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails)) if new_track: self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_PLAYBACK_STARTED, new_track)) + self.mass.signal_event(EVENT_PLAYBACK_STARTED, new_track.streamdetails)) self._last_track = new_track await self.__save_to_file() if self._last_player_state != self._player.state: diff --git a/music_assistant/models/playerstate.py b/music_assistant/models/playerstate.py index 6ca6e2e0..34336119 100755 --- a/music_assistant/models/playerstate.py +++ b/music_assistant/models/playerstate.py @@ -8,13 +8,3 @@ class PlayerState(str, Enum): Stopped = "stopped" Paused = "paused" Playing = "playing" - - # def from_string(self, string): - # if string == "off": - # return self.Off - # elif string == "stopped": - # return self.Stopped - # elif string == "paused": - # return self.Paused - # elif string == "playing": - # return self.Playing diff --git a/music_assistant/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py index 0262bf00..587ec2d7 100644 --- a/music_assistant/musicproviders/qobuz.py +++ b/music_assistant/musicproviders/qobuz.py @@ -283,24 +283,24 @@ class QobuzProvider(MusicProvider): if not self.__user_auth_info: return # TODO: need to figure out if the streamed track is purchased by user - if msg == EVENT_PLAYBACK_STARTED and msg_details.provider == self.prov_id: + if msg == EVENT_PLAYBACK_STARTED and msg_details["provider"] == self.prov_id: # report streaming started to qobuz device_id = self.__user_auth_info["user"]["device"]["id"] credential_id = self.__user_auth_info["user"]["credential"]["id"] user_id = self.__user_auth_info["user"]["id"] - format_id = msg_details.streamdetails["details"]["format_id"] + format_id = msg_details["details"]["format_id"] timestamp = int(time.time()) events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, - "track_id": msg_details.item_id, "purchase": False, "date": timestamp, + "track_id": msg_details["item_id"], "purchase": False, "date": timestamp, "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}] await self.__post_data("track/reportStreamingStart", data=events) - elif msg == EVENT_PLAYBACK_STOPPED and msg_details.provider == self.prov_id: + elif msg == EVENT_PLAYBACK_STOPPED and msg_details["provider"] == self.prov_id: # report streaming ended to qobuz user_id = self.__user_auth_info["user"]["id"] params = { 'user_id': user_id, - 'track_id': msg_details.item_id, - 'duration': int(msg_details.seconds_played) + 'track_id': msg_details["item_id"], + 'duration': int(msg_details["seconds_played"]) } await self.__get_data('/track/reportStreamingEnd', params) diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index 5a28bc3c..38f5a73d 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -9,7 +9,7 @@ import random import functools import urllib -from .constants import CONF_KEY_PLAYERPROVIDERS +from .constants import CONF_KEY_PLAYERPROVIDERS, EVENT_PLAYER_ADDED, EVENT_PLAYER_REMOVED, EVENT_HASS_ENTITY_CHANGED from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, \ get_ip, run_async_background_task, load_provider_modules from .models.media_types import MediaType, TrackQuality @@ -34,6 +34,8 @@ class PlayerManager(): # start providers for prov in self.providers.values(): await prov.setup() + # register state listener + await self.mass.add_event_listener(self.handle_mass_events, EVENT_HASS_ENTITY_CHANGED) @property def players(self): @@ -50,8 +52,9 @@ class PlayerManager(): async def add_player(self, player): ''' register a new player ''' + player._initialized = True self._players[player.player_id] = player - await self.mass.signal_event('player added', player) + await self.mass.signal_event(EVENT_PLAYER_ADDED, player.to_dict()) # TODO: turn on player if it was previously turned on ? LOGGER.info(f"New player added: {player.player_provider}/{player.player_id}") return player @@ -59,7 +62,7 @@ class PlayerManager(): async def remove_player(self, player_id): ''' handle a player remove ''' self._players.pop(player_id, None) - await self.mass.signal_event('player removed', player_id) + await self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id}) LOGGER.info(f"Player removed: {player_id}") async def trigger_update(self, player_id): @@ -114,6 +117,17 @@ class PlayerManager(): elif queue_opt == 'add': return await player.queue.append(queue_items) + async def handle_mass_events(self, msg, msg_details=None): + ''' listen to some events on event bus ''' + if msg == EVENT_HASS_ENTITY_CHANGED: + # handle players with hass integration enabled + player_ids = list(self._players.keys()) + for player_id in player_ids: + player = self._players[player_id] + if (msg_details['entity_id'] == player.settings.get('hass_power_entity') or + msg_details['entity_id'] == player.settings.get('hass_volume_entity')): + await player.update() + async def get_gain_correct(self, player_id, item_id, provider_id, replaygain=False): ''' get gain correction for given player / track combination ''' player = self._players[player_id] diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py index 44c4bc77..bee8acdf 100644 --- a/music_assistant/playerproviders/chromecast.py +++ b/music_assistant/playerproviders/chromecast.py @@ -32,6 +32,14 @@ PLAYER_CONFIG_ENTRIES = [ class ChromecastPlayer(Player): ''' Chromecast player object ''' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._poll_task = self.mass.event_loop.create_task(self.__poll_status()) + + def __del__(self): + if self._poll_task: + self._poll_task.cancel() + async def try_chromecast_command(self, cmd:types.MethodType, *args, **kwargs): ''' guard for disconnected socket client ''' def _try_chromecast_command(_cmd:types.MethodType, *_args, **_kwargs): @@ -71,9 +79,6 @@ class ChromecastPlayer(Player): async def cmd_power_off(self): ''' send power OFF command to player ''' self.powered = False - # power is not supported so send quit_app instead - if not self.group_parent: - await self.try_chromecast_command(self.cc.quit_app) async def cmd_volume_set(self, volume_level): ''' send new volume level command to player ''' @@ -178,6 +183,13 @@ class ChromecastPlayer(Player): else: send_queue() + @run_periodic(10) + async def __poll_status(self): + ''' request actual status from CC ''' + # this is needed to get some accurate media progress info + if self._state == PlayerState.Playing: + await self.try_chromecast_command(self.cc.media_controller.update_status) + async def handle_player_state(self, caststatus=None, mediastatus=None, connection_status=None): ''' handle a player state message from the socket ''' @@ -203,15 +215,6 @@ class ChromecastPlayer(Player): self.state = PlayerState.Stopped self.cur_uri = mediastatus.content_id self.cur_time = mediastatus.adjusted_current_time - # create update/poll task for the current time - async def poll_task(): - self.poll_task = True - while self.state == PlayerState.Playing: - self.cur_time = mediastatus.adjusted_current_time - await asyncio.sleep(1) - self.poll_task = False - if not self.poll_task and self.state == PlayerState.Playing: - self.mass.event_loop.create_task(poll_task()) class ChromecastProvider(PlayerProvider): ''' support for ChromeCast Audio ''' @@ -232,23 +235,14 @@ class ChromecastProvider(PlayerProvider): async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): ''' handle callback from multizone manager ''' if added_player: - player = await self.get_player(added_player) group_player = await self.get_player(str(mz._uuid)) - if player and group_player: - player.group_parent = group_player.player_id - LOGGER.debug("player %s added to group %s" %(player.name, group_player.name)) + group_player.add_group_child(added_player) elif removed_player: - player = await self.get_player(added_player) group_player = await self.get_player(str(mz._uuid)) - if player and group_player: - player.group_parent = None - LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name)) + group_player.remove_group_child(added_player) else: - for member in mz.members: - player = await self.get_player(member) - if player: - LOGGER.debug("player %s added to group %s" %(player.name, str(mz._uuid))) - player.group_parent = str(mz._uuid) + group_player = await self.get_player(str(mz._uuid)) + group_player.group_childs = mz.members @run_periodic(1800) async def __periodic_chromecast_discovery(self): @@ -266,9 +260,6 @@ class ChromecastProvider(PlayerProvider): for player in self.players: if not player.cc.socket_client or not player.cc.socket_client.is_connected: removed_players.append(player.player_id) - for child_player in player.group_childs: - # update childs - child_player.group_parent = None # cleanup cast object del player.cc # signal removed players @@ -304,7 +295,6 @@ class ChromecastProvider(PlayerProvider): player = ChromecastPlayer(self.mass, player_id, self.prov_id) player.cc = chromecast player.mz = None - player.poll_task = False self.supports_queue = True self.supports_gapless = False self.supports_crossfade = False @@ -312,7 +302,6 @@ class ChromecastProvider(PlayerProvider): status_listener = StatusListener(player_id, player.handle_player_state, self.mass.event_loop) if chromecast.cast_type == 'group': - player.is_group = True mz = MultizoneController(chromecast.uuid) mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop)) @@ -323,7 +312,7 @@ class ChromecastProvider(PlayerProvider): chromecast.media_controller.register_status_listener(status_listener) player.cc.wait() await self.add_player(player) - if player.is_group: + if player.mz: player.mz.update_members() diff --git a/music_assistant/playerproviders/sonos.py b/music_assistant/playerproviders/sonos.py new file mode 100644 index 00000000..596ee1dc --- /dev/null +++ b/music_assistant/playerproviders/sonos.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import aiohttp +from typing import List +import logging +import types + +from ..utils import run_periodic, LOGGER, try_parse_int +from ..models.playerprovider import PlayerProvider +from ..models.player import Player, PlayerState +from ..models.playerstate import PlayerState +from ..models.player_queue import QueueItem, PlayerQueue +from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT + +PROV_ID = 'sonos' +PROV_NAME = 'Sonos' +PROV_CLASS = 'SonosProvider' + +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + ] + +PLAYER_CONFIG_ENTRIES = [] + +class SonosPlayer(Player): + ''' Sonos player object ''' + + async def cmd_stop(self): + ''' send stop command to player ''' + self.soco.stop() + + async def cmd_play(self): + ''' send play command to player ''' + self.soco.play() + + async def cmd_pause(self): + ''' send pause command to player ''' + self.soco.pause() + + async def cmd_next(self): + ''' send next track command to player ''' + self.soco.next() + + async def cmd_previous(self): + ''' send previous track command to player ''' + self.soco.previous() + + async def cmd_power_on(self): + ''' send power ON command to player ''' + self.powered = True + + async def cmd_power_off(self): + ''' send power OFF command to player ''' + self.powered = False + # power is not supported so send stop instead + self.soco.stop() + + async def cmd_volume_set(self, volume_level): + ''' send new volume level command to player ''' + self.soco.volume = volume_level + + async def cmd_volume_mute(self, is_muted=False): + ''' send mute command to player ''' + self.soco.mute = is_muted + + async def cmd_play_uri(self, uri:str): + ''' play single uri on player ''' + self.soco.play_uri(uri) + + async def cmd_queue_play_index(self, index:int): + ''' + play item at index X on player's queue + :attrib index: (int) index of the queue item that should start playing + ''' + self.soco.play_from_queue(index) + + async def cmd_queue_load(self, queue_items:List[QueueItem]): + ''' load (overwrite) queue with new items ''' + self.soco.clear_queue() + for pos, item in enumerate(queue_items): + self.soco.add_uri_to_queue(item.uri, pos) + + async def cmd_queue_insert(self, queue_items:List[QueueItem], insert_at_index): + for pos, item in enumerate(queue_items): + self.soco.add_uri_to_queue(item.uri, insert_at_index+pos) + + async def cmd_queue_append(self, queue_items:List[QueueItem]): + ''' + append new items at the end of the queue + ''' + last_index = len(self.queue.items) + for pos, item in enumerate(queue_items): + self.soco.add_uri_to_queue(item.uri, last_index+pos) + + def _update_state(self, event=None): + ''' update state, triggerer by event ''' + if event: + variables = event.variables + if "volume" in variables: + self.volume_level = int(variables["volume"]["Master"]) + if "mute" in variables: + self.muted = variables["mute"]["Master"] == "1" + else: + self.volume_level = self.soco.volume + self.muted = self.soco.mute + transport_info = self.soco.get_current_transport_info() + current_transport_state = transport_info.get("current_transport_state") + if current_transport_state == "TRANSITIONING": + return + if self.soco.is_playing_tv or self.soco.is_playing_line_in: + self.powered = False + else: + new_state = self.__convert_state(current_transport_state) + self.state = new_state + track_info = self.soco.get_current_track_info() + self.cur_uri = track_info["uri"] + position_info = self.soco.avTransport.GetPositionInfo( + [("InstanceID", 0), ("Channel", "Master")]) + rel_time = self.__timespan_secs(position_info.get("RelTime")) + self.cur_time = rel_time + + @staticmethod + def __convert_state(sonos_state): + ''' convert sonos state to internal state ''' + if sonos_state == 'PLAYING': + return PlayerState.Playing + elif sonos_state == 'PAUSED_PLAYBACK': + return PlayerState.Paused + else: + return PlayerState.Stopped + + @staticmethod + def __timespan_secs(timespan): + """Parse a time-span into number of seconds.""" + if timespan in ("", "NOT_IMPLEMENTED", None): + return None + return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))) + + +class SonosProvider(PlayerProvider): + ''' support for Sonos speakers ''' + + def __init__(self, mass, conf): + super().__init__(mass, conf) + self.prov_id = PROV_ID + self.name = PROV_NAME + self._discovery_running = False + self.player_config_entries = PLAYER_CONFIG_ENTRIES + + async def setup(self): + ''' perform async setup ''' + self.mass.event_loop.create_task( + self.__periodic_discovery()) + + @run_periodic(1800) + async def __periodic_discovery(self): + ''' run sonos discovery on interval ''' + await self.run_discovery() + + async def run_discovery(self): + ''' background sonos discovery and handler ''' + if self._discovery_running: + return + self._discovery_running = True + LOGGER.debug("Sonos discovery started...") + import soco + discovered_devices = soco.discover() + new_device_ids = [item.uid for item in discovered_devices] + cur_player_ids = [item.player_id for item in self.players] + # remove any disconnected players... + for player in self.players: + if not player.is_group and not player.soco.uid in new_device_ids: + await self.remove_player(player.player_id) + # process new players + for device in discovered_devices: + if device.uid not in cur_player_ids and device.is_visible: + await self.__device_discovered(device) + # handle groups + if len(discovered_devices) > 0: + await self.__process_groups(discovered_devices[0].all_groups) + else: + await self.__process_groups([]) + + async def __device_discovered(self, soco_device): + '''handle new player ''' + player = SonosPlayer(self.mass, soco_device.uid, self.prov_id) + player.soco = soco_device + player.name = soco_device.player_name + self.supports_queue = True + self.supports_gapless = True + self.supports_crossfade = True + player._subscriptions = [] + player._media_position_updated_at = None + # handle subscriptions to events + def subscribe(service, action): + queue = _ProcessSonosEventQueue(action) + sub = service.subscribe(auto_renew=True, event_queue=queue) + player._subscriptions.append(sub) + subscribe(soco_device.avTransport, player._update_state) + subscribe(soco_device.renderingControl, player._update_state) + subscribe(soco_device.zoneGroupTopology, self.__topology_changed) + return await self.add_player(player) + + async def __process_groups(self, sonos_groups): + ''' process all sonos groups ''' + all_group_ids = [] + for group in sonos_groups: + all_group_ids.append(group.uid) + if group.uid not in self.mass.players._players: + # new group player + group_player = await self.__device_discovered(group.coordinator) + else: + group_player = await self.get_player(group.uid) + # check members + group_player.name = group.label + group_player.group_childs = [item.uid for item in group.members] + + def __topology_changed(self, event=None): + ''' + received topology changed event + from one of the sonos players + schedule discovery to work out the changes + ''' + self.mass.event_loop.create_task(self.run_discovery()) + +class _ProcessSonosEventQueue: + """Queue like object for dispatching sonos events.""" + + def __init__(self, handler): + """Initialize Sonos event queue.""" + self._handler = handler + + def put(self, item, block=True, timeout=None): + """Process event.""" + try: + self._handler(item) + except Exception as ex: + LOGGER.warning("Error calling %s: %s", self._handler, ex) \ No newline at end of file diff --git a/music_assistant/playerproviders/squeezebox.py b/music_assistant/playerproviders/squeezebox.py index 9599a8d4..f5ff8683 100644 --- a/music_assistant/playerproviders/squeezebox.py +++ b/music_assistant/playerproviders/squeezebox.py @@ -96,7 +96,7 @@ class PySqueezeProvider(PlayerProvider): if player: if player._heartbeat_task: player._heartbeat_task.cancel() - await self.mass.players.remove_player(player) + await self.mass.players.remove_player(player.player_id) class PySqueezePlayer(Player): ''' Squeezebox socket client ''' diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 1a67197a..8df418fc 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -141,9 +141,8 @@ def get_folder_size(folderpath): total_size_gb = total_size/float(1<<30) return total_size_gb - -def json_serializer(obj): - ''' json serializer to recursively create serializable values for custom data types ''' +def serialize_values(obj): + ''' recursively create serializable values for custom data types ''' def get_val(val): if isinstance(val, (int, str, bool, float)): return val @@ -164,8 +163,12 @@ def json_serializer(obj): for key, value in val.__dict__.items(): new_dict[key] = get_val(value) return new_dict - obj = get_val(obj) - return json.dumps(obj, skipkeys=True) + return get_val(obj) + + +def json_serializer(obj): + ''' json serializer to recursively create serializable values for custom data types ''' + return json.dumps(serialize_values(obj), skipkeys=True) def try_load_json_file(jsonfile): @@ -211,4 +214,4 @@ def load_provider_module(mass, module_name, prov_type): else: return None except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) \ No newline at end of file + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/web.py b/music_assistant/web.py index aeaaad82..cd3e0211 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -56,6 +56,7 @@ class Web(): app.add_routes([web.get('/api/players', self.players)]) app.add_routes([web.get('/api/players/{player_id}', self.player)]) app.add_routes([web.get('/api/players/{player_id}/queue', self.player_queue)]) + app.add_routes([web.get('/api/players/{player_id}/queue/{item_id}', self.player_queue_item)]) app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}', self.player_command)]) app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}/{cmd_args}', self.player_command)]) app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}', self.play_media)]) @@ -208,11 +209,19 @@ class Web(): limit = int(request.query.get('limit', 50)) offset = int(request.query.get('offset', 0)) player = await self.mass.players.get_player(player_id) - # queue_items = player.queue.items - # queue_items = [item.__dict__ for item in queue_items] - # print(queue_items) - # result = queue_items[offset:limit] return web.json_response(player.queue.items[offset:limit], dumps=json_serializer) + + async def player_queue_item(self, request): + ''' return item (by index or queue item id) from the player's queue ''' + player_id = request.match_info.get('player_id') + item_id = request.match_info.get('item_id') + player = await self.mass.players.get_player(player_id) + try: + item_id = int(item_id) + queue_item = await player.queue.get_item(item_id) + except: + queue_item = await player.queue.by_item_id(item_id) + return web.json_response(queue_item, dumps=json_serializer) async def index(self, request): index_file = os.path.join( @@ -230,7 +239,7 @@ class Web(): async def send_event(msg, msg_details): ws_msg = {"message": msg, "message_details": msg_details } try: - await ws.send_json(ws_msg, dumps=json_serializer) + await ws.send_json(ws_msg) except (AssertionError, asyncio.CancelledError): await self.mass.remove_event_listener(cb_id) cb_id = await self.mass.add_event_listener(send_event) diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js index cd552442..61d2cbc7 100755 --- a/music_assistant/web/components/player.vue.js +++ b/music_assistant/web/components/player.vue.js @@ -12,17 +12,17 @@ Vue.component("player", { - - - + + + - {{ active_player.cur_item ? active_player.cur_item.name : active_player.name }} - - + {{ cur_player_item ? cur_player_item.name : active_player.name }} + + {{ artist.name }} - + @@ -31,7 +31,7 @@ Vue.component("player", {
- + {{ player_time_str_cur }} {{ player_time_str_total }} @@ -103,15 +103,15 @@ Vue.component("player", { -
+
- {{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }} + {{ player.is_group ? 'speaker_group' : 'speaker' }} {{ player.name }} - + {{ $t('state.' + player.state) }} @@ -143,7 +143,25 @@ Vue.component("player", { $_veeValidate: { validator: "new" }, - watch: {}, + watch: { + cur_queue_item: function (val) { + // get info for current track + if (!val) + this.cur_player_item = null; + else { + const api_url = '/api/players/' + this.active_player_id + '/queue/' + val; + axios + .get(api_url) + .then(result => { + if (result.data) + this.cur_player_item = result.data; + }) + .catch(error => { + console.log("error", error); + }); + } + } + }, data() { return { menu: false, @@ -153,7 +171,8 @@ Vue.component("player", { file: "", audioPlayer: null, audioPlayerId: '', - audioPlayerName: '' + audioPlayerName: '', + cur_player_item: null } }, mounted() { @@ -164,7 +183,12 @@ Vue.component("player", { this.connectWS(); }, computed: { - + cur_queue_item() { + if (this.active_player) + return this.active_player.cur_queue_item; + else + return null; + }, active_player() { if (this.players && this.active_player_id && this.active_player_id in this.players) return this.players[this.active_player_id]; @@ -179,23 +203,23 @@ Vue.component("player", { }; }, progress() { - if (!this.active_player.cur_item) + if (!this.cur_player_item) return 0; - var total_sec = this.active_player.cur_item.duration; + var total_sec = this.cur_player_item.duration; var cur_sec = this.active_player.cur_time; var cur_percent = cur_sec/total_sec*100; return cur_percent; }, player_time_str_cur() { - if (!this.active_player.cur_item || !this.active_player.cur_time) + if (!this.cur_player_item || !this.active_player.cur_time) return "0:00"; var cur_sec = this.active_player.cur_time; return cur_sec.toString().formatDuration(); }, player_time_str_total() { - if (!this.active_player.cur_item) + if (!this.cur_player_item) return "0:00"; - var total_sec = this.active_player.cur_item.duration; + var total_sec = this.cur_player_item.duration; return total_sec.toString().formatDuration(); } }, @@ -230,12 +254,6 @@ Vue.component("player", { switchPlayer (new_player_id) { this.active_player_id = new_player_id; }, - isGroup(player_id) { - for (var item in this.players) - if (this.players[item].group_parent == player_id && this.players[item].enabled) - return true; - return false; - }, setPlayerVolume: function(player_id, new_volume) { this.players[player_id].volume_level = new_volume; if (new_volume == 'up') @@ -385,7 +403,7 @@ Vue.component("player", { // TODO: store previous player in local storage if (!this.active_player_id || !this.players[this.active_player_id].enabled) for (var player_id in this.players) - if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) { + if (this.players[player_id].state == 'playing' && this.players[player_id].enabled) { // prefer the first playing player this.active_player_id = player_id; break; @@ -393,7 +411,7 @@ Vue.component("player", { if (!this.active_player_id || !this.players[this.active_player_id].enabled) for (var player_id in this.players) { // fallback to just the first player - if (this.players[player_id].enabled && !this.players[player_id].group_parent) + if (this.players[player_id].enabled) { this.active_player_id = player_id; break; diff --git a/music_assistant/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js index 7ef20ab8..cefba0a5 100644 --- a/music_assistant/web/components/volumecontrol.vue.js +++ b/music_assistant/web/components/volumecontrol.vue.js @@ -4,7 +4,7 @@ Vue.component("volumecontrol", { - {{ isGroup ? 'speaker_group' : 'speaker' }} + {{ players[player_id].is_group ? 'speaker_group' : 'speaker' }} {{ players[player_id].name }} @@ -60,14 +60,9 @@ Vue.component("volumecontrol", { }, computed: { volumePlayerIds() { - var volume_ids = [this.player_id]; - for (var player_id in this.players) - if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled) - volume_ids.push(player_id); - return volume_ids; - }, - isGroup() { - return this.volumePlayerIds.length > 1; + var all_ids = [this.player_id]; + all_ids.push(...this.players[this.player_id].group_childs); + return all_ids; } }, mounted() { }, diff --git a/requirements.txt b/requirements.txt index d2b71b31..97b3fea1 100755 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ memory-tempfile aiohttp pyloudnorm SoundFile -aiorun \ No newline at end of file +aiorun +soco \ No newline at end of file -- 2.34.1