From: marcelveldt Date: Tue, 12 Nov 2019 20:49:51 +0000 (+0100) Subject: allow hot reloading of modules X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=6e2770c177c3cf05105b4e7023f1c78285b23763;p=music-assistant-server.git allow hot reloading of modules --- diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index 51684a17..6e16371d 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -65,6 +65,7 @@ class MusicAssistant(): except asyncio.CancelledError: LOGGER.info("Application shutdown") await self.signal_event("shutdown") + self.config.save() await self.db.close() await self.cache.close() diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py index dc7f4cdb..f2b68c44 100755 --- a/music_assistant/models/musicprovider.py +++ b/music_assistant/models/musicprovider.py @@ -17,17 +17,16 @@ class MusicProvider(): Uses a form of lazy provisioning to local db as cache """ - name = 'My great Music provider' # display name - prov_id = 'my_provider' # used as id - icon = '' - def __init__(self, mass): + """[DO NOT OVERRIDE]""" + self.prov_id = '' + self.name = '' self.mass = mass self.cache = mass.cache - async def setup(self): - """ async initialize of module """ - pass + async def setup(self, conf): + """[SHOULD OVERRIDE] Setup the provider""" + return False ### Common methods and properties #### diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py index 24d87042..edc194d8 100755 --- a/music_assistant/models/playerprovider.py +++ b/music_assistant/models/playerprovider.py @@ -17,16 +17,20 @@ class PlayerProvider(): Common methods usable for every provider Provider specific methods should be overriden in the provider specific implementation ''' - - def __init__(self, mass, conf): + def __init__(self, mass): + """[DO NOT OVERRIDE]""" + self.prov_id = '' + self.name = '' self.mass = mass - self.name = 'My great Musicplayer provider' # display name - self.prov_id = 'my_provider' # used as id - self.player_config_entries = [] # player specific config entries + self.cache = mass.cache + self.player_config_entries = [] - ### Common methods and properties #### + async def setup(self, conf): + """[SHOULD OVERRIDE] Setup the provider""" + return False + ### Common methods and properties #### @property def players(self): diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py index dcbaecbd..f8b7d43f 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/music_manager.py @@ -48,17 +48,26 @@ class MusicManager(): def __init__(self, mass): self.running_sync_jobs = [] self.mass = mass - # dynamically load musicprovider modules - self.providers = load_provider_modules(mass, CONF_KEY_MUSICPROVIDERS) + self.providers = {} async def setup(self): ''' async initialize of module ''' - # start providers - for prov in self.providers.values(): - await prov.setup() + # load providers + await self.load_modules() # schedule sync task self.mass.event_loop.create_task(self.__sync_music_providers()) + async def load_modules(self): + """Dynamically (un)load musicprovider modules.""" + prev_ids = list(self.providers.keys()) + await load_provider_modules(self.mass, + self.providers, CONF_KEY_MUSICPROVIDERS) + # schedule sync for any newly added providers + for prov_id in self.providers: + if prov_id not in prev_ids: + self.mass.event_loop.create_task( + self.sync_music_provider(prov_id)) + async def item(self, item_id, media_type: MediaType, diff --git a/music_assistant/musicproviders/file.py b/music_assistant/musicproviders/file.py index 6d265075..c5770079 100644 --- a/music_assistant/musicproviders/file.py +++ b/music_assistant/musicproviders/file.py @@ -13,7 +13,6 @@ from ..utils import run_periodic, LOGGER, parse_title_and_version from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist from ..constants import CONF_ENABLED -PROV_ID = 'file' PROV_NAME = 'Local files and playlists' PROV_CLASS = 'FileProvider' @@ -33,13 +32,9 @@ class FileProvider(MusicProvider): Supports having URI's from streaming providers within m3u playlist Should be compatible with LMS ''' - - def __init__(self, mass, conf): - self.name = PROV_NAME - self.prov_id = PROV_ID - self.mass = mass - self.cache = mass.cache + async def setup(self, conf): + """ setup the provider, return True if succesfull""" self._music_dir = conf["music_dir"] self._playlists_dir = conf["playlists_dir"] if not os.path.isdir(conf["music_dir"]): diff --git a/music_assistant/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py index 71cd4edd..1c488a66 100644 --- a/music_assistant/musicproviders/qobuz.py +++ b/music_assistant/musicproviders/qobuz.py @@ -15,7 +15,6 @@ from ..models import MusicProvider, MediaType, TrackQuality, \ from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, \ CONF_TYPE_PASSWORD, EVENT_STREAM_STARTED, EVENT_PLAYBACK_STOPPED -PROV_ID = 'qobuz' PROV_NAME = 'Qobuz' PROV_CLASS = 'QobuzProvider' @@ -28,21 +27,19 @@ class QobuzProvider(MusicProvider): http_session = None throttler = None + __username = None + __password = None + __user_auth_info = None + __logged_in = None - def __init__(self, mass, conf): - ''' Support for streaming music provider Qobuz ''' - super().__init__(mass) - self.name = PROV_NAME - self.prov_id = PROV_ID + async def setup(self, conf): + ''' perform async setup ''' self.__username = conf[CONF_USERNAME] self.__password = conf[CONF_PASSWORD] if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: raise Exception("Username and password must not be empty") self.__user_auth_info = None self.__logged_in = False - - async def setup(self): - ''' perform async setup ''' self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.throttler = Throttler(rate_limit=4, period=1) diff --git a/music_assistant/musicproviders/spotify.py b/music_assistant/musicproviders/spotify.py index 0bf77395..85597e17 100644 --- a/music_assistant/musicproviders/spotify.py +++ b/music_assistant/musicproviders/spotify.py @@ -16,7 +16,6 @@ from ..models import MusicProvider, MediaType, TrackQuality, \ AlbumType, Artist, Album, Track, Playlist from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD -PROV_ID = 'spotify' PROV_NAME = 'Spotify' PROV_CLASS = 'SpotifyProvider' @@ -31,20 +30,14 @@ class SpotifyProvider(MusicProvider): http_session = None sp_user = None - def __init__(self, mass, conf): - ''' Support for streaming provider Spotify ''' - super().__init__(mass) - self.name = PROV_NAME - self.prov_id = PROV_ID + async def setup(self, conf): + ''' perform async setup ''' self._cur_user = None if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: raise Exception("Username and password must not be empty") self._username = conf[CONF_USERNAME] self._password = conf[CONF_PASSWORD] self.__auth_token = {} - - async def setup(self): - ''' perform async setup ''' self.throttler = Throttler(rate_limit=4, period=1) self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) @@ -216,8 +209,8 @@ class SpotifyProvider(MusicProvider): item = await self.track(prov_item_id) await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) - LOGGER.debug("deleted item %s from %s - %s" % - (prov_item_id, self.prov_id, result)) + LOGGER.debug("deleted item %s from %s - %s", + prov_item_id, self.prov_id, result) async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): ''' add track(s) to playlist ''' @@ -254,7 +247,7 @@ class SpotifyProvider(MusicProvider): "content_type": "ogg", "sample_rate": 44100, "bit_depth": 16, - "provider": PROV_ID, + "provider": self.prov_id, "item_id": track.item_id } diff --git a/music_assistant/musicproviders/tunein.py b/music_assistant/musicproviders/tunein.py index d7988a8e..4e59a8e3 100644 --- a/music_assistant/musicproviders/tunein.py +++ b/music_assistant/musicproviders/tunein.py @@ -1,21 +1,15 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -import asyncio -import os from typing import List -import sys -import time from asyncio_throttle import Throttler -import json import aiohttp -from ..utils import run_periodic, LOGGER +from ..utils import LOGGER from ..models import MusicProvider, MediaType, TrackQuality, Radio from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD -PROV_ID = 'tunein' PROV_NAME = 'TuneIn Radio' PROV_CLASS = 'TuneInProvider' @@ -26,20 +20,18 @@ CONFIG_ENTRIES = [ ] class TuneInProvider(MusicProvider): - - def __init__(self, mass, conf): - ''' Support for streaming radio provider TuneIn ''' - super().__init__(mass) - self.name = PROV_NAME - self.prov_id = PROV_ID + _username = None + _password = None + http_session = None + throttler = None + + async def setup(self, conf): + ''' perform async setup ''' if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: raise Exception("Username and password must not be empty") self._username = conf[CONF_USERNAME] self._password = conf[CONF_PASSWORD] - - async def setup(self): - ''' perform async setup ''' self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.throttler = Throttler(rate_limit=1, period=1) diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index 48f0e4a8..9b61a98b 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -28,16 +28,19 @@ class PlayerManager(): def __init__(self, mass): self.mass = mass self._players = {} - # dynamically load musicprovider modules - self.providers = load_provider_modules(mass, CONF_KEY_PLAYERPROVIDERS) + self.providers = {} async def setup(self): ''' async initialize of module ''' - # start providers - for prov in self.providers.values(): - await prov.setup() + # load providers + await self.load_modules() # register state listener await self.mass.add_event_listener(self.handle_mass_events, EVENT_HASS_ENTITY_CHANGED) + + async def load_modules(self): + """Dynamically (un)load musicprovider modules.""" + await load_provider_modules(self.mass, + self.providers, CONF_KEY_PLAYERPROVIDERS) @property def players(self): diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py index e6cf2fac..a9c5eda7 100644 --- a/music_assistant/playerproviders/chromecast.py +++ b/music_assistant/playerproviders/chromecast.py @@ -36,7 +36,7 @@ class ChromecastPlayer(Player): def __init__(self, *args, **kwargs): self.__cc_report_progress_task = None super().__init__(*args, **kwargs) - + def __del__(self): if self.__cc_report_progress_task: self.__cc_report_progress_task.cancel() @@ -217,17 +217,13 @@ class ChromecastPlayer(Player): class ChromecastProvider(PlayerProvider): ''' support for ChromeCast Audio ''' + _discovery_running = False - def __init__(self, mass, conf): - super().__init__(mass, conf) - self.prov_id = PROV_ID - self.name = PROV_NAME + async def setup(self, conf): + ''' perform async setup ''' self._discovery_running = False logging.getLogger('pychromecast').setLevel(logging.WARNING) self.player_config_entries = PLAYER_CONFIG_ENTRIES - - async def setup(self): - ''' perform async setup ''' self.mass.event_loop.create_task( self.__periodic_chromecast_discovery()) diff --git a/music_assistant/playerproviders/sonos.py b/music_assistant/playerproviders/sonos.py index b77f32be..2e9b6d35 100644 --- a/music_assistant/playerproviders/sonos.py +++ b/music_assistant/playerproviders/sonos.py @@ -23,8 +23,6 @@ CONFIG_ENTRIES = [ (CONF_ENABLED, True, CONF_ENABLED), ] -PLAYER_CONFIG_ENTRIES = [] - class SonosPlayer(Player): ''' Sonos player object ''' @@ -163,15 +161,9 @@ class SonosPlayer(Player): 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): + _discovery_running = False + + async def setup(self, conf): ''' perform async setup ''' self.mass.event_loop.create_task( self.__periodic_discovery()) diff --git a/music_assistant/playerproviders/squeezebox.py b/music_assistant/playerproviders/squeezebox.py index 0581dbf4..94b05691 100644 --- a/music_assistant/playerproviders/squeezebox.py +++ b/music_assistant/playerproviders/squeezebox.py @@ -24,21 +24,13 @@ CONFIG_ENTRIES = [ (CONF_ENABLED, True, CONF_ENABLED), ] -PLAYER_CONFIG_ENTRIES = [] - class PySqueezeProvider(PlayerProvider): ''' Python implementation of SlimProto server ''' - def __init__(self, mass, conf): - super().__init__(mass, conf) - self.prov_id = PROV_ID - self.name = PROV_NAME - self.player_config_entries = PLAYER_CONFIG_ENTRIES - ### Provider specific implementation ##### - async def setup(self): + async def setup(self, conf): ''' async initialize of module ''' # start slimproto server self.mass.event_loop.create_task( diff --git a/music_assistant/playerproviders/webplayer.py b/music_assistant/playerproviders/webplayer.py index cc8c4057..b0d1d31f 100644 --- a/music_assistant/playerproviders/webplayer.py +++ b/music_assistant/playerproviders/webplayer.py @@ -24,8 +24,6 @@ CONFIG_ENTRIES = [ (CONF_ENABLED, True, CONF_ENABLED), ] -PLAYER_CONFIG_ENTRIES = [] - EVENT_WEBPLAYER_CMD = 'webplayer command' EVENT_WEBPLAYER_STATE = 'webplayer state' EVENT_WEBPLAYER_REGISTER = 'webplayer register' @@ -38,15 +36,9 @@ class WebPlayerProvider(PlayerProvider): and our internal event bus ''' - def __init__(self, mass, conf): - super().__init__(mass, conf) - self.prov_id = PROV_ID - self.name = PROV_NAME - self.player_config_entries = PLAYER_CONFIG_ENTRIES - ### Provider specific implementation ##### - async def setup(self): + async def setup(self, conf): ''' 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) diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 43be7c08..7b420417 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -3,7 +3,6 @@ import asyncio import logging -from concurrent.futures import ThreadPoolExecutor import socket import importlib import os @@ -15,7 +14,7 @@ except ImportError: import json LOGGER = logging.getLogger('music_assistant') -from .constants import CONF_KEY_MUSICPROVIDERS, CONF_ENABLED +from .constants import CONF_KEY_MUSICPROVIDERS, CONF_KEY_PLAYERPROVIDERS, CONF_ENABLED IS_HASSIO = os.path.isfile('/data/options.json') @@ -40,13 +39,13 @@ def run_background_task(corofn, *args, executor=None): def run_async_background_task(executor, corofn, *args): ''' run async task in background ''' def run_task(corofn, *args): - LOGGER.debug('running %s in background task' % corofn.__name__) + LOGGER.debug('running %s in background task', corofn.__name__) new_loop = asyncio.new_event_loop() asyncio.set_event_loop(new_loop) coro = corofn(*args) res = new_loop.run_until_complete(coro) new_loop.close() - LOGGER.debug('completed %s in background task' % corofn.__name__) + LOGGER.debug('completed %s in background task', corofn.__name__) return res return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args) @@ -70,7 +69,7 @@ async def iter_items(items): yield items else: for item in items: - yield items + yield item def try_parse_float(possible_float): try: @@ -204,38 +203,54 @@ def try_load_json_file(jsonfile): LOGGER.debug("Could not load json from file %s - %s" % (jsonfile, str(exc))) return None -def load_provider_modules(mass, prov_type=CONF_KEY_MUSICPROVIDERS): +async def load_provider_modules(mass, provider_modules, prov_type=CONF_KEY_MUSICPROVIDERS): ''' dynamically load music/player providers ''' - provider_modules = {} base_dir = os.path.dirname(os.path.abspath(__file__)) modules_path = os.path.join(base_dir, prov_type ) + # load modules for item in os.listdir(modules_path): if (os.path.isfile(os.path.join(modules_path, item)) and not item.startswith("_") and item.endswith('.py') and not item.startswith('.')): module_name = item.replace(".py","") - prov_mod = load_provider_module(mass, module_name, prov_type) - if prov_mod: - provider_modules[prov_mod.prov_id] = prov_mod - return provider_modules + if module_name not in provider_modules: + prov_mod = await load_provider_module(mass, module_name, prov_type) + if prov_mod: + provider_modules[module_name] = prov_mod + # unload modules (if needed) + removed_modules = [] + for prov_id, prov in provider_modules.items(): + if not mass.config[prov_type][prov_id][CONF_ENABLED]: + removed_modules.append(prov_id) + if hasattr(prov, 'http_session'): + await prov.http_session.close() + if prov_type == CONF_KEY_PLAYERPROVIDERS: + for player in prov.players: + await mass.players.remove_player(player.player_id) + for prov_id in removed_modules: + provider_modules.pop(prov_id, None) + LOGGER.info('Unloaded %s module', prov_id) - -def load_provider_module(mass, module_name, prov_type): +async def load_provider_module(mass, module_name, prov_type): ''' dynamically load music/player provider ''' - LOGGER.debug("Loading provider module %s" % module_name) try: prov_mod = importlib.import_module(f".{module_name}", f"music_assistant.{prov_type}") prov_conf_entries = prov_mod.CONFIG_ENTRIES - prov_id = prov_mod.PROV_ID + prov_id = module_name + prov_name = prov_mod.PROV_NAME + prov_class = prov_mod.PROV_CLASS # get/create config for the module prov_config = mass.config.create_module_config( prov_id, prov_conf_entries, prov_type) if prov_config[CONF_ENABLED]: - prov_mod_cls = getattr(prov_mod, prov_mod.PROV_CLASS) - provider = prov_mod_cls(mass, prov_config) - LOGGER.info("Successfully initialized module %s" % provider.name) + prov_mod_cls = getattr(prov_mod, prov_class) + provider = prov_mod_cls(mass) + provider.prov_id = prov_id + provider.name = prov_name + await provider.setup(prov_config) + LOGGER.info("Successfully initialized module %s", provider.name) return provider else: return None except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) + LOGGER.exception("Error loading module %s: %s", module_name, exc) diff --git a/music_assistant/web.py b/music_assistant/web.py index ffe27f01..2a50babe 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -13,75 +13,81 @@ import concurrent import threading from .models.media_types import MediaItem, MediaType, media_type_from_string from .utils import run_periodic, LOGGER, IS_HASSIO, run_async_background_task, get_ip, json_serializer +from .constants import CONF_KEY_PLAYERSETTINGS, CONF_KEY_MUSICPROVIDERS, CONF_KEY_PLAYERPROVIDERS CONF_KEY = 'web' if IS_HASSIO: # on hassio we use ingress - CONFIG_ENTRIES = [] + CONFIG_ENTRIES = [('https_port', 8096, 'web_https_port'), + ('ssl_certificate', '', 'web_ssl_cert'), + ('ssl_key', '', 'web_ssl_key'), + ('cert_fqdn_host', '', 'cert_fqdn_host')] else: - CONFIG_ENTRIES = [ - ('http_port', 8095, 'web_http_port'), - ('https_port', 8096, 'web_https_port'), - ('ssl_certificate', '', 'web_ssl_cert'), - ('ssl_key', '', 'web_ssl_key'), - ('cert_fqdn_host', '', 'cert_fqdn_host') - ] + CONFIG_ENTRIES = [('http_port', 8095, 'web_http_port'), + ('https_port', 8096, 'web_https_port'), + ('ssl_certificate', '', 'web_ssl_cert'), + ('ssl_key', '', 'web_ssl_key'), + ('cert_fqdn_host', '', 'cert_fqdn_host')] + class ClassRouteTableDef(web.RouteTableDef): def __repr__(self) -> str: return "".format(len(self._items)) - def route(self, - method: str, - path: str, - **kwargs): + def route(self, method: str, path: str, **kwargs): def inner(handler): handler.route_info = (method, path, kwargs) return handler + return inner def add_class_routes(self, instance) -> None: def predicate(member) -> bool: return all((inspect.iscoroutinefunction(member), hasattr(member, "route_info"))) + for _, handler in inspect.getmembers(instance, predicate): method, path, kwargs = handler.route_info super().route(method, path, **kwargs)(handler) + + routes = ClassRouteTableDef() + class Web(): """ webserver and json/websocket api """ runner = None - + def __init__(self, mass): self.mass = mass # load/create/update config - config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES) + config = self.mass.config.create_module_config(CONF_KEY, + CONFIG_ENTRIES) self.local_ip = get_ip() self.config = config if IS_HASSIO: - # retrieve ingress port + # retrieve ingress http port import requests - response = requests.get( - "http://hassio/addons/self/info", - headers = {"X-HASSIO-KEY": os.environ["HASSIO_TOKEN"]}).json() + url = 'http://hassio/addons/self/info' + headers = { "X-HASSIO-KEY":os.environ["HASSIO_TOKEN"] } + response = requests.get(url, headers=headers).json() self.http_port = response["data"]["ingress_port"] - self.https_port = 0 - self._enable_ssl = False else: # use settings from config self.http_port = config['http_port'] - enable_ssl = config['ssl_certificate'] and config['ssl_key'] - if config['ssl_certificate'] and not os.path.isfile( - config['ssl_certificate']): - enable_ssl = False - LOGGER.warning("SSL certificate file not found: %s", config['ssl_certificate']) - if config['ssl_key'] and not os.path.isfile(config['ssl_key']): - enable_ssl = False - LOGGER.warning( "SSL certificate key file not found: %s", config['ssl_key']) - self.https_port = config['https_port'] - self._enable_ssl = enable_ssl + enable_ssl = config['ssl_certificate'] and config['ssl_key'] + if config['ssl_certificate'] and not os.path.isfile( + config['ssl_certificate']): + enable_ssl = False + LOGGER.warning("SSL certificate file not found: %s", + config['ssl_certificate']) + if config['ssl_key'] and not os.path.isfile(config['ssl_key']): + enable_ssl = False + LOGGER.warning("SSL certificate key file not found: %s", + config['ssl_key']) + self.https_port = config['https_port'] + self._enable_ssl = enable_ssl async def setup(self): """ perform async setup """ @@ -89,20 +95,27 @@ class Web(): app = web.Application() app.add_routes(routes) app.add_routes([ - web.get('/stream/{player_id}', self.mass.http_streamer.stream, allow_head=False), - web.get('/stream/{player_id}/{queue_item_id}', self.mass.http_streamer.stream, allow_head=False), + web.get('/stream/{player_id}', + self.mass.http_streamer.stream, + allow_head=False), + web.get('/stream/{player_id}/{queue_item_id}', + self.mass.http_streamer.stream, + allow_head=False), web.get('/', self.index), web.get('/jsonrpc.js', self.json_rpc), web.post('/jsonrpc.js', self.json_rpc), web.get('/ws', self.websocket_handler) ]) - webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web/') + webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'web/') app.router.add_static("/", webdir) - + # Add CORS support to all routes - cors = aiohttp_cors.setup( app, + cors = aiohttp_cors.setup( + app, defaults={ - "*": aiohttp_cors.ResourceOptions( + "*": + aiohttp_cors.ResourceOptions( allow_credentials=True, expose_headers="*", allow_headers="*", @@ -117,14 +130,19 @@ class Web(): LOGGER.info("Started HTTP webserver on port %s", self.http_port) if self._enable_ssl: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain(self.config['ssl_certificate'], self.config['ssl_key']) - https_site = web.TCPSite(self.runner, '0.0.0.0', self.config['https_port'], ssl_context=ssl_context) + ssl_context.load_cert_chain(self.config['ssl_certificate'], + self.config['ssl_key']) + https_site = web.TCPSite(self.runner, + '0.0.0.0', + self.config['https_port'], + ssl_context=ssl_context) await https_site.start() - LOGGER.info("Started HTTPS webserver on port %s", self.config['https_port']) + LOGGER.info("Started HTTPS webserver on port %s", + self.config['https_port']) async def index(self, request): - index_file = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'web/index.html') + index_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'web/index.html') return web.FileResponse(index_file) @routes.get('/api/library/artists') @@ -132,7 +150,8 @@ class Web(): """Get all library artists.""" orderby = request.query.get('orderby', 'name') provider_filter = request.rel_url.query.get('provider') - iterator = self.mass.music.library_artists(orderby=orderby, provider_filter=provider_filter) + iterator = self.mass.music.library_artists( + orderby=orderby, provider_filter=provider_filter) return await self.__stream_json(request, iterator) @routes.get('/api/library/albums') @@ -140,7 +159,8 @@ class Web(): """Get all library albums.""" orderby = request.query.get('orderby', 'name') provider_filter = request.rel_url.query.get('provider') - iterator = self.mass.music.library_albums(orderby=orderby, provider_filter=provider_filter) + iterator = self.mass.music.library_albums( + orderby=orderby, provider_filter=provider_filter) return await self.__stream_json(request, iterator) @routes.get('/api/library/tracks') @@ -148,7 +168,8 @@ class Web(): """Get all library tracks.""" orderby = request.query.get('orderby', 'name') provider_filter = request.rel_url.query.get('provider') - iterator = self.mass.music.library_tracks(orderby=orderby, provider_filter=provider_filter) + iterator = self.mass.music.library_tracks( + orderby=orderby, provider_filter=provider_filter) return await self.__stream_json(request, iterator) @routes.get('/api/library/radios') @@ -156,7 +177,8 @@ class Web(): """Get all library radios.""" orderby = request.query.get('orderby', 'name') provider_filter = request.rel_url.query.get('provider') - iterator = self.mass.music.library_radios(orderby=orderby, provider_filter=provider_filter) + iterator = self.mass.music.library_radios( + orderby=orderby, provider_filter=provider_filter) return await self.__stream_json(request, iterator) @routes.get('/api/library/playlists') @@ -164,7 +186,8 @@ class Web(): """Get all library playlists.""" orderby = request.query.get('orderby', 'name') provider_filter = request.rel_url.query.get('provider') - iterator = self.mass.music.library_playlists(orderby=orderby, provider_filter=provider_filter) + iterator = self.mass.music.library_playlists( + orderby=orderby, provider_filter=provider_filter) return await self.__stream_json(request, iterator) @routes.put('/api/library') @@ -193,7 +216,7 @@ class Web(): return web.Response(text='invalid item or provider', status=501) result = await self.mass.music.artist(item_id, provider, lazy=lazy) return web.json_response(result, dumps=json_serializer) - + @routes.get('/api/albums/{item_id}') async def album(self, request): """ get full album details""" @@ -244,13 +267,17 @@ class Web(): media_id = request.match_info.get('media_id') provider = request.rel_url.query.get('provider') if (media_id is None or provider is None): - return web.Response(text='invalid media_id or provider', status=501) + return web.Response(text='invalid media_id or provider', + status=501) size = int(request.rel_url.query.get('size', 0)) img_file = await self.mass.music.get_image_thumb( - media_id, media_type, provider, size) + media_id, media_type, provider, size) if not img_file or not os.path.isfile(img_file): return web.Response(status=404) - headers = {'Cache-Control': 'max-age=86400, public', 'Pragma': 'public'} + headers = { + 'Cache-Control': 'max-age=86400, public', + 'Pragma': 'public' + } return web.FileResponse(img_file, headers=headers) @routes.get('/api/artists/{item_id}/toptracks') @@ -330,7 +357,10 @@ class Web(): if not media_types_query or "radios" in media_types_query: media_types.append(MediaType.Radio) # get results from database - result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online) + result = await self.mass.music.search(searchquery, + media_types, + limit=limit, + online=online) return web.json_response(result, dumps=json_serializer) @routes.get('/api/players') @@ -357,8 +387,8 @@ class Web(): result = await player_cmd() else: return web.Response(text='invalid command', status=501) - return web.json_response(result, dumps=json_serializer) - + return web.json_response(result, dumps=json_serializer) + @routes.post('/api/players/{player_id}/play_media/{queue_opt}') async def player_play_media(self, request): """ issue player play_media command""" @@ -369,9 +399,10 @@ class Web(): queue_opt = request.match_info.get('queue_opt', 'play') body = await request.json() media_items = await self.__media_items_from_body(body) - result = await self.mass.players.play_media(player_id, media_items, queue_opt) + result = await self.mass.players.play_media(player_id, media_items, + queue_opt) return web.json_response(result, dumps=json_serializer) - + @routes.get('/api/players/{player_id}/queue/items/{queue_item}') async def player_queue_item(self, request): """ return item (by index or queue item id) from the player's queue """ @@ -384,17 +415,19 @@ class Web(): except ValueError: queue_item = await player.queue.by_item_id(item_id) return web.json_response(queue_item, dumps=json_serializer) - + @routes.get('/api/players/{player_id}/queue/items') async def player_queue_items(self, request): """ return the items in the player's queue """ player_id = request.match_info.get('player_id') player = await self.mass.players.get_player(player_id) + async def queue_tracks_iter(): for item in player.queue.items: yield item + return await self.__stream_json(request, queue_tracks_iter()) - + @routes.get('/api/players/{player_id}/queue') async def player_queue(self, request): """ return the player queue details """ @@ -445,19 +478,33 @@ class Web(): conf_key = request.match_info.get('key') conf_subkey = request.match_info.get('subkey') new_values = await request.json() - LOGGER.debug(f'save config called for {conf_key}/{conf_subkey} - new value: {new_values}') + LOGGER.debug( + f'save config called for {conf_key}/{conf_subkey} - new value: {new_values}' + ) cur_values = self.mass.config[conf_key][conf_subkey] - result = {"success": True, "restart_required": False, "settings_changed": False} + result = { + "success": True, + "restart_required": False, + "settings_changed": False + } if cur_values != new_values: # config changed result["settings_changed"] = True self.mass.config[conf_key][conf_subkey] = new_values - if conf_key == "player_settings": - # player settings don't require restart, force update of player + if conf_key == CONF_KEY_PLAYERSETTINGS: + # player settings: force update of player self.mass.event_loop.create_task( self.mass.players.trigger_update(conf_subkey)) + elif conf_key == CONF_KEY_MUSICPROVIDERS: + # (re)load music provider modules + self.mass.event_loop.create_task( + self.mass.music.load_modules()) + elif conf_key == CONF_KEY_PLAYERPROVIDERS: + # (re)load player provider modules + self.mass.event_loop.create_task( + self.mass.players.load_modules()) else: - # TODO: allow some settings without restart ? + # other settings need restart result["restart_required"] = True self.mass.config.save() return web.json_response(result) @@ -469,26 +516,29 @@ class Web(): try: ws = web.WebSocketResponse() await ws.prepare(request) + # register callback for internal events async def send_event(msg, msg_details): - ws_msg = {"message": msg, "message_details": msg_details } + ws_msg = {"message": msg, "message_details": msg_details} try: 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) # process incoming messages async for msg in ws: if msg.type == aiohttp.WSMsgType.ERROR: LOGGER.debug('ws connection closed with exception %s' % - ws.exception()) + ws.exception()) elif msg.type != aiohttp.WSMsgType.TEXT: LOGGER.warning(msg.data) else: data = msg.json() # echo the websocket message on event bus # can be picked up by other modules, e.g. the webplayer - await self.mass.signal_event(data['message'], data['message_details']) + await self.mass.signal_event(data['message'], + data['message_details']) except (Exception, AssertionError, asyncio.CancelledError) as exc: LOGGER.warning("Websocket disconnected - %s" % str(exc)) finally: @@ -549,18 +599,20 @@ class Web(): else: return web.Response(text='command not supported') return web.Response(text='success') - + async def __media_items_from_body(self, data): """Helper to turn posted body data into media items.""" if not isinstance(data, list): data = [data] media_items = [] for item in data: - media_item = await self.mass.music.item( - item['item_id'], item['media_type'], item['provider'], lazy=True) + media_item = await self.mass.music.item(item['item_id'], + item['media_type'], + item['provider'], + lazy=True) media_items.append(media_item) return media_items - + async def __stream_json(self, request, iterator): """ stream items from async iterator as json object """ resp = web.StreamResponse(status=200,