some more refactoring
authormarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Wed, 23 Oct 2019 23:19:04 +0000 (01:19 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Wed, 23 Oct 2019 23:19:04 +0000 (01:19 +0200)
different approach for grouped players
first version of sonos support

18 files changed:
mass.py
music_assistant/__init__.py
music_assistant/constants.py
music_assistant/homeassistant.py
music_assistant/http_streamer.py
music_assistant/models/player.py
music_assistant/models/player_queue.py
music_assistant/models/playerstate.py
music_assistant/musicproviders/qobuz.py
music_assistant/player_manager.py
music_assistant/playerproviders/chromecast.py
music_assistant/playerproviders/sonos.py [new file with mode: 0644]
music_assistant/playerproviders/squeezebox.py
music_assistant/utils.py
music_assistant/web.py
music_assistant/web/components/player.vue.js
music_assistant/web/components/volumecontrol.vue.js
requirements.txt

diff --git a/mass.py b/mass.py
index ac14d227068c12fa6b3f3f2bcb323985f02be2f1..76472fb681c66c5bd13d33bc8dec869579e14bbb 100755 (executable)
--- 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
index 8cc2b00c1242c1084c9084d6ec38cfc66c214bac..559c74bbd71fde9c180be557467d971c0f0193f3 100644 (file)
@@ -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:
index d848e47a040785fc9a7e4817afc349496e2f143e..9c438c89077448652da3354799e15f93a617cd14 100755 (executable)
@@ -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"
index 8c7ba2cdf3c4405b362cf6cb68cba7bc26c93de8..6434e8759fc7750af848744e67decdedeaa002ae 100644 (file)
@@ -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)
 
index d29a21698577033364be6ebee7086b27da72a647..c262ef1edd367a4343aaf203271a7187a6fbbed2 100755 (executable)
@@ -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)
index f7e88e054a04e6ca0129afcc25873665e24d9328..29806f3101c64624553a0a8b1f0b1cceeb9a8369 100755 (executable)
@@ -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
index ab75dbf462543f8f5e46778a5bd7819acbb0910e..9093f94b95b488eda7a9c89347b314c36dd56213 100755 (executable)
@@ -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:
index 6ca6e2e02eca3401be97aa4194ec2d673dff9fe3..34336119300a2b39e929f0e2319d507f1b1dc9a7 100755 (executable)
@@ -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
index 0262bf0071098df6d7986cfd445a02213e0160b4..587ec2d7c93360125208fc32ad09593d701ecd6e 100644 (file)
@@ -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)
     
index 5a28bc3c88fd54c127b379d0e33e409734e2916c..38f5a73dc244eb52fe92daf7de9d8cfe26c55c9c 100755 (executable)
@@ -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]
index 44c4bc771db1e57cc462e0059191838e6b4d4365..bee8acdff3f156af7a1d8029ed2e34669e43dbb4 100644 (file)
@@ -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 (file)
index 0000000..596ee1d
--- /dev/null
@@ -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
index 9599a8d47735403698da521ed983e8ac54001de4..f5ff868371cf2c3fd55babe8b11c6d05aeaef075 100644 (file)
@@ -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 '''
index 1a67197afd537f4297b48ef12016141737a2a3a6..8df418fc92ff25b501202743265bd73bc4ede8da 100755 (executable)
@@ -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))
index aeaaad825a7fc4c5a23f0735ccefe8f414eb62e6..cd3e02115353f18c1169c69053302e7ffab841a9 100755 (executable)
@@ -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)
index cd55244290fe72947041a194d6f2ed000ef2f59e..61d2cbc78afda5cc942f7ee77e1521572617f64b 100755 (executable)
@@ -12,17 +12,17 @@ Vue.component("player", {
         <!-- now playing media -->
         <v-list-tile avatar ripple>
 
-              <v-list-tile-avatar v-if="active_player.cur_item" style="align-items:center;padding-top:15px;">
-                  <img v-if="active_player.cur_item.metadata && active_player.cur_item.metadata.image" :src="active_player.cur_item.metadata.image"/>
-                  <img v-if="!active_player.cur_item.metadata.image && active_player.cur_item.album && active_player.cur_item.album.metadata && active_player.cur_item.album.metadata.image" :src="active_player.cur_item.album.metadata.image"/>
+              <v-list-tile-avatar v-if="cur_player_item" style="align-items:center;padding-top:15px;">
+                  <img v-if="cur_player_item && cur_player_item.metadata && cur_player_item.metadata.image" :src="cur_player_item.metadata.image"/>
+                  <img v-if="cur_player_item && !cur_player_item.metadata.image && cur_player_item.album && cur_player_item.album.metadata && cur_player_item.album.metadata.image" :src="cur_player_item.album.metadata.image"/>
               </v-list-tile-avatar>
 
               <v-list-tile-content style="align-items:center;padding-top:15px;">
-                  <v-list-tile-title class="title">{{ active_player.cur_item ? active_player.cur_item.name : active_player.name }}</v-list-tile-title>
-                  <v-list-tile-sub-title v-if="active_player.cur_item && active_player.cur_item.artists">
-                      <span v-for="(artist, artistindex) in active_player.cur_item.artists">
+                  <v-list-tile-title class="title">{{ cur_player_item ? cur_player_item.name : active_player.name }}</v-list-tile-title>
+                  <v-list-tile-sub-title v-if="cur_player_item && cur_player_item.artists">
+                      <span v-for="(artist, artistindex) in cur_player_item.artists">
                           <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
-                          <label v-if="artistindex + 1 < active_player.cur_item.artists.length" :key="artistindex"> / </label>
+                          <label v-if="artistindex + 1 < cur_player_item.artists.length" :key="artistindex"> / </label>
                       </span>
                   </v-list-tile-sub-title>
               </v-list-tile-content>
@@ -31,7 +31,7 @@ Vue.component("player", {
 
           <!-- progress bar -->
           <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
-            <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
+            <v-layout row style="vertical-align: middle" v-if="cur_player_item">
               <span style="text-align:left; width:60px; margin-top:7px; margin-left:15px;">{{ player_time_str_cur }}</span>
               <v-progress-linear v-model="progress"></v-progress-linear>
               <span style="text-align:right; width:60px; margin-top:7px; margin-right: 15px;">{{ player_time_str_total }}</span>
@@ -103,15 +103,15 @@ Vue.component("player", {
         </v-card-title>
         <v-list two-line>
             <v-divider></v-divider>
-            <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && !player.group_parent">
+            <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && player.group_parents.length == 0">
               <v-list-tile avatar ripple style="margin-left: -5px; margin-right: -15px" @click="switchPlayer(player.player_id)" :style="active_player_id == player.player_id ? 'background-color: rgba(50, 115, 220, 0.3);' : ''">
                   <v-list-tile-avatar>
-                      <v-icon size="45">{{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }}</v-icon>
+                      <v-icon size="45">{{ player.is_group ? 'speaker_group' : 'speaker' }}</v-icon>
                   </v-list-tile-avatar>
                   <v-list-tile-content>
                       <v-list-tile-title class="title">{{ player.name }}</v-list-tile-title>
 
-                      <v-list-tile-sub-title v-if="player.cur_item" class="body-1" :key="player.state">
+                      <v-list-tile-sub-title v-if="cur_player_item" class="body-1" :key="player.state">
                           {{ $t('state.' + player.state) }}
                       </v-list-tile-sub-title>
 
@@ -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; 
index 7ef20ab8d81d889f0133476b2319d8595347cc4b..cefba0a550923e7093d30f717046c7c5472e34d8 100644 (file)
@@ -4,7 +4,7 @@ Vue.component("volumecontrol", {
                                <v-list>\r
                                        <v-list-tile avatar>\r
                                                <v-list-tile-avatar>\r
-                                                               <v-icon large>{{ isGroup ? 'speaker_group' : 'speaker' }}</v-icon>\r
+                                                               <v-icon large>{{ players[player_id].is_group ? 'speaker_group' : 'speaker' }}</v-icon>\r
                                                </v-list-tile-avatar>\r
                                                <v-list-tile-content>\r
                                                        <v-list-tile-title>{{ players[player_id].name }}</v-list-tile-title>\r
@@ -60,14 +60,9 @@ Vue.component("volumecontrol", {
        },\r
        computed: {\r
                        volumePlayerIds() {\r
-                       var volume_ids = [this.player_id];\r
-                       for (var player_id in this.players)\r
-                               if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled)\r
-                                       volume_ids.push(player_id);\r
-                       return volume_ids;\r
-               },\r
-               isGroup() {\r
-                       return this.volumePlayerIds.length > 1;\r
+                               var all_ids = [this.player_id];\r
+                               all_ids.push(...this.players[this.player_id].group_childs);\r
+                               return all_ids;\r
                }\r
   },\r
        mounted() { },\r
index d2b71b3146c6bc889bdababaf1cf5b3e3777ca37..97b3fea1e5082e583d2682b68265acc80038182b 100755 (executable)
@@ -14,4 +14,5 @@ memory-tempfile
 aiohttp
 pyloudnorm
 SoundFile
-aiorun
\ No newline at end of file
+aiorun
+soco
\ No newline at end of file