From ce8835bf046b462d888d0336ee8dc4cce90c2dbb Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Fri, 25 Oct 2019 00:17:33 +0200 Subject: [PATCH] stability and performance fixes --- mass.py | 2 +- music_assistant/__init__.py | 26 +- music_assistant/cache.py | 19 +- music_assistant/homeassistant.py | 12 +- music_assistant/http_streamer.py | 226 ++++++------------ music_assistant/models/player.py | 84 +++---- music_assistant/models/player_queue.py | 25 +- music_assistant/music_manager.py | 10 +- music_assistant/musicproviders/file.py | 25 +- music_assistant/musicproviders/qobuz.py | 18 +- music_assistant/playerproviders/chromecast.py | 80 +++---- music_assistant/playerproviders/sonos.py | 75 ++++-- music_assistant/playerproviders/squeezebox.py | 21 +- .../playerproviders/{web.py => webplayer.py} | 5 +- music_assistant/utils.py | 2 +- music_assistant/web.py | 1 - music_assistant/web/components/player.vue.js | 44 ++-- music_assistant/web/images/icons/sonos.png | Bin 0 -> 34269 bytes .../web/images/icons/webplayer.png | Bin 0 -> 32930 bytes music_assistant/web/strings.js | 4 + 20 files changed, 321 insertions(+), 358 deletions(-) rename music_assistant/playerproviders/{web.py => webplayer.py} (97%) create mode 100644 music_assistant/web/images/icons/sonos.png create mode 100644 music_assistant/web/images/icons/webplayer.png diff --git a/mass.py b/mass.py index 76472fb6..cd7dd1ed 100755 --- a/mass.py +++ b/mass.py @@ -85,7 +85,7 @@ if __name__ == "__main__": event_loop.set_debug(True) logger.setLevel(logging.DEBUG) logging.getLogger('aiosqlite').setLevel(logging.INFO) - logging.getLogger('asyncio').setLevel(logging.INFO) + logging.getLogger('asyncio').setLevel(logging.WARNING) else: logger.setLevel(logging.INFO) # start music assistant! diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index 559c74bb..f1bf784c 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -11,6 +11,7 @@ import uuid import json import time import logging +import threading from .database import Database from .config import MassConfig @@ -60,14 +61,13 @@ class MusicAssistant(): def handle_exception(self, loop, context): ''' global exception handler ''' + LOGGER.debug(f"Caught exception: {context}") loop.default_exception_handler(context) - #LOGGER.exception(f"Caught exception: {context}") async def signal_event(self, msg, msg_details:dict): ''' signal (systemwide) event ''' - if not (msg_details == None or isinstance(msg_details, (str, int, dict))): + if not (msg_details == None or isinstance(msg_details, (str, 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: @@ -81,4 +81,22 @@ class MusicAssistant(): async def remove_event_listener(self, cb_id): ''' remove callback from our event listeners ''' - self.event_listeners.pop(cb_id, None) \ No newline at end of file + self.event_listeners.pop(cb_id, None) + + def create_task(self, corofcn, wait_for_result=False, ignore_exception=None): + ''' helper to create a new task on the main event loop ''' + if threading.current_thread() is threading.main_thread(): + if wait_for_result: + raise Exception("can not wait for result in main event loop!") + return self.event_loop.create_task(corofcn) + else: + # threadsafe + future = asyncio.run_coroutine_threadsafe(corofcn, self.event_loop) + if wait_for_result: + try: + return future.result() + except Exception as exc: + if ignore_exception and isinstance(exc, ignore_exception): + return None + raise exc + return future diff --git a/music_assistant/cache.py b/music_assistant/cache.py index e894bfdd..d6d92067 100644 --- a/music_assistant/cache.py +++ b/music_assistant/cache.py @@ -15,6 +15,7 @@ from .utils import run_periodic, LOGGER, parse_track_title class Cache(object): '''basic stateless caching system ''' + # TODO: convert to aiosql _database = None def __init__(self, datapath): @@ -27,7 +28,13 @@ class Cache(object): ''' async initialize of cache module ''' asyncio.create_task(self._do_cleanup()) - async def get(self, endpoint, checksum=""): + async def get_async(self, endpoint, checksum=""): + return await asyncio.get_running_loop().run_in_executor(None, self.get, endpoint, checksum) + + async def set_async(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)): + return await asyncio.get_running_loop().run_in_executor(None, self.set, endpoint, data, checksum, expiration) + + def get(self, endpoint, checksum=""): ''' get object from cache and return the results endpoint: the (unique) name of the cache object as reference @@ -44,7 +51,7 @@ class Cache(object): result = eval(cache_data[1]) return result - async def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)): + def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)): ''' set data in cache ''' @@ -55,6 +62,10 @@ class Cache(object): self._execute_sql(query, (endpoint, expires, data, checksum)) @run_periodic(3600) + async def auto_cleanup(self): + ''' scheduled auto cleanup task ''' + asyncio.get_running_loop().run_in_executor(None, self._do_cleanup) + async def _do_cleanup(self): '''perform cleanup task''' cur_time = datetime.datetime.now() @@ -166,12 +177,12 @@ def use_cache(cache_days=14, cache_hours=8): else: cache_str += ".%s%s" %(key,value) cache_str = cache_str.lower() - cachedata = await method_class.cache.get(cache_str, checksum=cache_checksum) + cachedata = await method_class.cache.get_async(cache_str, checksum=cache_checksum) if cachedata is not None: return cachedata else: result = await func(*args, **kwargs) - await method_class.cache.set(cache_str, result, checksum=cache_checksum, expiration=datetime.timedelta(days=cache_days, hours=cache_hours)) + await method_class.cache.set_async(cache_str, result, checksum=cache_checksum, expiration=datetime.timedelta(days=cache_days, hours=cache_hours)) return result return wrapped return wrapper diff --git a/music_assistant/homeassistant.py b/music_assistant/homeassistant.py index 6434e875..6c6cdc82 100644 --- a/music_assistant/homeassistant.py +++ b/music_assistant/homeassistant.py @@ -81,10 +81,10 @@ class HomeAssistant(): return self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) - self.mass.event_loop.create_task(self.__hass_websocket()) + self.mass.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()) + self.mass.create_task(self.__get_sources()) async def get_state_async(self, entity_id, attribute='state'): ''' get state of a hass entity (async)''' @@ -105,7 +105,7 @@ class HomeAssistant(): else: return state_obj else: - self.mass.event_loop.create_task(self.__request_state(entity_id)) + self.mass.create_task(self.__request_state(entity_id)) return None async def __request_state(self, entity_id): @@ -113,7 +113,7 @@ class HomeAssistant(): state_obj = await self.__get_data('states/%s' % entity_id) if 'state' in state_obj: self._tracked_entities[entity_id] = state_obj - self.mass.event_loop.create_task( + self.mass.create_task( self.mass.signal_event(EVENT_HASS_ENTITY_CHANGED, state_obj)) async def mass_event(self, msg, msg_details): @@ -126,7 +126,7 @@ class HomeAssistant(): if event_type == 'state_changed': 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.create_task( 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']) @@ -301,6 +301,8 @@ class HomeAssistant(): # LOGGER.info(data) elif msg.type == aiohttp.WSMsgType.ERROR: raise Exception("error in websocket") + except asyncio.CancelledError: + raise asyncio.CancelledError() except Exception as exc: LOGGER.exception(exc) await asyncio.sleep(10) diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py index c262ef1e..f32925f1 100755 --- a/music_assistant/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -32,80 +32,66 @@ class HTTPStreamer(): async def setup(self): ''' async initialize of module ''' pass - # self.mass.event_loop.create_task( + # self.mass.create_task( # asyncio.start_server(self.sockets_streamer, '0.0.0.0', 8093)) async def stream(self, http_request): ''' start stream for a player ''' - # make sure we have a valid player + # make sure we have valid params player_id = http_request.match_info.get('player_id','') player = await self.mass.players.get_player(player_id) - assert(player) + if not player: + return web.Response(status=404, reason="Player not found") + if not player.queue.use_queue_stream: + queue_item_id = http_request.match_info.get('queue_item_id') + queue_item = await player.queue.by_item_id(queue_item_id) + if not queue_item: + return web.Response(status=404, reason="Invalid Queue item Id") # prepare headers as audio/flac content resp = web.StreamResponse(status=200, reason='OK', - headers={ - 'Content-Type': 'audio/flac' - }) + headers={'Content-Type': 'audio/flac'}) await resp.prepare(http_request) - # send content only on GET request - if http_request.method.upper() != 'GET': - return resp - # stream audio + # run the streamer in executor to prevent the subprocess locking up our eventloop cancelled = threading.Event() if player.queue.use_queue_stream: - # use queue stream - bg_task = run_async_background_task( - None, - self.__stream_queue, player, resp, cancelled) + bg_task = self.mass.event_loop.run_in_executor(None, + self.__get_queue_stream, player, resp, cancelled) else: - # single track stream - queue_item_id = http_request.match_info.get('queue_item_id') - queue_item = await player.queue.by_item_id(queue_item_id) - assert(queue_item) - bg_task = run_async_background_task( - None, - self.__stream_single, player, queue_item, resp, cancelled) + bg_task = self.mass.event_loop.run_in_executor(None, + self.__get_queue_item_stream, player, queue_item, resp, cancelled) # let the streaming begin! try: await asyncio.gather(bg_task) except (asyncio.CancelledError, asyncio.TimeoutError): - LOGGER.debug("stream request cancelled") cancelled.set() - # wait for bg_task to finish - await asyncio.gather(bg_task) raise asyncio.CancelledError() return resp - async def __stream_single(self, player, queue_item, buffer, cancelled): + def __get_queue_item_stream(self, player, queue_item, buffer, cancelled): ''' start streaming single queue track ''' LOGGER.debug("stream single queue track started for track %s on player %s" % (queue_item.name, player.name)) - audio_stream = self.__get_audio_stream(player, queue_item, cancelled) - async for is_last_chunk, audio_chunk in audio_stream: + for is_last_chunk, audio_chunk in self.__get_audio_stream(player, queue_item, cancelled): if cancelled.is_set(): # http session ended # we must consume the data to prevent hanging subprocess instances continue # put chunk in buffer - asyncio.run_coroutine_threadsafe( - buffer.write(audio_chunk), - self.mass.event_loop) - # wait for the queue to consume the data - if not cancelled.is_set(): - await asyncio.sleep(0.5) + self.mass.create_task( + buffer.write(audio_chunk), wait_for_result=True, + ignore_exception=BrokenPipeError) # all chunks received: streaming finished - gc.collect() if cancelled.is_set(): LOGGER.debug("stream single track interrupted for track %s on player %s" % (queue_item.name, player.name)) else: # indicate EOF if no more data - asyncio.run_coroutine_threadsafe( - buffer.write_eof(), - self.mass.event_loop) + self.mass.create_task( + buffer.write_eof(), wait_for_result=True, + ignore_exception=BrokenPipeError) LOGGER.debug("stream single track finished for track %s on player %s" % (queue_item.name, player.name)) - async def __stream_queue(self, player, buffer, cancelled): + def __get_queue_stream(self, player, buffer, cancelled): ''' start streaming all queue tracks ''' sample_rate = try_parse_int(player.settings['max_sample_rate']) fade_length = try_parse_int(player.settings["crossfade_duration"]) @@ -119,8 +105,6 @@ class HTTPStreamer(): pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate args = 'sox -t %s - -t flac -C 0 -' % pcm_args # start sox process - # we use normal subprocess instead of asyncio because of bug with executor - # this should be fixed with python 3.8 sox_proc = subprocess.Popen(args, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE) @@ -131,14 +115,13 @@ class HTTPStreamer(): if not chunk: break if chunk and not cancelled.is_set(): - asyncio.run_coroutine_threadsafe( - buffer.write(chunk), self.mass.event_loop) + self.mass.create_task(buffer.write(chunk), + wait_for_result=True, ignore_exception=BrokenPipeError) del chunk # indicate EOF if no more data if not cancelled.is_set(): - asyncio.run_coroutine_threadsafe( - buffer.write_eof(), self.mass.event_loop) - LOGGER.debug("stream queue player %s: fill buffer completed" % player.name) + self.mass.create_task(buffer.write_eof(), + wait_for_result=True, ignore_exception=BrokenPipeError) # start fill buffer task in background fill_buffer_thread = threading.Thread(target=fill_buffer) fill_buffer_thread.start() @@ -167,7 +150,7 @@ class HTTPStreamer(): prev_chunk = None bytes_written = 0 # handle incoming audio chunks - async for is_last_chunk, chunk in self.__get_audio_stream( + for is_last_chunk, chunk in self.__get_audio_stream( player, queue_track, cancelled, chunksize=fade_bytes, resample=sample_rate): cur_chunk += 1 @@ -188,6 +171,12 @@ class HTTPStreamer(): first_part, std_err = subprocess.Popen(args, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE).communicate(prev_chunk + chunk) + if len(first_part) < fade_bytes: + # part is too short after the strip action?! + # so we just cut off at the fade position + first_part = prev_chunk+chunk + if len(first_part) >= fade_bytes: + first_part = first_part[fade_bytes:] fade_in_part = first_part[:fade_bytes] remaining_bytes = first_part[fade_bytes:] del first_part @@ -207,12 +196,18 @@ class HTTPStreamer(): prev_chunk = None # needed to prevent this chunk being sent again ### HANDLE LAST PART OF TRACK elif prev_chunk and is_last_chunk: - # last chunk received so create the fadeout_part with the previous chunk and this chunk + # last chunk received so create the last_part with the previous chunk and this chunk # and strip off silence args = 'sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse' % (pcm_args, pcm_args) last_part, stderr = subprocess.Popen(args, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE).communicate(prev_chunk + chunk) + if len(last_part) < fade_bytes: + # part is too short after the strip action + # so we just cut off at the fade position + last_part = prev_chunk+chunk + if len(last_part) >= fade_bytes: + last_part = last_part[:fade_bytes] if not player.queue.crossfade_enabled: # crossfading is not enabled so just pass the (stripped) audio data sox_proc.stdin.write(last_part) @@ -221,25 +216,15 @@ class HTTPStreamer(): del chunk else: # handle crossfading support - if len(last_part) < fade_bytes: - # not enough data for crossfade duration after the strip action... - last_part = prev_chunk + chunk - if len(last_part) < fade_bytes: - # still not enough data so we'll skip the crossfading - LOGGER.debug("not enough data for fadeout so skip crossfade... %s" % len(last_part)) - sox_proc.stdin.write(last_part) - bytes_written += len(last_part) - del last_part - else: - # store fade section to be picked up for next track - last_fadeout_data = last_part[-fade_bytes:] - remaining_bytes = last_part[:-fade_bytes] - # write remaining bytes - sox_proc.stdin.write(remaining_bytes) - bytes_written += len(remaining_bytes) - del last_part - del remaining_bytes - del chunk + # store fade section to be picked up for next track + last_fadeout_data = last_part[-fade_bytes:] + remaining_bytes = last_part[:-fade_bytes] + # write remaining bytes + sox_proc.stdin.write(remaining_bytes) + bytes_written += len(remaining_bytes) + del last_part + del remaining_bytes + del chunk ### MIDDLE PARTS OF TRACK else: # middle part of the track @@ -251,9 +236,6 @@ class HTTPStreamer(): else: prev_chunk = chunk del chunk - ### throttle to prevent entire track sitting in memory - if not cancelled.is_set(): - await asyncio.sleep(fade_length) # end of the track reached if cancelled.is_set(): # break out the loop if the http session is cancelled @@ -263,7 +245,6 @@ class HTTPStreamer(): accurate_duration = bytes_written / int(sample_rate * 4 * 2) queue_track.duration = accurate_duration LOGGER.debug("Finished Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name)) - LOGGER.debug("bytes written: %s - duration: %s" % (bytes_written, accurate_duration)) # run garbage collect manually to avoid too much memory fragmentation gc.collect() # end of queue reached, pass last fadeout bits to final output @@ -277,18 +258,21 @@ class HTTPStreamer(): 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) + if cancelled.is_set(): + LOGGER.info("streaming of queue for player %s interrupted" % player.name) + else: + LOGGER.info("streaming of queue for player %s completed" % player.name) - async def __get_audio_stream(self, player, queue_item, cancelled, + def __get_audio_stream(self, player, queue_item, cancelled, chunksize=128000, resample=None): ''' get audio stream from provider and apply additional effects/processing where/if needed''' # get stream details from provider # sort by quality and check track availability for prov_media in sorted(queue_item.provider_ids, key=operator.itemgetter('quality'), reverse=True): - streamdetails = asyncio.run_coroutine_threadsafe( + streamdetails = self.mass.create_task( self.mass.music.providers[prov_media['provider']].get_stream_details(prov_media['item_id']), - self.mass.event_loop).result() + wait_for_result=True) if streamdetails: streamdetails['player_id'] = player.player_id if not 'item_id' in streamdetails: @@ -304,7 +288,7 @@ class HTTPStreamer(): yield (True, b'') return # get sox effects and resample options - sox_options = await self.__get_player_sox_options(player, streamdetails) + sox_options = self.__get_player_sox_options(player, streamdetails) outputfmt = 'flac -C 0' if resample: outputfmt = 'raw -b 32 -c 2 -e signed-integer' @@ -321,12 +305,10 @@ class HTTPStreamer(): args = '%s | sox -t %s - -t %s - %s' % (streamdetails["path"], streamdetails["content_type"], outputfmt, sox_options) # start sox process - # we use normal subprocess instead of asyncio because of bug with executor - # this should be fixed with python 3.8 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, streamdetails), self.mass.event_loop) + self.mass.create_task( + self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)) # yield chunks from stdout # we keep 1 chunk behind to detect end of stream properly bytes_sent = 0 @@ -339,32 +321,26 @@ class HTTPStreamer(): chunk = process.stdout.read(chunksize) if len(chunk) < chunksize: # last chunk - LOGGER.debug("last chunk received") bytes_sent += len(chunk) yield (True, chunk) break else: bytes_sent += len(chunk) yield (False, chunk) - # fire event that streaming has ended - asyncio.run_coroutine_threadsafe( - self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails), self.mass.event_loop) - # send task to main event loop to analyse the audio + self.mass.create_task(self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)) + # send task to background to analyse the audio if queue_item.media_type == MediaType.Track: - self.mass.event_loop.call_soon_threadsafe( - asyncio.ensure_future, self.__analyze_audio(streamdetails)) - # run garbage collect manually to avoid too much memory fragmentation - gc.collect() + self.mass.event_loop.run_in_executor(None, self.__analyze_audio, streamdetails) - async def __get_player_sox_options(self, player, streamdetails): + def __get_player_sox_options(self, player, streamdetails): ''' get player specific sox effect options ''' sox_options = [] # volume normalisation - gain_correct = asyncio.run_coroutine_threadsafe( + gain_correct = self.mass.create_task( self.mass.players.get_gain_correct( player.player_id, streamdetails["item_id"], streamdetails["provider"]), - self.mass.event_loop).result() + wait_for_result=True) if gain_correct != 0: sox_options.append('vol %s dB ' % gain_correct) # downsample if needed @@ -382,21 +358,20 @@ class HTTPStreamer(): sox_options.append(player.settings['sox_options']) return " ".join(sox_options) - async def __analyze_audio(self, streamdetails): + def __analyze_audio(self, streamdetails): ''' analyze track audio, for now we only calculate EBU R128 loudness ''' 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 - track_loudness = await self.mass.db.get_track_loudness( - streamdetails["item_id"], streamdetails["provider"]) + track_loudness = self.mass.create_task(self.mass.db.get_track_loudness( + streamdetails["item_id"], streamdetails["provider"]), wait_for_result=True) if track_loudness == None: # only when needed we do the analyze stuff LOGGER.debug('Start analyzing track %s' % item_key) if streamdetails['type'] == 'url': - async with aiohttp.ClientSession() as session: - async with session.get(streamdetails["path"], verify_ssl=False) as resp: - audio_data = await resp.read() + import urllib + audio_data = urllib.request.urlopen(streamdetails["path"]).read() elif streamdetails['type'] == 'executable': audio_data = subprocess.check_output(streamdetails["path"], shell=True) # calculate BS.1770 R128 integrated loudness @@ -405,7 +380,8 @@ 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(streamdetails["item_id"], streamdetails["provider"], loudness) + self.mass.create_task( + 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) @@ -429,62 +405,8 @@ class HTTPStreamer(): process = subprocess.Popen(args, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE) crossfade_part, stderr = process.communicate() - LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part)) fadeinfile.close() fadeoutfile.close() del fadeinfile del fadeoutfile return crossfade_part - - async def start_stream(self, clients_needed): - # wait for clients - print("wait for clients...") - track = asyncio.run_coroutine_threadsafe( - self.mass.music.track('2741', provider='database'), - self.mass.event_loop).result() - player_id = '1523403a-4cc4-f151-29d1-758822807128' - player = self.mass.players._players[player_id] - cancelled = threading.Event() - # wait for clients - while len(self.stream_clients) < clients_needed: - await asyncio.sleep(0.1) - # start streaming - while self.stream_clients: - audio_stream = self.__get_audio_stream(player, track, cancelled) - async for is_last_chunk, audio_chunk in audio_stream: - for client in self.stream_clients: - try: - client.write(audio_chunk) - await client.drain() - except ConnectionResetError: - print('client disconnected') - client.close() - self.stream_clients.remove(client) - await asyncio.sleep(1) - print("all clients disconnected") - return - - async def add_client(self, client_writer, client_msg): - print("new client connected!") - for line in client_msg.decode().split('\r\n'): - print(line) - msg = 'HTTP/1.0 200 OK\r\n' - msg += "Content-Type: audio/flac\r\n" - msg += "Transfer-Encoding: chunked\r\n\r\n" - client_writer.write(msg.encode()) - await client_writer.drain() - self.stream_clients.append(client_writer) - if len(self.stream_clients) == 1: - bg_task = run_async_background_task( - None, - self.start_stream, 2) - - async def sockets_streamer(self, reader, writer): - while True: - request = await reader.read(1024) - if request: - await self.add_client(writer, request) - else: - print("client lost") - break - diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 29806f31..9ee63171 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -102,7 +102,7 @@ class Player(): #### Common implementation, should NOT be overrridden ##### - def __init__(self, mass, player_id, prov_id, is_group=False): + def __init__(self, mass, player_id, prov_id): # private attributes self.mass = mass self._player_id = player_id # unique id for this player @@ -110,7 +110,6 @@ class Player(): self._name = '' 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 @@ -118,20 +117,13 @@ class Player(): self._volume_level = 0 self._muted = False self._queue = PlayerQueue(mass, self) - self._player_settings = None + self.__update_player_settings() 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 - - def __del__(self): - if self._update_cur_time_task: - self._update_cur_time_task.cancel() - @property def player_id(self): ''' [PROTECTED] player_id of this player ''' @@ -155,7 +147,7 @@ class Player(): ''' [PROTECTED] set (real) name of this player ''' if name != self._name: self._name = name - self.mass.event_loop.create_task(self.update()) + self.mass.create_task(self.update()) @property def is_group(self): @@ -185,25 +177,25 @@ class Player(): ''' [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()) + self.mass.create_task(self.update()) for child_player_id in group_childs: - self.mass.event_loop.create_task( + self.mass.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.create_task(self.update()) + self.mass.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.create_task(self.update()) + self.mass.create_task( self.mass.players.trigger_update(child_player_id)) @property @@ -215,7 +207,6 @@ class Player(): 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 @@ -224,7 +215,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(update_queue=True)) + self.mass.create_task(self.update(update_queue=True)) @property def powered(self): @@ -251,7 +242,7 @@ 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.create_task(self.update()) @property def cur_time(self): @@ -271,7 +262,7 @@ class Player(): if cur_time != self._cur_time: self._cur_time = cur_time self._media_position_updated_at = time.time() - self.mass.event_loop.create_task(self.update(update_queue=True)) + self.mass.create_task(self.update(update_queue=True)) @property def media_position_updated_at(self): @@ -293,7 +284,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(update_queue=True)) + self.mass.create_task(self.update(update_queue=True)) @property def volume_level(self): @@ -325,10 +316,10 @@ class Player(): volume_level = try_parse_int(volume_level) if volume_level != self._volume_level: self._volume_level = volume_level - self.mass.event_loop.create_task(self.update()) + self.mass.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.create_task( self.mass.players.trigger_update(group_parent_id)) @property @@ -342,7 +333,7 @@ class Player(): is_muted = try_parse_bool(is_muted) if is_muted != self._muted: self._muted = is_muted - self.mass.event_loop.create_task(self.update()) + self.mass.create_task(self.update()) @property def enabled(self): @@ -444,13 +435,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 - elif self.settings.get('play_power_on'): - await self.play() + if self.settings.get('play_power_on'): + # play player's own queue if it has items + if self._queue.items: + await self.play() + # fallback to the first group parent with items + else: + for group_parent_id in self.group_parents: + group_player = await self.mass.players.get_player(group_parent_id) + if group_player and group_player.queue.items: + await group_player.play() + break async def power_off(self): ''' [PROTECTED] send power OFF command to player ''' @@ -551,34 +547,24 @@ class Player(): async def update(self, update_queue=False): ''' [PROTECTED] signal player updated ''' - self.get_player_settings() 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): - ''' [PROTECTED] get (or create) player config settings ''' - if self._player_settings: - return self._player_settings + ''' [PROTECTED] get player config settings ''' + if self.player_id in self.mass.config['player_settings']: + return self.mass.config['player_settings'][self.player_id] else: - return self.get_player_settings() + self.__update_player_settings() + return self.mass.config['player_settings'][self.player_id] - def get_player_settings(self): - ''' [PROTECTED] get (or create) player config settings ''' + def __update_player_settings(self): + ''' [PROTECTED] update player config settings ''' player_settings = self.mass.config['player_settings'].get(self.player_id,{}) # generate config for the player config_entries = [ # default config entries for a player @@ -608,8 +594,6 @@ class Player(): player_settings[key] = def_value self.mass.config['player_settings'][self.player_id] = player_settings self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries - self._player_settings = self.mass.config['player_settings'][self.player_id] - return player_settings def to_dict(self): ''' instance attributes as dict so it can be serialized to json ''' diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 9093f94b..ed90f345 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -48,7 +48,7 @@ class PlayerQueue(): self._save_busy_ = False self._last_track = None # load previous queue settings from disk - self.mass.event_loop.create_task(self.__load_from_file()) + self.mass.event_loop.run_in_executor(None, self.__load_from_file) @property def shuffle_enabled(self): @@ -151,11 +151,11 @@ class PlayerQueue(): # shuffle requested self._shuffle_enabled = True await self.load(self._items) - self.mass.event_loop.create_task(self._player.update()) + self.mass.create_task(self._player.update()) elif self._shuffle_enabled and not enable_shuffle: self._shuffle_enabled = False # TODO: Unshuffle the list ? - self.mass.event_loop.create_task(self._player.update()) + self.mass.create_task(self._player.update()) async def next(self): ''' request next track in queue ''' @@ -220,9 +220,10 @@ class PlayerQueue(): :param queue_items: a list of QueueItem :param offset: offset from current queue position ''' - insert_at_index = self.cur_index + offset - if not self.items or insert_at_index > len(self.items): + + if not self.items or self.cur_index == None or self.cur_index + offset > len(self.items): return await self.load(queue_items) + insert_at_index = self.cur_index + offset if self.shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = self._items[:insert_at_index] + queue_items + self._items[insert_at_index:] @@ -300,13 +301,13 @@ class PlayerQueue(): # account for track changing state so trigger track change after 1 second if self._last_track and self._last_track.streamdetails: self._last_track.streamdetails["seconds_played"] = self._last_item_time - self.mass.event_loop.create_task( + self.mass.create_task( self.mass.signal_event(EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails)) - if new_track: - self.mass.event_loop.create_task( + if new_track and new_track.streamdetails: + self.mass.create_task( self.mass.signal_event(EVENT_PLAYBACK_STARTED, new_track.streamdetails)) - self._last_track = new_track - await self.__save_to_file() + self._last_track = new_track + self.mass.event_loop.run_in_executor(None, self.__save_to_file) if self._last_player_state != self._player.state: self._last_player_state = self._player.state if (self._player.cur_time == 0 and @@ -326,7 +327,7 @@ class PlayerQueue(): # can be extended with some more magic last last_played and stuff return random.sample(queue_items, len(queue_items)) - async def __load_from_file(self): + def __load_from_file(self): ''' try to load the saved queue for this player from file ''' player_safe_str = filename_from_string(self._player.player_id) settings_dir = os.path.join(self.mass.datapath, 'queue') @@ -343,7 +344,7 @@ class PlayerQueue(): except Exception as exc: LOGGER.debug("Could not load queue from disk - %s" % str(exc)) - async def __save_to_file(self): + def __save_to_file(self): ''' save current queue settings to file ''' if self._save_busy_: return diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py index fc8fa3fd..79a7df00 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/music_manager.py @@ -27,7 +27,7 @@ class MusicManager(): for prov in self.providers.values(): await prov.setup() # schedule sync task - self.mass.event_loop.create_task(self.sync_music_providers()) + self.mass.create_task(self.sync_music_providers()) async def item(self, item_id, media_type:MediaType, provider='database', lazy=True): ''' get single music item by id and media type''' @@ -254,22 +254,22 @@ class MusicManager(): # actually add the tracks to the playlist on the provider await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add) # schedule sync - self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id'])) + self.mass.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id'])) @run_periodic(3600) async def sync_music_providers(self): ''' periodic sync of all music providers ''' if self.sync_running: return - self.sync_running = True for prov_id in self.providers.keys(): - # sync library artists + self.sync_running = prov_id + # sync library items for each provider (if supported) await try_supported(self.sync_library_artists(prov_id)) await try_supported(self.sync_library_albums(prov_id)) await try_supported(self.sync_library_tracks(prov_id)) await try_supported(self.sync_playlists(prov_id)) await try_supported(self.sync_radios(prov_id)) - self.sync_running = False + self.sync_running = None async def sync_library_artists(self, prov_id): ''' sync library artists for given provider''' diff --git a/music_assistant/musicproviders/file.py b/music_assistant/musicproviders/file.py index 2773ef74..10e74071 100644 --- a/music_assistant/musicproviders/file.py +++ b/music_assistant/musicproviders/file.py @@ -248,23 +248,18 @@ class FileProvider(MusicProvider): tracks += await self.get_album_tracks(album.item_id) return tracks[:10] - async def get_stream_content_type(self, track_id): - ''' return the content type for the given track when it will be streamed''' + async def get_stream_details(self, track_id): + ''' return the content details for the given track when it will be streamed''' if not os.sep in track_id: track_id = base64.b64decode(track_id).decode('utf-8') - return track_id.split('.')[-1] - - async def get_audio_stream(self, track_id): - ''' get audio stream for a track ''' - if not os.sep in track_id: - track_id = base64.b64decode(track_id).decode('utf-8') - with open(track_id) as f: - while True: - line = f.readline() - if line: - yield line - else: - break + # TODO: retrieve sanple rate and bitdepth + return { + "type": "file", + "path": track_id, + "content_type": track_id.split('.')[-1], + "sample_rate": 44100, + "bit_depth": 16 + } async def __parse_track(self, filename): ''' try to parse a track from a filename with taglib ''' diff --git a/music_assistant/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py index 587ec2d7..25166cce 100644 --- a/music_assistant/musicproviders/qobuz.py +++ b/music_assistant/musicproviders/qobuz.py @@ -535,13 +535,17 @@ class QobuzProvider(MusicProvider): try: async with self.throttler: async with self.http_session.get(url, headers=headers, params=params, verify_ssl=False) as response: - result = await response.json() - if not result or 'error' in result: - LOGGER.error(url) + try: + result = await response.json() + if "error" in result: + return None + return result + except Exception as exc: + LOGGER.error(exc) + LOGGER.debug(url) LOGGER.debug(params) - LOGGER.debug(result) - return None - return result + result = response + LOGGER.debug(await response.text()) except Exception as exc: LOGGER.exception(exc) return None @@ -554,6 +558,8 @@ class QobuzProvider(MusicProvider): async with self.http_session.post(url, params=params, json=data, verify_ssl=False) as response: try: result = await response.json() + if "error" in result: + return None return result except Exception as exc: LOGGER.error(exc) diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py index bee8acdf..e2a01da6 100644 --- a/music_assistant/playerproviders/chromecast.py +++ b/music_assistant/playerproviders/chromecast.py @@ -9,6 +9,7 @@ import pychromecast from pychromecast.controllers.multizone import MultizoneController from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED import types +import time from ..utils import run_periodic, LOGGER, try_parse_int from ..models.playerprovider import PlayerProvider @@ -31,14 +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()) + self.__cc_report_progress_task = None def __del__(self): - if self._poll_task: - self._poll_task.cancel() + if self.__cc_report_progress_task: + self.__cc_report_progress_task.cancel() async def try_chromecast_command(self, cmd:types.MethodType, *args, **kwargs): ''' guard for disconnected socket client ''' @@ -183,22 +184,18 @@ 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 __report_progress(self): + ''' report current progress while playing ''' + # chromecast does not send updates of the player's progress (cur_time) + # so we need to send it in periodically + while self._state == PlayerState.Playing: + self.cur_time = self.cc.media_controller.status.adjusted_current_time + await asyncio.sleep(1) + self.__cc_report_progress_task = None async def handle_player_state(self, caststatus=None, - mediastatus=None, connection_status=None): + mediastatus=None): ''' handle a player state message from the socket ''' - # handle connection status - if connection_status: - if connection_status.status == CONNECTION_STATUS_DISCONNECTED: - # schedule a new scan which will handle group parent changes - self.mass.event_loop.create_task( - self.mass.players.providers[self.player_provider].start_chromecast_discovery()) # handle generic cast status if caststatus: self.muted = caststatus.volume_muted @@ -215,6 +212,8 @@ class ChromecastPlayer(Player): self.state = PlayerState.Stopped self.cur_uri = mediastatus.content_id self.cur_time = mediastatus.adjusted_current_time + if self._state == PlayerState.Playing and self.__cc_report_progress_task == None: + self.__cc_report_progress_task = self.mass.create_task(self.__report_progress()) class ChromecastProvider(PlayerProvider): ''' support for ChromeCast Audio ''' @@ -229,7 +228,7 @@ class ChromecastProvider(PlayerProvider): async def setup(self): ''' perform async setup ''' - self.mass.event_loop.create_task( + self.mass.create_task( self.__periodic_chromecast_discovery()) async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): @@ -247,24 +246,20 @@ class ChromecastProvider(PlayerProvider): @run_periodic(1800) async def __periodic_chromecast_discovery(self): ''' run chromecast discovery on interval ''' - await self.start_chromecast_discovery() + self.mass.event_loop.run_in_executor(None, self.run_chromecast_discovery) - async def start_chromecast_discovery(self): + def run_chromecast_discovery(self): ''' background non-blocking chromecast discovery and handler ''' if self._discovery_running: return self._discovery_running = True LOGGER.debug("Chromecast discovery started...") # remove any disconnected players... - removed_players = [] 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) # cleanup cast object del player.cc - # signal removed players - for player_id in removed_players: - await self.remove_player(player_id) + self.mass.create_task(self.remove_player(player.player_id)) # search for available chromecasts from pychromecast.discovery import start_discovery, stop_discovery def discovered_callback(name): @@ -272,19 +267,15 @@ class ChromecastProvider(PlayerProvider): discovery_info = listener.services[name] ip_address, port, uuid, model_name, friendly_name = discovery_info player_id = str(uuid) - player = asyncio.run_coroutine_threadsafe( - self.get_player(player_id), - self.mass.event_loop).result() - if not player: - asyncio.run_coroutine_threadsafe( - self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop) + if not player_id in self.mass.players._players: + self.__chromecast_discovered(player_id, discovery_info) listener, browser = start_discovery(discovered_callback) - await asyncio.sleep(15) # run discovery for 15 seconds + time.sleep(15) # run discovery for 15 seconds stop_discovery(browser) LOGGER.debug("Chromecast discovery completed...") self._discovery_running = False - async def __chromecast_discovered(self, player_id, discovery_info): + def __chromecast_discovered(self, player_id, discovery_info): ''' callback when a (new) chromecast device is discovered ''' from pychromecast import _get_chromecast_from_host, ChromecastConnectionError try: @@ -300,7 +291,7 @@ class ChromecastProvider(PlayerProvider): self.supports_crossfade = False # register status listeners status_listener = StatusListener(player_id, - player.handle_player_state, self.mass.event_loop) + player.handle_player_state, self.mass) if chromecast.cast_type == 'group': mz = MultizoneController(chromecast.uuid) mz.register_listener(MZListener(mz, @@ -311,11 +302,10 @@ class ChromecastProvider(PlayerProvider): chromecast.register_status_listener(status_listener) chromecast.media_controller.register_status_listener(status_listener) player.cc.wait() - await self.add_player(player) + self.mass.create_task(self.add_player(player)) if player.mz: player.mz.update_members() - def chunks(l, n): """Yield successive n-sized chunks from l.""" for i in range(0, len(l), n): @@ -323,22 +313,24 @@ def chunks(l, n): class StatusListener: - def __init__(self, player_id, status_callback, loop): + def __init__(self, player_id, status_callback, mass): self.__handle_callback = status_callback - self.loop = loop + self.mass = mass self.player_id = player_id def new_cast_status(self, status): ''' chromecast status changed (like volume etc.)''' - asyncio.run_coroutine_threadsafe( - self.__handle_callback(caststatus=status), self.loop) + self.mass.create_task( + self.__handle_callback(caststatus=status)) def new_media_status(self, status): ''' mediacontroller has new state ''' - asyncio.run_coroutine_threadsafe( - self.__handle_callback(mediastatus=status), self.loop) + self.mass.create_task( + self.__handle_callback(mediastatus=status)) def new_connection_status(self, status): ''' will be called when the connection changes ''' - asyncio.run_coroutine_threadsafe( - self.__handle_callback(connection_status=status), self.loop) + if status.status == CONNECTION_STATUS_DISCONNECTED: + # schedule a new scan which will handle reconnects and group parent changes + self.mass.event_loop.run_in_executor(None, + self.mass.players.providers[PROV_ID].run_chromecast_discovery) class MZListener: def __init__(self, mz, callback, loop): diff --git a/music_assistant/playerproviders/sonos.py b/music_assistant/playerproviders/sonos.py index 596ee1dc..ba658f70 100644 --- a/music_assistant/playerproviders/sonos.py +++ b/music_assistant/playerproviders/sonos.py @@ -6,6 +6,7 @@ import aiohttp from typing import List import logging import types +import time from ..utils import run_periodic, LOGGER, try_parse_int from ..models.playerprovider import PlayerProvider @@ -19,14 +20,22 @@ PROV_NAME = 'Sonos' PROV_CLASS = 'SonosProvider' CONFIG_ENTRIES = [ - (CONF_ENABLED, False, CONF_ENABLED), + (CONF_ENABLED, True, CONF_ENABLED), ] PLAYER_CONFIG_ENTRIES = [] class SonosPlayer(Player): ''' Sonos player object ''' - + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__sonos_report_progress_task = None + + def __del__(self): + if self.__sonos_report_progress_task: + self.__sonos_report_progress_task.cancel() + async def cmd_stop(self): ''' send stop command to player ''' self.soco.stop() @@ -94,6 +103,17 @@ class SonosPlayer(Player): for pos, item in enumerate(queue_items): self.soco.add_uri_to_queue(item.uri, last_index+pos) + async def __report_progress(self): + ''' report current progress while playing ''' + # sonos does not send instant updates of the player's progress (cur_time) + # so we need to send it in periodically + while self._state == PlayerState.Playing: + time_diff = time.time() - self.media_position_updated_at + adjusted_current_time = self._cur_time + time_diff + self.cur_time = adjusted_current_time + await asyncio.sleep(1) + self.__sonos_report_progress_task = None + def _update_state(self, event=None): ''' update state, triggerer by event ''' if event: @@ -111,15 +131,17 @@ class SonosPlayer(Player): 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 + return + 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 + if self._state == PlayerState.Playing and self.__sonos_report_progress_task == None: + self.__sonos_report_progress_task = self.mass.create_task(self.__report_progress()) @staticmethod def __convert_state(sonos_state): @@ -151,15 +173,15 @@ class SonosProvider(PlayerProvider): async def setup(self): ''' perform async setup ''' - self.mass.event_loop.create_task( + self.mass.create_task( self.__periodic_discovery()) @run_periodic(1800) async def __periodic_discovery(self): ''' run sonos discovery on interval ''' - await self.run_discovery() + self.mass.event_loop.run_in_executor(None, self.run_discovery) - async def run_discovery(self): + def run_discovery(self): ''' background sonos discovery and handler ''' if self._discovery_running: return @@ -167,24 +189,26 @@ class SonosProvider(PlayerProvider): LOGGER.debug("Sonos discovery started...") import soco discovered_devices = soco.discover() + if discovered_devices == None: + discovered_devices = [] 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) + self.mass.create_task(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) + self.__device_discovered(device) # handle groups if len(discovered_devices) > 0: - await self.__process_groups(discovered_devices[0].all_groups) + self.__process_groups(discovered_devices[0].all_groups) else: - await self.__process_groups([]) + self.__process_groups([]) - async def __device_discovered(self, soco_device): - '''handle new player ''' + def __device_discovered(self, soco_device): + '''handle new sonos player ''' player = SonosPlayer(self.mass, soco_device.uid, self.prov_id) player.soco = soco_device player.name = soco_device.player_name @@ -201,18 +225,19 @@ class SonosProvider(PlayerProvider): 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) + self.mass.create_task(self.add_player(player)) + return player - async def __process_groups(self, sonos_groups): + 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) + group_player = self.__device_discovered(group.coordinator) else: - group_player = await self.get_player(group.uid) + group_player = self.mass.players.get_player_sync(group.uid) # check members group_player.name = group.label group_player.group_childs = [item.uid for item in group.members] @@ -223,7 +248,7 @@ class SonosProvider(PlayerProvider): from one of the sonos players schedule discovery to work out the changes ''' - self.mass.event_loop.create_task(self.run_discovery()) + self.mass.event_loop.run_in_executor(None, self.run_discovery) class _ProcessSonosEventQueue: """Queue like object for dispatching sonos events.""" diff --git a/music_assistant/playerproviders/squeezebox.py b/music_assistant/playerproviders/squeezebox.py index f5ff8683..e60d1f2c 100644 --- a/music_assistant/playerproviders/squeezebox.py +++ b/music_assistant/playerproviders/squeezebox.py @@ -41,10 +41,10 @@ class PySqueezeProvider(PlayerProvider): async def setup(self): ''' async initialize of module ''' # start slimproto server - self.mass.event_loop.create_task( + self.mass.create_task( asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) # setup discovery - self.mass.event_loop.create_task(self.start_discovery()) + self.mass.create_task(self.start_discovery()) async def start_discovery(self): transport, protocol = await self.mass.event_loop.create_datagram_endpoint( @@ -84,7 +84,7 @@ class PySqueezeProvider(PlayerProvider): player_id = str(device_mac).lower() device_type = devices.get(dev_id, 'unknown device') player = PySqueezePlayer(self.mass, player_id, self.prov_id, device_type, writer) - self.mass.event_loop.create_task(self.mass.players.add_player(player)) + self.mass.create_task(self.mass.players.add_player(player)) elif player != None: player.process_msg(operation, packet) @@ -122,8 +122,8 @@ class PySqueezePlayer(Player): self.send_frame(b"setd", struct.pack("B", 4)) # TODO: remember last volume and power state - self.mass.event_loop.create_task(self.volume_set(40)) - self.mass.event_loop.create_task(self.power_off()) + self.mass.create_task(self.volume_set(40)) + self.mass.create_task(self.power_off()) self._heartbeat_task = asyncio.create_task(self.__send_heartbeat()) async def cmd_stop(self): @@ -243,7 +243,7 @@ class PySqueezePlayer(Player): ''' send command to Squeeze player''' packet = struct.pack('!H', len(data) + 4) + command + data self._writer.write(packet) - self.mass.event_loop.create_task(self._writer.drain()) + self.mass.create_task(self._writer.drain()) def send_version(self): self.send_frame(b'vers', b'7.8') @@ -318,7 +318,7 @@ class PySqueezePlayer(Player): LOGGER.debug("Decoder Ready for next track") next_item = self.queue.next_item if next_item: - self.mass.event_loop.create_task( + self.mass.create_task( self.__send_play(next_item.uri)) def stat_STMe(self, data): @@ -600,7 +600,7 @@ class TLVDiscoveryResponseDatagram(Datagram): if value is None: value = '' elif len(value) > 255: - LOGGER.warning("Response %s too long, truncating to 255 bytes" % typ) + # Response too long, truncating to 255 bytes value = value[:255] parts.extend((typ, chr(len(value)), value)) self.packet = ''.join(parts) @@ -619,7 +619,7 @@ class DiscoveryProtocol(): sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) def connection_lost(self, *args, **kwargs): - LOGGER.warning("Connection lost to discovery") + LOGGER.debug("Connection lost to discovery") def build_TLV_response(self, requestdata): responsedata = OrderedDict() @@ -655,7 +655,6 @@ class DiscoveryProtocol(): try: data = data.decode() dgram = Datagram.decode(data) - LOGGER.debug("Data received from %s: %s" % (addr, dgram)) if isinstance(dgram, ClientDiscoveryDatagram): self.sendDiscoveryResponse(addr) elif isinstance(dgram, TLVDiscoveryRequestDatagram): @@ -666,11 +665,9 @@ class DiscoveryProtocol(): def sendDiscoveryResponse(self, addr): dgram = DiscoveryResponseDatagram(get_hostname(), 3483) - LOGGER.debug("Sending discovery response %r" % (dgram.packet,)) self.transport.sendto(dgram.packet.encode(), addr) def sendTLVDiscoveryResponse(self, resonsedata, addr): dgram = TLVDiscoveryResponseDatagram(resonsedata) - LOGGER.debug("Sending discovery response %r" % (dgram.packet,)) self.transport.sendto(dgram.packet.encode(), addr) diff --git a/music_assistant/playerproviders/web.py b/music_assistant/playerproviders/webplayer.py similarity index 97% rename from music_assistant/playerproviders/web.py rename to music_assistant/playerproviders/webplayer.py index 6b6af1b4..a079b1d2 100644 --- a/music_assistant/playerproviders/web.py +++ b/music_assistant/playerproviders/webplayer.py @@ -16,7 +16,7 @@ from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQualit from ..constants import CONF_ENABLED -PROV_ID = 'web' +PROV_ID = 'webplayer' PROV_NAME = 'WebPlayer' PROV_CLASS = 'WebPlayerProvider' @@ -50,11 +50,10 @@ class WebPlayerProvider(PlayerProvider): ''' async initialize of module ''' await self.mass.add_event_listener(self.handle_mass_event, EVENT_WEBPLAYER_STATE) await self.mass.add_event_listener(self.handle_mass_event, EVENT_WEBPLAYER_REGISTER) - self.mass.event_loop.create_task(self.check_players()) + self.mass.create_task(self.check_players()) async def handle_mass_event(self, msg, msg_details): ''' received event for the webplayer component ''' - #print("%s ---> %s" %(msg, msg_details)) if msg == EVENT_WEBPLAYER_REGISTER: # register new player player_id = msg_details['player_id'] diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 8df418fc..90a2baee 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -39,7 +39,7 @@ def filename_from_string(string): keepcharacters = (' ','.','_') return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip() -def run_background_task(executor, corofn, *args): +def run_background_task(corofn, *args, executor=None): ''' run non-async task in background ''' return asyncio.get_event_loop().run_in_executor(executor, corofn, *args) diff --git a/music_assistant/web.py b/music_assistant/web.py index cd3e0211..685b846f 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -43,7 +43,6 @@ class Web(): async def setup(self): ''' perform async setup ''' - self.http_session = aiohttp.ClientSession() app = web.Application() app.add_routes([web.get('/jsonrpc.js', self.json_rpc)]) app.add_routes([web.post('/jsonrpc.js', self.json_rpc)]) diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js index 61d2cbc7..30d3502f 100755 --- a/music_assistant/web/components/player.vue.js +++ b/music_assistant/web/components/player.vue.js @@ -253,6 +253,7 @@ Vue.component("player", { }, switchPlayer (new_player_id) { this.active_player_id = new_player_id; + localStorage.setItem('active_player_id', new_player_id); }, setPlayerVolume: function(player_id, new_volume) { this.players[player_id].volume_level = new_volume; @@ -297,8 +298,8 @@ Vue.component("player", { } }, createAudioPlayer(data) { - if (navigator.userAgent.includes("WebKit")) - return // streaming flac not supported on webkit ?! + if (!navigator.userAgent.includes("Chrome")) + return // streaming flac only supported on chrome browser if (localStorage.getItem('audio_player_id')) // get player id from local storage this.audioPlayerId = localStorage.getItem('audio_player_id'); @@ -400,23 +401,30 @@ Vue.component("player", { } // select new active 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) { - // prefer the first playing player - this.active_player_id = player_id; - break; - } - 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.active_player_id = player_id; - break; - } + if (!this.active_player_id || !this.players[this.active_player_id].enabled) { + // prefer last selected player + last_player = localStorage.getItem('active_player_id') + if (last_player && this.players[last_player] && this.players[last_player].enabled) + this.active_player_id = last_player; + else + { + // prefer the first playing player + for (var player_id in this.players) + if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && this.players[player_id].group_parents.length == 0) { + this.active_player_id = player_id; + break; + } + // fallback to just the first player + if (!this.active_player_id || !this.players[this.active_player_id].enabled) + for (var player_id in this.players) { + if (this.players[player_id].enabled && this.players[player_id].group_parents.length == 0) + { + this.active_player_id = player_id; + break; + } + } } + } }.bind(this); this.ws.onclose = function(e) { diff --git a/music_assistant/web/images/icons/sonos.png b/music_assistant/web/images/icons/sonos.png new file mode 100644 index 0000000000000000000000000000000000000000..b0a2c0d056f0ffd176422f49fa25418692b4f6d0 GIT binary patch literal 34269 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 Z8DEmZ&!GzooOW?7h`?5ayR zYH%>?uH;s?yotRZRJiDxJMZm$%d@ukk+;y3cBjWyE^V-mi*`JW0w@FuW1x-7e-iyv z3;75DtN<`<+UTu{g>}VO#OthwFNGLF+awPK zC&pgt#6gYjlA%q5X4~oy9a1CV&`+RBdF1=M6ZXiCNGhAVJWl#{`)VO3qxwzlh zgYCDGVT@AP{Vla?PERK1R7(>Or2z1d9h?JAQ)i{cSLSWoelm)2;we`+O7w}lEL4_N zi0jj8dcU9c{`p;`Yx@_XgP&#NW}N7fVor7V>%|gUYEiB5H1RNqLjnLik2B6WGBOTz zA?#4%^1-iH*o@RLZuQb39xZ0J4w2vx?at zPNs56q%l_opadjHd5<+iS?M|#(1SNqF`Hc?mZYa+Hh5}4ih$lj{ zwQ)`%ybz!V^cG_7OLM5G@pBpDmr)ESE;C}ZGsSOH_9_cV?@N4)NUxN9vJaM&c5#(b z3;gPebd5s}o_UcD%fvMoCQk4I_q)Gu9G&h2;L6}S@tS-EYDd9I+b{Zu`G;1@Cr5UF^*TIgMj4UxTBRs2 z^8c2Gz9%$Qt0-?N-%5qplTWj719L1K0Qzci*m^eOE+kx;D_XB=49SEW)2#{P%_az! zyQ4Ncp|$R)8_lS*#5pC1F7wJ-)$#eLxy3+%KlGrHo>o^M5d4quJK`B9jjoCce`{p? zJ*muUyNsRG!p~p?@nbAEK(o!I-iJ`nKB`TB!*KlyK&o%t526j^HP zZf1mwv*^Y~wB&UtZ44~G1h}X-#@9ysJJH2&t76Wsd{qC!0fF}(?^T@1mTcTM6XNTerw(`)B3!#xtIN~=7yqK5h`~ft6z7KET~!-VDd+yZkz*da=ac4?-EYZ zlQ-^&w!5g^h=c^pptcfFd>LI|h{+~%1@4;FemgrKp`Inh^e%w_2Q7#m(I+=tyQcNM zhmFN0SjCeL{5a7B_5_qPewecUxSkaMTh>m&61{Qbfc^TII0qD62L>$s@v;!s(A7wC zSXaMijzwxl+l)A@&N%!I6CX|aZX!LolqKwBggS%PfFQ;}%H%ki9$%2c9qrl3=z8m~ zyb(z*{8SK5TYi{QoqT?XpKkw9m3F3Bn?I5Rh^B=DKtzcVDVsqm#j{+!e&Cw_i>JQM zlLeHnlmoZx4cs{FMN1$WfS&_5%)qkHIiL})-}}IDpa(MqG^5-W959SA1mW0D%qo|s zQ!tAz02&Pd2hZ-$DF%~x!^=kF6ow{oEYyC-L`Bx?OT+%^NLx^(4Y+_ zr2qia01QCOy}NnHaYSoeHgTo-liDmr#~;eYuWuWt%ICy zChY9E@aqEV;HDnx5Ldc{^@VQ}Zdq&S_p1ri%yzsW$+5z>2WrCNxv1x&0)wf+UR_3B z6^+jcl(o?D2#(nc;0`tD_m13Lbq=^BDCLt9A##5_<_c&+Li_^HZPaddYWF?+@=p8J znM~n7FAv;mpE*Un)&F8Eq7BSS9Dl1PGL5Q8`M%%e)H0xr(>@r)7%aH#P+TSp$ZT79 z%2RGy;bPHQ8#jI^FnFAKzX#jNPcCMZ+R`(>FryUK(3d37U~=)S?EkWW(~{)-i#BTp zV~SU#=Y_snsokmK2>d~zg#h^NmOLyYUx{>v8TDHW|ug*aF|bgve0+;+8)N!{UeSxzREGGIA59V*VxqyWQ`UO z3+1Q`&8SO$5BDj*#VHfj9W7U-F|W^4X?_A|1vE$-x`Jy`w=dU>4RDkXqH1;iWqI6p zV!-nQ2Njbi1aGAL7J*U|_i;HYjGOF7qfZ0e1@lEof8#1j_0}Q?K*Dor2X4;7fpY~V zXQCB%k->M)*}|!7ek{xk5Ed|Jt!UJ0k@o3Q(%bAKQy-*~m4c`gDT=#cUfRdauG4Xp z4J1s6J9h>(uSbn3DKa88AX{A&_y`v-2MUEL`<#C*2vkBr=hI3bA`QF~3yhvV4f~$8 z9n+~DM=LQ*4B$Q*V>ur}1-&oV@;0c}mUGYm^WC!jOceSNxV?K4b!u&J`v zmGp4Q^TY|4lP8W3UlNBkSjNVNr6iyldkrV$A=N9}Tt{ose^7at=}lgslOJ?DUqW~= zfWx}zfSA#Ahm(a&$T|2+{>=9&M$Zmb2m6Av<$PH1TMiO0g#g4)fe3gbnf4FymY#55 z9uXgZZLZ+YA7?%4#TMT|)h&v-$70&S(z{Sp3w5;#{Jbo>phDES`S&YGyfFB=KspKe zmLIPuI#Gn$xM$OXa+pVYu+Lzgr4CDHEnL`dHJr?W2q+d|wCEdPX&_24cBY-RoYC9Z z3^MRsf6jh*DNOoG%ehAX6;9C6G&o@{Do0kQHVv_jn|jJmtb8Y9wKjZ>a;1s1qdj}>4= zDDIE}7^0Gk`Z7~E9EUl=+iU)_O*x zxH{|G=r$AC!>}X!z~NZm1@ImPjB{^M6ug2YDI=#wE|`r6Lj=h850J>1`8EM?^DIRQ zn|j^JW#x!JfvunR=v0-QRe= zOwKA%GOx>{oMJl*I}R4*W1>*x5Ij0%(5trNll9u}jzD9lHvRr+Tu1tW+k~>P9#ySR z@rA+kF@m_gdbTV!i+{HNBZJNxLT1!eyz=LLLn;x}=EdP(4?`VqY}AxL46}cBiu|~?EsU_E5@=y6c}ZSmqq~kJf;I0 zLF{Exgn+7#K>}|kzm2sJ2A+eI0A(f%qBtnWIxsR!3xY#p@&kjYdub?6JD6t~rWGa% z&jBDDI!btlJBQ|KUDz~|2YXH%Meo%Ee3Qli4Nf~{5c584z6GT|UwZ;X-8?p5gCSx7 zRkQm1Y68$LEUu3>Lg+)F@LNh^D7r1m#;-pShPMyuyJj%n^WF`lv={epByC;*u{%pQ zE0KVdI&{qE4_!v$Xmq!IXN`Jj~UM44VN!emA(JTcQ|(X90WWn z^dRQ{L@NJBwU<7nYXws+aC<3%)CJ(bb%A%$m>Zj3RmRvIw|K*4%vf9PAjbb38;DXh zVOqxjH(>bctgPyaw zmq;qLp^h#diqX#_Lk3~{!jC{~-?#1uT~>tphge2P5KlU1w!@qbJ{+-e!hD`rSJUIp z5>&4$D@B8I=-%riKAEGhT3n3CW+-n|ZG{C=8@15xsN>L&=^+LL)%tEzg3>V0bmEV} ze;Cf7OaJXQa{LlJ^!W34$wCdq@0x49+2nd)zj}cb;}Fo~TLw@77$W{y+3b{l`GSO<&D}=U2Nnkouq-i`QUprXsqrllgLoYvaTPvLiL&WxDr+wORtsav8HL z>sf^A=s3;h`!iWd#4`_FQOO|Sy;Kc305Ou`m>~A_O>WBVuRL$u8HD1n7DE3#9mdGx-J&>=-3z#vG(x0FOYl7)!nK=MR3HhCJ zmy7XBIaRf?4Gp{L=ctj1O!mw9^GNsEUNIF-slq|jpTFJzww(iU!(mri*uXGb2w5X% z>2X9qI9f~laC+vguj#)$4B-RJLT%PHZo9WH0+7na=U7?jSoxuJnA&WsaO12n<=m1m#t z0td&k{0V;u^17+gWvIqZs*e$x@X&T1S1<__A@*%$??2?qIe+-Ai^G3sPg_vl1E_{+y} z;3&+dwu|(D%wTLoESph=Swn_kR0YV?9Tz$lF>h=|ZYuRG(J(N_4TO1VuWX5R~; z(?A%s9ga#tSHV`pk9*qW14jMy)BTD&j?bclsri}0um1-_f`@)V20J~9#9<2tQ7gQG z)cy5WX5)Eh7;f>mAM_FX!!|CgJ%>DiK+$4MiDdb;n=7>pYjK$&xB@A#fTiU-Qm4R4 z!<6X+T4E@41{1Eiji>xgIdJf@p=fX!xN}S1W|*hhqG14@-P87Z{Q1|U+B@}?SPMHv7l>i2&*Vq>XW-LKuZf>9MFfMQ{UC<;q0nQs6A-iT@E9aAJ<{wh5zl_uqT~hZAJMK{#fwu-qV5I# z7YjeW>Pk0L8Dc3R=A>=PP3D_3&_XtselT$1RMD$ifMlH(zz&9}z~l$iMY2xy$2-m& z71d|)wVLssc^e-I17~-=Z_UmYarM8$D8*2`GLb5Y9|*W*n@9p~i)KkTD+<)c*6CN+ z3y70HMq{8a!Kmsw&S_IHrz>a7!=KFlV>PVDtiv+$QjQ%C%X>IB8AYgdoj=!mC+Hbb z`ubOEsdm-kBIZXAnSOyu@FNW`KDvuVsytB^G_$p88LvaQxpb z&HOkD>v|1U-=bH&X>W{3kOC_>-o`9jdvhWi(qA3dNtOJdqN=MvBiY<|Y7>|&F z{|7&31pJsH)<%&g1l4#D`rEa3u$TA4RN9!}fCd7TsYtNq!#s}%Hs*)lMcYmDVyI}j1mm5v?&-yt9WX|uE`e%! zVT1gckvx|DaEo#?h9a~NYetzmIuJgYy1eXkNBOrJ9$tgYcZV&u?mzR306;BRNn;0u zPD(vLv71|w*-d<~%*XIeI>VwU*31RcRu}aDhr>b1Ecf&Vt1s;$nf}yKtxDAx5l4IZ zmdSM$Qp{NFHFcm6?5&^C>7kxk-9w5pLGLg37P+%4rV=Z!rrf#4E5WILr=ju@ccBT0 z?G41JAV(HmKGhT^rW2o8tJ%wix`#C`~-w;%9-46XLXLt3HSq7xk!Y>l-8gq`mbA zu$xGF4##1CmOT!t4Y8nHqyw`@LALhrd7yy7u(FRNu#Qd4rbrnL*>)_os?F>h1FUf1 zRHOujF5G@Z27-xs4sEgr0gK` zOM=pWZuvjeuom+BEClTBVD5BzP(snNRlR zlldIrY7?p?^CVK-EJe5WaX~EIFG1D#)c-9eW-x;&%F`wA{zo`)8EegdBbXWyNAY9P zT_=A|el+ok;|B~ah*x}3?b!rxRBHnzlZeP5*wH!A&%6P7 z2OE9(SJx+^uVl;~K6BbIleN#5U2Yev4tDqaK1?KNg8^MLOjG|3Z1U79|CuF!W+Go& zH-@#K0*X)-`+hTPXQGwCrWU9HMm_p6U8EudH?@3`xn8vZc#)Ai$VG+yiHbG5Tlhlg zvygZ2-Kt03v8QATXbZblicg}$k|eD4Up!{woXW!f|CDc<$HhqiC=i4enGBXOL)}LM zZ6J)>WShG5tL`H2wqM#slGW3OR{T1UfnMkqT>uVvMg~(YZE#^P5hl-C{?zoA(^?b^ zzuEoLVhzxl!|EEC?wqq{VH6OVn9+Vs_&@8sjvF_?i0J{v@)l*!jD)8o)-wE5WBwFW z@R9jVy@Sdb#n!QzJ zowCf!CIar9_{pow*y2YO>|W;OW%-~U}PzUP|- zl^~&$81PsoY?qQZv-U5z17shE?NNTS``Q;&ZE(X)EH`L|p$gzmpk8OoPw8v!qRPtf z?x_6-qpnlx#li!QB{f&YJV$~d7N?7kZ$?VMR1|XDqOh7U#1L>O zkOo)vKS*lrVu|g`^44jM96HlN#lRu}afW#PEU;UZ_PlVVHko+Z={3vdw-{-}LX%aw zD3Fx^G~RdiX+e(%Q6o|??^{qsRFdF8&;PUZ1W~@BR^^Y%#sH{E+4h_HN_q4l@?f&S zT4pfm{*JL~3R@EkCOPq?M_;eSx2m-jb0b01{SeZ|OU z3FbI$d=a(XI{9DV5*GwV+b52j#m9FquX`{I*6EELoW;j|Rp-djmFL>`j4B8|Yh?cI z=O5+LT^#}h-$3jXpxGjhiU=-RLH2L|LPhGdb+#^bbUzhrl|@rjX@1OL#KJi3aab~; za;{Pe+6HQ9^@Xe#u#AHWw}Jd7-6sYRJ_Ju27^*B*SMdQ?KHa@Bb7A~lTiodf4mpX# zP(PlJ?J$5FdZz>8>5uqUO!*K*1rPu3p+v)V#~~Du7RzebUU)O5ZWzA*nZ8V%cW<}y zgQb{g*vTC!&PM&RYPr!*6 z|0^+W5OrwSNM1Z2NWFs9gN9x<>W}5Qc?CG3ZE|#tB{SZm$ z)wl3I=1M0;_-+C!HZF>lytey8XDo7?Qr1E+SQ!vb>}-i$iVml-8yXiM42d3>xyY~^ zsv@|c)?YE_%v(fwY<{VpRfPs(bra42G~F{R2@rgi8&HVLV*qbhmJ9uk=6QduonH6* z=y`qi{SN*!t%^WUk!K$8T3R-Oqw- z{MA*m_Quz7%<0Y0;+rR0?MF03^+atF6CyH1(F?R->)TUyL=J$0w=UPU!2V%DNpb)3 zbF2Mm+uK967~SjF52w+P4p|lFzq;-KWLC%L6mc_*xIQv)+0(Z8GD+pjuVGoic6<9n zKUqKv7h2Od@d>!Mk6*>5jW*F?bB<7EAJ2R>DX_CVnOYD{Zvr9X@z6-;%Vj>&*@ua!(9{ zRrihrdE5mrGWR9~^HR0$i}&X58eBW2!t>{`q;jcubpHE+l4+^rUK2v_>zvE9|9f5X z(KSA^i!rw&w78XvI=v_5j`Hl%)!&LO39W><=`wKucV;)p{HYtgMOExga^{u(1!#QM zS252g@IHvNcUEI1Kz6wRk@=FO*SmIspMgz|S@-|WS$56Mw6s**gycEw=ea_=msu?9SjjZ5d5No>Rt4NUI*#DI zpYh`0GJVb#rTp!0=u=wsW$APo)xPSGJOkkBiEF=K(r!@_h3P@~2qISGihB~-FF)3-K&{~qL+_i6e5+5fdVg8R%L(y8k9yd8 zUbb`AHCYdAb^U0j#x`J{skmCCiS*-yf@9jI%HqMb*qdR#G_R_j)}d{KV=waBpaDb-W^?&nk3fFYq612e_mXD|LBk!c0uesew`ZZ6x4B?{gn)h#f1>w(}--*qY1krV(s;oLuLJWGm_Kd;q z+1ANYB@_}~z54Z_^*N=odMID>?u?U`y?}|h6L2q=Qaaob`pRl4q|=|d8MiI*Nc)7t zYfeu?pmK8hifV!JRo?wEHSS{1zPk+ZpU&P9ogzMxLCY(Ezzy?tpW#X+5#pf2d-~+So}z`X>~TdnCo+U7<0vYZ%pm>+3`{xJL-G z`ZJ&72PVD0z4jz&=UooggHDdNduHk6eT|E>BsElOh|i!3YAs#__`7RH1 zJgNSiV$}jBI^E>`FfPY(ewp;$uX7F3_wZxS?%6^|IxQH@UIWgyRPkI}s!1g=TvgE- zW7$-cyA=Mqlu6F7tGm0eCAnN+hleD?B!|GAbN z(Lge1#83;JpF(q{-Z`*9=dM%ImI!}Qw{h^!?fS$w4G}n%e3KDR;a`2`83dT627 zxAfehw8yF<<#NwHMH8Xn#9fsj%s?>A%L#KWND_*;^oECzwK_-kDi6f-aQ2-v`1k?5 zq->@vmxLT@N;fSA8SHuXg1pqH;ve=2~etg0mvUF*POm8#OkbCR~7l}=+tnhwn7KB z*Mr9$OO7~jjUIyPI?mMk8i8GQ)uG8rQj2leqC|8mi8=1qZAltQdw?XDt71agLG_qw z_P=~@w3*xSo(Qv39exH#U$??L3`HX5+h-tVEa_=95{x68Q5<=lnCr}LGuq6DO{A>~ zCU+ZKd=34qBgYFITorz5g|2;V=QfVY=!%t(-^e;X7pQqPt@UQrtbQ5m3*tU z%=Br5d?UKDsfiEn{js!H9ov|j$nu0BG{cUfd?)Ej?ZK_N#paSsYwSh}Y&aj53_NA! zN#F)N7clR{f5XSIx;b+Fn?3ZWugT*k6JUz8h!wyuYN(2*$DWnZX!ld8s!vK%CnmP` zW#Y-!b50QkVQEn$`3PZIlO?)1)XqF3uqb$2!v3x+$&=aXl@dy|ScgK(ZGX*(wk=#s z{k)a91@P{BHa=V}%L92+V+4B3nN`4d9nh8OPTl=11ta^GPny4!3Q=k)nO4ZJw!=LF>*G%DBM}hQ5bMGlLwxlM& zxk>-J-GoV4tnI2)5ibDoHs8I57zJF=-ieU(!DdgPGSnlxYIZ%5h#brsej!d}* zi37pdY9?MoeT5sjb1aa?nmW5ijrzP-9E=QJ*>4BSgxMqaNf6w6s78 zud%UP`wxyBWcwEfKhtS+BQ5>vvku+MtA}biWkT4jdM4t;9<@EtK`Of~AKvC$Jq%zp_@%i)@iwcRhNnnizH#T;XSVS8!_U6{nim+LTnByex0 z_1L7;b{?(d)1g15sO>A&Dv3{TJU`ogOx21!kk_7?nDYDb z0F#$!tJ1{h=2C#q4@`7mcHWY=ZC<;uE(!;&k(W9s0llRkCAj9b|EeZA1+w=Pc)98k zqGwk_)H>&pVc7bR@|xrvK`o9{+)K34I5+cm1H@xmw0O02Nf2@UnQU%5c-iV0UH!Tg z>KssTiHCtp47l!Qb}iMsjE?^Iv*3qRvBZqu7e}7l4E?}}ZijfKAtsn2%^1MvzalGU zB`mIU!eZ1>!FJ(&w;2kKnvg-z`eNe7&iSgM>T)XSm3Fa3h02H8YO@0kuCDbyYR;#a zdWQH+pu4uOR@8VT!pr#3Il;Ski9v6Skzm|_x7?gsvp;w+8 zrs!`t$yjjTPF-GYM%S4Whc&23$`z3?Vx0do>dFz zk8&BQ1)FezYwJ}TMSwJd6Q<(`R&-E&5ae(V+AeT}BTj|rnog0O} z%W-IPCS1F0^0EgrSHi&@@n+v~e)`CPDUmkD5?GepdjaXjJ_yURgC)fiixKtwfai9> zJ8^|{eP!;)j;A_i7xNErY>zjIxp?ON19?DTJY6I;i1HPtbJVfVQTd&j;TzHW6DiK* zs7_4Jkb()Olk=7rt1f1`Gcnd9Nqr=}TarmqFV?<8P^q;+}Xmdn>b43HvIq|=Cx?>Su5IQasElPYik~Hbyi<59<(&>szqh?xl^lf;g&O+!~wAM zb8}9J>fAYP9Q&y5ffFEwL-rkD_8!EXQ;Ufty%eOdG$#-7cdh4X;;GLGS$|5v?xU!d zeEu`4JT6pW&GyBe26Mg;2U{3^Ot}G0#e1Z2rK(VT<6(K?lntflBID4zGRewG|}Or@r7f4u+mHk@j576dWM zqXW1&cGq4U_Ho-vOJ>Wf&*=H{IJqyfz2)`>uNig(k;10HVTg?)FCc6S;i%73SQi5i z0~E*hFYN9+bV(g?v=W$DZ>j{(ZkIiBw5YkBQPCt8&IIf$h6EZl5zJ;H|ErLR-@MNQ(SB;TlJn&GJ`)l>~Bwy(qx%c zUBdsaxiZ30-m3~uwDv+7LKmpK1&XnX@ijo<4ArL?-t77D2+7bAT#*eH{}%)PNap>9 zMuG0&zvY5O&$Y+6A31iabt!uVQ^Qb1`WL$!{FMZ1o1B2=p`>-M9;J+r=FlN$#F+9m zvs6Of@**Fjo6s|8#9P-BtD(4%C$a&8Pk@FvIp&>V;WdMh{xg^J$``)^hgtG=s*S^t zzjDC%uC{TG&7-bch~2vf!(towGkGS8oUsT%39QiNPo3f6W531PzeM}1;9!i@QXpML z!_N3?2XmkA3V#h}owxuk7{aCnf%+5!tJ-T9e6&l$sfp4Ykje!bc?`C$WrW>fZ98md z?QdCCkTQb8d!_n_j~9qL3N{Ya^`u2i?aENUm)gVy-h3d??Zgg3Z)u6<>KAb&ryec zWdDxU4J7m8wZ^}{O?mH3J^CF|Hx9o{Vs-y|D-=sQ3Ut5k#FBz~T2}p_4BAG0aa#-C z2ra}V$#@LgRAZ)gI!t!&eju;h!N)(<)GeZsmWLJ|#A+SzWHJt>7(ag#$pF9jb)1Pz z#SnRhhE56`7EpmJmqNr@=Dn|-biga#I{0z5bxngR2Js9-3wc|nj^uBs`X%a{uFHA$ zij6zg47`4RIy;A-X5Ra@CGL-J6+`w$hru>)Jr8iWjoQA`^KBO+&DFx`KRFad*;=~u zh-NRbLmI#js&SvK9*bVZ4 zeebqh6Td$*O9{}8DQU&D;}^a@W@=D{*}|sql;i5RW{hm|Z6yiE9t@{CeXdJIaem$_ z6P<-p4ceYH4LIn3jJoY5wSU4%^~}E*bId<6d?&(H7LfEkK}}}5J8)gTX|K*b;twyd zUeMmyZlWf^+4#|=@qke)x1c^N17C2zod`=%})o~YpE-+cn${i+@;8b zG%o$(C(vFdsOS-I;V!44#~&)3i`wery#dwE2!r4i!?@sw{66FP`X3Kpb&nDCZ-keK zdXm+3EYwz%*GlRC?chue*rfCJRIkrn?5BX|Ve8y2Ka>@JVb?I(oh#{_Gfb|RI2@oWhru7LS zx#}NBkUnlgsbwSYV_w!{E2ah&d>v%c8WSqnbR~f&@T*p~Y4HZu zh$ z#mK;SSTC;-I*&w4J1@j4x<2}4H7{67z!Y=+a{W8OG>;2u*-IAyr@bWwj2}_mjUTmA zQ2lKWs9HBwatK#avO$XQYPD8i1p)Yc)-F8~L_VU@7E1|C!6Q1BQ{_4$V>-UQjc1L|UnzI8op-rMDbKbWTo z<|}t^t_MHJcQq0=Pc?qfqSMLLjU@zY%I3}lW50t~?%`fmPLwa)rI2K1t9S7Bal^S3Ex_XuW6^ul{biK0*;5b%W>;!`lyjK)cq zli|I7ZU@hK?wz6q@Prm8MUVK2NQ4hJ&@i0#n9X06RZ63-`Ki#%Qc0$#?${5azIi(%0Yj@7PGejR zPa}%Ir`s1oXKT!4{T>c()G^m4%&-u&>h&jFJ>DdZv?~H9_osweTert%Zo-(CpAu;f z*Rl;#NQw8{;k~~SmE3n-QtBRE2984#Unz4p0bS1pxL5#>1woPwd9bV>p!NAiE?;)- zSrXJaIhR`S@`T#7Iu6yYslT$GDjSeOhp#=CsC4L2h?*AL7wpJydrL7tmq0dpU!!a%J$H;bevu(_Xml8Nk1F)$q}zSL0gBcC5n3T|E z;cjG?yyQHR^xhOHYKdXk!C=N$(Zb<+B1US-=RqxDt2l7dlu&XpjtR}!s*XNhDDVv+ z@c5wh+31M#5N5295$FB-up`bKU}*`sY~i`)IPgQZQF&YIwdZ8&kS_JukILX{<4lf_ z&7T4{m2#+JR`)SBk~dA0zS8|Wq}TOx;kV_WKa#&bLHlq418z1|wyQE?Pn=o=`(HL( zT^TF;^}|OI#a{^AJAHwU^%MR+du^Vw(-BbONX zU?Qa*fuPL%>TCFxqZsiinhEXFsvb=v;_Y3Fg~EgBxb$Xu0c zm4Lu(i0gT{8no>m9pWX}9-P~E%j^?Vdz@jUW(IYxU9s#I&--%*I2}k^B1M%G^%`gB z#tg|Xg3mjU*65tui2uAj43(x z@^_(~y2qWcM`eiUDHz*XAc<4q?HCPsPeA=^I^X!ffFThO*q;@KT2mEJm+|=Y-DA5g3h=kCr(GU%Fwsbuu^2cV`Z%F^XaA*@ z&tn+;lP3M2QNPkJW&g^*hLoi0WF;!9nTVX% zV5!@BdCJ;<{3IO!derqMNXie-@B$5C-XG$?NL;?;YEr?obq2jB%2NL%5$X8!8|vMQ z8D0uA`nUHdv8)q~>SOCvES0+i)7-&FS!vmbAekTK!tshHHzzIozaOYA1`C@cwhrrk zaxlK)e{BEjT+(}+2j$S&lUPBVh^Qj)P8HBs#?T>P9CXF%9@AnC{BqwlRkj@gmJnwP zi+lGMk7mh7!gb9xj@38G9N?37duUIAhyrKOszIbu9j#AP-U|A7;@xP{`?UOGsB<$; zXp18i>^cZKCwS4Uubl+;IsD&_b%2mEn7mY@c0MKY7 zRleyi^u{45?`yeCC@h}lz)S~lCFtLw?5aTT`@@_o-$$LYw!%NY5cOd3)OZ%TU%_s)zT8Q=hV3Ea(f(F6VJ{4=< zi#?SH%Jlu+bkch+BxrRKPH6ksYK37*&1{P@$)lgCA2?e$$F(2)1 z+VoTC@wH*JAltn(NI0W;4dEvA1Gg>x9+@hZXKH#u@%Yr|bt%_-QjsjuY|z3t_P}$C z-OJ9y&wYfIumLkDvO}h6PRXKtc}3ZoJToGs15mmztzg>W(apZcxwP zN_K{$o~b?(L)GkdE7I}ML+a2>K%YGjv7G0MM~#9k7ItOSObhCKuRbzi$4h zJ9O<(xEtiirQShmz>1vzz!BRYzKr%C>b)xgTG;&!Jqb4h7M4kHn@Rk`f0j z6Eb|QF}^?kPEIx8BtJg5CNFpkz|-T$KC0aD_w+3kekoa~DAcdaJ{wi*=NiO3HqGCX zKY5|Q#VKF)=rZNHSA{KPD+!mk%izNS^i=}mVs&(#(aYa|wElgufNvH21Un6N&U*Dm z=V@2coIma4Slu3VVp03+pBr!#9F-?m;;Mc>x|BqEeQ|&DJ^I}N{=xM7U-Xb;i2C-b z68P|&@GLmGn;CsYn*fQInQ+VlUt?_k3y5~%5zwS=P~>@ zt*dhr*13vE;6ALOhuc69ADBU5{Nw%Q;0v?e%)l8=bT(I*rW=wtC)K;m>lp8&^MDq} zg`Q8E(o2y5C;>70XcA`%W+p@Vqa@As&it-3VwoQMpJP7YCH2(?mh3g4ctTkLJ6IXY zpnElS-qHdbEn*rMJ?e)#_ZbIk+VVh->N=69>){xy=c{_-qEJBX^k6Q)FkkD&2>u(+ z=Jg#2OMaM}FZ=q1IjY<@hY{4Iw%SWp5G4^bU0~!q_&0KCEd{@;$EDijAP=$q4~oh% z9AUH5Aw~oH4|Lw%vzc&#qlPJx(WDdObw9)S~u*&)uI8y9qN4psv*o&<4{zef6;K2Q9jhc{VEfp~o6{ z`P3S$u2X!qu~0At#FU^0Q}1lo@KI~7Po&$=Y#~_9?++aiO8}m|upo8j|7!26|C)Ti zxbLyi2t!)X5lW|&qAPCYiVlcMn zo_=26AD&;Hf8hC%7x#6pbI$cXdBs@aFs-RrgM(>f;-QR026Sw-)D}?S^MQ}Al_=kH zUtC$EJ0|`iGGkP~iMq3-VqMW}Mx;)zJe*o)lQY%tJ4&zy?-4i_JI|Mls-P=b|zG?{>}HCMw zUj{{pDWxpCo;^U9+y^OC#w0Md7gJA@hzu&}>Cy3mI{d&7ag4}o2ydGB!)Kl$0! zKttd;s5WxyE*nfM73?^qGXPUEaCi&7dh>;`R{WduK?JKS+Oz_2V1f0&=~wL7#DVco zX91A!9~8cIY#D6ed%#;|j5*iuS(Su^tg7d515i8OgJAf$x$Qy+DtMd46{!ut=#^mn zs(e(j=cp{TDwgGO6Buby|G2#)T^wN3Tr92%#+(~sspy4pA3BgN_&$X#qgMdklhq{I z-K}#yZIx51P1MVb7zpl3;`ry>ZYD;m&6w!edplgv9Dd^j^!8WD14TUzA65rX$8&Jx+ zhW~}s8}RO^P*Cm3uw&w-g*L?rT>+p~jdx#i8Ge=@y#vnnSehX3!v#EIgg^*}ul}lk z9;S7>dwTk3(iFop(vOqpCT=#FYjvs}ny*|eDa@5_d~S<=SA;=U*qvnnLktciz{Hy| zzBLeiHsOLcS_YJw?(bsIRRXb+gJUS2uslqEPtPD1&>R45KHA_uUN{{g`UD{43s0(ks4@j-U-}qG`n~G*Xq}K&)RvH56^>aY$ ze-E^nOG&Ms!dUA;9B2icS9JZO>vcDBr_a6L zUC6qv_(yZ{otK0yBLLPLo1cvN$@kSP*ax2*>nnh%V>l)Mupx`S4rGE7Vz7Cgg&_75 zz%GmR#ljEnfBq@pK}Y+8@rA6u7gyNxp9B8n1@Ff5?S?*VK(^_ZhN6|&AGUI@!>)sU z7Tu<^kCD1pBQuVASzum+V)qCq7=9iaIEO(}(FaG@?G+3t{Z35`b5H~~9e_v&7=gk7 zu<}0mm5tO7I$QJQTYzs#%zCF(?=w^CkYrP8Ni~}J6bwhmVU>Qne%G9=PQt&Q$PUqjS zd}`$3S5dFs)&q!6y?S;YgN>D^6oz1I~w7!$C zG6#@NGhks3r-f_^asV$^q-dLiS}7*#A1x?)UQ8r145h-gtG1|K<(GnmF5W-S8cvDs z3yR&5QfK^nK8pEHT?k9*B8p>ju4<83mr5+aE`vUG+Zx38I~(W&>r8U+R+loE-@CKu zA((#b%sfJeRq(akHwLR}pi6ZkvCY8Ur<#O7alovsnw>wm#OFvO?-QsRZ{44fI{NKrp0xW( zVZM+f2N9@GMd}!xcXVASob;dhe%XgwuY>mjU|zq-pG2^_pJ>9$9jFL9(l>1QzVJzO%ZC$`+7;Q(9j%biqod z7vGI!r6Z_(RPAk{;`>;=u?ab?Hc2=PO!I2ei*0-P?cm?JV`Y zw+HQfUW`^CdHd=)e5TL^VIb_n8{YkjMffeSg0%Jh8N(d_rdpzyzTih-ZVB3w41W~y z@l01hr!s6iwDMCf7&K$J_UO4clf+6rCx6rc92V_p|LO4gd?jA7?X(*Ut>97#c6VO(#T?TX@N|AXMs- zg5*fx4%|K=RKHnI5~kH#caia|g}V5Xzm32jJ#W~1^EL!&s%SOz%kiYVN(uJSK%(^< zrSFVjZiphKIg^n+Dn}^0ph}(hEms#qnJN0s*>wyN;{sPh z@G9svq{eTx$UUJd^%J7twYZGUmRd)1#|>mME%c$<=NsQ8od;J$j!pU)#Maxvm}R&< z$6R3xX8mU7jwU1zp`M*&;{wJno1uJ-c_dnj61jFiJFxOs@`5Om0NZZOSqvDGSznyY zpnD5rBc$ra9e;| z?9auo-FboJm;31%sOO#50A1c>WYh_BA(s@ewr$lPv+!mr2Hd9T8X@)t7dT{FZzHCY z1E=09o3+X*^H0sYvjbhI5)nBO^rPROGq#tUsb*DnQ?w6f)Oi*)AIR2GgJLeJu5>;$ z7YDMo+?$bLNB8avCD-?7V;5ml%-*SdV7myxf<&nV4nSI0*EiZh8@!0`wN&AVX|Kak#^I)8BHPil(W+_Jlzy;@I!Ipk-h5d zm-txfbwLNvXQPfZ(wZJD2!d%z${`#NF=2Qh&4!TSRi3ivcYoUnj6$wtu< zbu)epu~SY9vc}d}(k;Ai)54ockiTpp(_)m?=X*I*un$j(Rfoj7e+RXv>~;m;jmmFE zPv1M)=)*)_jK-6!AK3i#ax!A<%JUvPGs>IJ1KhBV@WN!@y(JHct$hkiL%XIEdcLgR z7j0a5fWub?x=h7oc;0MT0cGY@6g!+x`GXE-GU=9=`Mq(R?q|+_PqCMyHfZE80NaC{ z&O^0UE)AU6JSpWN3=HPc&uCzl0pz$o+!D7i#}`-gTq5FXQDLb6nwYr zMF`)E63*!Ly!DF~1ya%1%C`8?CUmNI@-ptPz(`Kfk_2TX#VLsc zO4}=`=-3?K!$tnxReIny;(ZTJePQuF&hn*<1psDZyTPeLKP$0%U?|c@>w=?pv_prt z>%4cSwyNpwZDpSSW9M;ygCg`Z-4?l@(EO3yeBPOJ&q_>H&D!Rj9mUqem)flxG>OSn z>U-W%q|N?u8E_j#A1$OslEjnCYxVDs$Rf@JgGXOkS_>TBeVMrhqX=xa>qw*=M%Z4- zz{X|*4q##tSZfqjOl*RmKgFQk`ziRk@2m?uX7-t3>Nd`Kls;UG^hnVVL%jZ-%*AXb zHkQ)fYQlCh8<(Fd1ckc$n1{O}g?X7EjbCX2-fXZ%`H0^}=2 zn6U3^RE8zSV?ufMU1ag3zg|Qa<&*`K`iX(wE(!csM=b{=d^K|Eeb|@9+=sd6KNRFu z8*A8;VOKaMuedM6w(C7^f++kJ+tB^;>{jz*lE!a*%U@6&jf` zmilYtKj}30IQ~Y+&sc`grN2ap5n2)J5n5V!ZNI6UK3qTtb?X|tO>*^g(n!*TC3sHb zXJcD;o-+}lq);@ybpjngQ=YreLcv4cQT$ecCD6R!UG%((-52^0BSoi2SLUS6&ysri z?SA}H(txD%PqXk=GsfEv@=)n8y6$r2ED>rdLQE1dE=A%vGtwi3p1wTn>hEJHB}RB} z8?L++LM3%_wq;Jm3&aNk-x;X_!A=bB9%*23>F~H8-!h&2jj#|=@An!8R=ilgp-78ySofjwkhNOdk=zR1FaR**_M_jM0%eHm3MlHob==M(Z^dy!Tb!mnC; zs4#)S7;LrdNU4HZzrsc8wo>q{TlK*>fPlL|e9|2}{97(jS0C zmAF7y*ZEY}9<%TW5?*XtM}1g28SN_tW+mW@we!`1WW?1Cq}2}DI1~(c&6m5NHb!*a z9Z?&00@7f|+%UPpMCC0`maKuaym8*{Nr6AkyL#NC{aqux zV|1U|LNOINW^eU9<{g1t{n?F#F7wt_#)yC$!fsr#vI@e4M$D2U`3up%B6M~s@`Iz>{E{lz2y zH}PjbTY6Dm`-G^m`K)>{Va!hY|Q*cO4k zd|zC*jdpRmKQl9P1~p`eOS`u((wbn#kA7h~c#bT6=w0!hT)l(>ZG|2@Mgupwvo*P3DgO+q0bVdcdM!So$`M)(??Q~~k_tuh3!vu+>v4LU?oJ4e z`C3w*b{JAKoEWI2kJS_htgdXJ15mubgbKS8nFr2$Zr=${EzX~~MZ+KDz1h_@vki$K zNP{9QGtHG>znTj_F`qJ-h74tR`6%(#^ALWEYE7vpTO`5hqXT{X)=<;Pn@Y8iP=62; z9`W}=Edh0~*nEiMofmSH0YEn$ggW~vHhBW+xt?J8HFd!Dd_$xiMn zb7+u}854s=v09|h2%E=9R+vJK>6I`g%^#`A_MXa>!2_4DSW-Zn8)^ltsD8579)hRV zd{1!z{e*E*#A6u5rv=k}B20$yxy@Uanu&_#o4n%#$Yk>4XB=0;aTCVh`SCl*^6ehK z*ZG#}9pS`pO@(g_W!#p97Ls|SQ&+yswoy+Nudgk|8e+>ycQ$W?SlO0%@BWo0Ff>D6 z>GJl*qWjAbgBg~K)0DHEnGSDn@JIEQ3cy1TUf;PG{vf%bWB(>JsqG|;Ohmo%aBhHz zgY9B#%d_!udJSH#(;@;^Qt{WG2YZZQvw( zQ0m-_T*>s&_7bxaGDL$w!f^C_%_A=YLg@QZ8itPIVD^?@+P#TRL)TU(F#>ULpy(Pz zt~&!kTbP0#l}~B4QSoonoOR@|gj4mGZx=Fi?2pie-%ds5d>^a6NOx{a{`uY{xy-x5 z^UDdnOs+F^26tK6*Zuyk#PQ)=;}Oaf8R>;VIkW?oun|Qj_{Foat4W&&Z>&pW_NU}C z_Ep)au*EtK1aM1m=?e9=Z|Np>-Ff=6=UuhW{1D8qKAjZ(CMIgT|B4`b?=V3sx2K{- z^HH*2>rU69!6IzRaol9{LwM8AeFoEBvw*wu5*X2LZ|M&>&F}au{A_>o>%`AsF2JRnM8LXnhq$e%DGf z1z_7AK}vxGjL)+ZGrX9koAuJGg88xJO1&kLwPoO+8`q}Z-sIpY$_^>iBPHT_-$tCT z;VCac#79^2v68-+-usF@AoXb54)*&`eR}uTOWdYE_BStlnABRo$~HE5n|qkXM`4YnVsq`-+y#~fQ!sGMCe`Rv>4##=e!WtMPuAy`;CvUl1ASCmDQ#;esr z3=J?CDx-B|$H#}7ee5DHiG&qatxVbtZXU&iJ$fj82&Eutefj17j_M^6G{n;b6Z>8H zomq{3_>bSrBgU>wuOM3Z0@taVc{!qaZ&q%FS4p5`p71}ZH`4c(GVE!rd?wna*2sEn zp)br-s-Wf8$=P|WMw^s4lYkUsl`y^j+|5;WQ2anau!Dj}Nq+EeDNbKW9X67GQ=`V%wJkn&xhwXk-XvdUZ7Z^;_=&QT)`RL} z)Wol-a0U!xS7&t*k^$pxQ2XlZ6?S0UnAgQY0 zqTlr=NfTRRQH~O~07T7gEL&cy#q~F%4?Xh6>W~y!wxWnlOW313hJ7f|7eNiKP`7E< z{UQrq_@D^+WLh-qmAhj9bLo49Q=y_M+<^scyUX0Txe%OLsge5#J)xv+k#%P7*b6)k z2U?eDDEDmiPbbt=|JdgkAdVei8XszVLLO?HEgtfzuc~@6I{1$9QIdgm3U~SKD0aGV z?wNQ3It_9CEwGZbpB?$-KbBr=evRD(z2jNOb+mIoTm0{`^ePc<+|0r=V77SJr@qgx z`huUk-j4L$-*PyM!;%~=m>#=CeU+gLY1e8v70nv_>h{7KbnM|_Jq^WHQkJhWJ|L%< z_Dz!SpS1!{R(4~4+&a(CpqBZl2~-lt<@z6~ZHq@6(Q``IWY<3|9ycs~uO~@*|M_`K zrRjfMB@=n-=CMxcnM7;7LTXGFQ*2T?q@ALon>RHWuaEWp*u9MJjck2gSA0qf7R4ty z=smepFS-G~pu2}#{?9iDE1h7cKv-P_OexIg(#Y{fb%3ZRJnEwA?dMmc|H>vfBBf+J zV{RB~tWvQ7kEMxXN0R%UmOiqe>K1mn2DK~$XSLncC%}_sAfe`SXd2I7c~0qXwo=ZV zlmfWH7=Huw2^QM*LMe1|h3WR4rM8l%e5N2SJzybP<8#@lEo(=E+!;K-_pSUi+4OV$ z+T}BRje~63VDF<=Y&sojyO(luroj^Uy7x|AfQk;|nl3yr;gE&pg>}ZVmIrgy=D3f3bgXS;EK`MZ#cq!zyI>_1P`t0Y<{ zbua>tz~!`p>(K0oX}m~$0v>gP@^U^2#0ShD+Xi&XO3ujGd?1@6NW;GNSdOrF6L;{r z;SaBOW0^k#B8F?pTS6-atHaCD3)_;C{{HS1p<)~5pLjgh$ zdLhzzzgCf`?C)7_?6dYFZ6Sc!?&>}c>;Sl2z3>=0^qHh4N?_Rr3Bl+vR1~$D%JSUx z@x`I_z0(n1S~dgfenU6$#UEasXMj^U3t$Z9VvfQD983kH^;tqT&CO%6bTY+% zT>(SDHcp59Q$y`O$pEFssRKShkng(OVHM<5!OyKsJ;hlKx^>Ron;+(W{$kK^SoDyX zr@H{-{gu~nz!4a_Xl1U?p0lSMfZNs4NFV{AZ(%@z?fW-9jm3PSaK@M`(S!FWR|`1BV~A)FfAy zgT2)y8!J|O8JoTk)S{&^O!}L5Ahuod`*;19k2e5Aa-)U@=i<6l4`tv#iN?Vtv-~P&4Sk@z9F}+wWhTBE12bI1eiicj{(5eTJoq~|FAN*i##UD+ch2}P z?yI@<9i7=#%ige5Q@%OMJY9`ax$!_ThhMb!XrsFI6^T%S8%E~jZlQA2es`lEI4TKw zjU(pr?p!_3j_zDS!?`h#s9b8T)n8YV{~SrubFxm~-(vO~&Z!#as+=BZedrrme{1yF zV@cDG;XiqHiL=McO)+5=VcVZS?)e^{kgxUm)I9y%_VI73)(?r_6G`9S3%x6KMaQC_ znRZ(dd5&3Vpt6{vT7WLpj(fD^+X>!kOK&wYgWv9>Gv)?fH1hnah})lcv97dz)5wvyit_H`Z7}qRCYkPIy?e64@y_=OsCJU!i;s}2_0OT7kZUatM$Z9k7m>?8}&v)J7;i8ejbr3}@a zL-5mJUZZu}fG91dw$P!g2#GI}!0^7aU|{|8(C9snmtr&y*QVceIv?FN-e~a`9$7+c znb(Rn#3L2Sx|eD6dtq`AOl7*xP*X$U5INx2{fB+Vp)dJCmA(`u8~XKHlS1UK0`0Gk z1xT(+=Uld|a|g*^m-@;~pVHVn$O5d_>s_`j{NoQyJ;yu6_jzR{*H|bz zWRAtojJnAro0+V2DI7#ZLSnUDwDU_yZ^)8b+R@W-G#@U8FS-gUS87-{v$x&)2djH} z#FiVIt1cq0=)pFiyb&Vw`3OtN%7I> zi*3N!nGsD0vysmF37>RvQ`|(pUxJ1t2}przp#aHO(l zWAm}NgIKB&UZ8&&#E5ulx=F{saaUJf^q=tSv1kl8#zmh9!a*d1j!8?W1DF(zDXFlg z^R-FYh838nn4Q2xctF+v8_*X==G}H?U)>9G_p>9bK@{{%) zhcT@`d*_;HR3)z~RN@DDJTs6JU1~Gw#N}tjWc>T5Oorrt!zL?t%v;fq!08N_+Gtex zQpaqD+^>JJ(D$pm-kDOhKiIe?(J}*qmw#=eyWGXV47VneZq`K{iX~rBuiirnlE&_Q z7Xzn+%0ZNSXWh4Z7N3^4$1nQ~$DjUr9#NgMW+uST)ujZka6Q-6x7_-~l7(KSWugls z4ZjHJTCXsppDxE-s3>MuuKY3hbP4x;H|x9#7v%hXk?{*@&LEOO)!ml!S0-3A8Hg0O zreFV}Ma!g>?8`;{nl6DV`{RF{Ntgya(X426)TFuw8Yu{fi%zStWKA9d`I5mD6|@bw07hxvh0P42)WWvp z`)0?K0hV^81eV<0bX~#S{WF@-_$PylBtV(@%uDO?)2Y?JDh~P)Oc-7Kd8p>& zVtOBa-%DIKwL3_wkBV%;IUCw18*gbN9}?cMgBFYqmOGd7rfCj05r5DGp=p!rfK7^^ ztr5mSr(LMOFwnIqhM)*LdkO1IY$VK%LESVm48CPPnGq3-uDtq-d}ru*5(1s!&&6x8 zX=>E?6tbO)(%hAm;({;;5feh3d*w3iThJ8Qx}7o}lM_q71+${-LCG#5*)DSq{$X*< zqg6@(^jXPLVD2CU>_KFz-MFs}%R51$-_wi9OeBtWe&a)2xR ztUjiYfh@RTGcl5aQcV8@W*s0}x4G4X(B`2*zK+Y&CaN@okr1Ef_67NfMo%jEQQ^rF zy32O6EH)^461pf2%pN8Zx^)s?)XxM?hsI(j8~_Hbei$+438s?p9#$a3Ymoq)V@JkAHynIe5T$7OdMxXf3==mdBDIoUYpFbf8`H>*8Ex+~7AHcuDh3V>$0p3ckg(Qy zTfm?Ldl@9ErJ^qIhPDec=BP~t7LoEFV6K0uM`U^7S=7M;i z_~g>NFY$H{HdQz`n8zx;{w!9hc-+IM{9`v3E$|UwPkllEu#x-=h&t1EJ3qGW-4LTo z$=pwN66Vb_SLD6XuMENeEJ3m(_JE6%NlRPhop9THtt|XaKyqosty5toCuh3RCBDiZ z0z+>i-|m(c4I(LaJ1^(IP1M*G>%cw%IX3~-p=HwcdqfkAcg(hP4yxAxKetbantOUj z{ocsiV*h-^Ha9CJ=!#w_;iP$Xn-R(yB=Sa>=D11&=XSU9>Qvac{XeHLFSploG_Bb9 zV3{O&NIe9BUZxU6GHb2o3Yiuld|g`x+)kaYW^mw^wp}=q@ya`MQ3~MYCq0(IO$}ME zIXdN0=3sS%z2IQm>`Ny@3IdWXqg#(41%o@^9uZq#orl6lj>w-g4c(Lz72h&;ErU)Z ztft7jp$*7jl7sYjn}*Kev7S+yh;8msL+yUW@%vBpcp)d|D&3!vhOPV0@*NBPGrvS4 zOV{k@!Q89J-a-_As4Q^qw#WYb@iuK(lbqY#Xl0}16&)OxX9jk_BrI36qLPQa(6LKm zrPyH7VESXM$lOL!irH$Y#>=X3CUEu)zhGgc7|l$-NtEhFjk>fcPJ~pNRgHHQ!zdA^ zb{0o*Sd}=le_rS1BK0=elV_J~V0BFOGaMo+=hP&ElZgTG5o@CeXHWqXxTqjwca$wI zZNHhdaz90+Y@dP+v0I5Rthu((Ivr?h%FqaisLINo$@JaCLsj8?q%+r{_EPkn-n+F! zdw%Jo|4dBsv)Qw}5>3-&bi+r$64QADazmtom%urD&^(yPlQ6<>sKjA#^TJa*q6nPm^DW{H1o4%LmG&)k(?Vk3K&^ z^#|Pqbn-M3rW_@+fMn}`TzyTC6ho)XX$Y8@ztCjtVTGNARJ>L80MBB2ulKe?6DEZ&Yqt1vfCS&5B@)9awl`P z$)XbnIsgq|c5;Lf&bIlZ#!@>7+vf

2;l`!V;;u5PGbPwe@kDNu2Jo8$&7F+Krlg}KmiYkuhPj7 zf7hGD^Peu>ySa2U_zj3fQC$GfZ&Bh9tJ!+iPN-FK6Zftzw=4jsmxH+U(`0bh3nl(& z>^lQ&$pfGMp`4v`n`hU&cebBrSC<|9GU0q5O~$3wqvO(8?A|YDAnHG$PIk9&kZ`}Q zBRe2a;LV@g45G$Qsd(o6jfZ= z9qy1Y%i_BBeLdnD$6+|~?Ld4cM7DYQuV0-N+4tW#I$Y`)@qFqJWH2)UHU+l&1M0%w zxEEiX!iVBpI@`X6;Qu$p2^E5+BG%S*BdR@Xw8Kg zbv7BO1L2?6v(y@_2Mo*lV>i*7!!rdV4gfz#nkJ{#lGHE5e!!;n9~U1*g6-F$_(-PH zlOF|xishpsY zxnA!vhi@mx{XgU=*|b@NW)zQ3c)s*oS|l$f-EBb=`RDT7OhV1ptJcYJ%fIopjfgAM zP~fS60l&>VqV>l^AlG)>p*uPZzU#oo&!$RU1O6frWigF6Rm~40lY(1iRptD=iy^ws z9|5i3iEA&KudXNO4H1&CIP8Gu(ul)7!s?m#qlADos;2i5w%2afr zeCiD!?C(BO-CzBu%|?FW3S!45#1iWv_?o;Zkk>9}#Fwz;_4H~1mL%u@{4SBLw(u348a?-<@e{&wBiLvf7qc0e08$R@pqmtnL2?My zD4Gk+G!L*ic1dGCe!_a&1-|lcw-K5WE$1gq1s&67$feZ0M%aNaD!RbhaK_7NgxLwN z;_3|Nv|6H-yUo=WP~o}!rTST!0BOtV$V1^%GiaTK&^bsOWic1hpf~E2G^d$zq~T zZe^%a)`m}APQP&)%{#+rb@%w?3aVVc`S!z0!lYaU&6oC8Q?8^h@+Sa(u!_Ab9;61R zNvNI{F_<|Sh;1CQ&#%nnxeT3=uua}8+xLzeibZ={pEDjF1@qL{%^MM$F zlc*=7*o1uIdds5zIAn|sh1NsDRw$L#sPW3N?a_}9IFkagdzv!v(B0R3&UN0Js;VDr zLq$cnfl`*SQK`6k3uJ%5DnPF1;%*+kexTMZ!JhFqWqnrJ&AwDaQsE=nB4A+oH zGS3fW;=?xWaKR6K9XGP&Jy-JJR^B6Hf32UtGY-W}72 zTg#A+Hl~Sy3qca#W&3^)HOtz@&3=KcY_*>jgu@4;rx%bNq6miz z+yI7KQVy158F1XD626+NK+U{*d-YpAn09*GqsG8KfTK4)ul^u-dX5eSLxq~c$T@$| zqFP{v2iO$WUmrBx9zRnd02km(|JBe~TO)gv8T~Gw!Qo}4(qF3`7FtZctH#xLgBZyW za_Km)IYLmqzbip36kv1!fGe-+Yc@FtJ5O(qz1L38jz7%}Qprt(X8PXz=-}3Bhhl1* z;`;VFSURk|Pk)$`yxv8+7C3s%dX*ggCj{dQ@-VNpNvG|;?tZtmdOwk^-n?5Hw0{`1)X!&R zv$d~UpWzxvEquqyFFMR(b-#Oshm{aY*pmccpnR8lVh}A0YUmciu7eOJpSi7&k!~8r zzI7!*u*$5-YLh`v15QBf+hesRA@NWe008nMz+nI&4(1yF|BwHd4v5ICaTDhhb3`9S OXBYL&^s00mBK{B2gAP{! literal 0 HcmV?d00001 diff --git a/music_assistant/web/strings.js b/music_assistant/web/strings.js index ffa09bd5..bd8025d4 100644 --- a/music_assistant/web/strings.js +++ b/music_assistant/web/strings.js @@ -35,6 +35,8 @@ const messages = { file: "Filesystem", chromecast: "Chromecast", squeezebox: "Squeezebox support", + sonos: "Sonos", + webplayer: "Web Player (Chrome browser only)", username: "Username", password: "Password", hostname: "Hostname (or IP)", @@ -125,6 +127,8 @@ const messages = { file: "Bestandssysteem", chromecast: "Chromecast", squeezebox: "Squeezebox ondersteuning", + sonos: "Sonos", + webplayer: "Web Player (alleen Chrome browser)", username: "Gebruikersnaam", password: "Wachtwoord", hostname: "Hostnaam (of IP)", -- 2.34.1