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