From 78118219f67abed34a3033d5ae2846a2434745b2 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 5 Aug 2022 13:16:11 +0200 Subject: [PATCH 01/24] refact(client): rewrite of client connection --- src/aura_engine/client/client.py | 273 ------------------------- src/aura_engine/client/playerclient.py | 4 +- src/aura_engine/core/client.py | 205 +++++++++++++++++++ src/aura_engine/mixer.py | 12 +- 4 files changed, 215 insertions(+), 279 deletions(-) delete mode 100644 src/aura_engine/client/client.py create mode 100644 src/aura_engine/core/client.py diff --git a/src/aura_engine/client/client.py b/src/aura_engine/client/client.py deleted file mode 100644 index 0f09c82..0000000 --- a/src/aura_engine/client/client.py +++ /dev/null @@ -1,273 +0,0 @@ -# -# Aura Engine (https://gitlab.servus.at/aura/engine) -# -# Copyright (C) 2017-2020 - The Aura Engine Team. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -import configparser -import logging -import socket -import urllib.parse -from multiprocessing import Lock - -from aura_engine.base.exceptions import LQConnectionError -from aura_engine.base.utils import TerminalColors - - -class LiquidSoapClient: - """ - LiquidSoapClient Class - - Connects to a LiquidSoap instance over a socket and sends commands to it - - #TODO Refactor class: https://gitlab.servus.at/aura/engine/-/issues/65 - """ - - mutex = None - logger = None - debug = False - socket_path = "" - disable_logging = True - - def __init__(self, config, socket_filename): - """ - Constructor - @type socket_path: string - @param socket_path: Der Pfad zum Socket des Liquidsoap-Scripts - """ - self.logger = logging.getLogger("AuraEngine") - socket_path = config.get("socket_dir") + "/" + socket_filename - self.socket_path = config.to_abs_path(socket_path) - - self.logger.debug("LiquidSoapClient using socketpath: " + self.socket_path) - - # init - self.mutex = Lock() - self.connected = False - self.can_connect = True - self.message = "" - self.socket = None - self.metareader = configparser.ConfigParser() - - def connect(self): - """ - Verbindung herstellen - """ - try: - self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.socket.connect(self.socket_path) - except socket.error as e: - msg = "Cannot connect to socketpath " + self.socket_path + ". Reason: " + str(e) - self.logger.critical(TerminalColors.RED.value + msg + TerminalColors.ENDC.value) - self.can_connect = False - self.connected = False - # raise e - else: - self.can_connect = True - self.connected = True - return True - - # AttributeError('characters_written') - - def is_connected(self): - return self.connected - - def write(self, data): - """ - Auf den Socket schreiben - @type data: string - @param data: Der String der gesendet wird - """ - if self.connected: - self.socket.sendall(data.decode("UTF-8")) - - def read_all(self, timeout=2): - """ - Vom Socket lesen, bis dieser "END" sendet - @type timeout: int - @param timeout: Ein optionales Timeout - @rtype: string - @return: Die Antwort des Liquidsoap-Servers - """ - # make socket non blocking - # self.client.setblocking(0) - - data = "" - - try: - # set timeout - self.socket.settimeout(timeout) - - # acquire the lock - self.mutex.acquire() - - while True: - data += self.socket.recv(1).decode("utf-8") - - # receive as long as we are not at the END or recv a Bye! from liquidsoap - if data.find("END\r\n") != -1 or data.find("Bye!\r\n") != -1: - data.replace("END\r\n", "") - break - - # release the lock - self.mutex.release() - - except Exception as e: - self.logger.error(TerminalColors.RED.value + str(e) + TerminalColors.ENDC.value) - self.mutex.release() - - return data - - def read(self): - """ - read from socket and store return value in self.message - @rtype: string - @return: The answer of liquidsoap server - """ - if self.connected: - ret = self.read_all().splitlines() - - try: - last = ret.pop() # pop out end - - if len(ret) > 1: - self.message = str.join(" - ", ret) - elif len(ret) == 1: - self.message = ret[0] - - if last == "Bye!": - self.message = last - - except Exception as e: - self.logger.error(str(e)) - - return self.message - - def close(self): - """ - Quit senden und Verbindung schließen - """ - if self.connected: - message = "quit\r" - self.socket.sendall(message.decode("UTF-8")) - self.socket.close() - self.connected = False - - def command(self, namespace, command, param=""): - """ - Kommando an Liquidosap senden - @type command: string - @param command: Kommando - @type namespace: string - @param namespace: Namespace/Kanal der angesprochen wird - @type param: mixed - @param param: ein optionaler Parameter - @rtype: string - @return: Die Antwort des Liquidsoap-Servers - """ - - param = param.strip() if param.strip() == "" else " " + urllib.parse.unquote(param.strip()) - if self.connected: - # print namespace + '.' + command + param + "\n" - if namespace == "": - message = str(command) + str(param) + str("\n") - else: - message = str(namespace) + str(".") + str(command) + str(param) + str("\n") - - try: - if not self.disable_logging: - self.logger.debug( - "LiquidSoapClient sending to LiquidSoap Server: " - + message[0 : len(message) - 1] - ) - - # send all the stuff over the socket to liquidsoap server - self.socket.sendall(message.encode()) - - if not self.disable_logging: - self.logger.debug("LiquidSoapClient waiting for reply from LiquidSoap Server") - - # wait for reply - self.read() - - if not self.disable_logging: - self.logger.debug("LiquidSoapClient got reply: " + self.message) - except BrokenPipeError as e: - self.logger.error( - TerminalColors.RED.value - + "Detected a problem with liquidsoap connection while sending: " - + message - + ". Reason: " - + str(e) - + "! Trying to reconnect." - + TerminalColors.RED.value - ) - self.connect() - raise - - except Exception as e: - self.logger.error("Unexpected error: " + str(e)) - raise - - return self.message - else: - msg = "LiquidsoapClient not connected to LiquidSoap Server" - self.logger.error(msg) - raise LQConnectionError(msg) - - def help(self): - """ - get liquidsoap server help - @rtype: string - @return: the response of the liquidsoap server - """ - if self.connected: - self.command("help", "") - return self.message - - def version(self): - """ - Liquidsoap get version - @rtype: string - @return: the response of the liquidsoap server - """ - if self.connected: - message = "version" - self.command(message, "") - return self.message - - def uptime(self): - """ - Liquidsoap get uptime - @rtype: string - @return: Die Antwort des Liquidsoap-Servers - """ - - if self.connected: - self.command("uptime", "") - return self.message - - def byebye(self): - """ - Liquidsoap say byebye - @rtype: string - @return: Die Antwort des Liquidsoap-Servers - """ - - if self.connected: - self.command("", "quit") - return self.message diff --git a/src/aura_engine/client/playerclient.py b/src/aura_engine/client/playerclient.py index 7594cea..c7557bb 100644 --- a/src/aura_engine/client/playerclient.py +++ b/src/aura_engine/client/playerclient.py @@ -17,10 +17,10 @@ # along with this program. If not, see . -from aura_engine.client.client import LiquidSoapClient +from aura_engine.core.client import CoreConnection -class LiquidSoapPlayerClient(LiquidSoapClient): +class LiquidSoapPlayerClient(CoreConnection): # TODO Refactor class: https://gitlab.servus.at/aura/engine/-/issues/65 diff --git a/src/aura_engine/core/client.py b/src/aura_engine/core/client.py new file mode 100644 index 0000000..6a70aaf --- /dev/null +++ b/src/aura_engine/core/client.py @@ -0,0 +1,205 @@ +# +# Aura Engine (https://gitlab.servus.at/aura/engine) +# +# Copyright (C) 2017-now() - The Aura Engine Team. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +""" +Message and connection handling to Engine Core (Liquidsoap). +""" + + +import logging +import socket +import urllib.parse +from multiprocessing import Lock + +from aura_engine.base.config import AuraConfig +from aura_engine.base.exceptions import LQConnectionError +from aura_engine.base.utils import SimpleUtil as SU + + +class CoreConnection: + """ + Handles connections and sends commands to Engine Core (Liquidsoap). + """ + + ENCODING = "UTF-8" + + logger = None + skip_logging = ["aura_engine.status"] + config = None + socket_path = None + socket = None + mutex = None + connected = None + message = None + + def __init__(self, config, socket_filename): + """ + Initialize the connection. + """ + self.logger = logging.getLogger("AuraEngine") + self.config = AuraConfig.config() + socket_path = config.get("socket_dir") + "/" + socket_filename + self.socket_path = config.to_abs_path(socket_path) + self.logger.debug("LiquidSoapClient using socketpath: " + self.socket_path) + + self.mutex = Lock() + self.connected = False + self.message = "" + self.socket = None + + def connect(self): + """ + Connect to Liquidsoap socket. + """ + try: + self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.connect(self.socket_path) + except socket.error as e: + msg = f"Cannot connect to socket at '{self.socket_path}'" + self.logger.critical(SU.red(msg), e) + self.connected = False + except Exception as e: + msg = "Unknown error while connecting to socket at '{self.socket_path}'" + self.logger.critical(SU.red(msg), e) + self.connected = False + else: + self.connected = True + return True + + def is_connected(self): + """ + Return `True` if a connection is established. + """ + return self.connected + + def close(self): + """ + Send quit command and close connection. + """ + if self.connected: + message = "quit\r" + self.socket.sendall(message.decode(CoreConnection.ENCODING)) + self.socket.close() + self.connected = False + + def command(self, namespace: str, command: str, param: str = "") -> str: + """ + Send command to Liquidsoap. + + Args: + namespace (str): Command namespace + command (str): Command + param (str, optional): Optional parameters + + Raises: + LQConnectionError: Thrown when not connected + + Returns: + str: Result of the command + """ + param = param.strip() + param = " " + urllib.parse.unquote(param) if param != "" else "" + namespace += "." if namespace else "" + + if self.connected: + message = f"{namespace}{command}{param}\n" + self.log_debug(namespace, command, f"Send message:\n{message}") + + try: + self.socket.sendall(message.encode()) + self.log_debug(namespace, command, "Waiting for reply...") + self.read() + self.log_debug(namespace, command, f"Got reply: {self.message}") + except BrokenPipeError: + msg = "Broken Pipe. Reconnecting..." + self.logger.error(SU.red(msg)) + self.connect() + raise + except Exception as e: + msg = f"Unexpected error while sending command '{message}'" + self.logger.error(SU.red(msg), e) + raise + + return self.message + else: + msg = "LiquidsoapClient not connected to LiquidSoap Server" + self.logger.error(SU.red(msg)) + raise LQConnectionError(msg) + + def read_all(self, timeout: int = 2) -> str: + """ + Read data from the socket until `END` signal is received. + + Args: + timeout (int, optional): Reading timeout in seconds. Defaults to 2. + + Returns: + str: The response + + """ + data = "" + try: + self.socket.settimeout(timeout) + self.mutex.acquire() + while True: + data += self.socket.recv(1).decode(CoreConnection.ENCODING) + if data.find("END\r\n") != -1 or data.find("Bye!\r\n") != -1: + data.replace("END\r\n", "") + break + self.mutex.release() + except Exception as e: + msg = "Unknown error while socket.read_all()" + self.logger.error(SU.red(msg), e) + self.mutex.release() + return data + + def read(self) -> str: + """ + Read from socket and store return value in `self.message` and return it. + + Returns: + str: message read from socket + + """ + if self.connected: + ret = self.read_all().splitlines() + try: + last = ret.pop() + if last != "Bye!": + if len(ret) > 1: + self.message = str.join(" - ", ret) + elif len(ret) == 1: + self.message = ret[0] + else: + self.message = last + except Exception as e: + msg = "Unknown error while socket.read()" + self.logger.error(SU.red(msg), e) + return self.message + return None + + def log_debug(self, namespace: str, command: str, msg: str) -> bool: + """ + Check if the command is excluded from debug logging. + + This is meant to avoid log-pollution by status and fade commands. + """ + if f"{namespace}{command}" not in self.skip_logging: + self.logger.debug(f"[{namespace}.{command}] {msg}") diff --git a/src/aura_engine/mixer.py b/src/aura_engine/mixer.py index c68b995..ca51cde 100644 --- a/src/aura_engine/mixer.py +++ b/src/aura_engine/mixer.py @@ -388,8 +388,9 @@ class Mixer: self.logger.info(SU.pink(msg)) # Enable logging, which might have been disabled in a previous fade-out + # TODO refactor self.connector.disable_logging = True - self.connector.client.disable_logging = True + # self.connector.client.disable_logging = True for i in range(target_volume): self.channel_volume(channel.value, i + 1) @@ -400,8 +401,9 @@ class Mixer: self.fade_in_active = False if not self.fade_out_active: + # TODO refactor self.connector.disable_logging = False - self.connector.client.disable_logging = False + # self.connector.client.disable_logging = False except LQConnectionError as e: self.logger.critical(str(e)) @@ -442,8 +444,9 @@ class Mixer: # Disable logging... it is going to be enabled again after fadein and -out is # finished + # TODO refactor self.connector.disable_logging = True - self.connector.client.disable_logging = True + # self.connector.client.disable_logging = True for i in range(volume): self.channel_volume(channel.value, volume - i - 1) @@ -456,7 +459,8 @@ class Mixer: self.fade_out_active = False if not self.fade_in_active: self.connector.disable_logging = False - self.connector.client.disable_logging = False + # TODO refactor + # self.connector.client.disable_logging = False except LQConnectionError as e: self.logger.critical(str(e)) -- GitLab From 095ac1f3078a022c1c9fa6cb5210f49b4a1704e0 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 5 Aug 2022 14:18:42 +0200 Subject: [PATCH 02/24] add new private decorator --- src/aura_engine/base/lang.py | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/aura_engine/base/lang.py diff --git a/src/aura_engine/base/lang.py b/src/aura_engine/base/lang.py new file mode 100644 index 0000000..c181410 --- /dev/null +++ b/src/aura_engine/base/lang.py @@ -0,0 +1,49 @@ +# +# Aura Engine (https://gitlab.servus.at/aura/engine) +# +# Copyright (C) 2017-2020 - The Aura Engine Team. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +""" +A collection of Python language utilities. +""" + +import inspect +from functools import wraps + + +def private(member): + """ + @private Decorator. + + Use this to annotate your methods for private-visibility. + + This is an more expressive alternative to the pythonic underscore visibility. + """ + + @wraps(member) + def wrapper(*args): + me = member.__name__ + stack = inspect.stack() + calling_class = stack[1][0].f_locals["self"].__class__.__name__ + calling_method = stack[1][0].f_code.co_name + if calling_method not in dir(args[0]) and calling_method is not me: + msg = f'"{me}(..)" called by "{calling_class}.{calling_method}(..)" is private' + print(msg) + raise Exception(msg) + return member(*args) + + return wrapper -- GitLab From b04409921a6ac15b288f5d2d176c00254b19100a Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 5 Aug 2022 14:19:05 +0200 Subject: [PATCH 03/24] refact: set method visibility --- src/aura_engine/core/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/aura_engine/core/client.py b/src/aura_engine/core/client.py index 6a70aaf..867ec52 100644 --- a/src/aura_engine/core/client.py +++ b/src/aura_engine/core/client.py @@ -29,6 +29,7 @@ from multiprocessing import Lock from aura_engine.base.config import AuraConfig from aura_engine.base.exceptions import LQConnectionError +from aura_engine.base.lang import private from aura_engine.base.utils import SimpleUtil as SU @@ -143,6 +144,7 @@ class CoreConnection: self.logger.error(SU.red(msg)) raise LQConnectionError(msg) + @private def read_all(self, timeout: int = 2) -> str: """ Read data from the socket until `END` signal is received. @@ -170,6 +172,7 @@ class CoreConnection: self.mutex.release() return data + @private def read(self) -> str: """ Read from socket and store return value in `self.message` and return it. @@ -195,6 +198,7 @@ class CoreConnection: return self.message return None + @private def log_debug(self, namespace: str, command: str, msg: str) -> bool: """ Check if the command is excluded from debug logging. -- GitLab From c7d8ff5874f0016ae3a63848e1662d3c32432803 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 5 Aug 2022 14:23:24 +0200 Subject: [PATCH 04/24] refact: merge lang utils in module --- src/aura_engine/base/api.py | 2 +- src/aura_engine/base/lang.py | 16 +++++++++++++++- src/aura_engine/base/utils.py | 14 -------------- src/aura_engine/engine.py | 2 +- src/aura_engine/plugins/clock.py | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/aura_engine/base/api.py b/src/aura_engine/base/api.py index 50fce61..1b66744 100644 --- a/src/aura_engine/base/api.py +++ b/src/aura_engine/base/api.py @@ -26,7 +26,7 @@ import logging import requests -from aura_engine.base.utils import DotDict +from aura_engine.base.lang import DotDict from aura_engine.base.utils import SimpleUtil as SU diff --git a/src/aura_engine/base/lang.py b/src/aura_engine/base/lang.py index c181410..8f6f043 100644 --- a/src/aura_engine/base/lang.py +++ b/src/aura_engine/base/lang.py @@ -18,7 +18,7 @@ """ -A collection of Python language utilities. +A collection of Python meta-programming and language utilities. """ import inspect @@ -47,3 +47,17 @@ def private(member): return member(*args) return wrapper + + +class DotDict(dict): + """ + Wrap a Dictionary with `DotDict` to allow property access using the dot.notation. + + Args: + dict (_type_): The dictionary + + """ + + __getattr__ = dict.get + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ diff --git a/src/aura_engine/base/utils.py b/src/aura_engine/base/utils.py index 738d231..5d74eaa 100644 --- a/src/aura_engine/base/utils.py +++ b/src/aura_engine/base/utils.py @@ -174,20 +174,6 @@ class SimpleUtil: return TerminalColors.CYAN.value + text + TerminalColors.ENDC.value -class DotDict(dict): - """ - Wrap a Dictionary with `DotDict` to allow property access using the dot.notation. - - Args: - dict (_type_): The dictionary - - """ - - __getattr__ = dict.get - __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ - - class TerminalColors(Enum): """ Colors for formatting terminal output. diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index db5e1c8..1197d18 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -36,7 +36,7 @@ from aura_engine.base.exceptions import ( LQConnectionError, LQStreamException, ) -from aura_engine.base.utils import DotDict +from aura_engine.base.lang import DotDict from aura_engine.base.utils import SimpleUtil as SU from aura_engine.channels import ( Channel, diff --git a/src/aura_engine/plugins/clock.py b/src/aura_engine/plugins/clock.py index 45a9f21..3ad8d32 100644 --- a/src/aura_engine/plugins/clock.py +++ b/src/aura_engine/plugins/clock.py @@ -26,7 +26,7 @@ from datetime import datetime, timedelta from aura_engine.base.api import SimpleApi from aura_engine.base.config import AuraConfig -from aura_engine.base.utils import DotDict +from aura_engine.base.lang import DotDict from aura_engine.resources import ResourceUtil -- GitLab From 63eccac64ff2de7c7a050fa89d2167a2a8e17063 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Mon, 8 Aug 2022 20:58:04 +0200 Subject: [PATCH 05/24] Add new @synchronized decorator #65 --- src/aura_engine/base/lang.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/aura_engine/base/lang.py b/src/aura_engine/base/lang.py index 8f6f043..4e8188c 100644 --- a/src/aura_engine/base/lang.py +++ b/src/aura_engine/base/lang.py @@ -18,16 +18,40 @@ """ -A collection of Python meta-programming and language utilities. +A collection of meta-programming and language utilities. """ import inspect from functools import wraps +from multiprocessing import Lock + + +def synchronized(member): + """ + @synchronized decorator. + + Lock a method for synchronized access only. + """ + mutex = Lock() + + @wraps(member) + def wrapper(*args, **vargs): + result = "" + try: + mutex.acquire() + result = member(*args, **vargs) + mutex.release() + except Exception as e: + mutex.release() + raise e + return result + + return wrapper def private(member): """ - @private Decorator. + @private decorator. Use this to annotate your methods for private-visibility. @@ -35,7 +59,7 @@ def private(member): """ @wraps(member) - def wrapper(*args): + def wrapper(*args, **vargs): me = member.__name__ stack = inspect.stack() calling_class = stack[1][0].f_locals["self"].__class__.__name__ @@ -44,7 +68,7 @@ def private(member): msg = f'"{me}(..)" called by "{calling_class}.{calling_method}(..)" is private' print(msg) raise Exception(msg) - return member(*args) + return member(*args, **vargs) return wrapper -- GitLab From 0c1bf46649a60a5db245d8b13063018c5d5a52bb Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Tue, 9 Aug 2022 20:14:53 +0200 Subject: [PATCH 06/24] chore(project): add meta data --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3e9bd11..fdc55af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,11 @@ +[project] +name = "aura_engine" +description = "AURA Engine scheduling and control" +readme = "README.md" +requires-python = ">=3.9" +keywords = ["radio", "scheduling", "audio"] +license = { text = "AGPL 3" } + [tool.black] line-length = 99 target-version = ["py38"] -- GitLab From d20d31284b4a8b13b209f84cac3a1abd8aa542a7 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Tue, 9 Aug 2022 20:18:20 +0200 Subject: [PATCH 07/24] chore(vscode): add .env for tests to discover src --- .env | 1 + 1 file changed, 1 insertion(+) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..d28a0ee --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PYTHONPATH=src \ No newline at end of file -- GitLab From 0b6486d521648841349a738115ecf57f78c3b541 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Tue, 9 Aug 2022 20:22:44 +0200 Subject: [PATCH 08/24] refact(decorator): store sync lock on instance --- src/aura_engine/base/lang.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/aura_engine/base/lang.py b/src/aura_engine/base/lang.py index 4e8188c..87f91d9 100644 --- a/src/aura_engine/base/lang.py +++ b/src/aura_engine/base/lang.py @@ -30,19 +30,22 @@ def synchronized(member): """ @synchronized decorator. - Lock a method for synchronized access only. + Lock a method for synchronized access only. The lock is stored to the function or class + instance, depending on what is available. """ - mutex = Lock() @wraps(member) - def wrapper(*args, **vargs): + def wrapper(*args, **kwargs): + lock = vars(member).get("_synchronized_lock", None) result = "" try: - mutex.acquire() - result = member(*args, **vargs) - mutex.release() + if lock is None: + lock = vars(member).setdefault("_synchronized_lock", Lock()) + lock.acquire() + result = member(*args, **kwargs) + lock.release() except Exception as e: - mutex.release() + lock.release() raise e return result @@ -59,7 +62,7 @@ def private(member): """ @wraps(member) - def wrapper(*args, **vargs): + def wrapper(*args, **kwargs): me = member.__name__ stack = inspect.stack() calling_class = stack[1][0].f_locals["self"].__class__.__name__ @@ -68,7 +71,7 @@ def private(member): msg = f'"{me}(..)" called by "{calling_class}.{calling_method}(..)" is private' print(msg) raise Exception(msg) - return member(*args, **vargs) + return member(*args, **kwargs) return wrapper -- GitLab From 5788a8a7340d5b6f9ab42f25c4a45ea85a86d74b Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Tue, 9 Aug 2022 20:23:05 +0200 Subject: [PATCH 09/24] test(decorator): synchronized --- tests/test_synchronized.py | 182 +++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/test_synchronized.py diff --git a/tests/test_synchronized.py b/tests/test_synchronized.py new file mode 100644 index 0000000..5cc6b3f --- /dev/null +++ b/tests/test_synchronized.py @@ -0,0 +1,182 @@ +# +# Aura Engine (https://gitlab.servus.at/aura/engine) +# +# Copyright (C) 2017-now() - The Aura Engine Team. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import threading +import time +import unittest + +from aura_engine.base.lang import synchronized + + +class MessageHolder: + message = None + + def __init__(self): + self.message = "no message" + + def set_message(self, message: str, sleep_time: float): + print(f"Updating message to '{message}' in {sleep_time} seconds") + time.sleep(sleep_time) + self.message = message + # print("Message updated") + + @synchronized + def set_synced_message(self, message: str, sleep_time: float): + print(f"Synced: Updating message to '{message}' in {sleep_time} seconds") + time.sleep(sleep_time) + self.message = message + + def get_message(self): + return self.message + + +class TestSynchronized(unittest.TestCase): + """ + Testing the Logger. + """ + + mh = None + + def setUp(self): + self.mh = MessageHolder() + + def test_not_synchronized(self): + + # Functions to update the message via threads + def fast_cat_1(): + self.mh.set_message("fast cat 1", 0.05) + + def sleepy_dog_1(): + self.mh.set_message("sleepy dog 1", 0.5) + + def fast_cat_2(): + self.mh.set_message("fast cat 2", 0.1) + + def sleepy_dog_2(): + self.mh.set_message("sleepy dog 2", 1) + + # CASE#0: Get initial message + msg = self.mh.get_message() + print(msg) + self.assertEqual("no message", msg) + + # Start threads + thread1 = threading.Thread(target=fast_cat_1) + thread2 = threading.Thread(target=sleepy_dog_1) + thread3 = threading.Thread(target=sleepy_dog_2) + thread4 = threading.Thread(target=fast_cat_2) + thread1.start() + thread2.start() + thread3.start() + thread4.start() + + # CASE#1: First thread quickly updates the message + time.sleep(0.08) + msg = self.mh.get_message() + print(msg) + self.assertEqual("fast cat 1", msg) + + # CASE#2: Last thread has overtaken the two slow ones + time.sleep(0.12) + msg = self.mh.get_message() + print(msg) + self.assertEqual("fast cat 2", msg) + + # # CASE#3: Slow one arrived + time.sleep(0.5) + msg = self.mh.get_message() + print(msg) + self.assertEqual("sleepy dog 1", msg) + + # # CASE#3: The other slow one arrived + time.sleep(0.5) + msg = self.mh.get_message() + print(msg) + self.assertEqual("sleepy dog 2", msg) + + thread1.join() + thread2.join() + thread3.join() + thread4.join() + + def test_synchronized(self): + + # Functions to update the message via threads + def fast_cat_1(): + self.mh.set_synced_message("fast cat 1", 0.05) + + def sleepy_dog_1(): + self.mh.set_synced_message("sleepy dog 1", 0.5) + + def fast_cat_2(): + self.mh.set_synced_message("fast cat 2", 0.1) + + def sleepy_dog_2(): + self.mh.set_synced_message("sleepy dog 2", 1) + + # CASE#0: Get initial message + msg = self.mh.get_message() + print(msg) + self.assertEqual("no message", msg) + + # Start threads + thread1 = threading.Thread(target=fast_cat_1) + thread2 = threading.Thread(target=sleepy_dog_1) + thread3 = threading.Thread(target=sleepy_dog_2) + thread4 = threading.Thread(target=fast_cat_2) + thread1.start() + time.sleep(0.01) + thread2.start() + time.sleep(0.01) + thread3.start() + time.sleep(0.01) + thread4.start() + + # CASE#1: First thread quickly updates the message + time.sleep(0.1) + msg = self.mh.get_message() + print(msg) + self.assertEqual("fast cat 1", msg) + + # # CASE#2: Any fast cat has to wait for this dog + time.sleep(0.5) + msg = self.mh.get_message() + print(msg) + self.assertEqual("sleepy dog 1", msg) + + # # CASE#3: And for the other dog too + time.sleep(1) + msg = self.mh.get_message() + print(msg) + self.assertEqual("sleepy dog 2", msg) + + # # CASE#3: Finally it's the fast cats turn + time.sleep(0.2) + msg = self.mh.get_message() + print(msg) + self.assertEqual("fast cat 2", msg) + + thread1.join() + thread2.join() + thread3.join() + thread4.join() + + +if __name__ == "__main__": + unittest.main() -- GitLab From 1890574fa36df7a1914e118239e6323f0a52f48a Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Wed, 10 Aug 2022 18:36:17 +0200 Subject: [PATCH 10/24] Refact(client): New core client implementation #65 --- src/aura_engine/base/exceptions.py | 94 ----- src/aura_engine/client/connector.py | 265 ------------- src/aura_engine/client/playerclient.py | 489 ------------------------ src/aura_engine/core/client.py | 251 ++++++++---- src/aura_engine/engine.py | 207 ++++------ src/aura_engine/mixer.py | 172 +++------ src/aura_engine/plugins/mailer.py | 18 +- src/aura_engine/plugins/monitor.py | 8 - src/aura_engine/scheduling/scheduler.py | 13 +- 9 files changed, 327 insertions(+), 1190 deletions(-) delete mode 100644 src/aura_engine/base/exceptions.py delete mode 100644 src/aura_engine/client/connector.py delete mode 100644 src/aura_engine/client/playerclient.py diff --git a/src/aura_engine/base/exceptions.py b/src/aura_engine/base/exceptions.py deleted file mode 100644 index 8cbb5fd..0000000 --- a/src/aura_engine/base/exceptions.py +++ /dev/null @@ -1,94 +0,0 @@ -# -# Aura Engine (https://gitlab.servus.at/aura/engine) -# -# Copyright (C) 2017-2020 - The Aura Engine Team. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -""" -A collection of exceptions. -""" - -# Scheduler Exceptions - - -class NoProgrammeLoadedException(Exception): - """ - Exception thrown when no programme data could be loaded. - """ - - pass - - -class NoActiveTimeslotException(Exception): - """ - Exception thrown when there is no timeslot active. - """ - - pass - - -# Soundsystem and Mixer Exceptions - - -class LoadSourceException(Exception): - """ - Exception thrown when some source could not be loaded. - """ - - pass - - -class InvalidChannelException(Exception): - """ - Exception thrown when the given channel is invalid. - """ - - pass - - -class PlaylistException(Exception): - """ - Exception thrown when the playlist is invalid. - """ - - pass - - -class NoActiveEntryException(Exception): - """ - Exception thrown when there is no playlist entry active. - """ - - pass - - -# Liquidsoap Exceptions - - -class LQConnectionError(Exception): - """ - Exception thrown when there is a connection problem with Liquidsoap. - """ - - pass - - -class LQStreamException(Exception): - """ - Exception thrown when there is a problem with an audio stream in Liquidsoap. - """ - - pass diff --git a/src/aura_engine/client/connector.py b/src/aura_engine/client/connector.py deleted file mode 100644 index 4785d6c..0000000 --- a/src/aura_engine/client/connector.py +++ /dev/null @@ -1,265 +0,0 @@ -# -# Aura Engine (https://gitlab.servus.at/aura/engine) -# -# Copyright (C) 2017-2020 - The Aura Engine Team. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -import logging -import time - -from aura_engine.base.config import AuraConfig -from aura_engine.base.exceptions import LQConnectionError -from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.base.utils import TerminalColors -from aura_engine.client.playerclient import LiquidSoapPlayerClient - - -class PlayerConnector: - """ - Establishes a Socket connection to Liquidsoap. - - #TODO Refactor class: https://gitlab.servus.at/aura/engine/-/issues/65 - """ - - client = None - logger = None - transaction = 0 - connection_attempts = 0 - disable_logging = False - event_dispatcher = None - has_connection = None - - def __init__(self, event_dispatcher): - """ - Constructor - - Args: - config (AuraConfig): The configuration - """ - self.config = AuraConfig.config() - self.logger = logging.getLogger("AuraEngine") - self.client = LiquidSoapPlayerClient(self.config, "engine.sock") - self.event_dispatcher = event_dispatcher - self.has_connection = False - - def send_lqc_command(self, namespace, command, *args): - """ - Ein Kommando an Liquidsoap senden - @type lqs_instance: object - @param lqs_instance: Instance of LiquidSoap Client - @type namespace: string - @param namespace: Namespace of function - @type command: string - @param command: Function name - @type args: list - @param args: List of parameters - @rtype: string - @return: Response from LiquidSoap - """ - lqs_instance = self.client - try: - if not self.disable_logging: - if command == "": - self.logger.debug( - "LiquidSoapCommunicator is calling " + str(namespace) + str(args) - ) - else: - self.logger.debug( - "LiquidSoapCommunicator is calling " - + str(namespace) - + "." - + str(command) - + str(args) - ) - - # call wanted function ... - - # FIXME REFACTOR all calls in a common way - if command in [ - "queue_push", - "queue_seek", - "queue_clear", - "playlist_uri_set", - "playlist_uri_clear", - "stream_set_url", - "stream_start", - "stream_stop", - "stream_status", - "set_track_metadata", - ]: - - func = getattr(lqs_instance, command) - result = func(str(namespace), *args) - - elif namespace == "mixer": # or namespace == "mixer_fallback": - func = getattr(lqs_instance, command) - result = func(str(namespace), *args) - else: - func = getattr(lqs_instance, namespace) - result = func(command, *args) - - if not self.disable_logging: - self.logger.debug("LiquidSoapCommunicator got response " + str(result)) - - self.connection_attempts = 0 - - return result - - except LQConnectionError as e: - self.logger.error( - "Connection Error when sending " + str(namespace) + "." + str(command) + str(args) - ) - if self.try_to_reconnect(): - time.sleep(0.2) - self.connection_attempts += 1 - if self.connection_attempts < 5: - # reconnect - self.__open_conn(self.client) - self.logger.info( - "Trying to resend " + str(namespace) + "." + str(command) + str(args) - ) - # grab return value - retval = self.send_lqc_command(namespace, command, *args) - # disconnect - self.__close_conn(self.client) - # return the val - return retval - else: - if command == "": - msg = ( - "Rethrowing Exception while trying to send " - + str(namespace) - + str(args) - ) - else: - msg = ( - "Rethrowing Exception while trying to send " - + str(namespace) - + "." - + str(command) - + str(args) - ) - - self.logger.info(msg) - self.disable_transaction(socket=self.client, force=True) - raise e - else: - self.event_dispatcher.on_critical( - "Critical Liquidsoap connection issue", - "Could not connect to Liquidsoap after multiple attempts", - e, - ) - raise e - - def try_to_reconnect(self): - self.enable_transaction() - return self.transaction > 0 - - def enable_transaction(self, socket=None): - # set socket to playout if nothing else is given - if socket is None: - socket = self.client - - self.transaction = self.transaction + 1 - - self.logger.debug( - TerminalColors.WARNING.value - + "Enabling transaction! cnt: " - + str(self.transaction) - + TerminalColors.ENDC.value - ) - - if self.transaction > 1: - return - - try: - self.__open_conn(socket) - except FileNotFoundError: - self.disable_transaction(socket=socket, force=True) - subject = "CRITICAL Exception when connecting to Liquidsoap" - msg = "socket file " + socket.socket_path + " not found. Is liquidsoap running?" - self.logger.critical(SU.red(msg)) - # Not using this for now, as it should be triggered by "on_sick(..)" as well - if False: - self.event_dispatcher.on_critical(subject, msg, None) - - def disable_transaction(self, socket=None, force=False): - if not force: - # nothing to disable - if self.transaction == 0: - return - - # decrease transaction counter - self.transaction = self.transaction - 1 - - # debug msg - self.logger.debug( - TerminalColors.WARNING.value - + "DISabling transaction! cnt: " - + str(self.transaction) - + TerminalColors.ENDC.value - ) - - # return if connection is still needed - if self.transaction > 0: - return - else: - self.logger.debug( - TerminalColors.WARNING.value - + "Forcefully DISabling transaction! " - + TerminalColors.ENDC.value - ) - - # close conn and set transactioncounter to 0 - self.__close_conn(socket) - self.transaction = 0 - - def __open_conn(self, socket): - # already connected - # if self.transaction > 1: - # return - - if self.has_connection: - return - - self.logger.debug( - TerminalColors.GREEN.value - + "LiquidSoapCommunicator opening conn" - + TerminalColors.ENDC.value - ) - - # try to connect - socket.connect() - self.has_connection = True - - def __close_conn(self, socket): - # set socket to playout - if socket is None: - socket = self.client - - # do not disconnect if a transaction is going on - if self.transaction > 0: - return - - # say bye - # socket.byebye() - - # debug msg - self.logger.debug( - TerminalColors.BLUE.value - + "LiquidSoapCommunicator closed conn" - + TerminalColors.ENDC.value - ) diff --git a/src/aura_engine/client/playerclient.py b/src/aura_engine/client/playerclient.py deleted file mode 100644 index c7557bb..0000000 --- a/src/aura_engine/client/playerclient.py +++ /dev/null @@ -1,489 +0,0 @@ -# -# Aura Engine (https://gitlab.servus.at/aura/engine) -# -# Copyright (C) 2017-2020 - The Aura Engine Team. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from aura_engine.core.client import CoreConnection - - -class LiquidSoapPlayerClient(CoreConnection): - - # TODO Refactor class: https://gitlab.servus.at/aura/engine/-/issues/65 - - # - # Mixer - # - - # def mixer(self, mixer_id, command, *args): - # if command == "status": - # return self.mixer_status(mixer_id, *args) - - # if command == "inputs": - # return self.mixer_inputs(mixer_id) - - # if command == "volume": - # return self.mixer_volume(mixer_id, *args) - - # if command == "select": - # if len(args) == 2: - # return self.mixer_select(mixer_id, args[0], args[1]) - - # if command == "activate": - # if len(args) == 2: - # return self.mixer_activate(mixer_id, args[0], args[1]) - - # return "LiquidSoapPlayerClient does not understand mixer."+command+str(args) - - def mixer_outputs(self, mixer_id): - # send command - self.command(mixer_id, "outputs") - return self.message - - def mixer_inputs(self, mixer_id): - # send command - self.command(mixer_id, "inputs") - - # convert to list and return it - return self.message.strip().split(" ") - - def mixer_status(self, mixer_id, pos=""): - """ - Get state of a source in the mixer - @type pos: string - @param pos: Mixerposition - @rtype: string - @return: Response from LiquidSoap - """ - self.command(mixer_id, "status", str(pos)) - return self.message - - # def input_status(self, input_id): - # """ - # Retrieves the status of the given input - - # Args: - # input_id (_type_): _description_ - # """ - # self.command(input_id, "status") - # return self.message - - def mixer_volume(self, mixer_id, pos, volume): - """ - Sets some mixer channel to the given volume - - Args: - pos (Integer): The channel number - volume (Integer): The volume - - Returns: - (String): Liquidsoap server response - """ - self.command(mixer_id, "volume", str(pos) + " " + str(volume)) - return self.message - - def mixer_select(self, mixer_id, pos, select): - """ - Selects some mixer channel or vice versa. - - Args: - pos (Integer): The channel number - select (Boolean): Select or deselect - - Returns: - (String): Liquidsoap server response - """ - self.command(mixer_id, "select", str(pos) + " " + str(select).lower()) - return self.message - - def mixer_activate(self, mixer_id, pos, activate): - """ - Selects some mixer channel and increases the volume to 100 or vice versa. - - Args: - pos (Integer): The channel number - activate (Boolean): Activate or deactivate - - Returns: - (String): Liquidsoap server response - """ - self.command(mixer_id, "activate", str(pos) + " " + str(activate).lower()) - return self.message - - # - # Channel (general) - # - - def set_track_metadata(self, channel, json_meta): - """ - Sets additional metadata for the current track - """ - self.command(channel, "set_track_metadata", json_meta) - return self.message - - # - # Queues - # - - def queue_push(self, channel, uri): - """ - Pushes the passed file URI to the `equeue` playlist channel. - - Args: - channel (String): Liquidsoap Source ID - uri (String): Path to the file - """ - self.command(channel, "push", uri) - return self.message - - def queue_seek(self, channel, duration): - """ - Forward the playing `equeue` track/playlist of the given channel. - - Args: - channel (String): Liquidsoap Source ID - duration (Integer): Seek duration ins seconds - - Returns: - Liquidsoap server response - """ - self.command(channel, "seek", str(duration)) - return self.message - - def queue_clear(self, channel): - """ - Clears all `equeue` playlist entries of the given channel. - - Args: - channel (String): Liquidsoap Source ID - duration (Integer): Seek duration ins seconds - - Returns: - Liquidsoap server response - """ - self.command(channel, "clear") - return self.message - - # - # Playlist - # - - def playlist_uri_set(self, channel, uri): - """ - Sets the URI of a playlist source. - - Args: - channel (String): Liquidsoap Source ID - uri (String): URI to the playlist file - - Returns: - Liquidsoap server response - """ - self.command(channel, "uri", uri) - return self.message - - def playlist_uri_clear(self, channel): - """ - Clears the URI of a playlist source. - - Args: - channel (String): Liquidsoap Source ID - uri (String): URI to the playlist file - - Returns: - Liquidsoap server response - """ - self.command(channel, "clear") - return self.message - - # - # Stream - # - - def stream_set_url(self, channel, url): - """ - Sets the URL on the given HTTP channel. - """ - self.command(channel, "url", url) - return self.message - - def stream_start(self, channel): - """ - Starts the HTTP stream set with `stream_set_url` on the given channel. - """ - self.command(channel, "start") - return self.message - - def stream_stop(self, channel): - """ - Stops the HTTP stream on the given channel. - """ - self.command(channel, "stop") - return self.message - - def stream_status(self, channel): - """ - Returns the status of the HTTP stream on the given channel. - """ - self.command(channel, "status") - return self.message - - # - # General Entries - # - - def entry_status(self, rid): - """ - Retrieves the status of a given entry. - - Args: - rid (String): Resource ID (RID) - - Returns: - Liquidsoap server response - """ - self.command("request", "status", str(rid)) - return self.message - - # - # Other - # - - def uptime(self, command=""): # no command will come - """ - Retrieves how long the engine is running already. - """ - return self.command("", "uptime") - - def version(self, command=""): # no command will come - """ - Retrieves the Liquidsoap version. - """ - return self.command("", "version") - - def engine(self, command, *args): - """ - Retrieves the state of all input and outputs. - """ - if command == "version": - return self.engine_version() - if command == "state": - return self.engine_state() - if command == "update_config": - return self.engine_update_config(str(args[0])) - if command == "set_track_metadata": - return self.engine_set_track_metadata(str(args[0])) - - return "LiquidSoapPlayerClient does not understand engine." + command + str(args) - - def engine_version(self): - """ - Retrieves the state of all input and outputs. - """ - self.command("aura_engine", "version") - return self.message - - def engine_state(self): - """ - Retrieves the state of all input and outputs. - """ - self.command("aura_engine", "status") - return self.message - - def engine_update_config(self, json_config): - """ - Updates the config - """ - self.command("aura_engine", "update_config", json_config) - return self.message - - # def skip(self, namespace="playlist", pos=""): - # """ - # Source skippen - # @type namespace: string - # @param namespace: Namespace der Source - # @type pos: string - # @param pos: Die Position - optional - Position des Channels vom Mixer benötigt - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('skip', namespace, pos) - # return self.message - - # def remove(self, pos, namespace="playlist"): - # """ - # Track aus der secondary_queue oder der Playlist entfernen - # @type pos: string - # @param pos: Die Position - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('remove', namespace, str(pos)) - # return self.message - - # def insert(self, uri, pos='0', namespace="playlist"): - # """ - # Track einfügen - # @type uri: string - # @param uri: Uri einer Audiodatei - # @type pos: string - # @param pos: Die Position - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('insert', namespace, str(pos) + ' ' + uri) - # return self.message - - # def move(self, fromPos, toPos, namespace="playlist"): - # """ - # Track von Position fromPos nach Position toPos verschieben - # @type fromPos: string/int - # @param fromPos: Position des zu verschiebenden Tracks - # @type toPos: string - # @param toPos: Die Position zu der verschoben werden soll - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('move', namespace, str(fromPos) + ' ' + str(toPos)) - # return self.message - - # def play(self, namespace="playlist"): - # """ - # Source abspielen - funktioniert nur bei Playlist - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('play', namespace) - # return self.message - - # def pause(self, namespace="playlist"): - # """ - # Source pausieren/stoppen - funktioniert nur bei Playlist - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('pause', namespace) - # return self.message - - # def flush(self, namespace="playlist"): - # """ - # Playlist leeren - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('flush', namespace) - # return self.message - - # def playlistData(self): - # """ - # Metadaten der Playlist ausgeben - # @rtype: string - # @return: Ein Json-String - # """ - # self.command('data', 'playlist') - # return self.message - - # def get_queue(self, namespace="ch1", queue='queue'): - # """ - # Queue eines Kanals ausgeben - # @type namespace: string - # @param namespace: Namespace der Source - # @type queue: string - # @param queue: Name des queues (queue, primary_queue, secondary_queue) - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command(queue, namespace) - # return self.message - - # def loadPlaylist(self, uri, params="", namespace="playlist"): - # """ - # Playlist laden - # @type uri: string - # @param uri: Uri einer Playlist im XSPF-Format - # @type params: string - # @param params: obsolete - # @type namespace: string - # @param namespace: Namespace der Source - hier nur playlist - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('load', namespace, uri + params) - # return self.message - - # def currentTrack(self, namespace="request"): - # """ - # Das oder die ID(s) der gerade abgespielten requests erhalten - # @type namespace: string - # @param namespace: Namespace der Source - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers (als String) - # """ - # self.command('on_air', namespace) - # return self.message - - def volume(self, pos, volume, namespace="mixer"): - """ - Lautstärke eines Kanals setzen - @type pos: int/string - @param pos: Die Position/ Nummer des Kanals (playlist=0) - @type volume: int/string - @param volume: Zahl von 1 -100 - @type namespace: string - @param namespace: Namespace der Source (immer mixer) - @rtype: string - @return: Die Antwort des Liquidsoap-Servers - """ - self.command("volume", namespace, str(pos) + " " + str(volume)) - return self.message - - # def playlist_remaining(self): - # """ - # Wie lange läuft der aktuelle Track der Playlist noch - # @rtype: string - # @return: Die Antwort des Liquidsoap-Servers - # """ - # self.command('remaining', 'playlist') - # return self.message - - # def list_channels(self): - # """ - # Channels auflisten (Simple JSON) - # """ - # # Liquidsoap Kommando - - # channels = self.sendLqcCommand(self.lqc, 'mixer', 'inputs') - - # if not isinstance(channels, list): - # self.error('02') - # elif len(channels) < 1: - # self.warning('01') - # else: - # self.success('00', channels) - - # self.notifyClient() diff --git a/src/aura_engine/core/client.py b/src/aura_engine/core/client.py index 867ec52..b41f403 100644 --- a/src/aura_engine/core/client.py +++ b/src/aura_engine/core/client.py @@ -21,18 +21,156 @@ Message and connection handling to Engine Core (Liquidsoap). """ - import logging import socket import urllib.parse -from multiprocessing import Lock from aura_engine.base.config import AuraConfig -from aura_engine.base.exceptions import LQConnectionError -from aura_engine.base.lang import private +from aura_engine.base.lang import private, synchronized from aura_engine.base.utils import SimpleUtil as SU +class CoreConnectionError(Exception): + """ + Exception thrown when there is a connection problem with Liquidsoap. + """ + + pass + + +class CoreClient: + """ + Client managing communication with Engine Core (Liquidsoap). + """ + + skip_log_commands = ("aura_engine.status", "volume") + + instance = None + logger = None + connection = None + event_dispatcher = None + conn = None + + def __init__(self): + """ + Initialize the connection. + """ + self.logger = logging.getLogger("AuraEngine") + self.config = AuraConfig.config() + self.conn = CoreConnection() + + @staticmethod + def get_instance(): + """ + Get an instance of the client singleton. + """ + if not CoreClient.instance: + CoreClient.instance = CoreClient() + return CoreClient.instance + + def set_event_dispatcher(self, event_dispatcher): + """ + Set an instance of the event dispatcher. + """ + self.event_dispatcher = event_dispatcher + + @synchronized + def connect(self): + """ + Open connection. + + @synchronized + """ + try: + if not self.conn.is_connected(): + self.conn.open() + except CoreConnectionError as e: + self.logger.critical(SU.red(e.message)) + self.event_dispatcher.on_critical("Client connection error", e.message, str(e)) + + @synchronized + def disconnect(self): + """ + Close the connection. + + @synchronized + """ + if not self.conn.is_connected(): + self.conn.close() + + @synchronized + def exec(self, namespace: str, action: str, args: str = "") -> str: + """ + Execute a command. + + Args: + namespace (str): The namespace for the command to execute. + action (str): The action to execute. + args (str, optional): Arguments passed with the action. Defaults to "". + + Raises: + CoreConnectionError: Raised when there is a connection or communication error. + + Returns: + str: result of the command (optional). + + @synchronized + + """ + response = None + + if not self.conn.is_connected(): + self.conn.open() + try: + command = self.build_command(namespace, action, args) + self.log_debug(command, f"[>>] {command}") + response = self.conn.send(command) + if response: + self.log_debug(command, f"[<<] {response}") + except CoreConnectionError as e: + msg = "Error while issuing command to Liquidsoap" + self.event_dispatcher.on_critical("Core client connection issue", msg, str(e)) + raise CoreConnectionError(msg, e) + return response + + @private + def build_command(self, namespace: str, action: str, args: str) -> str: + """ + Construct a command string for sending to Liquidsoap. + + Args: + namespace (str): The namespace for the command to execute. + action (str): The action to execute. + args (str, optional): Arguments passed with the action. Defaults to "". + + Returns: + str: The command string + + @private + """ + args = str(args).strip() + args = " " + urllib.parse.unquote(args) if args != "" else "" + namespace = str(namespace) + "." if namespace else "" + command = f"{namespace}{action}{args}" + return command + + @private + def log_debug(self, command: str, log_message: str): + """ + Check if the command is excluded from debug logging. + + This is meant to avoid log-pollution by status and fade commands. + + @private + + """ + if self.config.get("log_level") == "debug": + cmds = CoreClient.skip_log_commands + base_cmd = command.split(" ")[0] + if not base_cmd.startswith(cmds): + self.logger.debug(log_message) + + class CoreConnection: """ Handles connections and sends commands to Engine Core (Liquidsoap). @@ -41,30 +179,32 @@ class CoreConnection: ENCODING = "UTF-8" logger = None - skip_logging = ["aura_engine.status"] - config = None socket_path = None socket = None - mutex = None connected = None message = None - def __init__(self, config, socket_filename): + def __init__(self): """ Initialize the connection. """ self.logger = logging.getLogger("AuraEngine") - self.config = AuraConfig.config() - socket_path = config.get("socket_dir") + "/" + socket_filename + config = AuraConfig.config() + socket_path = config.get("socket_dir") + "/engine.sock" self.socket_path = config.to_abs_path(socket_path) - self.logger.debug("LiquidSoapClient using socketpath: " + self.socket_path) + self.logger.debug(f"Using socket at '{self.socket_path}'") - self.mutex = Lock() self.connected = False self.message = "" self.socket = None - def connect(self): + def is_connected(self): + """ + Return `True` if a connection is established. + """ + return self.connected + + def open(self): """ Connect to Liquidsoap socket. """ @@ -72,23 +212,21 @@ class CoreConnection: self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.connect(self.socket_path) + except FileNotFoundError as e: + msg = f"Socket file at '{self.socket_path}' not found. Is Liquidsoap running?" + self.connected = False + raise CoreConnectionError(msg, e) except socket.error as e: msg = f"Cannot connect to socket at '{self.socket_path}'" - self.logger.critical(SU.red(msg), e) self.connected = False + raise CoreConnectionError(msg, e) except Exception as e: - msg = "Unknown error while connecting to socket at '{self.socket_path}'" - self.logger.critical(SU.red(msg), e) + msg = f"Unknown error while connecting to socket at '{self.socket_path}'" self.connected = False + raise CoreConnectionError(msg, e) else: + self.connection_attempts = 0 self.connected = True - return True - - def is_connected(self): - """ - Return `True` if a connection is established. - """ - return self.connected def close(self): """ @@ -100,49 +238,37 @@ class CoreConnection: self.socket.close() self.connected = False - def command(self, namespace: str, command: str, param: str = "") -> str: + def send(self, command: str) -> str: """ Send command to Liquidsoap. Args: - namespace (str): Command namespace - command (str): Command - param (str, optional): Optional parameters + command (str): The command string to be executed Raises: - LQConnectionError: Thrown when not connected + CoreConnectionError: Thrown when not connected Returns: str: Result of the command """ - param = param.strip() - param = " " + urllib.parse.unquote(param) if param != "" else "" - namespace += "." if namespace else "" + result = None + command += "\n" - if self.connected: - message = f"{namespace}{command}{param}\n" - self.log_debug(namespace, command, f"Send message:\n{message}") - - try: - self.socket.sendall(message.encode()) - self.log_debug(namespace, command, "Waiting for reply...") - self.read() - self.log_debug(namespace, command, f"Got reply: {self.message}") - except BrokenPipeError: - msg = "Broken Pipe. Reconnecting..." - self.logger.error(SU.red(msg)) - self.connect() - raise - except Exception as e: - msg = f"Unexpected error while sending command '{message}'" - self.logger.error(SU.red(msg), e) - raise + try: + self.socket.sendall(command.encode()) + result = self.read() + except BrokenPipeError: + msg = "Broken Pipe while sending command" + self.logger.info(SU.pink(msg)) + self.connected = False + raise CoreConnectionError(msg) + except Exception as e: + msg = "Unknown Error while sending command" + self.logger.error(SU.red(msg), e) + self.connected = False + raise CoreConnectionError(msg) - return self.message - else: - msg = "LiquidsoapClient not connected to LiquidSoap Server" - self.logger.error(SU.red(msg)) - raise LQConnectionError(msg) + return str(result) @private def read_all(self, timeout: int = 2) -> str: @@ -155,21 +281,20 @@ class CoreConnection: Returns: str: The response + @private + """ data = "" try: self.socket.settimeout(timeout) - self.mutex.acquire() while True: data += self.socket.recv(1).decode(CoreConnection.ENCODING) if data.find("END\r\n") != -1 or data.find("Bye!\r\n") != -1: data.replace("END\r\n", "") break - self.mutex.release() except Exception as e: msg = "Unknown error while socket.read_all()" self.logger.error(SU.red(msg), e) - self.mutex.release() return data @private @@ -180,6 +305,8 @@ class CoreConnection: Returns: str: message read from socket + @private + """ if self.connected: ret = self.read_all().splitlines() @@ -197,13 +324,3 @@ class CoreConnection: self.logger.error(SU.red(msg), e) return self.message return None - - @private - def log_debug(self, namespace: str, command: str, msg: str) -> bool: - """ - Check if the command is excluded from debug logging. - - This is meant to avoid log-pollution by status and fade commands. - """ - if f"{namespace}{command}" not in self.skip_logging: - self.logger.debug(f"[{namespace}.{command}] {msg}") diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index 1197d18..2482251 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -30,15 +30,13 @@ from threading import Thread from aura_engine.base.api import LiquidsoapUtil as LU from aura_engine.base.config import AuraConfig -from aura_engine.base.exceptions import ( - InvalidChannelException, - LoadSourceException, - LQConnectionError, - LQStreamException, -) from aura_engine.base.lang import DotDict from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.channels import ( +from aura_engine.control import EngineControlInterface +from aura_engine.core.client import CoreClient, CoreConnectionError +from aura_engine.events import EngineEventDispatcher +from aura_engine.resources import ResourceClass, ResourceUtil +from src.aura_engine.channels import ( Channel, ChannelResolver, ChannelRouter, @@ -48,11 +46,23 @@ from aura_engine.channels import ( ResourceType, TransitionType, ) -from aura_engine.client.connector import PlayerConnector -from aura_engine.control import EngineControlInterface -from aura_engine.events import EngineEventDispatcher -from aura_engine.mixer import Mixer, MixerType -from aura_engine.resources import ResourceClass, ResourceUtil +from src.aura_engine.mixer import Mixer, MixerType + + +class InvalidChannelException(Exception): + """ + Exception thrown when the given channel is invalid. + """ + + pass + + +class LoadSourceException(Exception): + """ + Exception thrown when some source could not be loaded or updated. + """ + + pass class Engine: @@ -64,11 +74,9 @@ class Engine: engine_time_offset = 0.0 logger = None eci = None - channels = None scheduler = None event_dispatcher = None - plugins = None - connector = None + client = None playout_state = None def __init__(self): @@ -81,7 +89,6 @@ class Engine: self.logger = logging.getLogger("AuraEngine") self.config = AuraConfig.config() Engine.engine_time_offset = float(self.config.get("engine_latency_offset")) - self.plugins = dict() self.start() def start(self): @@ -92,7 +99,8 @@ class Engine: """ self.event_dispatcher = EngineEventDispatcher(self) self.eci = EngineControlInterface(self, self.event_dispatcher) - self.connector = PlayerConnector(self.event_dispatcher) + self.client = CoreClient.get_instance() + self.client.set_event_dispatcher(self.event_dispatcher) self.event_dispatcher.on_initialized() while not self.is_connected(): @@ -105,7 +113,7 @@ class Engine: else: self.logger.info(SU.red("Error while updating playout config")) - self.player = Player(self.connector, self.event_dispatcher) + self.player = Player(self.client, self.event_dispatcher) self.event_dispatcher.on_boot() self.logger.info(EngineSplash.splash_screen(self.config)) @@ -123,10 +131,10 @@ class Engine: try: self.uptime() has_connection = True - except LQConnectionError: - self.logger.info("Liquidsoap is not running so far") + except CoreConnectionError: + self.logger.debug("Liquidsoap is not running so far") except Exception as e: - self.logger.error("Cannot check if Liquidsoap is running. Reason: " + str(e)) + self.logger.error("Cannot check if Liquidsoap is running. \nReason: {e.message}", e) return has_connection @@ -134,7 +142,7 @@ class Engine: """ Retrieve the state of all inputs and outputs. """ - state = self.connector.send_lqc_command("engine", "state") + state = self.client.exec("aura_engine", "status") state = DotDict(LU.json_to_dict(state)) # Initialize state @@ -173,7 +181,7 @@ class Engine: "fallback_show_name": self.config.get("fallback_show_name"), } json_config = json.dumps(playout_config, ensure_ascii=False) - res = self.connector.send_lqc_command("engine", "update_config", json_config) + res = self.client.exec("aura_engine", "update_config", json_config) return res def init_version(self) -> dict: @@ -192,7 +200,7 @@ class Engine: with open(os.path.join("", "VERSION")) as version_file: ctrl_version = version_file.read().strip() - versions = self.connector.send_lqc_command("engine", "version") + versions = self.client.exec("aura_engine", "version") versions = DotDict(json.loads(versions)) self.config.set("version_control", ctrl_version) self.config.set("version_core", versions.core) @@ -202,9 +210,7 @@ class Engine: """ Retrieve the uptime of Liquidsoap. """ - self.connector.enable_transaction() - data = self.connector.send_lqc_command("uptime", "") - self.connector.disable_transaction() + data = self.client.exec("", "uptime") return data @staticmethod @@ -253,27 +259,27 @@ class Player: config = None logger = None - connector = None + client = None channels = None channel_router = None event_dispatcher = None mixer = None - def __init__(self, connector, event_dispatcher): + def __init__(self, client, event_dispatcher): """ Initialize the player. Args: - connector (Connector): Connector for the playout + client (CoreClient): Client for connecting to Engine Core (Liquidsoap) event_dispatcher (EventDispather): Dispatcher for issuing events """ self.config = AuraConfig.config() self.logger = logging.getLogger("AuraEngine") self.event_dispatcher = event_dispatcher - self.connector = connector + self.client = client self.channel_router = ChannelRouter() - self.mixer = Mixer(self.config, MixerType.MAIN, self.connector) + self.mixer = Mixer(self.config, MixerType.MAIN, self.client) def preload(self, entry): """ @@ -298,7 +304,7 @@ class Player: def set_metadata(): track_meta = ResourceUtil.generate_track_metadata(entry, True) json_meta = json.dumps(track_meta, ensure_ascii=False) - res = self.connector.send_lqc_command(entry.channel, "set_track_metadata", json_meta) + res = self.client.exec(entry.channel, "set_track_metadata", json_meta) self.logger.info(f"Response for '{entry.channel}.set_track_metadata': {res}") if res not in LiquidsoapResponse.SUCCESS.value: msg = f"Error while setting metadata on {entry.channel} to:\n{json_meta}" @@ -308,7 +314,7 @@ class Player: if entry.get_content_type() in ResourceClass.LIVE.types: entry.channel = ChannelResolver.live_channel_for_resource(entry.source) if entry.channel is None: - self.logger.critical(SU.red("Invalid live channel '{entry.source}' requested!")) + self.logger.critical(SU.red("No live channel for '{entry.source}' source")) entry.previous_channel = None set_metadata() is_ready = True @@ -364,7 +370,7 @@ class Player: channels = self.channel_router.get_free_channel(channel_type) for entry in entries: entry.status = EntryPlayState.LOADING - self.logger.info("Loading entry '%s'", entry) + self.logger.info(f"Loading entry '{entry}'") # Choose and save the input channel entry.previous_channel, entry.channel = channels @@ -394,11 +400,10 @@ class Player: otherwise a new channel of the same type is activated """ - with suppress(LQConnectionError): + with suppress(CoreConnectionError): mixer = self.mixer # Instant activation or fade-in - self.connector.enable_transaction() if transition == TransitionType.FADE: mixer.channel_select(entry.channel.value, True) mixer.fade_in(entry.channel, entry.volume) @@ -408,7 +413,6 @@ class Player: mixer.channel_activate(entry.channel.value, True) msg = f"Activate channel '{entry.channel}'" self.logger.info(SU.pink(msg)) - self.connector.disable_transaction() # Update active channel for the current channel type self.channel_router.set_active(entry.channel) @@ -434,8 +438,7 @@ class Player: channel (Channel): The channel to stop playing or fade-out transition (TransitionType): The type of transition to use e.g. fade-out """ - with suppress(LQConnectionError): - self.connector.enable_transaction() + with suppress(CoreConnectionError): if not channel: self.logger.warn(SU.red("Cannot stop, no channel passed")) return @@ -446,7 +449,6 @@ class Player: self.mixer.channel_volume(channel, 0) self.logger.info(SU.pink(f"Mute channel '{channel}' with {transition}")) - self.connector.disable_transaction() self.event_dispatcher.on_stop(channel) # @@ -474,10 +476,8 @@ class Player: while not self.stream_is_ready(entry.channel, entry.source): self.logger.info("Loading Stream ...") if retries >= max_retries: - raise LoadSourceException( - "Could not connect to stream while waiting for %s seconds!" - % str(retries * retry_delay) - ) + msg = f"Stream connection failed after {retries * retry_delay} seconds!" + raise LoadSourceException(msg) time.sleep(retry_delay) retries += 1 @@ -494,32 +494,32 @@ class Player: channel (Channel): The stream channel url (String): The stream URL + Raises: + (LoadSourceException): When stream cannot be set or stopped. + Returns: (Boolean): `True` if successful """ result = None - - self.connector.enable_transaction() - result = self.connector.send_lqc_command(channel, "stream_stop") + result = self.client.exec(channel, "stop") if result not in LiquidsoapResponse.SUCCESS.value: - self.logger.error("%s.stop result: %s" % (channel, result)) - raise LQStreamException("Error while stopping stream!") + self.logger.error(f"{channel}.stop result: {result}") + raise LoadSourceException("Error while stopping stream!") - result = self.connector.send_lqc_command(channel, "stream_set_url", url) + result = self.client.exec(channel, "url", url) if result not in LiquidsoapResponse.SUCCESS.value: - self.logger.error("%s.set_url result: %s" % (channel, result)) - raise LQStreamException("Error while setting stream URL!") + self.logger.error(f"{channel}.url result: {result}") + raise LoadSourceException("Error while setting stream URL!") - # Liquidsoap ignores commands sent without a certain timeout + # TODO Review: Liquidsoap ignores commands sent without a certain timeout time.sleep(2) - result = self.connector.send_lqc_command(channel, "stream_start") - self.logger.info("%s.start result: %s" % (channel, result)) + result = self.client.exec(channel, "start") + self.logger.info(f"{channel}.start result: {result}") - self.connector.disable_transaction() return result def stream_is_ready(self, channel, url): @@ -538,29 +538,21 @@ class Player: """ result = None - - self.connector.enable_transaction() - - result = self.connector.send_lqc_command(channel, "stream_status") - self.logger.info("%s.status result: %s" % (channel, result)) + result = self.client.exec(channel, "status") + self.logger.info(f"{channel}.status result: {result}") if not result.startswith(LiquidsoapResponse.STREAM_STATUS_CONNECTED.value): return False lqs_url = result.split(" ")[1] if not url == lqs_url: - self.logger.error( - "Wrong URL '%s' set for channel '%s', expected: '%s'." % (lqs_url, channel, url) - ) + msg = f"Wrong URL '{lqs_url}' set for channel '{channel}', expected: '{url}'." + self.logger.error(msg) return False - self.connector.disable_transaction() - stream_buffer = self.config.get("input_stream_buffer") - self.logger.info( - "Ready to play stream, but wait %s seconds until the buffer is filled..." - % str(stream_buffer) - ) + msg = f"Ready to play stream, but wait {stream_buffer} seconds to fill buffer..." + self.logger.info(msg) time.sleep(round(float(stream_buffer))) return True @@ -587,23 +579,22 @@ class Player: ): raise InvalidChannelException - self.connector.enable_transaction() audio_store = self.config.abs_audio_store_path() extension = self.config.get("audio_source_extension") filepath = ResourceUtil.source_to_filepath(audio_store, source, extension) - self.logger.info(SU.pink(f"{channel}.queue_push('{filepath}')")) + self.logger.info(SU.pink(f"{channel}.push('{filepath}')")) if metadata: filepath = ResourceUtil.lqs_annotate(filepath, metadata) - result = self.connector.send_lqc_command(channel, "queue_push", filepath) - self.logger.info("%s.queue_push result: %s" % (channel, result)) - self.connector.disable_transaction() + result = self.client.exec(channel, "push", filepath) + self.logger.info(f"{channel}.push result: {result}") # If successful, Liquidsoap returns a resource ID of the queued track resource_id = -1 try: resource_id = int(result) except ValueError: - self.logger.error(SU.red("Got an invalid resource ID: '%s'" % result)) + msg = SU.red(f"Got invalid resource ID: '{result}'") + self.logger.error(msg) return False return resource_id >= 0 @@ -626,10 +617,8 @@ class Player: ): raise InvalidChannelException - self.connector.enable_transaction() - result = self.connector.send_lqc_command(channel, "queue_seek", str(seconds_to_seek)) - self.logger.info("%s.seek result: %s" % (channel, result)) - self.connector.disable_transaction() + result = self.client.exec(channel, "seek", str(seconds_to_seek)) + self.logger.info(f"{channel}.seek result: {result}") return result @@ -655,70 +644,22 @@ class Player: # means, this channel should not be used for at least some seconds # (including clearing time). clear_timeout = 10 - self.logger.info(f"Clearing channel '{channel}' in {clear_timeout} seconds") + self.logger.info(f"Clear channel '{channel}' in {clear_timeout} seconds") time.sleep(clear_timeout) # Deactivate channel - self.connector.enable_transaction() response = self.mixer.channel_activate(channel.value, False) - self.connector.disable_transaction() msg = f"Deactivate channel '{channel}' with result '{response}'" self.logger.info(SU.pink(msg)) # Remove all items from queue - self.connector.enable_transaction() - result = self.connector.send_lqc_command(channel, "queue_clear") - self.connector.disable_transaction() - msg = f"Clear queue channel '{channel}' with result '{result}'" + result = self.client.exec(channel, "clear") + + msg = f"Cleared queue channel '{channel}' with result '{result}'" self.logger.info(SU.pink(msg)) Thread(target=clean_up).start() - # - # Channel Type - Playlist - # - - def playlist_set_uri(self, channel, playlist_uri): - """ - Set the URI of a playlist. - - Args: - channel (Channel): The channel to push the file to - playlist_uri (String): The path to the playlist - - Returns: - (String): Liquidsoap response - - """ - self.logger.info(SU.pink("Setting URI of playlist '%s' to '%s'" % (channel, playlist_uri))) - - self.connector.enable_transaction() - result = self.connector.send_lqc_command(channel, "playlist_uri_set", playlist_uri) - self.logger.info("%s.playlist_uri result: %s" % (channel, result)) - self.connector.disable_transaction() - - return result - - def playlist_clear_uri(self, channel): - """ - Clear the URI of a playlist. - - Args: - channel (Channel): The channel to push the file to - - Returns: - (String): Liquidsoap response - - """ - self.logger.info(SU.pink("Clearing URI of playlist '%s'" % (channel))) - - self.connector.enable_transaction() - result = self.connector.send_lqc_command(channel, "playlist_uri_clear") - self.logger.info("%s.playlist_uri_clear result: %s" % (channel, result)) - self.connector.disable_transaction() - - return result - class EngineSplash: """Print the splash and version information on boot.""" diff --git a/src/aura_engine/mixer.py b/src/aura_engine/mixer.py index ca51cde..dc72c19 100644 --- a/src/aura_engine/mixer.py +++ b/src/aura_engine/mixer.py @@ -26,8 +26,8 @@ import time from enum import Enum from aura_engine.base.api import LiquidsoapUtil as LU -from aura_engine.base.exceptions import LQConnectionError from aura_engine.base.utils import SimpleUtil as SU +from aura_engine.core.client import CoreConnectionError class MixerType(Enum): @@ -63,13 +63,13 @@ class Mixer: config = None logger = None - connector = None + client = None mixer_id = None channels = None fade_in_active = None fade_out_active = None - def __init__(self, config, mixer_id, connector): + def __init__(self, config, mixer_id, client): """ Initialize the mixer object. @@ -82,7 +82,7 @@ class Mixer: self.mixer_id = mixer_id self.fade_in_active = None self.fade_out_active = None - self.connector = connector + self.client = client self.mixer_initialize() # @@ -98,14 +98,13 @@ class Mixer: - Initialize default channels per type """ - self.connector.enable_transaction() + time.sleep(1) # TODO Check is this is still required channels = self.mixer_channels_reload() # TODO Graceful reboot: At some point the current track playing could # resume inside Liquidsoap in case only Engine restarted (See #77). for channel in channels: self.channel_volume(channel, "0") - self.connector.disable_transaction() def mixer_status(self): """ @@ -113,22 +112,17 @@ class Mixer: """ cnt = 0 inputstate = {} - - self.connector.enable_transaction() inputs = self.mixer_channels() - for channel in inputs: inputstate[channel] = self.channel_status(cnt) cnt = cnt + 1 - - self.connector.disable_transaction() return inputstate def mixer_outputs(self): """ Retrieve the state of all inputs and outputs. """ - outputs = self.connector.send_lqc_command(self.mixer_id.value, "mixer_outputs") + outputs = self.client.exec(self.mixer_id.value, "outputs") outputs = LU.json_to_dict(outputs) return outputs @@ -137,7 +131,8 @@ class Mixer: Retrieve all mixer channels. """ if self.channels is None or len(self.channels) == 0: - self.channels = self.connector.send_lqc_command(self.mixer_id.value, "mixer_inputs") + channel_str = self.client.exec(self.mixer_id.value, "inputs") + self.channels = channel_str.split(" ") return self.channels def mixer_channels_selected(self): @@ -147,7 +142,6 @@ class Mixer: cnt = 0 activeinputs = [] - self.connector.enable_transaction() inputs = self.mixer_channels() for channel in inputs: @@ -156,7 +150,6 @@ class Mixer: activeinputs.append(channel) cnt = cnt + 1 - self.connector.disable_transaction() return activeinputs def mixer_channels_except(self, input_type): @@ -167,9 +160,8 @@ class Mixer: activemixer_copy = self.mixer_channels().copy() activemixer_copy.remove(input_type) except ValueError as e: - self.logger.error( - "Requested channel (%s) not in channel-list. Reason: %s" % (input_type, str(e)) - ) + msg = f"Requested channel type '{input_type}' not in channel-list" + self.logger.error(SU.red(msg), e) except AttributeError: self.logger.critical("Empty channel list") @@ -200,9 +192,8 @@ class Mixer: channels = self.mixer_channels() index = channels.index(channel) if index < 0: - self.logger.critical( - f"There's no valid channel number for channel ID '{channel.value}'" - ) + msg = f"There's no valid channel number for channel ID '{channel.value}'" + self.logger.critical(SU.red(msg)) return None return index @@ -217,7 +208,7 @@ class Mixer: (String): Channel status info as a String """ - return self.connector.send_lqc_command(self.mixer_id.value, "mixer_status", channel_number) + return self.client.exec(self.mixer_id.value, "status", channel_number) def channel_select(self, channel, select): """ @@ -236,14 +227,13 @@ class Mixer: try: index = channels.index(channel) if len(channel) < 1: - self.logger.critical("Cannot select channel. There are no channels!") + self.logger.critical(SU.red("Cannot select channel because there are no channels")) else: - message = self.connector.send_lqc_command( - self.mixer_id.value, "mixer_select", index, select - ) + select = "true" if select else "false" + message = self.client.exec(self.mixer_id.value, "select", f"{index} {select}") return message except Exception as e: - self.logger.critical("Ran into exception when selecting channel. Reason: " + str(e)) + self.logger.critical(SU.red("Ran into exception when selecting channel"), e) def channel_activate(self, channel, activate): """ @@ -266,14 +256,13 @@ class Mixer: try: index = channels.index(channel) if len(channel) < 1: - self.logger.critical("Cannot activate channel. There are no channels!") + self.logger.critical(SU.red("Cannot activate channel. There are no channels!")) else: - message = self.connector.send_lqc_command( - self.mixer_id.value, "mixer_activate", index, activate - ) + activate = "true" if activate else "false" + message = self.client.exec(self.mixer_id.value, "activate", f"{index} {activate}") return message except Exception as e: - self.logger.critical("Ran into exception when activating channel. Reason: " + str(e)) + self.logger.critical(SU.red("Ran into exception when activating channel."), e) def channel_current_volume(self, channel): """ @@ -286,8 +275,9 @@ class Mixer: if volume: return int(volume.split("%")[0]) else: - self.logger.error(f"Invalid volume for channel {channel.value} (status: '{status}'") - return 0 + msg = f"Invalid volume for channel {channel.value} (status: '{status}'" + self.logger.error(SU.red(msg)) + return -1 def channel_volume(self, channel, volume): """ @@ -307,38 +297,23 @@ class Mixer: channels = self.mixer_channels() index = channels.index(channel) except ValueError as e: - msg = f"Cannot set volume of channel '{channel}' to {volume}. Reason: {str(e)}" - self.logger.error(SU.red(msg)) + msg = f"Cannot set volume of channel '{channel}' to {volume}" + self.logger.error(SU.red(msg), e) return - try: - if len(channel) < 1: - msg = f"Cannot set volume of channel '{channel}', because no channels available" - self.logger.warning(SU.red(msg)) - else: - playout_volume = str(int(volume) / 100) # 100% volume equals 1 - message = self.connector.send_lqc_command( - self.mixer_id.value, "mixer_volume", str(index), playout_volume - ) - - if not self.connector.disable_logging: - if message.find("volume=" + str(volume) + "%"): - msg = f"Set volume of channel '{channel}' to {volume}" - self.logger.info(SU.pink(msg)) - else: - msg(f"Error setting volume of channel '{channel}': {message}") - self.logger.warning(SU.red(msg)) + # try: + if len(channel) < 1: + msg = f"Cannot set volume of channel '{channel}', because no channels available" + self.logger.error(SU.red(msg)) + else: + playout_volume = str(int(volume) / 100) # 100% volume equals 1 + args = f"{str(index)} {playout_volume}" + message = self.client.exec(self.mixer_id.value, "volume", args) + if not message.find(f"volume={volume}%"): + msg(f"Error setting volume of channel '{channel}': {message}") + self.logger.error(SU.red(msg)) - return message - except AttributeError as e: # (LQConnectionError, AttributeError): - self.connector.disable_transaction(force=True) - msg = SU.red( - "Ran into exception when setting volume of channel " - + channel - + ". Reason: " - + str(e) - ) - self.logger.error(msg) + return message # # Fading @@ -358,18 +333,14 @@ class Mixer: """ try: current_volume = self.channel_current_volume(channel) - if current_volume == volume: - self.logger.warning( - f"Current volume for channel {channel.value} is already at target volume of" - f" {volume}% SKIPPING..." - ) + msg = f"Skip fade in of {channel}: Already at target volume of {volume}%" + self.logger.info(msg) return elif current_volume > volume: - self.logger.warning( - f"Current volume {current_volume}% of channel {channel.value} exceeds target" - f" volume of {volume}% SKIPPING..." - ) + msg = f"Skip fade in of {channel}: Current volume of {current_volume}% exceeds \ + target volume of {volume}%" + self.logger.info(msg) return fade_in_time = float(self.config.get("fade_in_time")) @@ -377,36 +348,17 @@ class Mixer: if fade_in_time > 0: self.fade_in_active = True target_volume = volume - step = fade_in_time / target_volume - - msg = "Starting to fading-in '%s'. Step is %ss and target volume is %s." % ( - channel, - str(step), - str(target_volume), - ) + msg = f"Fade in '{channel}' to {target_volume} ({step}s steps)" self.logger.info(SU.pink(msg)) - - # Enable logging, which might have been disabled in a previous fade-out - # TODO refactor - self.connector.disable_logging = True - # self.connector.client.disable_logging = True - for i in range(target_volume): self.channel_volume(channel.value, i + 1) time.sleep(step) - - msg = "Finished with fading-in '%s'." % channel + msg = f"Fade in of '{channel}' done" self.logger.info(SU.pink(msg)) - self.fade_in_active = False - if not self.fade_out_active: - # TODO refactor - self.connector.disable_logging = False - # self.connector.client.disable_logging = False - - except LQConnectionError as e: - self.logger.critical(str(e)) + except CoreConnectionError as e: + self.logger.critical(SU.red(e.message), e) return False return True @@ -428,41 +380,23 @@ class Mixer: volume = current_volume if current_volume == 0: - self.logger.warning( - f"Current volume for channel {channel.value} is already at target volume of" - f" 0%. SKIPPING..." - ) + msg = f"Channel {channel} already at target volume of 0%. SKIPPING..." + self.logger.info(msg) return fade_out_time = float(self.config.get("fade_out_time")) if fade_out_time > 0: step = abs(fade_out_time) / current_volume - - msg = "Starting to fading-out '%s'. Step is %ss." % (channel, str(step)) + msg = f"Start to fade out '{channel}' ({step}s step)" self.logger.info(SU.pink(msg)) - - # Disable logging... it is going to be enabled again after fadein and -out is - # finished - # TODO refactor - self.connector.disable_logging = True - # self.connector.client.disable_logging = True - for i in range(volume): self.channel_volume(channel.value, volume - i - 1) time.sleep(step) - - msg = "Finished with fading-out '%s'" % channel + msg = f"Finished with fading-out '{channel}'" self.logger.info(SU.pink(msg)) - # Enable logging again - self.fade_out_active = False - if not self.fade_in_active: - self.connector.disable_logging = False - # TODO refactor - # self.connector.client.disable_logging = False - - except LQConnectionError as e: - self.logger.critical(str(e)) + except CoreConnectionError as e: + self.logger.critical(SU.red(e.message), e) return False return True diff --git a/src/aura_engine/plugins/mailer.py b/src/aura_engine/plugins/mailer.py index eabbe20..2500e6b 100644 --- a/src/aura_engine/plugins/mailer.py +++ b/src/aura_engine/plugins/mailer.py @@ -99,12 +99,11 @@ class AuraMailer: ) self.mail.notify_admin(subject, message) - def on_critical(self, subject, message, data=None): + def on_critical(self, data): """ Call when some critical event occurs. """ - if not data: - data = "" + (subject, message, data) = data self.mail.notify_admin(subject, message + "\n\n" + str(data)) @@ -142,9 +141,8 @@ class MailService: """ if self.admin_mails_enabled == "false": - self.logger.warning( - SU.red("No admin mail sent, because doing so is disabled in engine.ini!") - ) + # msg = "No admin mail sent, because doing so is disabled in engine.ini!" + # self.logger.warning(SU.red(msg)) return False admin_mails = self.admin_mails.split() @@ -162,12 +160,8 @@ class MailService: """ if self.coordinator_mails_enabled == "false": - self.logger.warning( - SU.yellow( - "No programme coordinator mail sent, because doing so is disabled in" - " engine.ini!" - ) - ) + # msg = "No programme coordinator mail sent, because it is disabled in engine.ini!" + # self.logger.warning(SU.yellow(msg)) return False coordinator_mails = self.coordinator_mails.split() diff --git a/src/aura_engine/plugins/monitor.py b/src/aura_engine/plugins/monitor.py index f91aa4f..1aee8aa 100644 --- a/src/aura_engine/plugins/monitor.py +++ b/src/aura_engine/plugins/monitor.py @@ -99,9 +99,6 @@ class AuraMonitor: self.status["api"]["engine"] = dict() self.already_invalid = False - # Register as an engine plugin - self.engine.plugins["monitor"] = self - # Heartbeat settings self.heartbeat_running = False self.heartbeat_server = self.config.get("heartbeat_server") @@ -240,12 +237,9 @@ class AuraMonitor: liq_version = self.config.get("version_liquidsoap") self.status["engine"]["version"] = ctrl_version - - self.engine.player.connector.enable_transaction() self.status["lqs"]["version"] = {"core": core_version, "liquidsoap": liq_version} self.status["lqs"]["outputs"] = self.engine.player.mixer.mixer_outputs() self.status["lqs"]["mixer"] = self.engine.player.mixer.mixer_status() - self.engine.player.connector.disable_transaction() self.status["api"]["steering"]["url"] = self.config.get("api_steering_status") self.status["api"]["steering"]["available"] = self.validate_url_connection( self.config.get("api_steering_status") @@ -268,9 +262,7 @@ class AuraMonitor: """ Refresh the vital status info which are required for the engine to survive. """ - self.engine.player.connector.enable_transaction() self.status["lqs"]["status"] = self.engine.update_playout_state() - self.engine.player.connector.disable_transaction() self.status["lqs"]["available"] = self.status["lqs"]["status"] is not None self.status["audio_source"] = self.validate_directory(self.config.abs_audio_store_path()) diff --git a/src/aura_engine/scheduling/scheduler.py b/src/aura_engine/scheduling/scheduler.py index cfbafc0..4cd57ff 100644 --- a/src/aura_engine/scheduling/scheduler.py +++ b/src/aura_engine/scheduling/scheduler.py @@ -26,17 +26,24 @@ import threading import time from aura_engine.base.config import AuraConfig -from aura_engine.base.exceptions import LoadSourceException, NoActiveTimeslotException from aura_engine.base.utils import SimpleUtil as SU from aura_engine.channels import ChannelType, EntryPlayState, TransitionType from aura_engine.control import EngineExecutor -from aura_engine.engine import Engine +from aura_engine.engine import Engine, LoadSourceException from aura_engine.resources import ResourceClass, ResourceUtil from aura_engine.scheduling.models import AuraDatabaseModel from aura_engine.scheduling.programme import ProgrammeService from aura_engine.scheduling.utils import TimeslotRenderer +class NoActiveTimeslotException(Exception): + """ + Exception thrown when there is no timeslot active. + """ + + pass + + class AuraScheduler(threading.Thread): """The programme scheduler. @@ -542,7 +549,7 @@ class PlayCommand(EngineExecutor): if entries[-1].status != EntryPlayState.READY: msg = f"Entries didn't reach 'ready' state during preloading (Entries: {entries_str})" - self.logger.critical(SU.red(msg)) + self.logger.warning(SU.red(msg)) def do_play(self, entries): """ -- GitLab From 7378edf4741cda901a813cb8fb059726be573ee8 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Wed, 10 Aug 2022 18:37:12 +0200 Subject: [PATCH 11/24] refact: for compare inherit enum types from str --- src/aura_engine/channels.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aura_engine/channels.py b/src/aura_engine/channels.py index b078bf6..13758cb 100644 --- a/src/aura_engine/channels.py +++ b/src/aura_engine/channels.py @@ -29,7 +29,7 @@ from aura_engine.base.utils import SimpleUtil as SU from aura_engine.resources import ResourceType -class TransitionType(Enum): +class TransitionType(str, Enum): """ Types of fade-in and fade-out transition. """ @@ -38,7 +38,7 @@ class TransitionType(Enum): FADE = "fade" -class Channel(Enum): +class Channel(str, Enum): """ Channel name mappings to the Liqidsoap channel/source IDs. """ @@ -169,7 +169,7 @@ class ChannelType(Enum): return str(self.value["id"]) -class EntryPlayState(Enum): +class EntryPlayState(str, Enum): """Play-state of a playlist entry.""" UNKNOWN = "unknown" @@ -179,7 +179,7 @@ class EntryPlayState(Enum): FINISHED = "finished" -class LiquidsoapResponse(Enum): +class LiquidsoapResponse(str, Enum): """Response values from Liquidsoap.""" # There are some weird variations of responses coming -- GitLab From 725821a1471c736b5a1e486b682f7b380da0a508 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Wed, 10 Aug 2022 18:42:48 +0200 Subject: [PATCH 12/24] docs(docstring): remove args --- src/aura_engine/base/lang.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/aura_engine/base/lang.py b/src/aura_engine/base/lang.py index 87f91d9..5cd5f8c 100644 --- a/src/aura_engine/base/lang.py +++ b/src/aura_engine/base/lang.py @@ -78,11 +78,7 @@ def private(member): class DotDict(dict): """ - Wrap a Dictionary with `DotDict` to allow property access using the dot.notation. - - Args: - dict (_type_): The dictionary - + Wrap a dictionary with `DotDict()` to allow property access using the dot.notation. """ __getattr__ = dict.get -- GitLab From f35638a9f04bb62f479da0788dd239e8a3ae80c8 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Thu, 11 Aug 2022 18:43:13 +0200 Subject: [PATCH 13/24] refact: remove obsolete code --- src/aura_engine/control.py | 163 +------------------------------------ 1 file changed, 1 insertion(+), 162 deletions(-) diff --git a/src/aura_engine/control.py b/src/aura_engine/control.py index ab5384c..f52b864 100644 --- a/src/aura_engine/control.py +++ b/src/aura_engine/control.py @@ -21,176 +21,15 @@ Remote-control the Engine with these services. """ -import json + import logging -import socket import time from datetime import datetime, timedelta from threading import Lock, Thread, Timer -from http_parser.http import HttpStream -from http_parser.reader import SocketReader - -from aura_engine.base.api import LiquidsoapUtil as LU -from aura_engine.base.config import AuraConfig from aura_engine.base.utils import SimpleUtil as SU -class EngineControlInterface: - """ - Provides ability to control the engine in various ways. - """ - - config = None - logger = None - engine = None - event_dispatcher = None - sci = None - - def __init__(self, engine, event_dispatcher): - """ - Initialize the ECI. - """ - self.engine = engine - self.config = AuraConfig.config() - self.logger = logging.getLogger("AuraEngine") - if self.config.get("enable_sci", "false") == "true": - self.logger.info(SU.yellow("[ECI] Socket Control Interface starting ...")) - self.sci = SocketControlInterface.get_instance(event_dispatcher) - else: - self.logger.debug(SU.yellow("[ECI] Socket Control Interface disabled")) - - def terminate(self): - """ - Terminate the instance and all related objects. - """ - if self.sci: - self.sci.terminate() - self.logger.info(SU.yellow("[ECI] terminated.")) - - -class SocketControlInterface: - """ - Network socket server to control a running Engine from Liquidsoap. - - Note this server only allows a single connection at once. This - service is primarily utilized to store new playlogs. - """ - - DEFAULT_CONTROL_HOST = "0.0.0.0:1337" - ACTION_ON_METADATA = "on_metadata" - - instance = None - config = None - logger = None - server = None - event_dispatcher = None - - def __init__(self, event_dispatcher): - """ - Constructor. - - Args: - config (AuraConfig): Engine configuration - logger (AuraLogger): The logger - """ - if SocketControlInterface.instance: - raise Exception(SU.red("[ECI] Socket server is already running!")) - - SocketControlInterface.instance = self - self.config = AuraConfig.config() - self.logger = logging.getLogger("AuraEngine") - self.event_dispatcher = event_dispatcher - default_host = SocketControlInterface.DEFAULT_CONTROL_HOST - url_parts = self.config.get("api_engine_control_host", default_host).split(":") - host = url_parts[0] - port = int(url_parts[1]) - thread = Thread(target=self.run, args=(self.logger, host, port)) - thread.start() - - @staticmethod - def get_instance(event_dispatcher): - """ - Return the Singleton. - """ - if not SocketControlInterface.instance: - SocketControlInterface.instance = SocketControlInterface(event_dispatcher) - return SocketControlInterface.instance - - def run(self, logger, host, port): - """ - Start the socket server. - """ - while True: - try: - self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server.bind((host, port)) - break - except OSError: - wait_time = 2 - msg = f"Cannot bind to Socket. Retry in {wait_time} seconds..." - self.logger.error(SU.red(msg)) - time.sleep(wait_time) - - logger.info(SU.yellow(f"[ECI] Listening at {host}:{port}")) - self.server.listen() - - while True: - (conn, client) = self.server.accept() - while True: - r = SocketReader(conn) - p = HttpStream(r) - data = p.body_file().read() - msg = f"[ECI] Received socket data from {str(client)}: {str(data)}" - logger.debug(SU.yellow(msg)) - - try: - data = data.decode("utf-8") - data = LU.json_to_dict(data) - self.process(logger, json.loads(data)) - conn.sendall(b"\n[ECI] processing done.\n") - except Exception as e: - logger.error(SU.red(f"[ECI] Error while processing request: {data}"), e) - - conn.close() - break - - def process(self, logger, data): - """ - Process incoming actions. - """ - if "action" in data: - if data["action"] == SocketControlInterface.ACTION_ON_METADATA: - - def get_field(field): - for item in data["meta"]: - if item[0] == field: - return item[1] - return None - - meta_data = {} - meta_data["duration"] = data["track_duration"] - meta_data["filename"] = get_field("filename") - meta_data["on_air"] = get_field("on_air") - msg = f"[ECI] Exec action: {SocketControlInterface.ACTION_ON_METADATA}" - logger.debug(SU.yellow(msg)) - self.event_dispatcher.on_metadata(meta_data) - msg = f"[ECI] Event '{SocketControlInterface.ACTION_ON_METADATA}' issued" - logger.info(SU.yellow(msg)) - else: - logger.error(SU.red("[ECI] Unknown action: " + data["action"])) - else: - logger.error(SU.red(f"[ECI] Missing action in request: {data}")) - - def terminate(self): - """ - Call when a shutdown signal is received. - """ - SocketControlInterface.instance = None - self.server.close() - self.logger.info(SU.yellow("[ECI] Shutting down...")) - - class EngineExecutor(Timer): """ Base class for timed or threaded execution of Engine commands. -- GitLab From 5c3cd1f9b18ef4b151cadc536412a6dead897c73 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 12 Aug 2022 16:22:25 +0200 Subject: [PATCH 14/24] Refact(mixer): OO separation and encapsulation #65 --- src/aura_engine/base/api.py | 11 + src/aura_engine/channels.py | 287 --------------- src/aura_engine/core/channels.py | 454 ++++++++++++++++++++++++ src/aura_engine/core/client.py | 91 ++++- src/aura_engine/core/mixer.py | 411 +++++++++++++++++++++ src/aura_engine/engine.py | 371 ++++++------------- src/aura_engine/mixer.py | 402 --------------------- src/aura_engine/plugins/monitor.py | 4 +- src/aura_engine/resources.py | 72 +++- src/aura_engine/scheduling/scheduler.py | 19 +- 10 files changed, 1117 insertions(+), 1005 deletions(-) delete mode 100644 src/aura_engine/channels.py create mode 100644 src/aura_engine/core/channels.py create mode 100644 src/aura_engine/core/mixer.py delete mode 100644 src/aura_engine/mixer.py diff --git a/src/aura_engine/base/api.py b/src/aura_engine/base/api.py index 1b66744..0185342 100644 --- a/src/aura_engine/base/api.py +++ b/src/aura_engine/base/api.py @@ -219,3 +219,14 @@ class LiquidsoapUtil: data = data.replace("-", " ") data = requests.utils.unquote(data) return json.loads(data) + + @staticmethod + def annotate_uri(uri: str, annotations: dict) -> str: + """ + Wrap the given URI with the passed annotation dictionary. + """ + metadata = "" + for k, v in annotations.items(): + metadata += f'{k}="{v}",' + uri = f"annotate:{metadata[:-1]}:{uri}" + return uri diff --git a/src/aura_engine/channels.py b/src/aura_engine/channels.py deleted file mode 100644 index 13758cb..0000000 --- a/src/aura_engine/channels.py +++ /dev/null @@ -1,287 +0,0 @@ -# -# Aura Engine (https://gitlab.servus.at/aura/engine) -# -# Copyright (C) 2017-2020 - The Aura Engine Team. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -""" -Configuration and helpers for channel types. -""" - -import logging -from enum import Enum - -from aura_engine.base.config import AuraConfig -from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.resources import ResourceType - - -class TransitionType(str, Enum): - """ - Types of fade-in and fade-out transition. - """ - - INSTANT = "instant" - FADE = "fade" - - -class Channel(str, Enum): - """ - Channel name mappings to the Liqidsoap channel/source IDs. - """ - - QUEUE_A = "in_filesystem_0" - QUEUE_B = "in_filesystem_1" - HTTP_A = "in_http_0" - HTTP_B = "in_http_1" - LIVE_0 = "linein_0" - LIVE_1 = "linein_1" - LIVE_2 = "linein_2" - LIVE_3 = "linein_3" - LIVE_4 = "linein_4" - FALLBACK_QUEUE_A = "in_fallback_scheduled_0" - FALLBACK_QUEUE_B = "in_fallback_scheduled_1" - FALLBACK_FOLDER = "fallback_folder" - FALLBACK_PLAYLIST = "fallback_playlist" - - def __str__(self): - return str(self.value) - - -class ChannelResolver: - """ - Helpers for resolving channel enumerations. - """ - - @staticmethod - def channel_by_string(channel: str): - """ - Return the channel enum for a given channel string from Liquidsoap. - """ - if not channel: - return None - - if channel == Channel.QUEUE_A.value: - return Channel.QUEUE_A - elif channel == Channel.QUEUE_B.value: - return Channel.QUEUE_B - elif channel == Channel.HTTP_A.value: - return Channel.HTTP_A - elif channel == Channel.HTTP_B.value: - return Channel.HTTP_B - elif channel == Channel.LIVE_0.value: - return Channel.LIVE_0 - elif channel == Channel.LIVE_1.value: - return Channel.LIVE_1 - elif channel == Channel.LIVE_2.value: - return Channel.LIVE_2 - elif channel == Channel.LIVE_3.value: - return Channel.LIVE_3 - elif channel == Channel.LIVE_4.value: - return Channel.LIVE_4 - elif channel == Channel.FALLBACK_QUEUE_A.value: - return Channel.FALLBACK_QUEUE_A - elif channel == Channel.FALLBACK_QUEUE_B.value: - return Channel.FALLBACK_QUEUE_B - elif channel == Channel.FALLBACK_FOLDER.value: - return Channel.FALLBACK_FOLDER - elif channel == Channel.FALLBACK_PLAYLIST.value: - return Channel.FALLBACK_PLAYLIST - else: - return None - - @staticmethod - def live_channel_for_resource(channel: str): - """ - Return the channel enum for a given live channel string from Tank. - """ - if not channel: - return None - channel = "linein_" + channel.split("line://")[1] - - if channel == Channel.LIVE_0.value: - return Channel.LIVE_0 - elif channel == Channel.LIVE_1.value: - return Channel.LIVE_1 - elif channel == Channel.LIVE_2.value: - return Channel.LIVE_2 - elif channel == Channel.LIVE_3.value: - return Channel.LIVE_3 - elif channel == Channel.LIVE_4.value: - return Channel.LIVE_4 - else: - return None - - -class ChannelType(Enum): - """ - Engine channel types mapped to `Entry` source types. - """ - - QUEUE = {"id": "fs", "numeric": 0, "channels": [Channel.QUEUE_A, Channel.QUEUE_B]} - HTTP = {"id": "http", "numeric": 1, "channels": [Channel.HTTP_A, Channel.HTTP_B]} - LIVE = { - "id": "live", - "numeric": 3, - "channels": [ - Channel.LIVE_0, - Channel.LIVE_1, - Channel.LIVE_2, - Channel.LIVE_3, - Channel.LIVE_4, - ], - } - FALLBACK_QUEUE = { - "id": "fallback_queue", - "numeric": 4, - "channels": [Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B], - } - FALLBACK_POOL = { - "id": "fallback_pool", - "numeric": 5, - "channels": [Channel.FALLBACK_FOLDER, Channel.FALLBACK_PLAYLIST], - } - - @property - def channels(self): - """Retrieve all channels for given type.""" - return self.value["channels"] - - @property - def numeric(self): - """Retrieve numeric representation of channel type for given type.""" - return self.value["numeric"] - - def __str__(self): - return str(self.value["id"]) - - -class EntryPlayState(str, Enum): - """Play-state of a playlist entry.""" - - UNKNOWN = "unknown" - LOADING = "loading" - READY = "ready_to_play" - PLAYING = "playing" - FINISHED = "finished" - - -class LiquidsoapResponse(str, Enum): - """Response values from Liquidsoap.""" - - # There are some weird variations of responses coming - SUCCESS = ["Done", "Done!", "Donee!", "OK"] - STREAM_STATUS_POLLING = "polling" - STREAM_STATUS_STOPPED = "stopped" - STREAM_STATUS_CONNECTED = "connected" - - -class ChannelRouter: - """ - Wires source types with channels and channel-types. - """ - - logger = None - config: AuraConfig = None - resource_mapping = None - active_channel: Channel = None - - def __init__(self): - """ - Initialize the channel router. - """ - self.logger = logging.getLogger("AuraEngine") - self.config = AuraConfig.config() - - self.resource_mapping = { - ResourceType.FILE: ChannelType.QUEUE, - ResourceType.STREAM_HTTP: ChannelType.HTTP, - ResourceType.LINE: ChannelType.LIVE, - ResourceType.PLAYLIST: ChannelType.QUEUE, - ResourceType.POOL: ChannelType.QUEUE, - } - - def set_active(self, channel: Channel): - """ - Set the currently active channel. - """ - self.active_channel = channel - - def get_active(self): - """ - Retrieve the currently active channel. - """ - return self.active_channel - - def type_of_channel(self, channel): - """ - Retrieve a `ChannelType` for the given `Channel`. - """ - if channel in ChannelType.QUEUE.channels: - return ChannelType.QUEUE - elif channel in ChannelType.FALLBACK_QUEUE.channels: - return ChannelType.FALLBACK_QUEUE - elif channel in ChannelType.HTTP.channels: - return ChannelType.HTTP - elif channel in ChannelType.LIVE.channels: - return ChannelType.LIVE - else: - return None - - def is_any_queue(self, channel): - """ - Evaluate if the channel is of any queue type. - """ - ct = self.type_of_channel(channel) - if ct == ChannelType.QUEUE or ct == ChannelType.FALLBACK_QUEUE: - return True - return False - - def type_for_resource(self, resource_type): - """ - Retrieve a `ChannelType` for the given `ResourceType`. - - Only default mappings can be evaluatated. Custom variations - like fallback channels are not respected. - """ - return self.resource_mapping.get(resource_type) - - def get_free_channel(self, channel_type: ChannelType) -> Channel: - """ - Return any _free_ channel of the given type. - - A channel which is not currently active is seen as _free_. - - Args: - channel_type (Channel): The type of channel to be retrieved - - Returns: - (Channel, Channel): The active and next free channel of the requested type - - """ - free_channel: Channel = None - if self.type_of_channel(self.active_channel) == channel_type: - free_channels = [c for c in channel_type.channels if c != self.active_channel] - if len(free_channels) < 1: - msg = f"Requesting channel of type '{channel_type}' but none free. \ - Active channel: '{self.active_channel}'" - self.logger.critical(SU.red(msg)) - else: - free_channel = free_channels[0] - else: - free_channel = channel_type.channels[0] - self.logger.info(SU.pink(f"Got free '{channel_type}' channel '{free_channel}'")) - return (self.active_channel, free_channel) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py new file mode 100644 index 0000000..2920edc --- /dev/null +++ b/src/aura_engine/core/channels.py @@ -0,0 +1,454 @@ +# +# Aura Engine (https://gitlab.servus.at/aura/engine) +# +# Copyright (C) 2017-2020 - The Aura Engine Team. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +""" +Channels Module. + +Base types for channels: + - ChannelName: Valid channel names as defined in core. + - ChannelType: Holds mappings to concrete channel names. + +Channel definitions: + + - GenericChannel: All other channels inherit from this one. + - QueueChannel: Handles queues such as filesystem entries. + - StreamChannel: Handles stream connections. + - AnalogChannel: Handles analog audio input. +""" + +import logging +import time +from enum import Enum + +from aura_engine.base.api import LiquidsoapUtil as LU +from aura_engine.base.config import AuraConfig +from aura_engine.base.lang import private +from aura_engine.base.utils import SimpleUtil as SU + + +class ChannelName(str, Enum): + """ + Allowed channel names. + + These are name mappings to the Liqidsoap channel IDs. + """ + + QUEUE_A = "in_filesystem_0" + QUEUE_B = "in_filesystem_1" + HTTP_A = "in_http_0" + HTTP_B = "in_http_1" + LIVE_0 = "linein_0" + LIVE_1 = "linein_1" + LIVE_2 = "linein_2" + LIVE_3 = "linein_3" + LIVE_4 = "linein_4" + FALLBACK_FOLDER = "fallback_folder" + FALLBACK_PLAYLIST = "fallback_playlist" + + def __str__(self): + return str(self.value) + + +class ChannelType(dict, Enum): + """ + Available channel types. + + Engine channel types mapped to `Entry` source types. + """ + + QUEUE = {"id": "fs", "numeric": 0, "channels": [ChannelName.QUEUE_A, ChannelName.QUEUE_B]} + HTTP = {"id": "http", "numeric": 1, "channels": [ChannelName.HTTP_A, ChannelName.HTTP_B]} + LIVE = { + "id": "live", + "numeric": 3, + "channels": [ + ChannelName.LIVE_0, + ChannelName.LIVE_1, + ChannelName.LIVE_2, + ChannelName.LIVE_3, + ChannelName.LIVE_4, + ], + } + FALLBACK_POOL = { + "id": "fallback_pool", + "numeric": 5, + "channels": [ChannelName.FALLBACK_FOLDER, ChannelName.FALLBACK_PLAYLIST], + } + + @property + def channels(self): + """ + Retrieve all channels for given type. + """ + return self.value["channels"] + + @property + def numeric(self): + """ + Retrieve numeric representation of channel type for given type. + """ + return self.value["numeric"] + + def __str__(self): + return str(self.value["id"]) + + +class GenericChannel: + """ + Base class for channel implementations. + """ + + logger: None + config: None + + type: ChannelType = None + name: ChannelName = None + channel_index: int = None + + def __init__(self, channel_index: int, channel_name: int, mixer): + """ + Initialize the channel instance. + + Args: + channel_index (int): Index of the channel on the mixer + channel_name (ChannelName): Name of the channel + mixer (Mixer): The mixer instance + + """ + self.config = AuraConfig.config() + self.logger = logging.getLogger("AuraEngine") + self.mixer = mixer + self.name = channel_name + self.channel_index = channel_index + + def get_type(self) -> ChannelType: + """ + Retrieve the `ChannelType`. + """ + return self.type + + def set_track_metadata(self, json_metadata: str) -> str: + """ + Set the metadata as current track metadata on the given channel. + """ + response = self.mixer.client.exec(self.name, "set_track_metadata", json_metadata) + self.logger.info(f"Response for '{self.name}.set_track_metadata': {response}") + if response not in PlayoutStatusResponse.SUCCESS.value: + msg = f"Error while setting metadata on {self.name} to:\n{json_metadata}" + self.logger.error(SU.red(msg)) + return response + + def load(self): + """ + Interface definition for loading a channel track. + """ + pass + + def fade_in(self, volume: int, instant=False): + """ + Perform a fade-in for the given channel. + """ + if instant: + self.logger.info(SU.pink(f"Activate channel {self}")) + self.mixer.activate_channel(self, True) + else: + self.logger.info(SU.pink(f"Fade in channel {self}")) + self.mixer.select_channel(self, True) + # if response not in PlayoutStatusResponse.SUCCESS.value: + # msg = f"Error while selecting channel {self.name}: {response}" + # self.logger.error(SU.red(msg)) + return self.mixer.fade_in(self, volume) + + def fade_out(self, instant=False): + """ + Perform a fade-out for the given channel starting at its current volume. + + Args: + instant(bool): If true the fade instantly jumps to zero volume + """ + if instant: + self.logger.info(SU.pink(f"Activate channel {self}")) + self.mixer.activate_channel(self, False) + else: + self.logger.info(SU.pink(f"Fade out channel {self}")) + self.mixer.fade_out(self) + self.mixer.select_channel(self, False) + + def __str__(self): + """ + String representation of the Channel. + """ + return f"[{self.channel_index} : {self.name}]" + + +class QueueChannel(GenericChannel): + """ + Channel for queues such as a collection of filesystem URIs. + """ + + def __init__(self, channel_index, channel_name, mixer): + """ + Initialize the queue channel instance. + + Args: + mixer (Mixer): The mixer instance + channel_index (int): Channel index on the mixer + + """ + self.type = ChannelType.QUEUE + super().__init__(channel_index, channel_name, mixer) + + def load(self, uri, metadata): + """ + Load the provided URI and pass metadata. + """ + self.logger.info(SU.pink(f"{self.name}.push('{uri}')")) + if metadata: + uri = LU.annotate_uri(uri, metadata) + response = self.mixer.client.exec(self.name, "push", uri) + self.logger.info(f"{self.name}.push result: {response}") + + # If successful, Liquidsoap returns a resource ID of the queued track + resource_id = -1 + try: + resource_id = int(response) + except ValueError: + msg = SU.red(f"Got invalid resource ID: '{response}'") + self.logger.error(msg) + return False + return resource_id >= 0 + + def roll(self, seconds_to_roll): + """ + Fast-forward to a a time position in the queue track. + """ + response = self.mixer.client.exec(self.name, "seek", str(seconds_to_roll)) + self.logger.info(f"{self.name}.seek result: {response}") + return response + + def flush(self): + """ + Remove all items from queue. + """ + response = self.mixer.client.exec(self.name, "clear") + msg = f"Cleared queue channel '{self.name}' with result '{response}'" + self.logger.info(SU.pink(msg)) + return response + + +class StreamChannel(GenericChannel): + """ + Channel for audio stream input. + """ + + def __init__(self, channel_index, channel_name, mixer): + """ + Initialize the queue channel instance. + + Args: + mixer (Mixer): The mixer instance + channel_index (int): Channel index on the mixer + + """ + self.type = ChannelType.HTTP + super().__init__(channel_index, channel_name, mixer) + + def load(self, url): + """ + Load the given stream entry and updates the entries's status codes. + + Args: + entry (Entry): The entry to be pre-loaded + + Returns: + (Boolean): `True` if successful + + """ + self.stop() + self.set_url(url) + # TODO Review if still valid: Liquidsoap ignores commands sent without a certain timeout + time.sleep(2) + self.start() + + # TODO Review if that's still required: + time.sleep(1) + retry_delay = self.config.get("input_stream_retry_delay") + max_retries = self.config.get("input_stream_max_retries") + retries = 0 + + while not self.is_ready(url): + self.logger.info("Loading Stream ...") + if retries >= max_retries: + msg = f"Stream connection failed after {retries * retry_delay} seconds!" + raise LoadSourceException(msg) + time.sleep(retry_delay) + retries += 1 + return True + + @private + def is_ready(self, url): + """ + Check if the stream on the given channel is ready to play. + + Note this method is blocking some serious amount of time even when successful; hence it is + worth being called asynchronously. + + Args: + channel (ChannelName): The stream channel + url (String): The stream URL + + Returns: + (Boolean): `True` if successful + + @private + + """ + is_ready = True + response = self.mixer.client.exec(self.name, "status") + self.logger.info(f"{self.name}.status result: {response}") + if not response.startswith(PlayoutStatusResponse.STREAM_STATUS_CONNECTED.value): + return False + + lqs_url = response.split(" ")[1] + + if not url == lqs_url: + msg = f"Wrong URL '{lqs_url}' set for channel '{self.name}', expected: '{url}'." + self.logger.error(msg) + is_ready = False + + if is_ready: + stream_buffer = self.config.get("input_stream_buffer") + msg = f"Ready to play stream, but wait {stream_buffer} seconds to fill buffer..." + self.logger.info(msg) + time.sleep(round(float(stream_buffer))) + + return is_ready + + @private + def stop(self): + """ + Stop the stream. + """ + response = self.mixer.client.exec(self.name, "stop") + if response not in PlayoutStatusResponse.SUCCESS.value: + self.logger.error(f"{self.name}.stop result: {response}") + # FIXME use another exception + raise LoadSourceException("Error while stopping stream!") + return response + + @private + def set_url(self, url): + """ + Set the stream URL. + """ + response = self.mixer.client.exec(self.name, "url", url) + if response not in PlayoutStatusResponse.SUCCESS.value: + self.logger.error(f"{self.name}.url result: {response}") + # FIXME use another exception + raise LoadSourceException("Error while setting stream URL!") + return response + + @private + def start(self): + """ + Start the stream URL. + """ + response = self.mixer.client.exec(self.name, "start") + self.logger.info(f"{self.name}.start result: {response}") + return response + + +class AnalogChannel(GenericChannel): + """ + Channel for analog audio input. + """ + + def __init__(self, channel_index, channel_name, mixer): + """ + Initialize the queue channel instance. + + Args: + mixer (Mixer): The mixer instance + channel_index (int): Channel index on the mixer + + """ + self.type = ChannelType.LIVE + super().__init__(channel_index, channel_name, mixer) + + +class ChannelFactory: + """ + A factory to construct channels based on a given channel name. + """ + + def __init__(self, mixer): + """ + Initialize the channel factory. + + Args: + mixer (Mixer): The mixer instance + + """ + self.config = AuraConfig() + self.logger = logging.getLogger("AuraEngine") + self.mixer = mixer + + def create_channel(self, channel_index, channel_name: ChannelName, mixer) -> GenericChannel: + """ + Create a channel with the provided details. + + Depending on the given channel name, a different channel is instantiated. + + Args: + channel_index (int): The index of the channel on the mixer + channel_name (ChannelName): The channel name as defined in Liquidsoap + mixer (Mixer): The mixer instance + + Returns: + (GenericChannel): A concrete implementation of the generic channel + + """ + if channel_name in ChannelType.QUEUE.channels: + self.logger.debug(f"Create new QUEUE channel '{channel_name}'") + return QueueChannel(channel_index, channel_name, mixer) + if channel_name in ChannelType.HTTP.channels: + self.logger.debug(f"Create new STREAM channel '{channel_name}'") + return StreamChannel(channel_index, channel_name, mixer) + if channel_name in ChannelType.LIVE.channels: + self.logger.debug(f"Create new ANALOG channel '{channel_name}'") + return AnalogChannel(channel_index, channel_name, mixer) + + +class PlayoutStatusResponse(str, Enum): + """ + Response values indicating some status. + """ + + SUCCESS = ["OK", "Done", "Done!", "Donee!"] + STREAM_STATUS_POLLING = "polling" + STREAM_STATUS_STOPPED = "stopped" + STREAM_STATUS_CONNECTED = "connected" + + +class LoadSourceException(Exception): + """ + Exception thrown when some source could not be loaded or updated. + """ + + pass diff --git a/src/aura_engine/core/client.py b/src/aura_engine/core/client.py index b41f403..ef38bae 100644 --- a/src/aura_engine/core/client.py +++ b/src/aura_engine/core/client.py @@ -28,14 +28,8 @@ import urllib.parse from aura_engine.base.config import AuraConfig from aura_engine.base.lang import private, synchronized from aura_engine.base.utils import SimpleUtil as SU - - -class CoreConnectionError(Exception): - """ - Exception thrown when there is a connection problem with Liquidsoap. - """ - - pass +from aura_engine.core.mixer import Mixer +from aura_engine.events import EngineEventDispatcher class CoreClient: @@ -51,29 +45,24 @@ class CoreClient: event_dispatcher = None conn = None - def __init__(self): + def __init__(self, event_dispatcher: EngineEventDispatcher): """ - Initialize the connection. + Initialize the client. """ self.logger = logging.getLogger("AuraEngine") self.config = AuraConfig.config() + self.event_dispatcher = event_dispatcher self.conn = CoreConnection() @staticmethod - def get_instance(): + def get_instance(event_dispatcher: EngineEventDispatcher): """ Get an instance of the client singleton. """ if not CoreClient.instance: - CoreClient.instance = CoreClient() + CoreClient.instance = CoreClient(event_dispatcher) return CoreClient.instance - def set_event_dispatcher(self, event_dispatcher): - """ - Set an instance of the event dispatcher. - """ - self.event_dispatcher = event_dispatcher - @synchronized def connect(self): """ @@ -171,6 +160,64 @@ class CoreClient: self.logger.debug(log_message) +class PlayoutClient(CoreClient): + """ + Client managing communication with Engine Core (Liquidsoap). + """ + + mixer = None + + def __init__(self, event_dispatcher: EngineEventDispatcher): + """ + Initialize the client. + """ + super().__init__(event_dispatcher) + self.mixer = Mixer("mixer", self) + + @staticmethod + def get_instance(event_dispatcher: EngineEventDispatcher) -> CoreClient: + """ + Get an instance of the client singleton. + """ + if not PlayoutClient.instance: + PlayoutClient.instance = PlayoutClient(event_dispatcher) + return PlayoutClient.instance + + def get_mixer(self): + """ + Get the mixer instance. + """ + return self.mixer + + # ns:* + + def get_uptime(self) -> str: + """ + Get info on how long core is running already. + """ + return self.exec("", "uptime") + + # ns:aura_engine + + def get_version(self) -> str: + """ + Get JSON with version information. + """ + return self.exec("aura_engine", "version") + + def get_status(self) -> str: + """ + Get engine status such as uptime and fallback mode. + """ + return self.exec("aura_engine", "status") + + def set_config(self, json_config: str) -> str: + """ + Send JSON with configuration options to core. + """ + return self.exec("aura_engine", "update_config", json_config) + + class CoreConnection: """ Handles connections and sends commands to Engine Core (Liquidsoap). @@ -324,3 +371,11 @@ class CoreConnection: self.logger.error(SU.red(msg), e) return self.message return None + + +class CoreConnectionError(Exception): + """ + Exception thrown when there is a connection problem with Liquidsoap. + """ + + pass diff --git a/src/aura_engine/core/mixer.py b/src/aura_engine/core/mixer.py new file mode 100644 index 0000000..060a013 --- /dev/null +++ b/src/aura_engine/core/mixer.py @@ -0,0 +1,411 @@ +# +# Aura Engine (https://gitlab.servus.at/aura/engine) +# +# Copyright (C) 2017-2020 - The Aura Engine Team. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +""" +The virtual mixer as it is realized using Liquidsoap. + +A mixer consists of general methods to adjust volume, enable/disable channels etc. It also has +set of channels with their own control options. +""" + +import logging +import time + +from aura_engine.base.api import LiquidsoapUtil as LU +from aura_engine.base.config import AuraConfig +from aura_engine.base.lang import DotDict, private +from aura_engine.base.utils import SimpleUtil as SU +from aura_engine.core.channels import ( + ChannelFactory, + ChannelName, + ChannelType, + GenericChannel, +) + + +class Mixer: + """ + A virtual mixer. + """ + + config = None + logger = None + client = None + mixer_id = None + channels = None + channel_names = None + active_channel: ChannelName = None + + def __init__(self, mixer_id: str, client): + """ + Initialize the mixer. + + It loads all the available channels from Liquidsoap and pulls the faders down to zero. + + Args: + mixer_id (str): The ID of the mixer in Liquidsoap + client (PlayoutClient): The client for controlling playout + + """ + self.config = AuraConfig.config() + self.logger = logging.getLogger("AuraEngine") + self.mixer_id = mixer_id + self.client = client + self.channel_names = [] + self.channels = {} + + time.sleep(1) # TODO Check is this is still required + self.refresh_channels() + + # TODO Graceful reboot: At some point the current track playing could + # resume inside Liquidsoap in case only Engine restarted (See #77). + for n in self.channel_names: + self.set_channel_volume(self.channels.get(n), 0) + + def get_inputs(self) -> dict: + """ + Return the state of all mixer input channels. + """ + self.refresh_channels() + inputs = {} + + for idx, channel in enumerate(self.channel_names): + inputs[channel] = self.get_channel_status(idx) + return inputs + + def get_outputs(self) -> dict: + """ + Retrieve the state of all mixer outputs. + """ + outputs = self.client.exec(self.mixer_id, "outputs") + outputs = LU.json_to_dict(outputs) + return outputs + + def set_active_channel(self, channel: ChannelName): + """ + Set the currently active channel. + + TODO active channel state should be handled internally. + """ + self.active_channel = channel + + def get_active_channel(self) -> ChannelName: + """ + Retrieve the currently active channel. + + TODO active channel state should be handled internally. + """ + return self.active_channel + + def get_channel(self, channel_name: ChannelName) -> GenericChannel: + """ + Retrieve a channel identified by name. + """ + self.channels.get(channel_name) + + def get_free_channel(self, channel_type: ChannelType) -> GenericChannel: + """ + Return any _free_ channel of the given type. + + A channel which is not currently active is seen as _free_. + + Args: + channel_type (ChannelType): The type of channel to be retrieved + + Returns: + (ChannelName, ChannelName): The active and next free channel of the requested type + + """ + free_channel: ChannelName = None + if self.channels.get(self.active_channel) == channel_type: + free_channels = [c for c in channel_type.channels if c != self.active_channel] + if len(free_channels) < 1: + msg = f"Requesting channel of type '{channel_type}' but none free. \ + Active channel: '{self.active_channel}'" + self.logger.critical(SU.red(msg)) + else: + free_channel = free_channels[0] + else: + free_channel = channel_type.channels[0] + self.logger.info(SU.pink(f"Got free '{channel_type}' channel '{free_channel}'")) + return self.channels.get(free_channel) + + def select_channel(self, channel: GenericChannel, select: bool) -> str: + """ + Select or deselect some mixer channel. + + Args: + channel (ChannelName): The channel number + select (Boolean): Select or deselect + + Returns: + (String): Liquidsoap server response + + """ + self.refresh_channels() + + try: + if not self.channels: + self.logger.critical(SU.red("Cannot select channel cuz there are no channels")) + else: + index = channel.channel_index + select = "true" if select else "false" + # TODO message holds the new channel status: store it + # 'ready=true selected=true single=false volume=0% remaining=inf' + message = self.client.exec(self.mixer_id, "select", f"{index} {select}") + return message + except Exception as e: + self.logger.critical(SU.red("Ran into exception when selecting channel"), e) + + def activate_channel(self, channel: GenericChannel, activate: bool) -> str: + """ + Activate a channel. + + Combined call of following to save execution time: + - Select some mixer channel + - Increase the volume to 100, + + Args: + channel (ChannelName): The channel number + activate (bool): Activate or deactivate + + Returns: + (str): Liquidsoap server response + + """ + self.refresh_channels() + + try: + if not self.channels: + self.logger.critical(SU.red("Cannot activate channel cuz there are no channels")) + else: + index = channel.channel_index + activate = "true" if activate else "false" + message = self.client.exec(self.mixer_id, "activate", f"{index} {activate}") + return message + except Exception as e: + self.logger.critical(SU.red("Ran into exception when activating channel."), e) + + def fade_in(self, channel: GenericChannel, volume: int = 100) -> bool: + """ + Perform a fade-in for the given channel. + + Args: + channel (GenericChannel): The channel to fade + volume (Integer): The target volume + + Returns: + (bool): `True` if successful + + TODO Think about using native Liquidsoap fading. This should bring better performance + and wanted separation of concerns. + """ + try: + current_volume = self.get_channel_volume(channel) + if current_volume == volume: + msg = f"Skip fade in of {channel}: Already at target volume of {volume}%" + self.logger.info(msg) + return + elif current_volume > volume: + msg = f"Skip fade in of {channel}: Current volume of {current_volume}% exceeds \ + target volume of {volume}%" + self.logger.info(msg) + return + + fade_in_time = float(self.config.get("fade_in_time")) + + if fade_in_time > 0: + self.fade_in_active = True + target_volume = volume + step = fade_in_time / target_volume + msg = f"Fade in of {channel} to {target_volume} ({step}s steps)" + self.logger.info(SU.pink(msg)) + for i in range(target_volume): + self.set_channel_volume(channel, i + 1) + time.sleep(step) + msg = f"Fade in of {channel} done" + self.logger.info(SU.pink(msg)) + + except Exception as e: + self.logger.critical(SU.red(e.message), e) + return False + return True + + def fade_out(self, channel: GenericChannel) -> bool: + """ + Perform a fade-out for the given channel starting at its current volume. + + Args: + channel (GenericChannel): The channel to fade + + Returns: + (Boolean): `True` if successful + + TODO Think about using native Liquidsoap fading. This should bring better performance + and wanted separation of concerns. + """ + try: + current_volume = self.get_channel_volume(channel) + + if current_volume == 0: + msg = f"Channel {channel} already at target volume of 0%. SKIPPING..." + self.logger.info(msg) + return + + fade_out_time = float(self.config.get("fade_out_time")) + + if fade_out_time > 0: + step = abs(fade_out_time) / current_volume + msg = f"Start to fade out {channel} ({step}s step)" + self.logger.info(SU.pink(msg)) + for i in range(current_volume): + self.set_channel_volume(channel, current_volume - i - 1) + time.sleep(step) + msg = f"Finished fade out of {channel}" + self.logger.info(SU.pink(msg)) + + except Exception as e: + self.logger.critical(SU.red(e.message), e) + return False + return True + + # + # Private Methods + # + + @private + def refresh_channels(self): + """ + Retrieve all mixer channel names and create channel instances, if not available. + + @private + """ + + def create_channel(name): + self.logger.debug(f"Set new channel name '{name}'") + self.channel_names.append(name) + idx = self.channel_names.index(name) + channel = ChannelFactory.create_channel(self, idx, name, self) + self.channels[name] = channel + + if not self.channel_names: + + # Get channel names + channel_names = self.client.exec(self.mixer_id, "inputs") + channel_names = channel_names.split(" ") + # Create channels objects if not yet available + for name in channel_names: + try: + self.channel_names.index(name) + except ValueError: + create_channel(name) + + # Update channel status + # ... + + @private + def get_channel_number(self, channel_name: ChannelName) -> int: + """ + Return the channel number for the given channel name. + + Args: + channel (ChannelName): The channel + + Returns: + (Integer): The channel number + + @private + + """ + self.refresh_channels() + + index = self.channel_names.index(channel_name) + if index < 0: + msg = f"There's no valid channel number for channel ID '{channel_name}'" + self.logger.critical(SU.red(msg)) + return None + return index + + @private + def get_channel_status(self, channel_number: int) -> dict: + """ + Retrieve the status of a channel identified by the channel number. + + Args: + channel_number (int): The channel number + + Returns: + (dict): Channel status dictionary + + @private + + """ + response = self.client.exec(self.mixer_id, "status", channel_number) + # TODO separate method to be utilized by individual channels updates too + status = {} + pairs = response.split(" ") + for pair in pairs: + kv = pair.split("=") + status[kv[0]] = kv[1] + return DotDict(status) + + @private + def get_channel_volume(self, channel: GenericChannel) -> int: + """ + Retrieve the current volume of the channel. + + Args: + channel (GenericChannel): The channel + + Returns: + (int): Volume between 0 and 100 or -1 in case of an error + + @private + + """ + status = self.get_channel_status(channel.channel_index) + volume = status.get("volume") + if volume: + # TODO check if we now need to multiply by 100 (since Liquidsoap 2) + return int(volume.split("%")[0]) + else: + msg = f"Invalid volume for channel {channel} (status: '{status}'" + self.logger.error(SU.red(msg)) + return -1 + + @private + def set_channel_volume(self, channel: GenericChannel, volume: int): + """ + Set volume of a channel. + + Args: + channel (GenericChannel): The channel + volume (int): Volume between 0 and 100 + + @private + + """ + self.refresh_channels() + + playout_volume = str(int(volume) / 100) # 100% volume equals 1 + args = f"{channel.channel_index} {playout_volume}" + message = self.client.exec(self.mixer_id, "volume", args) + if not message.find(f"volume={volume}%"): + msg = f"Error setting volume of channel {channel}: {message}" + self.logger.error(SU.red(msg)) diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index 2482251..74048fb 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -26,43 +26,22 @@ import logging import os import time from contextlib import suppress +from enum import Enum from threading import Thread from aura_engine.base.api import LiquidsoapUtil as LU from aura_engine.base.config import AuraConfig from aura_engine.base.lang import DotDict from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.control import EngineControlInterface -from aura_engine.core.client import CoreClient, CoreConnectionError +from aura_engine.core.channels import ChannelName, ChannelType, GenericChannel +from aura_engine.core.client import CoreConnectionError, PlayoutClient from aura_engine.events import EngineEventDispatcher -from aura_engine.resources import ResourceClass, ResourceUtil -from src.aura_engine.channels import ( - Channel, - ChannelResolver, - ChannelRouter, - ChannelType, - EntryPlayState, - LiquidsoapResponse, +from aura_engine.resources import ( + ResourceClass, + ResourceMapping, ResourceType, - TransitionType, + ResourceUtil, ) -from src.aura_engine.mixer import Mixer, MixerType - - -class InvalidChannelException(Exception): - """ - Exception thrown when the given channel is invalid. - """ - - pass - - -class LoadSourceException(Exception): - """ - Exception thrown when some source could not be loaded or updated. - """ - - pass class Engine: @@ -73,10 +52,10 @@ class Engine: instance = None engine_time_offset = 0.0 logger = None - eci = None scheduler = None event_dispatcher = None client = None + playout = None playout_state = None def __init__(self): @@ -98,9 +77,6 @@ class Engine: Called when the connection to the sound-system implementation has been established. """ self.event_dispatcher = EngineEventDispatcher(self) - self.eci = EngineControlInterface(self, self.event_dispatcher) - self.client = CoreClient.get_instance() - self.client.set_event_dispatcher(self.event_dispatcher) self.event_dispatcher.on_initialized() while not self.is_connected(): @@ -113,7 +89,6 @@ class Engine: else: self.logger.info(SU.red("Error while updating playout config")) - self.player = Player(self.client, self.event_dispatcher) self.event_dispatcher.on_boot() self.logger.info(EngineSplash.splash_screen(self.config)) @@ -129,7 +104,11 @@ class Engine: """ has_connection = False try: - self.uptime() + if not self.playout: + self.playout = PlayoutClient.get_instance(self.event_dispatcher) + self.playout.get_uptime() + self.logger.info(SU.green("Initialize Player...")) + self.player = Player(self.playout, self.event_dispatcher) has_connection = True except CoreConnectionError: self.logger.debug("Liquidsoap is not running so far") @@ -142,7 +121,7 @@ class Engine: """ Retrieve the state of all inputs and outputs. """ - state = self.client.exec("aura_engine", "status") + state = self.playout.get_status() state = DotDict(LU.json_to_dict(state)) # Initialize state @@ -181,8 +160,8 @@ class Engine: "fallback_show_name": self.config.get("fallback_show_name"), } json_config = json.dumps(playout_config, ensure_ascii=False) - res = self.client.exec("aura_engine", "update_config", json_config) - return res + response = self.playout.set_config(json_config) + return response def init_version(self) -> dict: """ @@ -200,19 +179,12 @@ class Engine: with open(os.path.join("", "VERSION")) as version_file: ctrl_version = version_file.read().strip() - versions = self.client.exec("aura_engine", "version") + versions = self.playout.get_version() versions = DotDict(json.loads(versions)) self.config.set("version_control", ctrl_version) self.config.set("version_core", versions.core) self.config.set("version_liquidsoap", versions.liquidsoap) - def uptime(self): - """ - Retrieve the uptime of Liquidsoap. - """ - data = self.client.exec("", "uptime") - return data - @staticmethod def engine_time(): """ @@ -243,8 +215,6 @@ class Engine: """ if self.scheduler: self.scheduler.terminate() - if self.eci: - self.eci.terminate() # @@ -257,29 +227,46 @@ class Player: Engine Player. """ + class TransitionType(str, Enum): + """ + Types for instant and fade transitions. + """ + + INSTANT = "instant" + FADE = "fade" + + class EntryPlayState(str, Enum): + """ + Play-state of a playlist entry. + """ + + UNKNOWN = "unknown" + LOADING = "loading" + READY = "ready_to_play" + PLAYING = "playing" + FINISHED = "finished" + config = None logger = None - client = None channels = None - channel_router = None + resource_map = None event_dispatcher = None mixer = None - def __init__(self, client, event_dispatcher): + def __init__(self, playout, event_dispatcher): """ Initialize the player. Args: - client (CoreClient): Client for connecting to Engine Core (Liquidsoap) + playout (PlayoutClient): Client for connecting to Engine Core (Liquidsoap) event_dispatcher (EventDispather): Dispatcher for issuing events """ self.config = AuraConfig.config() self.logger = logging.getLogger("AuraEngine") self.event_dispatcher = event_dispatcher - self.client = client - self.channel_router = ChannelRouter() - self.mixer = Mixer(self.config, MixerType.MAIN, self.client) + self.resource_map = ResourceMapping() + self.mixer = playout.get_mixer() def preload(self, entry): """ @@ -297,53 +284,53 @@ class Player: entry (Entry): An array holding filesystem entries """ - entry.status = EntryPlayState.LOADING + entry.previous_channel = self.mixer.get_active_channel() + entry.status = Player.EntryPlayState.LOADING self.logger.info("Loading entry '%s'" % entry) is_ready = False def set_metadata(): track_meta = ResourceUtil.generate_track_metadata(entry, True) json_meta = json.dumps(track_meta, ensure_ascii=False) - res = self.client.exec(entry.channel, "set_track_metadata", json_meta) - self.logger.info(f"Response for '{entry.channel}.set_track_metadata': {res}") - if res not in LiquidsoapResponse.SUCCESS.value: - msg = f"Error while setting metadata on {entry.channel} to:\n{json_meta}" - self.logger.error(SU.red(msg)) + entry.channel.set_track_metadata(json_meta) # LIVE if entry.get_content_type() in ResourceClass.LIVE.types: - entry.channel = ChannelResolver.live_channel_for_resource(entry.source) + channel_name = self.resource_map.live_channel_for_resource(entry.source) + entry.channel = self.mixer.get_channel(channel_name) if entry.channel is None: + msg = f"Stored channel {entry.channel} in entry (prev: {entry.previous_channel})" + self.logger.info(SU.pink(msg)) + else: self.logger.critical(SU.red("No live channel for '{entry.source}' source")) - entry.previous_channel = None set_metadata() is_ready = True else: # Store channels for non-live entries - channel_type = self.channel_router.type_for_resource(entry.get_content_type()) - channels = self.channel_router.get_free_channel(channel_type) - entry.previous_channel, entry.channel = channels - msg = f"Stored channel '{entry.channel}' in entry (prev: '{entry.previous_channel}')" + channel_type = self.resource_map.type_for_resource(entry.get_content_type()) + entry.channel = self.mixer.get_free_channel(channel_type) + msg = f"Stored channel {entry.channel} in entry (prev: {entry.previous_channel})" self.logger.info(SU.pink(msg)) # QUEUE if entry.get_content_type() in ResourceClass.FILE.types: metadata = ResourceUtil.generate_track_metadata(entry) - is_ready = self.queue_push(entry.channel, entry.source, metadata) + file_path = ResourceUtil.source_to_filepath(entry.source, self.config) + is_ready = entry.channel.load(file_path, metadata) # STREAM elif entry.get_content_type() in ResourceClass.STREAM.types: - is_ready = self.stream_load_entry(entry) + is_ready = entry.channel.load(entry.source) if is_ready: set_metadata() - entry.status = EntryPlayState.READY + entry.status = Player.EntryPlayState.READY self.event_dispatcher.on_queue([entry]) - def preload_group(self, entries, channel_type=ChannelType.QUEUE): + def preload_group(self, entries): """ - Pre-Load multiple filesystem entries at once. + Preload multiple filesystem/queue entries at once. This call is required before the actual `play(..)` can happen. Due to their nature, non-filesystem entries cannot be queued using this method. In this case use @@ -359,6 +346,8 @@ class Player: channel_type (ChannelType): The type of channel where it should be queued (optional) """ + active_channel = self.mixer.get_active_channel() + free_channel = self.mixer.get_free_channel(ChannelType.QUEUE) channels = None # Validate entry type @@ -367,16 +356,18 @@ class Player: raise InvalidChannelException # Determine channel & queue entries - channels = self.channel_router.get_free_channel(channel_type) + for entry in entries: - entry.status = EntryPlayState.LOADING + entry.channel = free_channel + entry.previous_channel = active_channel + entry.status = Player.EntryPlayState.LOADING self.logger.info(f"Loading entry '{entry}'") # Choose and save the input channel - entry.previous_channel, entry.channel = channels metadata = ResourceUtil.generate_track_metadata(entry) - if self.queue_push(entry.channel, entry.source, metadata): - entry.status = EntryPlayState.READY + file_path = ResourceUtil.source_to_filepath(entry.source, self.config) + if entry.channel.load(file_path, metadata): + entry.status = Player.EntryPlayState.READY self.event_dispatcher.on_queue(entries) return channels @@ -394,235 +385,73 @@ class Player: Args: entry (PlaylistEntry): The audio source to be played - transition (TransitionType): The type of transition to use e.g. fade-in or instant - volume level. + transition (Player.TransitionType): The type of transition to use e.g. fade-in or + instant volume level. queue (Boolean): If `True` the entry is queued if the `ChannelType` does allow so; otherwise a new channel of the same type is activated """ with suppress(CoreConnectionError): - mixer = self.mixer # Instant activation or fade-in - if transition == TransitionType.FADE: - mixer.channel_select(entry.channel.value, True) - mixer.fade_in(entry.channel, entry.volume) - msg = f"Select channel '{entry.channel}' with {transition}" - self.logger.info(SU.pink(msg)) - else: - mixer.channel_activate(entry.channel.value, True) - msg = f"Activate channel '{entry.channel}'" - self.logger.info(SU.pink(msg)) + instant = not (transition == Player.TransitionType.FADE) + entry.channel.fade_in(entry.volume, instant=instant) # Update active channel for the current channel type - self.channel_router.set_active(entry.channel) + self.mixer.set_active_channel(entry.channel) prev_channel = entry.previous_channel if prev_channel in ChannelType.QUEUE.channels: - msg = f"About to clear previous channel '{prev_channel}'..." + msg = f"About to clear previous channel {prev_channel}..." self.logger.info(SU.pink(msg)) - self.queue_clear(prev_channel) + prev_channel.flush() self.event_dispatcher.on_play(entry) # Store most recent channel to timeslot, providing knowledge about # which channel should be faded out at the end of the timeslot timeslot = entry.playlist.timeslot timeslot.latest_channel = entry.channel - msg = f"Stored recent entry's channel '{entry.channel}' to timeslot" + msg = f"Stored recent entry's channel {entry.channel} to timeslot" self.logger.info(SU.pink(msg)) - def stop(self, channel: Channel, transition: TransitionType): + def stop(self, channel: GenericChannel, transition: TransitionType): """ Stop the currently playing channel. Args: - channel (Channel): The channel to stop playing or fade-out - transition (TransitionType): The type of transition to use e.g. fade-out + channel (GenericChannel): The channel to stop playing or fade-out + transition (Player.TransitionType): The type of transition to use e.g. fade-out """ with suppress(CoreConnectionError): if not channel: self.logger.warn(SU.red("Cannot stop, no channel passed")) return + instant = not (transition == Player.TransitionType.FADE) + channel.fade_out(instant=instant) - if transition == TransitionType.FADE: - self.mixer.fade_out(channel) - else: - self.mixer.channel_volume(channel, 0) - - self.logger.info(SU.pink(f"Mute channel '{channel}' with {transition}")) + self.logger.info(SU.pink(f"Mute channel {channel} with {transition}")) self.event_dispatcher.on_stop(channel) - # - # Channel Type - Stream - # - - def stream_load_entry(self, entry): - """ - Load the given stream entry and updates the entries's status codes. - - Args: - entry (Entry): The entry to be pre-loaded - - Returns: - (Boolean): `True` if successful - - """ - self.stream_load(entry.channel, entry.source) - time.sleep(1) - - retry_delay = self.config.get("input_stream_retry_delay") - max_retries = self.config.get("input_stream_max_retries") - retries = 0 - - while not self.stream_is_ready(entry.channel, entry.source): - self.logger.info("Loading Stream ...") - if retries >= max_retries: - msg = f"Stream connection failed after {retries * retry_delay} seconds!" - raise LoadSourceException(msg) - time.sleep(retry_delay) - retries += 1 - - return True - - def stream_load(self, channel, url): - """ - Preload the stream URL on the given channel. - - Note this method is blocking some serious amount of time; hence it is worth being called - asynchronously. - - Args: - channel (Channel): The stream channel - url (String): The stream URL - - Raises: - (LoadSourceException): When stream cannot be set or stopped. - - Returns: - (Boolean): `True` if successful - - """ - result = None - result = self.client.exec(channel, "stop") - - if result not in LiquidsoapResponse.SUCCESS.value: - self.logger.error(f"{channel}.stop result: {result}") - raise LoadSourceException("Error while stopping stream!") - - result = self.client.exec(channel, "url", url) - - if result not in LiquidsoapResponse.SUCCESS.value: - self.logger.error(f"{channel}.url result: {result}") - raise LoadSourceException("Error while setting stream URL!") - - # TODO Review: Liquidsoap ignores commands sent without a certain timeout - time.sleep(2) - - result = self.client.exec(channel, "start") - self.logger.info(f"{channel}.start result: {result}") - - return result - - def stream_is_ready(self, channel, url): - """ - Check if the stream on the given channel is ready to play. - - Note this method is blocking some serious amount of time even when successful; hence it is - worth being called asynchronously. - - Args: - channel (Channel): The stream channel - url (String): The stream URL - - Returns: - (Boolean): `True` if successful - - """ - result = None - result = self.client.exec(channel, "status") - self.logger.info(f"{channel}.status result: {result}") - - if not result.startswith(LiquidsoapResponse.STREAM_STATUS_CONNECTED.value): - return False - - lqs_url = result.split(" ")[1] - if not url == lqs_url: - msg = f"Wrong URL '{lqs_url}' set for channel '{channel}', expected: '{url}'." - self.logger.error(msg) - return False - - stream_buffer = self.config.get("input_stream_buffer") - msg = f"Ready to play stream, but wait {stream_buffer} seconds to fill buffer..." - self.logger.info(msg) - time.sleep(round(float(stream_buffer))) - return True - # # Channel Type - Queue # - def queue_push(self, channel, source, metadata): - """ - Add an filesystem URI to the given `ChannelType.QUEUE` channel. - - Args: - channel (Channel): The channel to push the file to - source (String): The URI of the file - metadata (dict): additional meta data to be wrapped with the URI - - Returns: - (Boolean): `True` if successful - - """ - if ( - channel not in ChannelType.QUEUE.channels - and channel not in ChannelType.FALLBACK_QUEUE.channels - ): - raise InvalidChannelException - - audio_store = self.config.abs_audio_store_path() - extension = self.config.get("audio_source_extension") - filepath = ResourceUtil.source_to_filepath(audio_store, source, extension) - self.logger.info(SU.pink(f"{channel}.push('{filepath}')")) - if metadata: - filepath = ResourceUtil.lqs_annotate(filepath, metadata) - result = self.client.exec(channel, "push", filepath) - self.logger.info(f"{channel}.push result: {result}") - - # If successful, Liquidsoap returns a resource ID of the queued track - resource_id = -1 - try: - resource_id = int(result) - except ValueError: - msg = SU.red(f"Got invalid resource ID: '{result}'") - self.logger.error(msg) - return False - - return resource_id >= 0 - - def queue_seek(self, channel, seconds_to_seek): + def queue_seek(self, channel, seconds_to_roll): """ Pre-roll the player of the given `ChannelType.QUEUE` channel by (n) seconds. Args: - channel (Channel): The channel to push the file to - seconds_to_seeks (Float): The seconds to skip + channel (ChannelName): The channel to push the file to + seconds_to_roll (Float): The seconds to seek Returns: (String): Liquidsoap response """ - if ( - channel not in ChannelType.QUEUE.channels - and channel not in ChannelType.FALLBACK_QUEUE.channels - ): + if not channel.type == ChannelType.QUEUE: raise InvalidChannelException + return channel.roll(seconds_to_roll) - result = self.client.exec(channel, "seek", str(seconds_to_seek)) - self.logger.info(f"{channel}.seek result: {result}") - - return result - - def queue_clear(self, channel: Channel): + def queue_clear(self, channel: ChannelName): """ Clear any tracks left in the queue. @@ -633,32 +462,38 @@ class Player: The channel is cleared asynchronously. Args: - channel (Channel): The channel to clear + channel (ChannelName): The channel to clear """ - if not self.channel_router.is_any_queue(channel): + if not channel.type == ChannelType.QUEUE: raise InvalidChannelException def clean_up(): # Wait some moments, if there is some long fade-out. Note, this also # means, this channel should not be used for at least some seconds # (including clearing time). + # + # TODO Review if this is still needed - since Liquidsoap 2 the time + # required for clearing a queue should be almost zero. clear_timeout = 10 - self.logger.info(f"Clear channel '{channel}' in {clear_timeout} seconds") + self.logger.info(f"Clear channel {channel} in {clear_timeout} seconds") time.sleep(clear_timeout) # Deactivate channel - response = self.mixer.channel_activate(channel.value, False) - msg = f"Deactivate channel '{channel}' with result '{response}'" + response = self.mixer.activate_channel(channel.value, False) + msg = f"Deactivate channel {channel} with result '{response}'" self.logger.info(SU.pink(msg)) + channel.flush(channel) - # Remove all items from queue - result = self.client.exec(channel, "clear") + Thread(target=clean_up).start() - msg = f"Cleared queue channel '{channel}' with result '{result}'" - self.logger.info(SU.pink(msg)) - Thread(target=clean_up).start() +class InvalidChannelException(Exception): + """ + Exception thrown when the given channel is invalid. + """ + + pass class EngineSplash: diff --git a/src/aura_engine/mixer.py b/src/aura_engine/mixer.py deleted file mode 100644 index dc72c19..0000000 --- a/src/aura_engine/mixer.py +++ /dev/null @@ -1,402 +0,0 @@ -# -# Aura Engine (https://gitlab.servus.at/aura/engine) -# -# Copyright (C) 2017-2020 - The Aura Engine Team. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -""" -The virtual mixer as it is realized using Liquidsoap. -""" - -import logging -import time -from enum import Enum - -from aura_engine.base.api import LiquidsoapUtil as LU -from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.core.client import CoreConnectionError - - -class MixerType(Enum): - """ - Types of mixers mapped to the Liquidsoap mixer ids. - """ - - MAIN = "mixer" - - -class MixerUtil: - """ - Little helpers for the mixer. - """ - - @staticmethod - def channel_status_dict(status): - """ - Transform a channel status string to a dictionary. - """ - s = {} - pairs = status.split(" ") - for pair in pairs: - kv = pair.split("=") - s[kv[0]] = kv[1] - return s - - -class Mixer: - """ - A virtual mixer. - """ - - config = None - logger = None - client = None - mixer_id = None - channels = None - fade_in_active = None - fade_out_active = None - - def __init__(self, config, mixer_id, client): - """ - Initialize the mixer object. - - Args: - config (AuraConfig): The configuration - - """ - self.config = config - self.logger = logging.getLogger("AuraEngine") - self.mixer_id = mixer_id - self.fade_in_active = None - self.fade_out_active = None - self.client = client - self.mixer_initialize() - - # - # Mixer - # - - def mixer_initialize(self): - """ - Initialize the mixer. - - It does: - - Pull all faders down to volume 0. - - Initialize default channels per type - - """ - - time.sleep(1) # TODO Check is this is still required - channels = self.mixer_channels_reload() - # TODO Graceful reboot: At some point the current track playing could - # resume inside Liquidsoap in case only Engine restarted (See #77). - for channel in channels: - self.channel_volume(channel, "0") - - def mixer_status(self): - """ - Return the state of all mixer channels. - """ - cnt = 0 - inputstate = {} - inputs = self.mixer_channels() - for channel in inputs: - inputstate[channel] = self.channel_status(cnt) - cnt = cnt + 1 - return inputstate - - def mixer_outputs(self): - """ - Retrieve the state of all inputs and outputs. - """ - outputs = self.client.exec(self.mixer_id.value, "outputs") - outputs = LU.json_to_dict(outputs) - return outputs - - def mixer_channels(self): - """ - Retrieve all mixer channels. - """ - if self.channels is None or len(self.channels) == 0: - channel_str = self.client.exec(self.mixer_id.value, "inputs") - self.channels = channel_str.split(" ") - return self.channels - - def mixer_channels_selected(self): - """ - Retrieve all selected channels of the mixer. - """ - cnt = 0 - activeinputs = [] - - inputs = self.mixer_channels() - - for channel in inputs: - status = self.channel_status(cnt) - if "selected=true" in status: - activeinputs.append(channel) - cnt = cnt + 1 - - return activeinputs - - def mixer_channels_except(self, input_type): - """ - Retrieve all mixer channels except the ones of the given type. - """ - try: - activemixer_copy = self.mixer_channels().copy() - activemixer_copy.remove(input_type) - except ValueError as e: - msg = f"Requested channel type '{input_type}' not in channel-list" - self.logger.error(SU.red(msg), e) - except AttributeError: - self.logger.critical("Empty channel list") - - return activemixer_copy - - def mixer_channels_reload(self): - """ - Reload all mixer channels. - """ - self.channels = None - return self.mixer_channels() - - # - # Channel - # - - def channel_number(self, channel): - """ - Return the channel number for the given channel ID. - - Args: - channel (Channel): The channel - - Returns: - (Integer): The channel number - - """ - channels = self.mixer_channels() - index = channels.index(channel) - if index < 0: - msg = f"There's no valid channel number for channel ID '{channel.value}'" - self.logger.critical(SU.red(msg)) - return None - return index - - def channel_status(self, channel_number): - """ - Retrieve the status of a channel identified by the channel number. - - Args: - channel_number (Integer): The channel number - - Returns: - (String): Channel status info as a String - - """ - return self.client.exec(self.mixer_id.value, "status", channel_number) - - def channel_select(self, channel, select): - """ - Select or deselect some mixer channel. - - Args: - pos (Integer): The channel number - select (Boolean): Select or deselect - - Returns: - (String): Liquidsoap server response - - """ - channels = self.mixer_channels() - - try: - index = channels.index(channel) - if len(channel) < 1: - self.logger.critical(SU.red("Cannot select channel because there are no channels")) - else: - select = "true" if select else "false" - message = self.client.exec(self.mixer_id.value, "select", f"{index} {select}") - return message - except Exception as e: - self.logger.critical(SU.red("Ran into exception when selecting channel"), e) - - def channel_activate(self, channel, activate): - """ - Activate a channel. - - Combined call of following to save execution time: - - Select some mixer channel - - Increase the volume to 100, - - Args: - channel (Channel): The channel number - activate (Boolean): Activate or deactivate - - Returns: - (String): Liquidsoap server response - - """ - channels = self.mixer_channels() - - try: - index = channels.index(channel) - if len(channel) < 1: - self.logger.critical(SU.red("Cannot activate channel. There are no channels!")) - else: - activate = "true" if activate else "false" - message = self.client.exec(self.mixer_id.value, "activate", f"{index} {activate}") - return message - except Exception as e: - self.logger.critical(SU.red("Ran into exception when activating channel."), e) - - def channel_current_volume(self, channel): - """ - Retrieve the current volume of the channel. - """ - channel_number = self.channel_number(channel.value) - status = self.channel_status(channel_number) - channel_status = MixerUtil.channel_status_dict(status) - volume = channel_status.get("volume") - if volume: - return int(volume.split("%")[0]) - else: - msg = f"Invalid volume for channel {channel.value} (status: '{status}'" - self.logger.error(SU.red(msg)) - return -1 - - def channel_volume(self, channel, volume): - """ - Set volume of a channel. - - Args: - channel (Channel): The channel - volume (Integer): Volume between 0 and 100 - - """ - channel = str(channel) - try: - if str(volume) == "100": - channels = self.mixer_channels() - index = channels.index(channel) - else: - channels = self.mixer_channels() - index = channels.index(channel) - except ValueError as e: - msg = f"Cannot set volume of channel '{channel}' to {volume}" - self.logger.error(SU.red(msg), e) - return - - # try: - if len(channel) < 1: - msg = f"Cannot set volume of channel '{channel}', because no channels available" - self.logger.error(SU.red(msg)) - else: - playout_volume = str(int(volume) / 100) # 100% volume equals 1 - args = f"{str(index)} {playout_volume}" - message = self.client.exec(self.mixer_id.value, "volume", args) - if not message.find(f"volume={volume}%"): - msg(f"Error setting volume of channel '{channel}': {message}") - self.logger.error(SU.red(msg)) - - return message - - # - # Fading - # - - def fade_in(self, channel, volume): - """ - Perform a fade-in for the given channel. - - Args: - channel (Channel): The channel to fade - volume (Integer): The target volume - - Returns: - (Boolean): `True` if successful - - """ - try: - current_volume = self.channel_current_volume(channel) - if current_volume == volume: - msg = f"Skip fade in of {channel}: Already at target volume of {volume}%" - self.logger.info(msg) - return - elif current_volume > volume: - msg = f"Skip fade in of {channel}: Current volume of {current_volume}% exceeds \ - target volume of {volume}%" - self.logger.info(msg) - return - - fade_in_time = float(self.config.get("fade_in_time")) - - if fade_in_time > 0: - self.fade_in_active = True - target_volume = volume - step = fade_in_time / target_volume - msg = f"Fade in '{channel}' to {target_volume} ({step}s steps)" - self.logger.info(SU.pink(msg)) - for i in range(target_volume): - self.channel_volume(channel.value, i + 1) - time.sleep(step) - msg = f"Fade in of '{channel}' done" - self.logger.info(SU.pink(msg)) - - except CoreConnectionError as e: - self.logger.critical(SU.red(e.message), e) - return False - return True - - def fade_out(self, channel, volume=None): - """ - Perform a fade-out for the given channel starting at its current volume. - - Args: - channel (Channel): The channel to fade - volume (Integer): The start volume - - Returns: - (Boolean): `True` if successful - - """ - try: - current_volume = self.channel_current_volume(channel) - if not volume: - volume = current_volume - - if current_volume == 0: - msg = f"Channel {channel} already at target volume of 0%. SKIPPING..." - self.logger.info(msg) - return - - fade_out_time = float(self.config.get("fade_out_time")) - - if fade_out_time > 0: - step = abs(fade_out_time) / current_volume - msg = f"Start to fade out '{channel}' ({step}s step)" - self.logger.info(SU.pink(msg)) - for i in range(volume): - self.channel_volume(channel.value, volume - i - 1) - time.sleep(step) - msg = f"Finished with fading-out '{channel}'" - self.logger.info(SU.pink(msg)) - - except CoreConnectionError as e: - self.logger.critical(SU.red(e.message), e) - return False - return True diff --git a/src/aura_engine/plugins/monitor.py b/src/aura_engine/plugins/monitor.py index 1aee8aa..03bde4e 100644 --- a/src/aura_engine/plugins/monitor.py +++ b/src/aura_engine/plugins/monitor.py @@ -238,8 +238,8 @@ class AuraMonitor: self.status["engine"]["version"] = ctrl_version self.status["lqs"]["version"] = {"core": core_version, "liquidsoap": liq_version} - self.status["lqs"]["outputs"] = self.engine.player.mixer.mixer_outputs() - self.status["lqs"]["mixer"] = self.engine.player.mixer.mixer_status() + self.status["lqs"]["outputs"] = self.engine.player.mixer.get_outputs() + self.status["lqs"]["mixer"] = self.engine.player.mixer.get_inputs() self.status["api"]["steering"]["url"] = self.config.get("api_steering_status") self.status["api"]["steering"]["available"] = self.validate_url_connection( self.config.get("api_steering_status") diff --git a/src/aura_engine/resources.py b/src/aura_engine/resources.py index d1141d4..b037bb1 100644 --- a/src/aura_engine/resources.py +++ b/src/aura_engine/resources.py @@ -23,6 +23,8 @@ Utilities and mappings for media resources. from enum import Enum +from aura_engine.core.channels import ChannelName, ChannelType + class ResourceType(Enum): """ @@ -36,6 +38,50 @@ class ResourceType(Enum): POOL = "pool:" +class ResourceMapping: + """ + Wires source types with channel-types. + """ + + resource_mapping = None + + def __init__(self): + """ + Initialize the resource mapping. + """ + + self.resource_mapping = { + ResourceType.FILE: ChannelType.QUEUE, + ResourceType.STREAM_HTTP: ChannelType.HTTP, + ResourceType.LINE: ChannelType.LIVE, + ResourceType.PLAYLIST: ChannelType.QUEUE, + ResourceType.POOL: ChannelType.QUEUE, + } + + def type_for_resource(self, resource_type: ResourceType) -> ChannelType: + """ + Retrieve a `ChannelType` for the given `ResourceType`. + + Only default mappings can be evaluatated. Custom variations + like fallback channels are not respected. + """ + return self.resource_mapping.get(resource_type) + + def live_channel_for_resource(channel: str): + """ + Return the channel enum for a given live channel string from Tank. + """ + if not channel: + return None + channel_name = "linein_" + channel.split("line://")[1] + + for cn in ChannelName: + if cn.value == channel_name: + return cn + + return None + + class ResourceClass(Enum): """ Media content classes. @@ -144,7 +190,7 @@ class ResourceUtil(Enum): file.close() @staticmethod - def source_to_filepath(base_dir, source, source_extension): + def source_to_filepath(source_uri, config): """ Create path from URI and extension. @@ -155,19 +201,20 @@ class ResourceUtil(Enum): a valid extension. Args: - basi_dir (String): The location of the audio store. - source (String): The URI of the file - source_extension (String): The file extension of audio sources + source_uri (String): The URI of the file + config (AuraConfig): The configuration Returns: - path (String): Absolute file path + path (String): Absolute file path """ - path = source[7:] + path = source_uri[7:] if path.startswith("/"): return path else: - return base_dir + "/" + path + source_extension + base_dir = config.abs_audio_store_path() + extension = config.get("audio_source_extension") + return base_dir + "/" + path + extension @staticmethod def get_entries_string(entries): @@ -184,17 +231,6 @@ class ResourceUtil(Enum): s = str(entries) return s - @staticmethod - def lqs_annotate(uri: str, annotations: dict) -> str: - """ - Wrap the given URI with the passed annotation dictionary. - """ - metadata = "" - for k, v in annotations.items(): - metadata += f'{k}="{v}",' - uri = f"annotate:{metadata[:-1]}:{uri}" - return uri - @staticmethod def generate_track_metadata( entry, diff --git a/src/aura_engine/scheduling/scheduler.py b/src/aura_engine/scheduling/scheduler.py index 4cd57ff..751a47f 100644 --- a/src/aura_engine/scheduling/scheduler.py +++ b/src/aura_engine/scheduling/scheduler.py @@ -27,9 +27,9 @@ import time from aura_engine.base.config import AuraConfig from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.channels import ChannelType, EntryPlayState, TransitionType from aura_engine.control import EngineExecutor -from aura_engine.engine import Engine, LoadSourceException +from aura_engine.core.channels import LoadSourceException +from aura_engine.engine import Engine, Player from aura_engine.resources import ResourceClass, ResourceUtil from aura_engine.scheduling.models import AuraDatabaseModel from aura_engine.scheduling.programme import ProgrammeService @@ -150,10 +150,9 @@ class AuraScheduler(threading.Thread): entry (PlaylistEntry): """ - if entry.channel in ChannelType.FALLBACK_QUEUE.channels: - return # Nothing to do atm + pass # # METHODS @@ -494,7 +493,7 @@ class TimeslotCommand(EngineExecutor): channel = timeslot.latest_channel if channel: self.logger.info(f"Stopping channel {channel}") - self.engine.player.stop(channel, TransitionType.FADE) + self.engine.player.stop(channel, Player.TransitionType.FADE) else: msg = f"There is no channel to stop for timeslot #{timeslot.timeslot_id}" self.logger.error(SU.red(msg)) @@ -540,14 +539,14 @@ class PlayCommand(EngineExecutor): try: if entries[0].get_content_type() in ResourceClass.FILE.types: self.logger.info(SU.cyan(f"=== preload_group('{entries_str}') ===")) - self.engine.player.preload_group(entries, ChannelType.QUEUE) + self.engine.player.preload_group(entries) else: self.logger.info(SU.cyan(f"=== preload('{entries_str}') ===")) self.engine.player.preload(entries[0]) except LoadSourceException as e: self.logger.critical(SU.red(f"Could not preload entries {entries_str}"), e) - if entries[-1].status != EntryPlayState.READY: + if entries[-1].status != Player.EntryPlayState.READY: msg = f"Entries didn't reach 'ready' state during preloading (Entries: {entries_str})" self.logger.warning(SU.red(msg)) @@ -557,16 +556,16 @@ class PlayCommand(EngineExecutor): """ entries_str = ResourceUtil.get_entries_string(entries) self.logger.info(SU.cyan(f"=== play('{entries_str}') ===")) - if entries[-1].status != EntryPlayState.READY: + if entries[-1].status != Player.EntryPlayState.READY: # Let 'em play anyway ... msg = ( f"PLAY: The entry/entries are not yet ready to be played" f" (Entries: {entries_str})" ) self.logger.critical(SU.red(msg)) - while entries[-1].status != EntryPlayState.READY: + while entries[-1].status != Player.EntryPlayState.READY: self.logger.info("PLAY: Wait a little bit until preloading is done ...") time.sleep(2) - self.engine.player.play(entries[0], TransitionType.FADE) + self.engine.player.play(entries[0], Player.TransitionType.FADE) self.logger.info(self.engine.scheduler.timeslot_renderer.get_ascii_timeslots()) -- GitLab From 1b21a87ee3593d309dc1bc5658b636600bbca4dc Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 12 Aug 2022 17:45:37 +0200 Subject: [PATCH 15/24] refact(load): encapsulate metadata update #65 --- src/aura_engine/core/channels.py | 91 +++++++++++++++++++++++--------- src/aura_engine/engine.py | 44 +++++++-------- 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py index 2920edc..995ca39 100644 --- a/src/aura_engine/core/channels.py +++ b/src/aura_engine/core/channels.py @@ -29,9 +29,10 @@ Channel definitions: - GenericChannel: All other channels inherit from this one. - QueueChannel: Handles queues such as filesystem entries. - StreamChannel: Handles stream connections. - - AnalogChannel: Handles analog audio input. + - AnalogueChannel: Handles analogue audio input. """ +import json import logging import time from enum import Enum @@ -143,22 +144,22 @@ class GenericChannel: """ return self.type - def set_track_metadata(self, json_metadata: str) -> str: - """ - Set the metadata as current track metadata on the given channel. - """ - response = self.mixer.client.exec(self.name, "set_track_metadata", json_metadata) - self.logger.info(f"Response for '{self.name}.set_track_metadata': {response}") - if response not in PlayoutStatusResponse.SUCCESS.value: - msg = f"Error while setting metadata on {self.name} to:\n{json_metadata}" - self.logger.error(SU.red(msg)) - return response - - def load(self): + def load(self, metadata: dict = None) -> bool: """ Interface definition for loading a channel track. + + Args: + uri (str): The URI to load + metadata (dict): Metadata to assign to the channel, when playing (optional) + + Returns: + (bool): True if track loaded successfully + """ - pass + if metadata: + json_meta = json.dumps(metadata, ensure_ascii=False) + self.set_track_metadata(json_meta) + return True def fade_in(self, volume: int, instant=False): """ @@ -196,6 +197,23 @@ class GenericChannel: """ return f"[{self.channel_index} : {self.name}]" + @private + def set_track_metadata(self, json_metadata: str) -> str: + """ + Set the metadata as current track metadata on the given channel. + + This is only needed for non-queue channels. They pass the metadata inline with their + request URIs + + @private + """ + response = self.mixer.client.exec(self.name, "set_track_metadata", json_metadata) + self.logger.info(f"Response for '{self.name}.set_track_metadata': {response}") + if response not in PlayoutStatusResponse.SUCCESS.value: + msg = f"Error while setting metadata on {self.name} to:\n{json_metadata}" + self.logger.error(SU.red(msg)) + return response + class QueueChannel(GenericChannel): """ @@ -214,9 +232,16 @@ class QueueChannel(GenericChannel): self.type = ChannelType.QUEUE super().__init__(channel_index, channel_name, mixer) - def load(self, uri, metadata): + def load(self, uri: str = None, metadata: dict = None): """ Load the provided URI and pass metadata. + + Args: + uri (str): The URI to load + metadata (dict): Metadata to assign to the channel, when playing (optional) + + Returns: + (bool): True if track loaded successfully """ self.logger.info(SU.pink(f"{self.name}.push('{uri}')")) if metadata: @@ -269,19 +294,20 @@ class StreamChannel(GenericChannel): self.type = ChannelType.HTTP super().__init__(channel_index, channel_name, mixer) - def load(self, url): + def load(self, uri: str = None, metadata: dict = None): """ Load the given stream entry and updates the entries's status codes. Args: - entry (Entry): The entry to be pre-loaded + uri (str): The URI to load + metadata (dict): Metadata to assign to the channel, when playing (optional) Returns: - (Boolean): `True` if successful + (bool): True if track loaded successfully """ self.stop() - self.set_url(url) + self.set_url(uri) # TODO Review if still valid: Liquidsoap ignores commands sent without a certain timeout time.sleep(2) self.start() @@ -292,14 +318,16 @@ class StreamChannel(GenericChannel): max_retries = self.config.get("input_stream_max_retries") retries = 0 - while not self.is_ready(url): + while not self.is_ready(uri): self.logger.info("Loading Stream ...") if retries >= max_retries: msg = f"Stream connection failed after {retries * retry_delay} seconds!" raise LoadSourceException(msg) time.sleep(retry_delay) retries += 1 - return True + + response = super().load(metadata) + return response @private def is_ready(self, url): @@ -374,9 +402,9 @@ class StreamChannel(GenericChannel): return response -class AnalogChannel(GenericChannel): +class AnalogueChannel(GenericChannel): """ - Channel for analog audio input. + Channel for analogue audio input. """ def __init__(self, channel_index, channel_name, mixer): @@ -391,6 +419,21 @@ class AnalogChannel(GenericChannel): self.type = ChannelType.LIVE super().__init__(channel_index, channel_name, mixer) + def load(self, uri: str = None, metadata: dict = None): + """ + Load the analogue channel. + + Args: + uri (str): For analogue source the URI is always null + metadata (dict): Metadata to assign to the channel, when playing (optional) + + Returns: + (bool): True if track loaded successfully + + """ + response = super().load(metadata) + return response + class ChannelFactory: """ @@ -432,7 +475,7 @@ class ChannelFactory: return StreamChannel(channel_index, channel_name, mixer) if channel_name in ChannelType.LIVE.channels: self.logger.debug(f"Create new ANALOG channel '{channel_name}'") - return AnalogChannel(channel_index, channel_name, mixer) + return AnalogueChannel(channel_index, channel_name, mixer) class PlayoutStatusResponse(str, Enum): diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index 74048fb..44352f4 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -284,48 +284,40 @@ class Player: entry (Entry): An array holding filesystem entries """ + is_queue = entry.get_content_type() in ResourceClass.FILE.types + metadata = ResourceUtil.generate_track_metadata(entry, not is_queue) entry.previous_channel = self.mixer.get_active_channel() entry.status = Player.EntryPlayState.LOADING self.logger.info("Loading entry '%s'" % entry) + uri = None is_ready = False - def set_metadata(): - track_meta = ResourceUtil.generate_track_metadata(entry, True) - json_meta = json.dumps(track_meta, ensure_ascii=False) - entry.channel.set_track_metadata(json_meta) - - # LIVE + # ANALOGUE if entry.get_content_type() in ResourceClass.LIVE.types: channel_name = self.resource_map.live_channel_for_resource(entry.source) - entry.channel = self.mixer.get_channel(channel_name) - if entry.channel is None: - msg = f"Stored channel {entry.channel} in entry (prev: {entry.previous_channel})" - self.logger.info(SU.pink(msg)) - else: - self.logger.critical(SU.red("No live channel for '{entry.source}' source")) - set_metadata() - is_ready = True + chosen_channel = self.mixer.get_channel(channel_name) else: - # Store channels for non-live entries channel_type = self.resource_map.type_for_resource(entry.get_content_type()) - entry.channel = self.mixer.get_free_channel(channel_type) - msg = f"Stored channel {entry.channel} in entry (prev: {entry.previous_channel})" - self.logger.info(SU.pink(msg)) + chosen_channel = self.mixer.get_free_channel(channel_type) # QUEUE - if entry.get_content_type() in ResourceClass.FILE.types: - metadata = ResourceUtil.generate_track_metadata(entry) - file_path = ResourceUtil.source_to_filepath(entry.source, self.config) - is_ready = entry.channel.load(file_path, metadata) - + if is_queue: + uri = ResourceUtil.source_to_filepath(entry.source, self.config) # STREAM elif entry.get_content_type() in ResourceClass.STREAM.types: - is_ready = entry.channel.load(entry.source) + uri = entry.source + + if not chosen_channel: + self.logger.critical(SU.red("No channel for '{entry.source}' source found")) + else: + entry.channel = chosen_channel + msg = f"Stored channel {entry.channel} in entry (prev: {entry.previous_channel})" + self.logger.info(SU.pink(msg)) + + is_ready = entry.channel.load(uri, metadata=metadata) if is_ready: - set_metadata() entry.status = Player.EntryPlayState.READY - self.event_dispatcher.on_queue([entry]) def preload_group(self, entries): -- GitLab From bffcb2a00549ce3c400b6002848efb855d87876c Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 12 Aug 2022 17:47:16 +0200 Subject: [PATCH 16/24] chore(spell): update excludes --- .codespellrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codespellrc b/.codespellrc index 2d26653..106ec71 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,5 +1,5 @@ [codespell] exclude-file = .gitignore -skip = src/aura_engine/client/playerclient.py,*.log,.git,*.png,*.db +skip = *.log,.git,*.png,*.db,*.pyc count = quiet-level = 1 \ No newline at end of file -- GitLab From 1158b801f8515bd537dbfee20f167be099b5d2dc Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Fri, 12 Aug 2022 17:52:10 +0200 Subject: [PATCH 17/24] chore: remove exclusion of app --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fdc55af..e1539e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,7 @@ line-length = 99 target-version = ["py38"] # TODO: Use extend-exclude as soon as Debian Bookworm is released. exclude = ''' - ^/src/aura_engine/app\.py$ - | ^/python/ + ^/python/ ''' [tool.isort] -- GitLab From bddcd35447f9687745bcfc5df07a9e7ea65077c9 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Wed, 17 Aug 2022 14:11:40 +0200 Subject: [PATCH 18/24] fix(spelling) --- docs/developer-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide.md b/docs/developer-guide.md index cd187cd..7d431a7 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -137,7 +137,7 @@ point in time and the involved phase before: - **Play-out**: Finally the actual play-out is happening. The faders of the virtual mixers are pushed all the way up, as soon it is "time to play" for one of the pre-loaded entries. - Transitions between playlist entries with different types of sources (file, stream and analog + Transitions between playlist entries with different types of sources (file, stream and analogue inputs) are performed automatically. At the end of each timeslot the channel is faded-out, no matter if the total length of the playlist entries would require a longer timeslot. -- GitLab From 27b12af55e9abd7bdc90b1d07e25a3ba8dbc40e4 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Wed, 17 Aug 2022 20:41:28 +0200 Subject: [PATCH 19/24] chore: consolidate target with engine-recorder --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 1ba3895..8ee4afd 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ help:: @echo " init.dev - init development environment" @echo " lint - verify code style" @echo " spell - check spelling of text" - @echo " style - apply automatic formatting" + @echo " format - apply automatic formatting" @echo " test - run test suite" @echo " log - tail log file" @echo " run - start app" @@ -94,7 +94,7 @@ lint:: spell:: codespell $(wildcard *.md) docs src tests config contrib -style:: +format:: python3 -m isort . black . -- GitLab From 3fd2e9ca370895c284706433e78ca8e643dc4b5b Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Wed, 17 Aug 2022 20:45:35 +0200 Subject: [PATCH 20/24] refact(oo): more separation of player commands #65 --- src/aura_engine/core/channels.py | 113 +++++++++++++++++------ src/aura_engine/core/mixer.py | 39 +++++--- src/aura_engine/engine.py | 114 +++++++----------------- src/aura_engine/scheduling/models.py | 3 - src/aura_engine/scheduling/scheduler.py | 57 ++++++------ 5 files changed, 173 insertions(+), 153 deletions(-) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py index 995ca39..32d9c3a 100644 --- a/src/aura_engine/core/channels.py +++ b/src/aura_engine/core/channels.py @@ -36,6 +36,7 @@ import json import logging import time from enum import Enum +from threading import Thread from aura_engine.base.api import LiquidsoapUtil as LU from aura_engine.base.config import AuraConfig @@ -158,7 +159,7 @@ class GenericChannel: """ if metadata: json_meta = json.dumps(metadata, ensure_ascii=False) - self.set_track_metadata(json_meta) + return self.set_track_metadata(json_meta) return True def fade_in(self, volume: int, instant=False): @@ -167,14 +168,12 @@ class GenericChannel: """ if instant: self.logger.info(SU.pink(f"Activate channel {self}")) - self.mixer.activate_channel(self, True) + return self.mixer.activate_channel(self, True) else: self.logger.info(SU.pink(f"Fade in channel {self}")) - self.mixer.select_channel(self, True) - # if response not in PlayoutStatusResponse.SUCCESS.value: - # msg = f"Error while selecting channel {self.name}: {response}" - # self.logger.error(SU.red(msg)) - return self.mixer.fade_in(self, volume) + faded = self.mixer.select_channel(self, True) + selected = self.mixer.fade_in(self, volume) + return faded and selected def fade_out(self, instant=False): """ @@ -185,11 +184,20 @@ class GenericChannel: """ if instant: self.logger.info(SU.pink(f"Activate channel {self}")) - self.mixer.activate_channel(self, False) + return self.mixer.activate_channel(self, False) else: self.logger.info(SU.pink(f"Fade out channel {self}")) - self.mixer.fade_out(self) - self.mixer.select_channel(self, False) + faded = self.mixer.fade_out(self) + selected = self.mixer.select_channel(self, False) + return faded and selected + + def roll(self, seconds_to_roll): + """ + Fast-forward to a position in time within the queue track. + + Most channels do not support this feature. + """ + return True def __str__(self): """ @@ -198,21 +206,29 @@ class GenericChannel: return f"[{self.channel_index} : {self.name}]" @private - def set_track_metadata(self, json_metadata: str) -> str: + def set_track_metadata(self, json_metadata: str) -> bool: """ Set the metadata as current track metadata on the given channel. This is only needed for non-queue channels. They pass the metadata inline with their request URIs + Args: + json_metadata(str): String containing metadata as JSON + + Returns: + (bool): True if metadata successfully set + @private """ response = self.mixer.client.exec(self.name, "set_track_metadata", json_metadata) - self.logger.info(f"Response for '{self.name}.set_track_metadata': {response}") + msg = f"Response for '{self.name}.set_track_metadata': {response}" + self.logger.info(SU.pink(msg)) if response not in PlayoutStatusResponse.SUCCESS.value: msg = f"Error while setting metadata on {self.name} to:\n{json_metadata}" self.logger.error(SU.red(msg)) - return response + return False + return True class QueueChannel(GenericChannel): @@ -236,6 +252,9 @@ class QueueChannel(GenericChannel): """ Load the provided URI and pass metadata. + Does not load the `super` implementation, as queues have their individual approach to + pass metadata in the URI. + Args: uri (str): The URI to load metadata (dict): Metadata to assign to the channel, when playing (optional) @@ -247,7 +266,7 @@ class QueueChannel(GenericChannel): if metadata: uri = LU.annotate_uri(uri, metadata) response = self.mixer.client.exec(self.name, "push", uri) - self.logger.info(f"{self.name}.push result: {response}") + self.logger.debug(SU.pink(f"{self.name}.push result: {response}")) # If successful, Liquidsoap returns a resource ID of the queued track resource_id = -1 @@ -259,22 +278,58 @@ class QueueChannel(GenericChannel): return False return resource_id >= 0 - def roll(self, seconds_to_roll): + def roll(self, seconds): + """ + Fast-forward to a position in time within the queue track. + + Args: + seconds(int): How many seconds the FFWD should performed + + Returns: + (bool): True after successful roll + + """ + response = self.mixer.client.exec(self.name, "seek", str(seconds)) + self.logger.info(SU.pink(f"{self.name}.seek result: {response}")) + return True + + def fade_out(self, instant=False): """ - Fast-forward to a a time position in the queue track. + Fade out channel and flush the queue. + + Args: + instant (bool, optional): Instant volume change instead of fade. Defaults to False + + Returns: + (bool): True after successful fade out """ - response = self.mixer.client.exec(self.name, "seek", str(seconds_to_roll)) - self.logger.info(f"{self.name}.seek result: {response}") + response = super().fade_out(instant) + self.flush() return response + @private def flush(self): """ Remove all items from queue. + + @private """ - response = self.mixer.client.exec(self.name, "clear") - msg = f"Cleared queue channel '{self.name}' with result '{response}'" - self.logger.info(SU.pink(msg)) - return response + + def flush_queue(): + # Wait some moments, if there is some long fade-out. Note, this also + # means, this channel should not be used for at least some seconds + # (including clearing time). + clear_timeout = 5 + msg = f"Clearing channel {self} in {clear_timeout} seconds" + self.logger.info(SU.pink(msg)) + time.sleep(clear_timeout) + + # Deactivate channel + response = self.mixer.client.exec(self.name, "clear") + msg = f"Cleared queue channel '{self.name}' with result '{response}'" + self.logger.info(SU.pink(msg)) + + Thread(target=flush_queue).start() class StreamChannel(GenericChannel): @@ -318,8 +373,9 @@ class StreamChannel(GenericChannel): max_retries = self.config.get("input_stream_max_retries") retries = 0 + self.logger.debug(SU.pink(f"Loading stream '{uri}'")) + while not self.is_ready(uri): - self.logger.info("Loading Stream ...") if retries >= max_retries: msg = f"Stream connection failed after {retries * retry_delay} seconds!" raise LoadSourceException(msg) @@ -349,7 +405,8 @@ class StreamChannel(GenericChannel): """ is_ready = True response = self.mixer.client.exec(self.name, "status") - self.logger.info(f"{self.name}.status result: {response}") + msg = f"{self.name}.status result: {response}" + self.logger.info(SU.pink(msg)) if not response.startswith(PlayoutStatusResponse.STREAM_STATUS_CONNECTED.value): return False @@ -363,7 +420,7 @@ class StreamChannel(GenericChannel): if is_ready: stream_buffer = self.config.get("input_stream_buffer") msg = f"Ready to play stream, but wait {stream_buffer} seconds to fill buffer..." - self.logger.info(msg) + self.logger.info(SU.pink(msg)) time.sleep(round(float(stream_buffer))) return is_ready @@ -375,7 +432,7 @@ class StreamChannel(GenericChannel): """ response = self.mixer.client.exec(self.name, "stop") if response not in PlayoutStatusResponse.SUCCESS.value: - self.logger.error(f"{self.name}.stop result: {response}") + self.logger.error(SU.red(f"{self.name}.stop result: {response}")) # FIXME use another exception raise LoadSourceException("Error while stopping stream!") return response @@ -387,7 +444,7 @@ class StreamChannel(GenericChannel): """ response = self.mixer.client.exec(self.name, "url", url) if response not in PlayoutStatusResponse.SUCCESS.value: - self.logger.error(f"{self.name}.url result: {response}") + self.logger.error(SU.red(f"{self.name}.url result: {response}")) # FIXME use another exception raise LoadSourceException("Error while setting stream URL!") return response @@ -398,7 +455,7 @@ class StreamChannel(GenericChannel): Start the stream URL. """ response = self.mixer.client.exec(self.name, "start") - self.logger.info(f"{self.name}.start result: {response}") + self.logger.info(SU.pink(f"{self.name}.start result: {response}")) return response diff --git a/src/aura_engine/core/mixer.py b/src/aura_engine/core/mixer.py index 060a013..0d794d5 100644 --- a/src/aura_engine/core/mixer.py +++ b/src/aura_engine/core/mixer.py @@ -50,7 +50,7 @@ class Mixer: mixer_id = None channels = None channel_names = None - active_channel: ChannelName = None + active_channel_name: ChannelName = None def __init__(self, mixer_id: str, client): """ @@ -70,7 +70,7 @@ class Mixer: self.channel_names = [] self.channels = {} - time.sleep(1) # TODO Check is this is still required + time.sleep(1) # TODO Check if this is still required self.refresh_channels() # TODO Graceful reboot: At some point the current track playing could @@ -97,27 +97,37 @@ class Mixer: outputs = LU.json_to_dict(outputs) return outputs - def set_active_channel(self, channel: ChannelName): + def set_active_channel_name(self, channel_name: ChannelName): """ Set the currently active channel. TODO active channel state should be handled internally. """ - self.active_channel = channel + self.active_channel_name = channel_name - def get_active_channel(self) -> ChannelName: + def get_active_channel_name(self) -> ChannelName: """ Retrieve the currently active channel. TODO active channel state should be handled internally. """ - return self.active_channel + return self.active_channel_name def get_channel(self, channel_name: ChannelName) -> GenericChannel: """ Retrieve a channel identified by name. """ - self.channels.get(channel_name) + if channel_name: + return self.channels.get(channel_name) + return None + + def get_active_channel(self) -> GenericChannel: + """ + Retrieve a channel identified by name. + """ + if self.active_channel_name: + return self.channels.get(self.active_channel_name) + return None def get_free_channel(self, channel_type: ChannelType) -> GenericChannel: """ @@ -133,8 +143,9 @@ class Mixer: """ free_channel: ChannelName = None - if self.channels.get(self.active_channel) == channel_type: - free_channels = [c for c in channel_type.channels if c != self.active_channel] + active_channel = self.get_active_channel() + if active_channel and active_channel.type == channel_type: + free_channels = [c for c in channel_type.channels if c != self.active_channel_name] if len(free_channels) < 1: msg = f"Requesting channel of type '{channel_type}' but none free. \ Active channel: '{self.active_channel}'" @@ -234,13 +245,13 @@ class Mixer: self.fade_in_active = True target_volume = volume step = fade_in_time / target_volume - msg = f"Fade in of {channel} to {target_volume} ({step}s steps)" - self.logger.info(SU.pink(msg)) + msg = f"Fade in of {channel} to {target_volume}% ({step}s steps)" + self.logger.debug(SU.pink(msg)) for i in range(target_volume): self.set_channel_volume(channel, i + 1) time.sleep(step) msg = f"Fade in of {channel} done" - self.logger.info(SU.pink(msg)) + self.logger.debug(SU.pink(msg)) except Exception as e: self.logger.critical(SU.red(e.message), e) @@ -273,12 +284,12 @@ class Mixer: if fade_out_time > 0: step = abs(fade_out_time) / current_volume msg = f"Start to fade out {channel} ({step}s step)" - self.logger.info(SU.pink(msg)) + self.logger.debug(SU.pink(msg)) for i in range(current_volume): self.set_channel_volume(channel, current_volume - i - 1) time.sleep(step) msg = f"Finished fade out of {channel}" - self.logger.info(SU.pink(msg)) + self.logger.debug(SU.pink(msg)) except Exception as e: self.logger.critical(SU.red(e.message), e) diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index 44352f4..5d76f4d 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -27,13 +27,12 @@ import os import time from contextlib import suppress from enum import Enum -from threading import Thread from aura_engine.base.api import LiquidsoapUtil as LU from aura_engine.base.config import AuraConfig from aura_engine.base.lang import DotDict from aura_engine.base.utils import SimpleUtil as SU -from aura_engine.core.channels import ChannelName, ChannelType, GenericChannel +from aura_engine.core.channels import ChannelType, QueueChannel from aura_engine.core.client import CoreConnectionError, PlayoutClient from aura_engine.events import EngineEventDispatcher from aura_engine.resources import ( @@ -124,21 +123,21 @@ class Engine: state = self.playout.get_status() state = DotDict(LU.json_to_dict(state)) + def dispatch_fallback_event(): + timeslot = self.scheduler.programme.get_current_timeslot() + fallback_show_name = self.config.get("fallback_show_name") + self.event_dispatcher.on_fallback_active(timeslot, fallback_show_name) + # Initialize state if not self.playout_state: if state.is_fallback: self.logger.info(SU.yellow("Set initial playout state to FALLBACK")) - timeslot = self.scheduler.programme.get_current_timeslot() - fallback_show_name = self.config.get("fallback_show_name") - self.event_dispatcher.on_fallback_active(timeslot, fallback_show_name) + dispatch_fallback_event() else: self.logger.info(SU.green("Set initial playout state")) elif state and self.playout_state.is_fallback != state.is_fallback: if state.is_fallback: self.logger.info(SU.yellow("Playout turned into FALLBACK state")) - timeslot = self.scheduler.programme.get_current_timeslot() - fallback_show_name = self.config.get("fallback_show_name") - self.event_dispatcher.on_fallback_active(timeslot, fallback_show_name) else: self.logger.info(SU.green("Playout turned back into normal state")) @@ -215,6 +214,7 @@ class Engine: """ if self.scheduler: self.scheduler.terminate() + # TODO terminate core connection # @@ -286,9 +286,8 @@ class Player: """ is_queue = entry.get_content_type() in ResourceClass.FILE.types metadata = ResourceUtil.generate_track_metadata(entry, not is_queue) - entry.previous_channel = self.mixer.get_active_channel() entry.status = Player.EntryPlayState.LOADING - self.logger.info("Loading entry '%s'" % entry) + self.logger.debug(SU.pink(f"Loading entry '{entry}'")) uri = None is_ready = False @@ -310,8 +309,8 @@ class Player: if not chosen_channel: self.logger.critical(SU.red("No channel for '{entry.source}' source found")) else: + msg = f"Assign channel {entry.channel} to entry" entry.channel = chosen_channel - msg = f"Stored channel {entry.channel} in entry (prev: {entry.previous_channel})" self.logger.info(SU.pink(msg)) is_ready = entry.channel.load(uri, metadata=metadata) @@ -338,7 +337,6 @@ class Player: channel_type (ChannelType): The type of channel where it should be queued (optional) """ - active_channel = self.mixer.get_active_channel() free_channel = self.mixer.get_free_channel(ChannelType.QUEUE) channels = None @@ -351,9 +349,8 @@ class Player: for entry in entries: entry.channel = free_channel - entry.previous_channel = active_channel entry.status = Player.EntryPlayState.LOADING - self.logger.info(f"Loading entry '{entry}'") + self.logger.debug(SU.pink(f"Loading entry '{entry}'")) # Choose and save the input channel metadata = ResourceUtil.generate_track_metadata(entry) @@ -364,11 +361,11 @@ class Player: self.event_dispatcher.on_queue(entries) return channels - def play(self, entry, transition): + def play(self, entry, transition: TransitionType): """ Play a new `Entry`. - In case of a new timeslot (or some intended, immediate transition), a clean channel is + In case of a new timeslot (or some intended, immediate transition), a prepared channel is selected and transitions between old and new channel is performed. This method expects that the entry is pre-loaded using `preload(..)` or @@ -385,99 +382,50 @@ class Player: """ with suppress(CoreConnectionError): - # Instant activation or fade-in + # Stop any active channel + self.stop(Player.TransitionType.FADE) + + # Start the new channel instant = not (transition == Player.TransitionType.FADE) entry.channel.fade_in(entry.volume, instant=instant) - # Update active channel for the current channel type - self.mixer.set_active_channel(entry.channel) - prev_channel = entry.previous_channel - if prev_channel in ChannelType.QUEUE.channels: - msg = f"About to clear previous channel {prev_channel}..." - self.logger.info(SU.pink(msg)) - prev_channel.flush() - self.event_dispatcher.on_play(entry) + # Update active channel + self.mixer.set_active_channel_name(entry.channel.name) - # Store most recent channel to timeslot, providing knowledge about - # which channel should be faded out at the end of the timeslot - timeslot = entry.playlist.timeslot - timeslot.latest_channel = entry.channel - msg = f"Stored recent entry's channel {entry.channel} to timeslot" - self.logger.info(SU.pink(msg)) + # Dispatch event + self.event_dispatcher.on_play(entry) - def stop(self, channel: GenericChannel, transition: TransitionType): + def stop(self, transition: TransitionType): """ - Stop the currently playing channel. + Stop the currently active channel either instantly or by fading out. Args: - channel (GenericChannel): The channel to stop playing or fade-out transition (Player.TransitionType): The type of transition to use e.g. fade-out """ with suppress(CoreConnectionError): + channel_name = self.mixer.get_active_channel_name() + channel = self.mixer.get_channel(channel_name) if not channel: - self.logger.warn(SU.red("Cannot stop, no channel passed")) + self.logger.info(SU.pink("Nothing to stop, no active channel")) return + instant = not (transition == Player.TransitionType.FADE) channel.fade_out(instant=instant) - - self.logger.info(SU.pink(f"Mute channel {channel} with {transition}")) self.event_dispatcher.on_stop(channel) - # - # Channel Type - Queue - # - - def queue_seek(self, channel, seconds_to_roll): + def roll(self, channel: QueueChannel, seconds: int) -> str: """ Pre-roll the player of the given `ChannelType.QUEUE` channel by (n) seconds. Args: channel (ChannelName): The channel to push the file to - seconds_to_roll (Float): The seconds to seek + seconds (Float): The seconds to seek Returns: - (String): Liquidsoap response - - """ - if not channel.type == ChannelType.QUEUE: - raise InvalidChannelException - return channel.roll(seconds_to_roll) + (bool): True after successful rolling - def queue_clear(self, channel: ChannelName): """ - Clear any tracks left in the queue. - - Dear queue channels, please leave the room as you would like to find it: - Every queue needs to be empty, before it can receive new items. Otherwise old tracks - continue to play, when such 'dirty' channel is selected. - - The channel is cleared asynchronously. - - Args: - channel (ChannelName): The channel to clear - - """ - if not channel.type == ChannelType.QUEUE: - raise InvalidChannelException - - def clean_up(): - # Wait some moments, if there is some long fade-out. Note, this also - # means, this channel should not be used for at least some seconds - # (including clearing time). - # - # TODO Review if this is still needed - since Liquidsoap 2 the time - # required for clearing a queue should be almost zero. - clear_timeout = 10 - self.logger.info(f"Clear channel {channel} in {clear_timeout} seconds") - time.sleep(clear_timeout) - - # Deactivate channel - response = self.mixer.activate_channel(channel.value, False) - msg = f"Deactivate channel {channel} with result '{response}'" - self.logger.info(SU.pink(msg)) - channel.flush(channel) - - Thread(target=clean_up).start() + return channel.roll(seconds) class InvalidChannelException(Exception): diff --git a/src/aura_engine/scheduling/models.py b/src/aura_engine/scheduling/models.py index 9746ae9..16f25e5 100644 --- a/src/aura_engine/scheduling/models.py +++ b/src/aura_engine/scheduling/models.py @@ -277,9 +277,6 @@ class Timeslot(DB.Model, AuraDatabaseModel): musicfocus = Column(String(256)) is_repetition = Column(Boolean()) - # Transients - latest_channel = None - @staticmethod def for_datetime(date_time): """Select a timeslot at the given datetime. diff --git a/src/aura_engine/scheduling/scheduler.py b/src/aura_engine/scheduling/scheduler.py index 751a47f..fbc2cac 100644 --- a/src/aura_engine/scheduling/scheduler.py +++ b/src/aura_engine/scheduling/scheduler.py @@ -193,32 +193,32 @@ class AuraScheduler(threading.Thread): # Calculate the seconds we have to fast-forward now_unix = Engine.engine_time() - seconds_to_seek = now_unix - active_entry.start_unix + seconds_to_roll = now_unix - active_entry.start_unix - # If the seek exceeds the length of the current track, + # If the roll exceeds the length of the current track, # there's no need to do anything - the scheduler takes care of the rest - if (seconds_to_seek + sleep_offset) > active_entry.duration: - self.logger.info( - "The FFWD [>>] range exceeds the length of the entry. Drink some tea and wait" - " for the sound of the next entry." - ) + if (seconds_to_roll + sleep_offset) > active_entry.duration: + msg = "The FFWD [>>] range exceeds the length of the entry. \ + Drink some tea and wait for the sound of the next entry." + self.logger.info(msg) else: # Preload and play active entry PlayCommand(self.engine, [active_entry]) # Fast-forward to the scheduled position - if seconds_to_seek > 0: - # Without plenty of timeout (10s) the seek doesn't work - def async_cue_seek(seconds_to_seek): - seconds_to_seek += sleep_offset + # TODO The roll should happen before the channel is active + if seconds_to_roll > 0: + # Without plenty of timeout (10s) the roll doesn't work + # TODO Check if this is still the case with Liquidsoap 2 + def async_preroll(seconds_to_roll): + seconds_to_roll += sleep_offset time.sleep(sleep_offset) - self.logger.info("Going to fast-forward %s seconds" % seconds_to_seek) - response = self.engine.player.queue_seek( - active_entry.channel, seconds_to_seek - ) - self.logger.info("Sound-system seek response: " + response) + self.logger.info("Going to fast-forward %s seconds" % seconds_to_roll) + rolled = self.engine.player.roll(active_entry.channel, seconds_to_roll) + if rolled: + self.logger.info("Pre-roll done") - thread = threading.Thread(target=async_cue_seek, args=(seconds_to_seek,)) + thread = threading.Thread(target=async_preroll, args=(seconds_to_roll,)) thread.start() elif ( @@ -245,7 +245,7 @@ class AuraScheduler(threading.Thread): timeslot = self.programme.get_current_timeslot() if timeslot: return self.resolve_playlist(timeslot) - return (None, None) + return (-1, None) def resolve_playlist(self, timeslot): """ @@ -490,13 +490,20 @@ class TimeslotCommand(EngineExecutor): """ self.logger.info(SU.cyan(f"=== on_timeslot_end('{timeslot}') ===")) self.engine.event_dispatcher.on_timeslot_end(timeslot) - channel = timeslot.latest_channel - if channel: - self.logger.info(f"Stopping channel {channel}") - self.engine.player.stop(channel, Player.TransitionType.FADE) - else: - msg = f"There is no channel to stop for timeslot #{timeslot.timeslot_id}" - self.logger.error(SU.red(msg)) + + def has_direct_successor(): + programme = self.engine.scheduler.programme + next_timeslot = programme.get_next_timeslots(1) + if next_timeslot: + next_timeslot = next_timeslot[0] + if next_timeslot.timeslot_start > timeslot.timeslot_end: + return False + return True + else: + return False + + if not has_direct_successor(): + self.engine.player.stop(Player.TransitionType.FADE) class PlayCommand(EngineExecutor): -- GitLab From 1949cb5894c7768cf04cf90e8315e506871cbf79 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Thu, 18 Aug 2022 12:05:25 +0200 Subject: [PATCH 21/24] refact: add channel status updates #65 --- src/aura_engine/core/channels.py | 87 +++++++++++++++++++++++++++++--- src/aura_engine/core/mixer.py | 77 ++++++++++++++++++---------- 2 files changed, 129 insertions(+), 35 deletions(-) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py index 32d9c3a..2c060ad 100644 --- a/src/aura_engine/core/channels.py +++ b/src/aura_engine/core/channels.py @@ -40,7 +40,7 @@ from threading import Thread from aura_engine.base.api import LiquidsoapUtil as LU from aura_engine.base.config import AuraConfig -from aura_engine.base.lang import private +from aura_engine.base.lang import DotDict, private from aura_engine.base.utils import SimpleUtil as SU @@ -114,6 +114,16 @@ class ChannelType(dict, Enum): class GenericChannel: """ Base class for channel implementations. + + Attributes: + type (ChannelType): Type of channel such as queue, stream or analogue + name (ChannelName): Name of the channel as defined in Liquidsoap + index (int): Position of the channel on the mixer + ready (bool): Indicates if the channel is ready to be used + selected (bool): Indicates if the channel is selected + single (bool): ? + volume (int): Volume from -1 to 100, where -1 indicates an error + remaining (float): Seconds remaining to be played """ logger: None @@ -121,7 +131,12 @@ class GenericChannel: type: ChannelType = None name: ChannelName = None - channel_index: int = None + index: int = None + ready: bool = None + selected = bool = None + single: bool = None + volume: int = None + remaining: float = None def __init__(self, channel_index: int, channel_name: int, mixer): """ @@ -137,7 +152,16 @@ class GenericChannel: self.logger = logging.getLogger("AuraEngine") self.mixer = mixer self.name = channel_name - self.channel_index = channel_index + self.index = channel_index + + def get_index(self) -> int: + """ + Retrieve the channel index. + + Returns: + int: The index + """ + return self.index def get_type(self) -> ChannelType: """ @@ -145,6 +169,46 @@ class GenericChannel: """ return self.type + def get_status(self) -> dict: + """ + Get channel status information. + + Returns: + dict: { + ready (bool): Indicates if the channel is ready to be used + selected (bool): Indicates if the channel is selected + single (bool): ? + volume (int): Volume from -1 to 100, where -1 indicates an error + remaining (float): Seconds remaining to be played + } + """ + return DotDict( + { + "ready": self.ready, + "selected": self.selected, + "single": self.single, + "volume": self.volume, + "remaining": self.remaining, + } + ) + + def set_status(self, ready: bool, selected: bool, single: bool, volume: int, remain: float): + """ + Set channel status information. + + Args: + ready (bool): Indicates if the channel is ready to be used + selected (bool): Indicates if the channel is selected + single (bool): ? + volume (int): Volume from -1 to 100, where -1 indicates an error + remaining (float): Seconds remaining to be played + """ + self.ready = ready + self.selected = selected + self.single = single + self.volume = volume + self.remaining = remain + def load(self, metadata: dict = None) -> bool: """ Interface definition for loading a channel track. @@ -165,6 +229,13 @@ class GenericChannel: def fade_in(self, volume: int, instant=False): """ Perform a fade-in for the given channel. + + Args: + instant(bool): If true the fade instantly jumps to target volume + + Returns: + (bool): True if fade successful + """ if instant: self.logger.info(SU.pink(f"Activate channel {self}")) @@ -181,6 +252,10 @@ class GenericChannel: Args: instant(bool): If true the fade instantly jumps to zero volume + + Returns: + (bool): True if fade successful + """ if instant: self.logger.info(SU.pink(f"Activate channel {self}")) @@ -203,7 +278,7 @@ class GenericChannel: """ String representation of the Channel. """ - return f"[{self.channel_index} : {self.name}]" + return f"[{self.index} : {self.name}]" @private def set_track_metadata(self, json_metadata: str) -> bool: @@ -433,7 +508,6 @@ class StreamChannel(GenericChannel): response = self.mixer.client.exec(self.name, "stop") if response not in PlayoutStatusResponse.SUCCESS.value: self.logger.error(SU.red(f"{self.name}.stop result: {response}")) - # FIXME use another exception raise LoadSourceException("Error while stopping stream!") return response @@ -445,7 +519,6 @@ class StreamChannel(GenericChannel): response = self.mixer.client.exec(self.name, "url", url) if response not in PlayoutStatusResponse.SUCCESS.value: self.logger.error(SU.red(f"{self.name}.url result: {response}")) - # FIXME use another exception raise LoadSourceException("Error while setting stream URL!") return response @@ -531,7 +604,7 @@ class ChannelFactory: self.logger.debug(f"Create new STREAM channel '{channel_name}'") return StreamChannel(channel_index, channel_name, mixer) if channel_name in ChannelType.LIVE.channels: - self.logger.debug(f"Create new ANALOG channel '{channel_name}'") + self.logger.debug(f"Create new ANALOGUE channel '{channel_name}'") return AnalogueChannel(channel_index, channel_name, mixer) diff --git a/src/aura_engine/core/mixer.py b/src/aura_engine/core/mixer.py index 0d794d5..d22e4fa 100644 --- a/src/aura_engine/core/mixer.py +++ b/src/aura_engine/core/mixer.py @@ -29,7 +29,7 @@ import time from aura_engine.base.api import LiquidsoapUtil as LU from aura_engine.base.config import AuraConfig -from aura_engine.base.lang import DotDict, private +from aura_engine.base.lang import private from aura_engine.base.utils import SimpleUtil as SU from aura_engine.core.channels import ( ChannelFactory, @@ -175,14 +175,14 @@ class Mixer: if not self.channels: self.logger.critical(SU.red("Cannot select channel cuz there are no channels")) else: - index = channel.channel_index + index = channel.get_index() select = "true" if select else "false" - # TODO message holds the new channel status: store it - # 'ready=true selected=true single=false volume=0% remaining=inf' - message = self.client.exec(self.mixer_id, "select", f"{index} {select}") - return message + response = self.client.exec(self.mixer_id, "select", f"{index} {select}") + self.update_channel_status(index, response) + return True except Exception as e: self.logger.critical(SU.red("Ran into exception when selecting channel"), e) + return False def activate_channel(self, channel: GenericChannel, activate: bool) -> str: """ @@ -206,12 +206,14 @@ class Mixer: if not self.channels: self.logger.critical(SU.red("Cannot activate channel cuz there are no channels")) else: - index = channel.channel_index + index = channel.get_index() activate = "true" if activate else "false" - message = self.client.exec(self.mixer_id, "activate", f"{index} {activate}") - return message + response = self.client.exec(self.mixer_id, "activate", f"{index} {activate}") + if response == "OK": + return True except Exception as e: self.logger.critical(SU.red("Ran into exception when activating channel."), e) + return False def fade_in(self, channel: GenericChannel, volume: int = 100) -> bool: """ @@ -308,27 +310,29 @@ class Mixer: @private """ - def create_channel(name): + def create_channel(name) -> int: self.logger.debug(f"Set new channel name '{name}'") self.channel_names.append(name) idx = self.channel_names.index(name) channel = ChannelFactory.create_channel(self, idx, name, self) self.channels[name] = channel + return idx if not self.channel_names: # Get channel names channel_names = self.client.exec(self.mixer_id, "inputs") channel_names = channel_names.split(" ") + # Create channels objects if not yet available for name in channel_names: try: self.channel_names.index(name) except ValueError: - create_channel(name) - - # Update channel status - # ... + idx = create_channel(name) + status = self.get_channel_status(idx) + msg = f"Channel #{idx} status: {status}" + self.logger.info(SU.pink(msg)) @private def get_channel_number(self, channel_name: ChannelName) -> int: @@ -368,13 +372,35 @@ class Mixer: """ response = self.client.exec(self.mixer_id, "status", channel_number) - # TODO separate method to be utilized by individual channels updates too + return self.update_channel_status(channel_number, response) + + @private + def update_channel_status(self, channel_number: int, status_string: str): + """ + Update channel status. + + Args: + channel_number (int): the index of the channel on the mixer + status_string (dict): channel status fields as single string + + @private + """ status = {} - pairs = response.split(" ") + pairs = status_string.split(" ") for pair in pairs: kv = pair.split("=") status[kv[0]] = kv[1] - return DotDict(status) + + channel_name = self.channel_names[channel_number] + channel: GenericChannel = self.channels.get(channel_name) + channel.set_status( + bool(status.get("ready")), + bool(status.get("selected")), + bool(status.get("single")), + int(status.get("volume").split("%")[0]), + float(status.get("remaining")), + ) + return channel.get_status() @private def get_channel_volume(self, channel: GenericChannel) -> int: @@ -390,15 +416,8 @@ class Mixer: @private """ - status = self.get_channel_status(channel.channel_index) - volume = status.get("volume") - if volume: - # TODO check if we now need to multiply by 100 (since Liquidsoap 2) - return int(volume.split("%")[0]) - else: - msg = f"Invalid volume for channel {channel} (status: '{status}'" - self.logger.error(SU.red(msg)) - return -1 + status = self.get_channel_status(channel.get_index()) + return status.volume @private def set_channel_volume(self, channel: GenericChannel, volume: int): @@ -414,9 +433,11 @@ class Mixer: """ self.refresh_channels() - playout_volume = str(int(volume) / 100) # 100% volume equals 1 - args = f"{channel.channel_index} {playout_volume}" + playout_volume = str(int(volume) / 100) + args = f"{channel.get_index()} {playout_volume}" message = self.client.exec(self.mixer_id, "volume", args) if not message.find(f"volume={volume}%"): msg = f"Error setting volume of channel {channel}: {message}" self.logger.error(SU.red(msg)) + else: + self.update_channel_status(channel.index, message) -- GitLab From fc8dff304e89545f626f12b69d62beb77b04df96 Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Thu, 18 Aug 2022 14:04:53 +0200 Subject: [PATCH 22/24] style: consolidate channel names #65 --- src/aura_engine/core/channels.py | 25 +++++++++++++------------ src/aura_engine/engine.py | 2 +- src/aura_engine/plugins/monitor.py | 2 +- src/aura_engine/resources.py | 2 +- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py index 2c060ad..ac8cda5 100644 --- a/src/aura_engine/core/channels.py +++ b/src/aura_engine/core/channels.py @@ -51,15 +51,15 @@ class ChannelName(str, Enum): These are name mappings to the Liqidsoap channel IDs. """ - QUEUE_A = "in_filesystem_0" - QUEUE_B = "in_filesystem_1" - HTTP_A = "in_http_0" - HTTP_B = "in_http_1" - LIVE_0 = "linein_0" - LIVE_1 = "linein_1" - LIVE_2 = "linein_2" - LIVE_3 = "linein_3" - LIVE_4 = "linein_4" + QUEUE_A = "in_queue_0" + QUEUE_B = "in_queue_1" + HTTP_A = "in_stream_0" + HTTP_B = "in_stream_1" + LIVE_0 = "in_line_0" + LIVE_1 = "in_line_1" + LIVE_2 = "in_line_2" + LIVE_3 = "in_line_3" + LIVE_4 = "in_line_4" FALLBACK_FOLDER = "fallback_folder" FALLBACK_PLAYLIST = "fallback_playlist" @@ -364,9 +364,10 @@ class QueueChannel(GenericChannel): (bool): True after successful roll """ - response = self.mixer.client.exec(self.name, "seek", str(seconds)) - self.logger.info(SU.pink(f"{self.name}.seek result: {response}")) - return True + response = self.mixer.client.exec(self.name, "roll", str(seconds)) + if response == "OK": + return True + return False def fade_out(self, instant=False): """ diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index 5d76f4d..2982e65 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -419,7 +419,7 @@ class Player: Args: channel (ChannelName): The channel to push the file to - seconds (Float): The seconds to seek + seconds (Float): The seconds to roll Returns: (bool): True after successful rolling diff --git a/src/aura_engine/plugins/monitor.py b/src/aura_engine/plugins/monitor.py index 03bde4e..7aea5d0 100644 --- a/src/aura_engine/plugins/monitor.py +++ b/src/aura_engine/plugins/monitor.py @@ -171,7 +171,7 @@ class AuraMonitor: try: if ( self.status["lqs"]["available"] - and self.status["lqs"]["mixer"]["in_filesystem_0"] + and self.status["lqs"]["mixer"]["in_queue_0"] and self.status["audio_source"]["exists"] ): diff --git a/src/aura_engine/resources.py b/src/aura_engine/resources.py index b037bb1..baa57d1 100644 --- a/src/aura_engine/resources.py +++ b/src/aura_engine/resources.py @@ -73,7 +73,7 @@ class ResourceMapping: """ if not channel: return None - channel_name = "linein_" + channel.split("line://")[1] + channel_name = "in_line_" + channel.split("line://")[1] for cn in ChannelName: if cn.value == channel_name: -- GitLab From 3c89c03d5108e7a4b941f6891cd71d01c0968d6d Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Thu, 18 Aug 2022 14:13:24 +0200 Subject: [PATCH 23/24] refact: ditch some sleeping states #65 --- src/aura_engine/core/channels.py | 13 ++++--------- src/aura_engine/core/mixer.py | 1 - 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py index ac8cda5..061f909 100644 --- a/src/aura_engine/core/channels.py +++ b/src/aura_engine/core/channels.py @@ -437,19 +437,14 @@ class StreamChannel(GenericChannel): (bool): True if track loaded successfully """ - self.stop() - self.set_url(uri) - # TODO Review if still valid: Liquidsoap ignores commands sent without a certain timeout - time.sleep(2) - self.start() - - # TODO Review if that's still required: - time.sleep(1) + self.logger.debug(SU.pink(f"Loading stream '{uri}'")) retry_delay = self.config.get("input_stream_retry_delay") max_retries = self.config.get("input_stream_max_retries") retries = 0 - self.logger.debug(SU.pink(f"Loading stream '{uri}'")) + self.stop() + self.set_url(uri) + self.start() while not self.is_ready(uri): if retries >= max_retries: diff --git a/src/aura_engine/core/mixer.py b/src/aura_engine/core/mixer.py index d22e4fa..a32cce7 100644 --- a/src/aura_engine/core/mixer.py +++ b/src/aura_engine/core/mixer.py @@ -70,7 +70,6 @@ class Mixer: self.channel_names = [] self.channels = {} - time.sleep(1) # TODO Check if this is still required self.refresh_channels() # TODO Graceful reboot: At some point the current track playing could -- GitLab From 35221d8efa02f3a52e6a15488a3934447c48b9bc Mon Sep 17 00:00:00 2001 From: David Trattnig Date: Thu, 18 Aug 2022 15:08:19 +0200 Subject: [PATCH 24/24] refact(oo): encapsulate channel state handling #65 --- src/aura_engine/core/channels.py | 2 ++ src/aura_engine/core/mixer.py | 32 +++++++++++++------------------- src/aura_engine/engine.py | 6 +----- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/aura_engine/core/channels.py b/src/aura_engine/core/channels.py index 061f909..ad0b034 100644 --- a/src/aura_engine/core/channels.py +++ b/src/aura_engine/core/channels.py @@ -244,6 +244,7 @@ class GenericChannel: self.logger.info(SU.pink(f"Fade in channel {self}")) faded = self.mixer.select_channel(self, True) selected = self.mixer.fade_in(self, volume) + self.mixer.set_active_channel(self) return faded and selected def fade_out(self, instant=False): @@ -264,6 +265,7 @@ class GenericChannel: self.logger.info(SU.pink(f"Fade out channel {self}")) faded = self.mixer.fade_out(self) selected = self.mixer.select_channel(self, False) + self.mixer.set_active_channel(None) return faded and selected def roll(self, seconds_to_roll): diff --git a/src/aura_engine/core/mixer.py b/src/aura_engine/core/mixer.py index a32cce7..03c09ea 100644 --- a/src/aura_engine/core/mixer.py +++ b/src/aura_engine/core/mixer.py @@ -96,22 +96,6 @@ class Mixer: outputs = LU.json_to_dict(outputs) return outputs - def set_active_channel_name(self, channel_name: ChannelName): - """ - Set the currently active channel. - - TODO active channel state should be handled internally. - """ - self.active_channel_name = channel_name - - def get_active_channel_name(self) -> ChannelName: - """ - Retrieve the currently active channel. - - TODO active channel state should be handled internally. - """ - return self.active_channel_name - def get_channel(self, channel_name: ChannelName) -> GenericChannel: """ Retrieve a channel identified by name. @@ -122,12 +106,22 @@ class Mixer: def get_active_channel(self) -> GenericChannel: """ - Retrieve a channel identified by name. + Retrieve the currently active channel. """ if self.active_channel_name: - return self.channels.get(self.active_channel_name) + return self.get_channel(self.active_channel_name) return None + def set_active_channel(self, channel: GenericChannel): + """ + Set the currently active channel. + """ + if channel: + self.active_channel_name = channel.name + else: + self.active_channel_name = None + self.logger.info(SU.pink("Reset active channel")) + def get_free_channel(self, channel_type: ChannelType) -> GenericChannel: """ Return any _free_ channel of the given type. @@ -330,7 +324,7 @@ class Mixer: except ValueError: idx = create_channel(name) status = self.get_channel_status(idx) - msg = f"Channel #{idx} status: {status}" + msg = f"Channel {self.get_channel(name)} status: {status}" self.logger.info(SU.pink(msg)) @private diff --git a/src/aura_engine/engine.py b/src/aura_engine/engine.py index 2982e65..658656f 100644 --- a/src/aura_engine/engine.py +++ b/src/aura_engine/engine.py @@ -389,9 +389,6 @@ class Player: instant = not (transition == Player.TransitionType.FADE) entry.channel.fade_in(entry.volume, instant=instant) - # Update active channel - self.mixer.set_active_channel_name(entry.channel.name) - # Dispatch event self.event_dispatcher.on_play(entry) @@ -403,8 +400,7 @@ class Player: transition (Player.TransitionType): The type of transition to use e.g. fade-out """ with suppress(CoreConnectionError): - channel_name = self.mixer.get_active_channel_name() - channel = self.mixer.get_channel(channel_name) + channel = self.mixer.get_active_channel() if not channel: self.logger.info(SU.pink("Nothing to stop, no active channel")) return -- GitLab