# 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