Source code for musicdb.lib.ws.websocket

# MusicDB,  a music manager with web-bases UI that focus on music.
# Copyright (C) 2017 - 2021  Ralf Stemmer <ralf.stemmer@gmx.net>
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""
This module provides low level classes for the WebSocket communication to the WebUI.

Most interesting are the following methods:

    * :meth:`~musicdb.lib.ws.websocket.WebSocket.SendPacket`
    * :meth:`~musicdb.lib.ws.websocket.WebSocket.BroadcastPacket`
    * :meth:`~musicdb.lib.ws.websocket.WebSocket.onMessage`
"""

from autobahn.asyncio.websocket import WebSocketServerProtocol, WebSocketServerFactory
import json
import time
import traceback
import logging


[docs]class MusicDBWebSocketFactory(WebSocketServerFactory): """ Derived from ``WebSocketServerFactory``. Implements some basic configuration and a broadcasting infrastructure to send packets to all connected clients. """ def __init__(self): WebSocketServerFactory.__init__(self) logging.debug("Using WebSocket module " + str(self.server)) from musicdb.mdbapi.server import cfg self.openHandshakeTimeout = cfg.websocket.opentimeout self.closeHandshakeTimeout = cfg.websocket.closetimeout self.clients = [] # for broadcast
[docs] def AddToBroadcast(self, client): """ This method registers a new client. The client must be of the low level class :class:`musicdb.lib.ws.websocket.WebSocket` or a derived class. Args: client: WebSocket connection handler Returns: *Nothing* """ if client not in self.clients: self.clients.append(client)
[docs] def RemoveFromBroadcast(self, client): """ Removes a client connection handler from the broadcast-list. Args: client: WebSocket connection handler Returns: *Nothing* """ if client in self.clients: self.clients.remove(client)
[docs] def BroadcastPacket(self, packet): """ This method broadcasts a packet to all connected clients. The ``method`` value in the packet gets forced to ``"broadcast"``. Args: packet: A packet dictionary that shall be send to all clients Returns: *Nothing* """ packet["method"] = "broadcast" logging.debug("Sending Broadcast Message. \033[1;30m(fncname = %s, fncsig = %s)", packet["fncname"], packet["fncsig"]) for client in self.clients: try: client.SendPacket(packet) except Exception as e: logging.warning("Sending broadcast packet failed for one client with error: %s\033[1;30m (Ignoring that client)", str(e))
[docs] def CloseConnections(self): """ This method initiates a closing handshake to all connections with error code ``1000`` and reason ``"Server shutdown"`` Returns: *Nothing* """ for client in self.clients: try: client.sendClose(1000, "Server shutdown") except Exception as e: logging.warning("Closing connection to clientpacket for one client with error: %s\033[1;30m (Ignoring that client)", str(e))
[docs]class WebSocket(WebSocketServerProtocol): """ Derived from ``WebSocketServerProtocol``. This class provides the low level WebSocket interface for MusicDBs WebSocket Interface. It manages the packet handling and handles callback routines. """ def __init__(self): WebSocketServerProtocol.__init__(self) self.connected = False
[docs] def BeautifyValues(self, packet, affectkey, old, new): """ This method can be used to beautify values inside nested dictionaries. Use-cases are replacing Division Slashes by normal slashes or hyphens by n-dashes in song names. It recursively scans through nested dictionaries and lists (``packet``) to find a specific key (``affectkey``). The content behind that key, in case it is of type string, gets scanned for ``old`` substring. This substring then gets replaced by the substirng ``new`` For easy to read code, the method returns the reference to ``packet``. Keep in mind that the content of `packet` will be changed even if the return value gets not assigned. The core algorithm was copied from `sotapme @ stackoverflow <https://stackoverflow.com/questions/14882138/replace-value-in-json-file-for-key-which-can-be-nested-by-n-levels/14882688>`: Args: packet: A nested dictionary/list affectkey (str): A key to search for old (str): A sub string to search for, inside the value referenced by ``affectkey`` new (str): A sub string to replace ``old`` Returns: A reference to ``packet`` Raises: TypeError: If ``affectkey``, ``old`` or ``new`` are not of type string Example: Replace divison slash (U+2215) by slash and hyphen by n-dash .. code-block:: python packet = self.BeautifyValues(packet, "name", "∕", "/"); packet = self.BeautifyValues(packet, "name", " - ", " – "); """ if type(affectkey) is not str: raise TypeError("affectkey must be of type str") if type(old) is not str: raise TypeError("old must be of type str") if type(new) is not str: raise TypeError("new must be of type str") origpacketref = packet if type(packet) is list: for entry in packet: self.BeautifyValues(entry, affectkey, old, new) elif type(packet) is dict: for key in packet.keys(): if key == affectkey: if type(packet[key]) is str: packet[key] = packet[key].replace(old, new) elif type(packet[key]) is dict: self.BeautifyValues(packet[key], affectkey, old, new) elif type(packet[key]) is list: for entry in packet[key]: self.BeautifyValues(entry, affectkey, old, new) else: return origpacketref return origpacketref
[docs] def SendPacket(self, packet): """ This method sends a packet via to the connected client. The format of the packet is described in the :doc:`/basics/webapi` documentation. The abstract process of sending the packet is shown in the following code: .. code-block:: python #packet = self.BeautifyValues(packet, "name", "∕", "/"); rawdata = json.dumps(packet) # Python Dict to JSON string rawdata = rawdata.encode("utf-8") # Encode as UTF-8 self.sendMessage(rawdata, False) # isBinary = False There is a race condition allowing calling ``SendPacket`` before the connection process is complete. To prevent problems, this method returns ``False`` if the connection is not established yet. Further more the state of the connection gets checked. If the connection state is not *OPEN*, ``False`` gets returned, too. All values behind the ``name`` key of the packet get beautified. It is assumed that those values are used to display information to the user. Args: packet: A packet dictionary that will be send to the client Returns: ``True`` on success, otherwise ``False`` Raises: TypeError: If packet is not of type ``dict`` RuntimeError: If *Autobahns* ``WebSocketServerProtocol`` class did not set an internal state. Should never happen, but happed once :) Example: Response to an album request by a client. .. code-block:: python response = {} response["method"] = "response" response["fncname"] = "GetAlbums" response["fncsig"] = request["fncsig"] response["arguments"] = albums response["pass"] = request["pass"] self.SendPacket(response) """ if type(packet) != dict: raise TypeError("Expecting a dictionary") if self.connected == False: logging.warning("Socket not conneced! \033[1;30m(message will be discard) %s", str(self)) return False #packet = self.BeautifyValues(packet, "name", "∕", "/"); #packet = self.BeautifyValues(packet, "name", " - ", " – "); rawdata = json.dumps(packet) rawdata = rawdata.encode("utf-8") if not hasattr(self, "state"): # This can hatten in some strange situation where Autobahn seems to be in a half-connected state. # Usually this should never happen, but happend at least once. logging.error("Websocket connetion has no stater!") raise RuntimeError("Autobahn totaly fucked it up") if self.state != WebSocketServerProtocol.STATE_OPEN: logging.warning("State of websocket is not STATE_OPEN! \033[0;33mState is %d. \033[1;30m(message will be discard)", self.state) # Possible states: # https://github.com/crossbario/autobahn-python/blob/2ea1c735f7a116a8b2cd64b3d38a91aba9ca35f8/autobahn/websocket/protocol.py#L422 # STATE_CLOSED = 0 # STATE_CONNECTING = 1 # STATE_CLOSING = 2 # STATE_OPEN = 3 return False try: self.sendMessage(rawdata, False) except Exception as e: logging.warning("Unexpected error while trying to send a message: %s! \033[0;33m(message will be discard)", str(e)) return False return True
[docs] def BroadcastPacket(self, packet): """ This method works line :meth:`~musicdb.lib.ws.websocket.WebSocket.SendPacket` only that the packet gets send to all clients. It uses the broadcasting method from :meth:`musicdb.lib.ws.websocket.MusicDBWebSocketFactory.BroadcastPacket` This method should be used with care because it can cause high traffic. Args: packet: A packet dictionary that will be send to all clients Returns: *Nothing* """ self.factory.BroadcastPacket(packet) return
[docs] def onConnect(self, request): """ Just prints the IP address of the connecting client. See for details. `ConnectionRequest in the Autobahn documentation <https://autobahn.readthedocs.io/en/latest/reference/autobahn.websocket.html?highlight=ConnectionRequest#autobahn.websocket.types.ConnectionRequest>`_ for details. Returns: *Nothing* """ logging.debug("Client connecting fron: %s"%(str(request.peer))) return
[docs] def onOpen(self): """ This method gets called by ``WebSocketServerProtocol`` implementation. It registers this connection to the broadcasting infrastructure of :class:`musicdb.lib.ws.websocket.MusicDBWebSocketFactory`. This method calls an ``onWSConnect()`` Method that must be implemented by the programmer who uses this class. """ logging.info("Websocket connection established.") self.connected = True self.factory.AddToBroadcast(self) self.onWSConnect() return
[docs] def onClose(self, wasClean, code, reason): """ This method gets called by ``WebSocketServerProtocol`` implementation. It removes this connection to the broadcasting infrastructure of :class:`musicdb.lib.ws.websocket.MusicDBWebSocketFactory`. This method calls an ``onWSDisconnect(wasClean:bool, closecode:int, closereason:str)`` Method that must be implemented by the programmer who uses this class. The ``onWSDisconnect`` method gets only called when there was a successful connection before! """ if self.connected: self.connected = False self.factory.RemoveFromBroadcast(self) self.onWSDisconnect(wasClean, code, reason) if code == WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL: logging.info("Websocket connection closed with \"Normal\" code.") elif code == WebSocketServerProtocol.CLOSE_STATUS_CODE_GOING_AWAY: logging.info("Websocket connection closed with \"Going Away\" code. \033[1;30m(Client just went away)") elif code == None and wasClean == True: logging.info("Websocket connection closed without an exit-code. \033[1;30m(Exitcode == None ; wasClean-Flag == True)") else: logging.warning("Websocket connection closed abnormaly! - \033[0;33m%s", self.wasNotCleanReason) return
[docs] def onOpenHandshakeTimeout(self): logging.warning("Open-Handshake Timeout! \033[0;33m(Connection will be closed)") WebSocketServerProtocol.onOpenHandshakeTimeout(self) return
[docs] def onCloseHandshakeTimeout(self): logging.warning("Close-Handshake Timeout!") WebSocketServerProtocol.onCloseHandshakeTimeout(self) return
[docs] def onMessage(self, payload, isBinary): """ This method gets called by ``WebSocketServerProtocol`` implementation. It handles the decoding of a received packet and provides the packet format as described in the :doc:`/basics/webapi` documentation. The decoding process can be abstracted as listed in the following code: .. code-block:: python # Check if payload is text if isBinary == True: return None # Create packet rawdata = payload.decode("utf-8") packet = json.loads(rawdata) # Provide packet to high level interface self.onCall(packet) Args: payload: The payload of a WebSocket message received from a client isBinary: ``True`` if binary data got received, False`` when text. This implementation allows only text. Return: ``None`` """ # I do not expect binary data if isBinary == True: logging.warning("Got a binary encoded message. \033[0;33m(Message will be ignored)") return None try: rawdata = payload.decode("utf-8") except: # hm… better do nothing :D logging.warning("Got a none utf-8 encoded message. \033[0;33m(Message will be ignored)") return None packet = json.loads(rawdata) # Handle packet try: self.onCall(packet) except Exception as e: logging.error("Unhandled exception in packet handler!") logging.exception("Trace: %s", e) return None
# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4