From: Marcel van der Veldt Date: Sat, 10 Oct 2020 23:39:06 +0000 (+0200) Subject: small fixes X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f2218de2b66730b0af49ba3c8b0d8ed9dd6226ea;p=music-assistant-server.git small fixes --- diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 0c0b5891..cfdab9dc 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -30,6 +30,14 @@ from zeroconf import NonUniqueNameException, ServiceInfo, Zeroconf LOGGER = logging.getLogger("mass") +def global_exception_handler(loop: asyncio.AbstractEventLoop, context: Dict) -> None: + """Global exception handler.""" + LOGGER.exception( + "Caught exception: %s", context.get("exception", context["message"]) + ) + loop.default_exception_handler(context) + + class MusicAssistant: """Main MusicAssistant object.""" @@ -63,7 +71,7 @@ class MusicAssistant: """Start running the music assistant server.""" # initialize loop self._loop = asyncio.get_event_loop() - self._loop.set_exception_handler(__handle_exception) + self._loop.set_exception_handler(global_exception_handler) self._loop.set_debug(self._debug) # create shared aiohttp ClientSession self._http_session = aiohttp.ClientSession( @@ -334,11 +342,3 @@ class MusicAssistant: LOGGER.exception("Error preloading module %s: %s", module_name, exc) else: LOGGER.debug("Successfully preloaded module %s", module_name) - - -def __handle_exception(loop: asyncio.AbstractEventLoop, context: Dict) -> None: - """Global exception handler.""" - LOGGER.exception( - "Caught exception: %s", context.get("exception", context["message"]) - ) - loop.default_exception_handler(context) diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py new file mode 100644 index 00000000..dacc85fd --- /dev/null +++ b/music_assistant/providers/builtin_player/__init__.py @@ -0,0 +1,260 @@ +"""Builtin player provider.""" +import logging +import time +from typing import List + +from music_assistant.helpers.typing import MusicAssistantType +from music_assistant.helpers.util import run_periodic +from music_assistant.models.config_entry import ConfigEntry +from music_assistant.models.player import ( + DeviceInfo, + PlaybackState, + Player, + PlayerFeature, +) +from music_assistant.models.provider import PlayerProvider + +PROV_ID = "builtin_player" +PROV_NAME = "Music Assistant" +LOGGER = logging.getLogger(PROV_ID) + +CONFIG_ENTRIES = [] +PLAYER_CONFIG_ENTRIES = [] +PLAYER_FEATURES = [] + +EVENT_WEBPLAYER_CMD = "webplayer command" +EVENT_WEBPLAYER_STATE = "webplayer state" +EVENT_WEBPLAYER_REGISTER = "webplayer register" + + +async def async_setup(mass): + """Perform async setup of this Plugin/Provider.""" + prov = MassPlayerProvider() + await mass.async_register_provider(prov) + + +class MassPlayerProvider(PlayerProvider): + """ + Built-in PlayerProvider. + + Provides virtual players in the frontend using websockets. + """ + + @property + def id(self) -> str: + """Return provider ID for this provider.""" + return PROV_ID + + @property + def name(self) -> str: + """Return provider Name for this provider.""" + return PROV_NAME + + @property + def config_entries(self) -> List[ConfigEntry]: + """Return Config Entries for this provider.""" + return [] + + async def async_on_start(self) -> bool: + """Handle initialization of the provider based on config.""" + # listen for websockets events to dynamically create players + self.mass.add_event_listener( + self.async_handle_mass_event, + [EVENT_WEBPLAYER_STATE, EVENT_WEBPLAYER_REGISTER], + ) + self.mass.add_job(self.async_check_players()) + return True + + async def async_on_stop(self): + """Handle correct close/cleanup of the provider on exit.""" + for player in self.players: + await player.async_cmd_stop() + + async def async_handle_mass_event(self, msg, msg_details): + """Handle received event for the webplayer component.""" + player = self.mass.players.get_player(msg_details["player_id"]) + if not player: + # register new player + player = WebsocketsPlayer( + self.mass, msg_details["player_id"], msg_details["name"] + ) + await self.mass.players.async_add_player(player) + await player.handle_player_state(msg_details) + + @run_periodic(30) + async def async_check_players(self) -> None: + """Invalidate players that did not send a heartbeat message in a while.""" + cur_time = time.time() + offline_players = [] + for player in self.players: + if not isinstance(player, WebsocketsPlayer): + continue + if cur_time - player.last_message > 30: + offline_players.append(player.player_id) + for player_id in offline_players: + await self.mass.players.async_remove_player(player_id) + + +class WebsocketsPlayer(Player): + """ + Implementation of a player using pure HTML/javascript. + + Used in the front-end. + Communication is handled through the websocket connection + and our internal event bus. + """ + + def __init__(self, mass: MusicAssistantType, player_id: str, player_name: str): + """Initialize the webplayer.""" + self._player_id = player_id + self._player_name = player_name + self._powered = True + self._elapsed_time = 0 + self._state = PlaybackState.Stopped + self._current_uri = "" + self._volume_level = 100 + self._muted = False + self._device_info = DeviceInfo() + self.last_message = time.time() + + async def handle_player_state(self, data: dict): + """Handle state event from player.""" + if "volume_level" in data: + self._volume_level = data["volume_level"] + if "muted" in data: + self._muted = data["muted"] + if "state" in data: + self._state = PlaybackState(data["state"]) + if "elapsed_time" in data: + self._elapsed_time = data["elapsed_time"] + if "current_uri" in data: + self._current_uri = data["current_uri"] + if "name" in data: + self._player_name = data["name"] + if "device_info" in data: + for key, value in data["device_info"].items(): + setattr(self._device_info, key, value) + self.last_message = time.time() + self.update_state() + + @property + def player_id(self) -> str: + """Return player id of this player.""" + return self._player_id + + @property + def provider_id(self) -> str: + """Return provider id of this player.""" + return PROV_ID + + @property + def name(self) -> str: + """Return name of the player.""" + return self._player_name + + @property + def powered(self) -> bool: + """Return current power state of player.""" + return self._powered + + @property + def elapsed_time(self) -> int: + """Return elapsed time of current playing media in seconds.""" + return self._elapsed_time + + @property + def state(self) -> PlaybackState: + """Return current PlaybackState of player.""" + return self._state + + @property + def current_uri(self) -> str: + """Return currently loaded uri of player (if any).""" + return self._current_uri + + @property + def volume_level(self) -> int: + """Return current volume level of player (scale 0..100).""" + return self._volume_level + + @property + def muted(self) -> bool: + """Return current mute state of player.""" + return self._muted + + @property + def device_info(self) -> DeviceInfo: + """Return the device info for this player.""" + return self._device_info + + @property + def should_poll(self) -> bool: + """Return True if this player should be polled for state updates.""" + return False + + @property + def features(self) -> List[PlayerFeature]: + """Return list of features this player supports.""" + return PLAYER_FEATURES + + @property + def config_entries(self) -> List[ConfigEntry]: + """Return player specific config entries (if any).""" + return PLAYER_CONFIG_ENTRIES + + async def async_cmd_play_uri(self, uri: str) -> None: + """ + Play the specified uri/url on the player. + + :param uri: uri/url to send to the player. + """ + data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri} + self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) + + async def async_cmd_stop(self) -> None: + """Send STOP command to player.""" + data = {"player_id": self.player_id, "cmd": "stop"} + self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) + + async def async_cmd_play(self) -> None: + """Send PLAY command to player.""" + data = {"player_id": self.player_id, "cmd": "play"} + self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) + + async def async_cmd_pause(self) -> None: + """Send PAUSE command to player.""" + data = {"player_id": self.player_id, "cmd": "pause"} + self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) + + async def async_cmd_power_on(self) -> None: + """Send POWER ON command to player.""" + self._powered = True + self.update_state() + + async def async_cmd_power_off(self) -> None: + """Send POWER OFF command to player.""" + await self.async_cmd_stop() + self._powered = False + self.update_state() + + async def async_cmd_volume_set(self, volume_level: int) -> None: + """ + Send volume level command to player. + + :param volume_level: volume level to set (0..100). + """ + data = { + "player_id": self.player_id, + "cmd": "volume_set", + "volume_level": volume_level, + } + self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) + + async def async_cmd_volume_mute(self, is_muted: bool = False) -> None: + """ + Send volume MUTE command to given player. + + :param is_muted: bool with new mute state. + """ + data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted} + self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) diff --git a/music_assistant/providers/builtin_player/icon.png b/music_assistant/providers/builtin_player/icon.png new file mode 100644 index 00000000..092121e1 Binary files /dev/null and b/music_assistant/providers/builtin_player/icon.png differ diff --git a/music_assistant/providers/mass/__init__.py b/music_assistant/providers/mass/__init__.py deleted file mode 100644 index 383bcc15..00000000 --- a/music_assistant/providers/mass/__init__.py +++ /dev/null @@ -1,444 +0,0 @@ -"""Builtin player provider.""" -import asyncio -import logging -import signal -import subprocess -import time -from typing import List - -from music_assistant.helpers.typing import MusicAssistantType -from music_assistant.helpers.util import get_hostname, run_periodic -from music_assistant.models.config_entry import ConfigEntry -from music_assistant.models.player import ( - DeviceInfo, - PlaybackState, - Player, - PlayerFeature, -) -from music_assistant.models.provider import PlayerProvider - -PROV_ID = "mass" -PROV_NAME = "Music Assistant" -LOGGER = logging.getLogger("mass_provider") - -CONFIG_ENTRIES = [] -PLAYER_CONFIG_ENTRIES = [] -PLAYER_FEATURES = [] - -EVENT_WEBPLAYER_CMD = "webplayer command" -EVENT_WEBPLAYER_STATE = "webplayer state" -EVENT_WEBPLAYER_REGISTER = "webplayer register" - - -async def async_setup(mass): - """Perform async setup of this Plugin/Provider.""" - prov = MassPlayerProvider() - await mass.async_register_provider(prov) - - -class MassPlayerProvider(PlayerProvider): - """ - Built-in PlayerProvider. - - Provides a single headless local player on the server using SoX. - Provides virtual players in the frontend using websockets. - """ - - @property - def id(self) -> str: - """Return provider ID for this provider.""" - return PROV_ID - - @property - def name(self) -> str: - """Return provider Name for this provider.""" - return PROV_NAME - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return Config Entries for this provider.""" - return [] - - async def async_on_start(self) -> bool: - """Handle initialization of the provider based on config.""" - # add local sox player on the server - player = BuiltinLocalPlayer("server_player", f"Server: {get_hostname()}") - self.mass.add_job(self.mass.players.async_add_player(player)) - # listen for websockets events to dynamically create players - self.mass.add_event_listener( - self.async_handle_mass_event, - [EVENT_WEBPLAYER_STATE, EVENT_WEBPLAYER_REGISTER], - ) - self.mass.add_job(self.async_check_players()) - return True - - async def async_on_stop(self): - """Handle correct close/cleanup of the provider on exit.""" - for player in self.players: - await player.async_cmd_stop() - - async def async_handle_mass_event(self, msg, msg_details): - """Handle received event for the webplayer component.""" - player = self.mass.players.get_player(msg_details["player_id"]) - if not player: - # register new player - player = WebsocketsPlayer( - self.mass, msg_details["player_id"], msg_details["name"] - ) - await self.mass.players.async_add_player(player) - await player.handle_player_state(msg_details) - - @run_periodic(30) - async def async_check_players(self) -> None: - """Invalidate players that did not send a heartbeat message in a while.""" - cur_time = time.time() - offline_players = [] - for player in self.players: - if not isinstance(player, WebsocketsPlayer): - continue - if cur_time - player.last_message > 30: - offline_players.append(player.player_id) - for player_id in offline_players: - await self.mass.players.async_remove_player(player_id) - - async def __async_handle_player_state(self, data): - """Handle state event from player.""" - player_id = data["player_id"] - player = self.mass.players.get_player(player_id) - if "volume_level" in data: - player.volume_level = data["volume_level"] - if "muted" in data: - player.muted = data["muted"] - if "state" in data: - player.state = PlaybackState(data["state"]) - if "cur_time" in data: - player.elapsed_time = data["elapsed_time"] - if "current_uri" in data: - player.current_uri = data["current_uri"] - if "powered" in data: - player.powered = data["powered"] - if "name" in data: - player.name = data["name"] - player.last_message = time.time() - player.update_state() - - -class BuiltinLocalPlayer(Player): - """Representation of a local player on the server using SoX.""" - - def __init__(self, player_id: str, name: str) -> None: - """Initialize the built-in player.""" - self._player_id = player_id - self._name = name - self._powered = False - self._elapsed_time = 0 - self._state = PlaybackState.Stopped - self._current_uri = "" - self._volume_level = 100 - self._muted = False - self._sox = None - self._progress_task = None - - @property - def player_id(self) -> str: - """Return player id of this player.""" - return self._player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return PROV_ID - - @property - def name(self) -> str: - """Return name of the player.""" - return self._name - - @property - def powered(self) -> bool: - """Return current power state of player.""" - return self._powered - - @property - def elapsed_time(self) -> float: - """Return elapsed_time of current playing uri in seconds.""" - return self._elapsed_time - - @property - def state(self) -> PlaybackState: - """Return current PlaybackState of player.""" - return self._state - - @property - def available(self) -> bool: - """Return current availablity of player.""" - return True - - @property - def current_uri(self) -> str: - """Return currently loaded uri of player (if any).""" - return self._current_uri - - @property - def volume_level(self) -> int: - """Return current volume level of player (scale 0..100).""" - return self._volume_level - - @property - def muted(self) -> bool: - """Return current mute state of player.""" - return self._muted - - @property - def is_group_player(self) -> bool: - """Return True if this player is a group player.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this player.""" - return DeviceInfo( - model="Demo", address="http://demo:12345", manufacturer=PROV_NAME - ) - - # SERVICE CALLS / PLAYER COMMANDS - - async def async_cmd_play_uri(self, uri: str): - """Play the specified uri/url on the player.""" - if self._sox: - await self.async_cmd_stop() - self._current_uri = uri - self._sox = subprocess.Popen(["play", "-t", "flac", "-q", uri]) - self._state = PlaybackState.Playing - self._powered = True - self.update_state() - - async def report_progress(): - """Report fake progress while sox is playing.""" - LOGGER.info("Playback started on player %s", self.name) - self._elapsed_time = 0 - while self._sox and not self._sox.poll(): - await asyncio.sleep(1) - self._elapsed_time += 1 - self.update_state() - LOGGER.info("Playback stopped on player %s", self.name) - self._elapsed_time = 0 - self._state = PlaybackState.Stopped - self.update_state() - - if self._progress_task: - self._progress_task.cancel() - self._progress_task = self.mass.add_job(report_progress) - - async def async_cmd_stop(self) -> None: - """Send STOP command to player.""" - if self._sox: - self._sox.terminate() - self._sox = None - self._state = PlaybackState.Stopped - self.update_state() - - async def async_cmd_play(self) -> None: - """Send PLAY command to player.""" - if self._sox: - self._sox.send_signal(signal.SIGCONT) - self._state = PlaybackState.Playing - self.update_state() - - async def async_cmd_pause(self): - """Send PAUSE command to given player.""" - if self._sox: - self._sox.send_signal(signal.SIGSTOP) - self._state = PlaybackState.Paused - self.update_state() - - async def async_cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - self._powered = True - self.update_state() - - async def async_cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - await self.async_cmd_stop() - self._powered = False - self.update_state() - - async def async_cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to given player. - - :param volume_level: volume level to set (0..100). - """ - self._volume_level = volume_level - self.update_state() - - async def async_cmd_volume_mute(self, is_muted=False): - """ - Send volume MUTE command to given player. - - :param is_muted: bool with new mute state. - """ - self._muted = is_muted - self.update_state() - - -class WebsocketsPlayer(Player): - """ - Implementation of a player using pure HTML/javascript. - - Used in the front-end. - Communication is handled through the websocket connection - and our internal event bus. - """ - - def __init__(self, mass: MusicAssistantType, player_id: str, player_name: str): - """Initialize the webplayer.""" - self._player_id = player_id - self._player_name = player_name - self._powered = True - self._elapsed_time = 0 - self._state = PlaybackState.Stopped - self._current_uri = "" - self._volume_level = 100 - self._muted = False - self.last_message = time.time() - - async def handle_player_state(self, data: dict): - """Handle state event from player.""" - if "volume_level" in data: - self._volume_level = data["volume_level"] - if "muted" in data: - self._muted = data["muted"] - if "state" in data: - self._state = PlaybackState(data["state"]) - if "elapsed_time" in data: - self._elapsed_time = data["elapsed_time"] - if "current_uri" in data: - self._current_uri = data["current_uri"] - if "powered" in data: - self._powered = data["powered"] - if "name" in data: - self._player_name = data["name"] - self.last_message = time.time() - self.update_state() - - @property - def player_id(self) -> str: - """Return player id of this player.""" - return self._player_id - - @property - def provider_id(self) -> str: - """Return provider id of this player.""" - return PROV_ID - - @property - def name(self) -> str: - """Return name of the player.""" - return self._player_name - - @property - def powered(self) -> bool: - """Return current power state of player.""" - return self._powered - - @property - def elapsed_time(self) -> int: - """Return elapsed time of current playing media in seconds.""" - return self._elapsed_time - - @property - def state(self) -> PlaybackState: - """Return current PlaybackState of player.""" - return self._state - - @property - def current_uri(self) -> str: - """Return currently loaded uri of player (if any).""" - return self._current_uri - - @property - def volume_level(self) -> int: - """Return current volume level of player (scale 0..100).""" - return self._volume_level - - @property - def muted(self) -> bool: - """Return current mute state of player.""" - return self._muted - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this player.""" - return DeviceInfo() - - @property - def should_poll(self) -> bool: - """Return True if this player should be polled for state updates.""" - return False - - @property - def features(self) -> List[PlayerFeature]: - """Return list of features this player supports.""" - return PLAYER_FEATURES - - @property - def config_entries(self) -> List[ConfigEntry]: - """Return player specific config entries (if any).""" - return PLAYER_CONFIG_ENTRIES - - async def async_cmd_play_uri(self, uri: str) -> None: - """ - Play the specified uri/url on the player. - - :param uri: uri/url to send to the player. - """ - data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_stop(self) -> None: - """Send STOP command to player.""" - data = {"player_id": self.player_id, "cmd": "stop"} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_play(self) -> None: - """Send PLAY command to player.""" - data = {"player_id": self.player_id, "cmd": "play"} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_pause(self) -> None: - """Send PAUSE command to player.""" - data = {"player_id": self.player_id, "cmd": "pause"} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_power_on(self) -> None: - """Send POWER ON command to player.""" - data = {"player_id": self.player_id, "cmd": "power_on"} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_power_off(self) -> None: - """Send POWER OFF command to player.""" - data = {"player_id": self.player_id, "cmd": "power_off"} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_volume_set(self, volume_level: int) -> None: - """ - Send volume level command to player. - - :param volume_level: volume level to set (0..100). - """ - data = { - "player_id": self.player_id, - "cmd": "volume_set", - "volume_level": volume_level, - } - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - - async def async_cmd_volume_mute(self, is_muted: bool = False) -> None: - """ - Send volume MUTE command to given player. - - :param is_muted: bool with new mute state. - """ - data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted} - self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) diff --git a/music_assistant/providers/mass/icon.png b/music_assistant/providers/mass/icon.png deleted file mode 100644 index 092121e1..00000000 Binary files a/music_assistant/providers/mass/icon.png and /dev/null differ diff --git a/music_assistant/web/endpoints/websocket.py b/music_assistant/web/endpoints/websocket.py index ead069d9..9b40d64f 100644 --- a/music_assistant/web/endpoints/websocket.py +++ b/music_assistant/web/endpoints/websocket.py @@ -1,7 +1,6 @@ """Websocket API endpoint.""" import logging -from asyncio import CancelledError import jwt import orjson @@ -29,7 +28,14 @@ async def async_websocket_handler(request: Request): if hasattr(msg_details, "to_dict"): msg_details = msg_details.to_dict() ws_msg = {"message": msg, "message_details": msg_details} - await ws_response.send_str(json_serializer(ws_msg).decode()) + try: + await ws_response.send_str(json_serializer(ws_msg).decode()) + # pylint: disable=broad-except + except Exception as exc: + LOGGER.debug( + "Error while trying to send message to websocket (probably disconnected): %s", + str(exc), + ) # process incoming messages async for msg in ws_response: @@ -87,9 +93,8 @@ async def async_websocket_handler(request: Request): else: # simply echo the message on the eventbus request.app["mass"].signal_event(msg, msg_details) - except (AssertionError, CancelledError): - LOGGER.debug("Websocket disconnected") finally: + LOGGER.debug("Websocket disconnected") for remove_callback in remove_callbacks: remove_callback() return ws_response