From 8bec4d4d38bd88dd6a7e6b8b891c3b70660a3061 Mon Sep 17 00:00:00 2001 From: David Trattnig <david.trattnig@o94.at> Date: Fri, 16 Oct 2020 20:27:47 +0200 Subject: [PATCH] Custom channel and resource routing. #43 --- modules/core/channels.py | 144 ++++++++++++++++++++++-- modules/core/resources.py | 193 ++++++++++++++++++++++++++++++++ modules/core/state.py | 25 +++-- modules/plugins/trackservice.py | 8 +- 4 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 modules/core/resources.py diff --git a/modules/core/channels.py b/modules/core/channels.py index 71b06a4a..23b299be 100644 --- a/modules/core/channels.py +++ b/modules/core/channels.py @@ -19,6 +19,8 @@ from enum import Enum +from modules.base.utils import SimpleUtil as SU +from modules.core.resources import ResourceType class TransitionType(Enum): @@ -28,14 +30,14 @@ class TransitionType(Enum): INSTANT = "instant" FADE = "fade" - class Channel(Enum): """ - Channel name mappings to the Liqidsoap channel IDs + Channel name mappings to the Liqidsoap channel IDs. """ - FILESYSTEM_A = "in_filesystem_0" - FILESYSTEM_B = "in_filesystem_1" - SCHEDULED_FALLBACK = "playlist_fallback_scheduled" + QUEUE_A = "in_filesystem_0" + QUEUE_B = "in_filesystem_1" + FALLBACK_QUEUE_A = "in_fallback_scheduled_0" + FALLBACK_QUEUE_B = "in_fallback_scheduled_1" HTTP_A = "in_http_0" HTTP_B = "in_http_1" HTTPS_A = "in_https_0" @@ -54,10 +56,15 @@ class ChannelType(Enum): """ Engine channel types mapped to `Entry` source types. """ - FILESYSTEM = { + QUEUE = { "id": "fs", "numeric": 0, - "channels": [Channel.FILESYSTEM_A, Channel.FILESYSTEM_B, Channel.SCHEDULED_FALLBACK] + "channels": [Channel.QUEUE_A, Channel.QUEUE_B] + } + FALLBACK_QUEUE = { + "id": "fallback_queue", + "numeric": 0, + "channels": [Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B] } HTTP = { "id": "http", @@ -106,3 +113,126 @@ class LiquidsoapResponse(Enum): STREAM_STATUS_STOPPED = "stopped" STREAM_STATUS_CONNECTED = "connected" + + +class ChannelRouter(): + """ + Wires source types with channels and channel-types. + """ + config = None + logger = None + resource_mapping = None + active_channels = None + + + def __init__(self, config, logger): + """ + Constructor + + Args: + config (AuraConfig): The configuration + """ + self.config = config + self.logger = logger + + self.resource_mapping = { + ResourceType.FILE: ChannelType.QUEUE, + ResourceType.STREAM_HTTP: ChannelType.HTTP, + ResourceType.STREAM_HTTPS: ChannelType.HTTPS, + ResourceType.LINE: ChannelType.LIVE, + ResourceType.PLAYLIST: ChannelType.QUEUE, + ResourceType.POOL: ChannelType.QUEUE + } + + self.active_channels = { + ChannelType.QUEUE: Channel.QUEUE_A, + ChannelType.FALLBACK_QUEUE: Channel.FALLBACK_QUEUE_A, + ChannelType.HTTP: Channel.HTTP_A, + ChannelType.HTTPS: Channel.HTTPS_A, + ChannelType.LIVE: Channel.LIVE_0 + } + + + def set_active(self, channel_type, channel): + """ + Set the channel for the given resource type active + """ + self.active_channels[channel_type] = channel + + + def get_active(self, channel_type): + """ + Retrieves the active channel for the given resource type + """ + return self.active_channels[channel_type] + + + def type_for_resource(self, resource_type): + """ + Retrieves 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 channel_swap(self, channel_type): + """ + Returns the currently inactive channel for a given type. For example if the currently some + file on channel QUEUE A is playing, the channel QUEUE B is returned for being used + to queue new entries. + + Args: + entry_type (ResourceType): The resource type such es file, stream or live source + + Returns: + (Channel, Channel): The previous and new channel + """ + previous_channel = self.active_channels[channel_type] + new_channel = None + msg = None + + if channel_type == ChannelType.QUEUE: + + if previous_channel == Channel.QUEUE_A: + new_channel = Channel.QUEUE_B + msg = "Swapped queue channel from A > B" + else: + new_channel = Channel.QUEUE_A + msg = "Swapped queue channel from B > A" + + elif channel_type == ChannelType.FALLBACK_QUEUE: + + if previous_channel == Channel.FALLBACK_QUEUE_A: + new_channel = Channel.FALLBACK_QUEUE_B + msg = "Swapped fallback queue channel from A > B" + else: + new_channel = Channel.FALLBACK_QUEUE_A + msg = "Swapped fallback channel from B > A" + + elif channel_type == ChannelType.HTTP: + + if previous_channel == Channel.HTTP_A: + new_channel = Channel.HTTP_B + msg = "Swapped HTTP Stream channel from A > B" + else: + new_channel = Channel.HTTP_A + msg = "Swapped HTTP Stream channel from B > A" + + elif channel_type == ChannelType.HTTPS: + + if previous_channel == Channel.HTTPS_A: + new_channel = Channel.HTTPS_B + msg = "Swapped HTTPS Stream channel from A > B" + else: + new_channel = Channel.HTTPS_A + msg = "Swapped HTTPS Stream channel from B > A" + + else: + self.logger.warning(SU.red(f"No channel to swap - invalid entry_type '{channel_type}'")) + + if msg: self.logger.info(SU.pink(msg)) + return (previous_channel, new_channel) + + diff --git a/modules/core/resources.py b/modules/core/resources.py new file mode 100644 index 00000000..f4e9527a --- /dev/null +++ b/modules/core/resources.py @@ -0,0 +1,193 @@ +# +# 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 <http://www.gnu.org/licenses/>. + + +from enum import Enum + + + +class ResourceType(Enum): + """ + Media content types. + """ + FILE = "file:" + STREAM_HTTP = "http:" + STREAM_HTTPS = "https:" + LINE = "line:" + PLAYLIST = "playlist:" + POOL = "pool:" + + +class ResourceClass(Enum): + """ + Media content classes. + """ + FILE = { + "id": "fs", + "numeric": 0, + "types": [ResourceType.FILE] + } + STREAM = { + "id": "fs", + "numeric": 0, + "types": [ResourceType.STREAM_HTTP, ResourceType.STREAM_HTTPS] + } + LIVE = { + "id": "http", + "numeric": 1, + "types": [ResourceType.LINE] + } + PLAYLIST = { + "id": "https", + "numeric": 2, + "types": [ResourceType.PLAYLIST, ResourceType.POOL] + } + + @property + def types(self): + return self.value["types"] + + @property + def numeric(self): + return self.value["numeric"] + + def __str__(self): + return str(self.value["id"]) + + + +class ResourceUtil(Enum): + """ + Utilities for different resource types. + """ + + + @staticmethod + def get_content_type(uri): + """ + Returns the content type identified by the passed URI. + + Args: + uri (String): The URI of the source + + Returns: + (ResourceType) + """ + if uri.startswith(ResourceType.STREAM_HTTPS.value): + return ResourceType.STREAM_HTTPS + if uri.startswith(ResourceType.STREAM_HTTP.value): + return ResourceType.STREAM_HTTP + if uri.startswith(ResourceType.POOL.value): + return ResourceType.POOL + if uri.startswith(ResourceType.FILE.value): + return ResourceType.FILE + if uri.startswith(ResourceType.LINE.value): + return ResourceType.LINE + + + @staticmethod + def get_content_class(content_type): + """ + Returns the content class identified by the passed type. + + Args: + content_type (ContentType): The content type + + Returns: + (ResourceType) + """ + if content_type in ResourceClass.FILE.types: + return ResourceClass.FILE + if content_type in ResourceClass.STREAM.types: + return ResourceClass.STREAM + if content_type in ResourceClass.LIVE.types: + return ResourceClass.LIVE + if content_type in ResourceClass.PLAYLIST.types: + return ResourceClass.PLAYLIST + + + @staticmethod + def generate_m3u_file(target_file, audio_store_path, entries, entry_extension): + """ + Writes a M3U file based on the given playlist object. + + Args: + target_file (File): The M3U playlist to write + audio_store_path (String): Folder containing the source files + entries (PlaylistEntry): Entries of the playlist + entry_extension (String): The file extension of the playlist entries + """ + file = open(target_file, "w") + fb = [ "#EXTM3U" ] + + for entry in entries: + if ResourceUtil.get_content_type(entry.source) == ResourceType.FILE: + path = ResourceUtil.uri_to_filepath(audio_store_path, entry.source, entry_extension) + fb.append(f"#EXTINF:{entry.duration},{entry.meta_data.artist} - {entry.meta_data.title}") + fb.append(path) + + file.writelines(fb) + file.close() + + + @staticmethod + def uri_to_filepath(base_dir, uri, source_extension): + """ + Converts a file-system URI to an actual, absolute path to the file. + + Args: + basi_dir (String): The location of the audio store. + uri (String): The URI of the file + source_extension (String): The file extension of audio sources + + Returns: + path (String): Absolute file path + """ + return base_dir + "/" + uri[7:] + source_extension + + + @staticmethod + def get_entries_string(entries): + """ + Returns a list of entries as String for logging purposes. + """ + s = "" + if isinstance(entries, list): + for entry in entries: + s += str(entry) + if entry != entries[-1]: s += ", " + else: + s = str(entries) + return s + + + @staticmethod + def lqs_annotate_cuein(uri, cue_in): + """ + Wraps the given URI with a Liquidsoap Cue In annotation. + + Args: + uri (String): The path to the audio source + cue_in (Float): The value in seconds wher the cue in should start + + Returns: + (String): The annotated URI + """ + if cue_in > 0.0: + uri = "annotate:liq_cue_in=\"%s\":%s" % (str(cue_in), uri) + return uri \ No newline at end of file diff --git a/modules/core/state.py b/modules/core/state.py index d366850b..b06e13f6 100644 --- a/modules/core/state.py +++ b/modules/core/state.py @@ -1,18 +1,18 @@ # # Aura Engine (https://gitlab.servus.at/aura/engine) # -# Copyright (C) 2020 - The Aura Engine Team. -# +# 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 <http://www.gnu.org/licenses/>. @@ -21,8 +21,8 @@ import logging from collections import deque from modules.base.exceptions import NoActiveEntryException -from modules.base.utils import SimpleUtil as SU, EngineUtil -from modules.core.channels import ChannelType +from modules.base.utils import SimpleUtil as SU +from modules.core.resources import ResourceClass, ResourceUtil @@ -31,8 +31,8 @@ class PlayerStateService: PlayerStateService keeps a short history of currently playing entries. It stores the recent active entries to a local cache `entry_history` being able to manage concurrently playing entries. - It also is in charge of storing relevant meta information of the currently playing entry to - the TrackService table. + It also is in charge of resolving relevant meta information of the currently playing entry for + the TrackService plugin. """ config = None @@ -90,11 +90,12 @@ class PlayerStateService: for entry in entries: entry_source = entry.source - if entry.channel in ChannelType.FILESYSTEM.channels: - base_dir = self.config.get("audiofolder") - entry_source = EngineUtil.uri_to_filepath(base_dir, entry.source) + if entry.get_content_type() in ResourceClass.FILE.types: + base_dir = self.config.get("audio_source_folder") + extension = self.config.get("audio_source_extension") + entry_source = ResourceUtil.uri_to_filepath(base_dir, entry.source, extension) if entry_source == source: - self.logger.info("Resolved '%s' entry '%s' for URI '%s'" % (entry.get_type(), entry, source)) + self.logger.info("Resolved '%s' entry '%s' for URI '%s'" % (entry.get_content_type(), entry, source)) result = entry break diff --git a/modules/plugins/trackservice.py b/modules/plugins/trackservice.py index 1faacdb5..9c615a44 100644 --- a/modules/plugins/trackservice.py +++ b/modules/plugins/trackservice.py @@ -22,7 +22,7 @@ import logging import requests from modules.base.utils import SimpleUtil as SU - +from modules.core.resources import ResourceUtil class TrackServiceHandler(): @@ -65,7 +65,8 @@ class TrackServiceHandler(): data["track_title"] = entry.meta_data.title data["track_duration"] = entry.duration data["track_num"] = entry.entry_num - data["track_type"] = entry.get_type().numeric + content_class = ResourceUtil.get_content_class(entry.get_content_type()) + data["track_type"] = content_class.numeric data["playlist_id"] = entry.playlist.playlist_id data["schedule_id"] = entry.playlist.schedule.schedule_id data["show_id"] = entry.playlist.schedule.show_id @@ -106,7 +107,8 @@ class TrackServiceHandler(): entry["track_title"] = e.meta_data.title entry["track_num"] = e.entry_num entry["track_duration"] = e.duration - entry["track_type"] = e.get_type().numeric + content_class = ResourceUtil.get_content_class(e.get_content_type) + entry["track_type"] = content_class.numeric entry = SU.clean_dictionary(entry) data["current_playlist"]["entries"].append(entry) -- GitLab