From bae487807582384553b335f63b37059a6be17b36 Mon Sep 17 00:00:00 2001
From: David Trattnig <david.trattnig@o94.at>
Date: Thu, 14 May 2020 16:45:49 +0200
Subject: [PATCH] Scheduling of Streams.

---
 modules/base/enum.py                          |  22 +-
 modules/base/exceptions.py                    |   5 +-
 modules/base/utils.py                         |   2 +-
 .../communication/liquidsoap/playerclient.py  |  52 +++-
 modules/core/engine.py                        | 225 +++++++++---------
 modules/database/model.py                     |   8 +-
 modules/scheduling/scheduler.py               | 129 +++++++---
 7 files changed, 266 insertions(+), 177 deletions(-)

diff --git a/modules/base/enum.py b/modules/base/enum.py
index 0ef18805..9d70eec7 100644
--- a/modules/base/enum.py
+++ b/modules/base/enum.py
@@ -50,8 +50,8 @@ class Channel(Enum):
     """
     FILESYSTEM_A = "in_filesystem_0"
     FILESYSTEM_B = "in_filesystem_1"
-    STREAM_A  = "http_1"
-    STREAM_B  = "http_2"
+    HTTP_A  = "in_http_0"
+    HTTP_B  = "in_http_1"
     LIVE_0 = "aura_linein_0"
     LIVE_1 = "aura_linein_1"
     LIVE_2 = "aura_linein_2"
@@ -70,9 +70,9 @@ class ChannelType(Enum):
         "id": "fs",
         "channels": [Channel.FILESYSTEM_A, Channel.FILESYSTEM_B]
     }
-    STREAM = {
+    HTTP = {
         "id": "http",
-        "channels": [Channel.STREAM_A, Channel.STREAM_B]
+        "channels": [Channel.HTTP_A, Channel.HTTP_A]
     }
     LIVE = {
         "id": "live",
@@ -118,3 +118,17 @@ class EntryQueueState(Enum):
     OKAY = "ok"
     CUT = "cut"
     OUT_OF_SCHEDULE = "oos"
+
+class EntryPlayState(Enum):
+    UNKNOWN = "unknown"
+    LOADING = "loading"
+    READY = "ready_to_play"
+    PLAYING = "playing"
+    FINISHED = "finished"
+
+class LiquidsoapResponse(Enum):
+    SUCCESS = "Done"
+    STREAM_STATUS_POLLING = "polling"
+    STREAM_STATUS_STOPPED = "stopped"
+    STREAM_STATUS_CONNECTED = "connected"
+    
\ No newline at end of file
diff --git a/modules/base/exceptions.py b/modules/base/exceptions.py
index d77d205a..0b8e9056 100644
--- a/modules/base/exceptions.py
+++ b/modules/base/exceptions.py
@@ -59,6 +59,9 @@ class MailingException(Exception):
 class LQConnectionError(Exception):
     pass
 
+class LQStreamException(Exception):
+    pass
 
 class RedisConnectionException(Exception):
-    pass
\ No newline at end of file
+    pass
+
diff --git a/modules/base/utils.py b/modules/base/utils.py
index f8772862..5f58af7d 100644
--- a/modules/base/utils.py
+++ b/modules/base/utils.py
@@ -48,7 +48,7 @@ class EngineUtil:
 
         """
         if uri.startswith("http"):
-            return ChannelType.STREAM
+            return ChannelType.HTTP
         if uri.startswith("pool") or uri.startswith("playlist") or uri.startswith("file"):
             return ChannelType.FILESYSTEM
         if uri.startswith("live") or uri.startswith("linein"):
diff --git a/modules/communication/liquidsoap/playerclient.py b/modules/communication/liquidsoap/playerclient.py
index 807422a9..339e19e3 100644
--- a/modules/communication/liquidsoap/playerclient.py
+++ b/modules/communication/liquidsoap/playerclient.py
@@ -22,7 +22,7 @@
 #  along with engine. If not, see <http://www.gnu.org/licenses/>.
 #
 
-from modules.base.enum import Channel, ChannelType
+from modules.base.enum import Channel
 from modules.communication.liquidsoap.client import LiquidSoapClient
 
 
@@ -58,14 +58,11 @@ class LiquidSoapPlayerClient(LiquidSoapClient):
 
         return "LiquidSoapPlayerClient does not understand mixer." + command + str(args)
 
-    # ------------------------------------------------------------------------------------------ #
-    def http(self, command, *args):
-        if command == "url":
-            return self.set_http_url(*args)
 
-        return "LiquidSoapPlayerClient does not understand http." + command + str(args)
 
-    # ------------------------------------------------------------------------------------------ #
+    #
+    # Playlist
+    #
 
     def playlist_push(self, channel, uri):
         """
@@ -114,6 +111,42 @@ class LiquidSoapPlayerClient(LiquidSoapClient):
         return self.message
 
 
+    #
+    # Stream
+    #
+
+    def http_set_url(self, channel, url):
+        """
+        Sets the URL on the given HTTP channel.
+        """
+        self.command(channel, 'url', url)
+        return self.message
+
+
+    def http_start(self, channel):
+        """
+        Starts the HTTP stream set with `stream_set_url` on the given channel.
+        """
+        self.command(channel, 'start')
+        return self.message
+
+
+    def http_stop(self, channel):
+        """
+        Stops the HTTP stream on the given channel.
+        """
+        self.command(channel, 'stop')
+        return self.message
+
+
+    def http_status(self, channel):
+        """
+        Returns the status of the HTTP stream on the given channel.
+        """
+        self.command(channel, 'status')
+        return self.message
+
+
     def uptime(self, command=""): # no command will come
         """
         Retrieves how long the engine is running already.
@@ -145,10 +178,7 @@ class LiquidSoapPlayerClient(LiquidSoapClient):
         self.command('auraengine', 'state')
         return self.message
 
-    # ------------------------------------------------------------------------------------------ #
-    def set_http_url(self, uri):
-        self.command('http', 'url', uri)
-        return self.message
+
 
     # ------------------------------------------------------------------------------------------ #
     def mixerinputs(self):
diff --git a/modules/core/engine.py b/modules/core/engine.py
index 3279bd22..88d6d5b1 100644
--- a/modules/core/engine.py
+++ b/modules/core/engine.py
@@ -33,9 +33,9 @@ from modules.core.state         import PlayerStateService
 from modules.core.monitor       import Monitoring
 from modules.communication.mail import AuraMailer
 
-from modules.base.enum          import ChannelType, Channel, TransitionType
+from modules.base.enum          import ChannelType, Channel, TransitionType, LiquidsoapResponse, EntryPlayState
 from modules.base.utils         import TerminalColors, SimpleUtil
-from modules.base.exceptions    import LQConnectionError, InvalidChannelException, NoActiveEntryException, EngineMalfunctionException
+from modules.base.exceptions    import LQConnectionError, InvalidChannelException, NoActiveEntryException, EngineMalfunctionException, LQStreamException
 
 
 class SoundSystem():
@@ -86,7 +86,7 @@ class SoundSystem():
         # Initialize Default Channels
         self.active_channel = {
             ChannelType.FILESYSTEM: Channel.FILESYSTEM_A,
-            ChannelType.STREAM: Channel.STREAM_A,
+            ChannelType.HTTP: Channel.HTTP_A,
             ChannelType.LIVE: Channel.LIVE_0
         }
         # self.active_entries = {}
@@ -213,21 +213,42 @@ class SoundSystem():
     #
 
 
-    # FIXME Currently not used, except for test class
-    # def get_active_channel(self):
-    #     """
-    #     Retrieves the active channel from programme.
+    def load(self, entry):
+        """
+        Preloads the entry. This is required before the actual `play(..)` can happen.
 
-    #     Returns:
-    #         (String):   The channel type, empty string if no channel is active.
-    #     """
-    #     active_entry = self.scheduler.get_active_entry()
-    #     if active_entry is None:
-    #         return ""
-    #     return active_entry.channel
+        Note his method is blocking until loading has finished. If this method is called 
+        asynchroniously, the progress on the preloading state can be looked up in `entry.state`.
+        """
+        entry.status = EntryPlayState.LOADING
+        self.logger.info("Loading entry '%s'" % entry)
 
-    # ------------------------------------------------------------------------------------------ #
+        self.enable_transaction()
+        self.player_state.set_active_entry(entry)
+        entry.channel = self.channel_swap(entry.type)
+        self.disable_transaction()
 
+        # PLAYLIST
+        if entry.type == ChannelType.FILESYSTEM:
+            self.playlist_push(entry.channel, entry.source)
+            
+        # STREAM
+        elif entry.type == ChannelType.HTTP:
+            self.http_load(entry.channel, entry.source)
+            time.sleep(1)
+
+            while not self.http_is_ready(entry.channel, entry.source):
+                self.logger.info("Loading Stream ...")
+                time.sleep(1)
+
+            entry.status = EntryPlayState.READY
+
+        # LIVE
+        else:
+            # TODO Select correct LINE-OUT channels as per entry
+            pass
+  
+            
 
 
     def play(self, entry, transition):
@@ -236,68 +257,25 @@ class SoundSystem():
         a clean channel is selected and transitions between old and new channel is performed.
 
         Args:
-            entry (PlaylistEntry):  The audio source to be played
+            entry (PlaylistEntry):          The audio source to be played
             transition (TransitionType):    The type of transition to use e.g. fade-out.
-            queue (Boolean):        If `True` the entry is queued if the `ChannelType` does allow so; 
+            queue (Boolean):                If `True` the entry is queued if the `ChannelType` does allow so; 
                 otherwise a new channel of the same type is activated
         
-        Raises:
-            (LQConnectionError): In case connecting to LiquidSoap isn't possible
         """
         try:
-            self.enable_transaction()
-
-            # channel = self.active_channel[entry.type]
-            # prev_channel = channel
-            # already_active = False
-            
-            #FIXME
-            # queue=False
-
-            # if self.active_channel_type == entry.type:
-            #     msg = SimpleUtil.pink("Channel type %s already active!" % str(entry.type))
-            #     self.logger.info(msg)
-                # already_active = True
-
-            self.player_state.set_active_entry(entry)
-
-            entry.channel = self.channel_swap(entry.type)
-            # entry.channel = channel
-
-            # PLAYLIST
-            if entry.type == ChannelType.FILESYSTEM:
-                # if not queue:
-                
-                self.playlist_push(entry.channel, entry.filename)
-                
-            # STREAM
-            elif entry.type == ChannelType.STREAM:
-                self.set_http_url(entry.channel, entry.source)
-                self.http_start_stop(entry.channel, True)
-                
-            # LIVE
-            else:
-                # TODO Select correct LINE-OUT channels as per entry
-                pass
-
-            # if not already_active:
-            #     self.channel_transition(prev_channel, channel, entry.volume, 0)
-
-            # Assign selected channel
             
-
             # Move channel volume all the way up
+            self.enable_transaction()
             if transition == TransitionType.FADE:
                 self.fade_in(entry)
             else:
                 self.channel_volume(entry.channel, entry.volume)
+            self.disable_transaction()
 
             # Update active channel and type
-            #self.active_channel_type = entry.type
             self.active_channel[entry.type] = entry.channel     
             
-
-            self.disable_transaction()
             
         except LQConnectionError:
             # we already caught and handled this error in __send_lqc_command__, 
@@ -352,27 +330,16 @@ class SoundSystem():
             pass
 
 
-    # def channel_transition(self, source_channel, target_channel, target_volume=100, transition_type=0):
-        
-    #     # Default: target_channel = 100% volume, source_channel = 0% volume
-    #     if transition_type == 0:
-
-    #         # Set volume of channel
-    #         self.channel_volume(target_channel, target_volume)
-
-    #         # Mute source channel
-    #         if target_channel != source_channel:
-    #             self.channel_volume(source_channel, 0)
-
-    #         # Set other channels to zero volume
-    #         # others = self.all_inputs_but(target_channel)
-    #         # self.logger.info("Setting Volume=0 for channels: %s" % str(others))
-    #         # for o in others:
-    #         #     self.channel_volume(o, 0)
-
-
 
     def channel_swap(self, channel_type):
+        """
+        Returns the currently in-active channel for a given type. For example if the currently some
+        file on channel FILESYSTEM_A is playing, the channel FILESYSTEM B is returned for being used
+        to queue new entries.
+
+        Args:
+            channel_type (ChannelType): The channel type such es filesystem, stream or live channel
+        """
         active_channel = self.active_channel[channel_type]
         channel = None
         msg = None
@@ -385,16 +352,19 @@ class SoundSystem():
                 channel = Channel.FILESYSTEM_A
                 msg = "Swapped filesystem channel from B > A"
             
-        elif channel_type == ChannelType.STREAM:
-            if active_channel == Channel.STREAM_A:
-                channel = Channel.STREAM_B
+            # TODO Clear old channel
+            
+        elif channel_type == ChannelType.HTTP:
+            if active_channel == Channel.HTTP_A:
+                channel = Channel.HTTP_B
                 msg = "Swapped stream channel from A > B"
+
             else:
-                channel = Channel.STREAM_A
+                channel = Channel.HTTP_A
                 msg = "Swapped stream channel from B > A"
+            
 
         if msg: self.logger.info(SimpleUtil.pink(msg))
-        # self.active_channel[channel_type] = channel
         return channel
 
 
@@ -488,46 +458,63 @@ class SoundSystem():
     #   Channel Type - Stream 
     #
 
+    def http_load(self, channel, url):
+        """
+        Preloads the stream URL on the given channel.
+        """
+        result = None
 
-    def stream_start(self, url):
-        try:
-            self.enable_transaction()
-            self.__send_lqc_command__(self.client, "http", "url", url)
-            self.__send_lqc_command__(self.client, "http", "start")
-            self.disable_transaction()
-        except LQConnectionError:
-            # we already caught and handled this error in __send_lqc_command__, but we do not want to execute this function further
-            pass
+        self.enable_transaction()
+        result = self.__send_lqc_command__(self.client, channel, "http_stop")
+        
+        if result != LiquidsoapResponse.SUCCESS.value:
+            self.logger.error("stream.stop result: " + result)
+            raise LQStreamException("Error while stopping stream!")
 
+        result = self.__send_lqc_command__(self.client, channel, "http_set_url", url)
 
-    def stream_stop(self, url):
-        try:
-            self.enable_transaction()
-            self.__send_lqc_command__(self.client, "http", "start")
-            self.disable_transaction()
-        except LQConnectionError:
-            # we already caught and handled this error in __send_lqc_command__, but we do not want to execute this function further
-            pass
+        if result != LiquidsoapResponse.SUCCESS.value:
+            self.logger.error("stream.set_url result: " + result)
+            raise LQStreamException("Error while setting stream URL!")
 
+        # Liquidsoap ignores commands sent without a certain timeout
+        time.sleep(2)
 
-    def http_start_stop(self, start):
-        if start:
-            cmd = "start"
-        else:
-            cmd = "stop"
+        result = self.__send_lqc_command__(self.client, channel, "http_start")
+        self.logger.info("stream.start result: " + result)
 
-        try:
-            self.enable_transaction()
-            self.__send_lqc_command__(self.client, "http", cmd)
-            self.disable_transaction()
-        except LQConnectionError:
-            # we already caught and handled this error in __send_lqc_command__, but we do not want to execute this function further
-            pass
+        self.disable_transaction()
+        return result
+
+
+
+    def http_is_ready(self, channel, url):
+        """
+        Checks if the stream on the given channel is ready to play.
+        """
+        result = None
+
+        self.enable_transaction()
+
+        result = self.__send_lqc_command__(self.client, channel, "http_status")
+        self.logger.info("stream.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))
+            return False
+
+        self.disable_transaction()
+
+        # Wait another 10 (!) seconds, because even now the old source might *still* be playing
+        self.logger.info("Ready to play stream, Liquidsoap wants you to wait another 10secs though...")
+        time.sleep(10)
+        return True
 
 
-    # ------------------------------------------------------------------------------------------ #
-    def set_http_url(self, uri):
-        return self.__send_lqc_command__(self.client, "http", "url", uri)
 
 
     #
@@ -830,7 +817,7 @@ class SoundSystem():
             # call wanted function ...
 
             # FIXME REFACTOR all calls in a common way
-            if command in  ["playlist_push", "playlist_seek", "playlist_clear"]:
+            if command in  ["playlist_push", "playlist_seek", "playlist_clear", "http_set_url", "http_start", "http_stop", "http_status"]:
                 func = getattr(lqs_instance, command)
                 result = func(str(namespace), *args)
             else:
diff --git a/modules/database/model.py b/modules/database/model.py
index ec5f2adc..3ba558b3 100644
--- a/modules/database/model.py
+++ b/modules/database/model.py
@@ -744,8 +744,8 @@ class SingleEntry(DB.Model, AuraDatabaseModel):
         type = EngineUtil.get_channel_type(self.uri)
         if type == ChannelType.FILESYSTEM:
             return Channel.FILESYSTEM_A
-        elif type == ChannelType.STREAM:
-            return Channel.STREAM_A
+        elif type == ChannelType.HTTP:
+            return Channel.HTTP_A
         else:
             return "foo:bar"
         #FIXME Extend & finalize!!
@@ -943,7 +943,7 @@ class SingleEntryMetaData(DB.Model, AuraDatabaseModel):
     #
     # def set_entry_type(self):
     #     if self.uri.startswith("http"):
-    #         self.type = ScheduleEntryType.STREAM
+    #         self.type = ScheduleEntryType.HTTP
     #     if self.uri.startswith("pool") or self.uri.startswith("playlist") or self.uri.startswith("file"):
     #         self.type = ScheduleEntryType.FILESYSTEM
     #     if self.uri.startswith("live") or self.uri.startswith("linein"):
@@ -1052,7 +1052,7 @@ class SingleEntryMetaData(DB.Model, AuraDatabaseModel):
     #     if self.type == self.type.LIVE_0 or self.type == self.type.LIVE_1 or self.type == self.type.LIVE_2 or self.type == self.type.LIVE_3 or self.type == self.type.LIVE_4:
     #         return "aura_linein_"+self.cleansource # .cleanprotocol[8]
     #
-    #     if self.type == self.type.STREAM:
+    #     if self.type == self.type.HTTP:
     #         return "http"
     #
     #
diff --git a/modules/scheduling/scheduler.py b/modules/scheduling/scheduler.py
index 6b09dcf7..7d506d00 100644
--- a/modules/scheduling/scheduler.py
+++ b/modules/scheduling/scheduler.py
@@ -37,7 +37,7 @@ from operator import attrgetter
 from modules.database.model import AuraDatabaseModel, Schedule, Playlist, PlaylistEntry, PlaylistEntryMetaData, SingleEntry, SingleEntryMetaData, TrackService
 
 from modules.base.exceptions import NoActiveScheduleException, NoActiveEntryException
-from modules.base.enum import Channel, ChannelType, TimerType, TransitionType, EntryQueueState
+from modules.base.enum import Channel, ChannelType, TimerType, TransitionType, EntryQueueState, EntryPlayState
 from modules.base.utils import SimpleUtil, TerminalColors
 from modules.communication.redis.messenger import RedisMessenger
 from modules.scheduling.calendar import AuraCalendarService
@@ -196,7 +196,8 @@ class AuraScheduler(threading.Thread):
             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.")
             else:
-                # Play active entry
+                # Load and play active entry
+                self.soundsystem.load(active_entry)
                 self.soundsystem.play(active_entry, TransitionType.FADE)
 
                 # Check if this is the last item of the schedule
@@ -213,6 +214,18 @@ class AuraScheduler(threading.Thread):
                     response = self.soundsystem.playlist_seek(active_entry.channel, seconds_to_seek)
                     self.soundsystem.disable_transaction()
                     self.logger.info("LiquidSoap seek response: " + response)
+        
+        elif active_entry.type == ChannelType.HTTP:
+                # Load and play active entry
+                self.soundsystem.load(active_entry)
+                self.soundsystem.play(active_entry, TransitionType.FADE)
+                self.queue_end_of_schedule(active_entry, True)
+                
+        elif active_entry.type == ChannelType.LIVE:
+            self.logger.warn("LIVE ENTRIES ARE NOT YET IMPLEMENTED!")
+        
+        else:
+            self.logger.critical("Unknown Entry Type: %s" % active_entry)
 
 
 
@@ -534,11 +547,11 @@ class AuraScheduler(threading.Thread):
             if active_entry:
                 # Open entries for current playlist
                 rest_of_playlist = active_entry.get_next_entries(True)
-                self.queue_playlist_entries(rest_of_playlist, True, True)
+                self.queue_playlist_entries(rest_of_playlist, False, True)
 
         if playlists:
             for next_playlist in playlists:
-               self.queue_playlist_entries(next_playlist.entries, True, True)
+               self.queue_playlist_entries(next_playlist.entries, False, True)
         
         self.logger.info(SimpleUtil.green("Finished queuing programme!"))
 
@@ -558,37 +571,13 @@ class AuraScheduler(threading.Thread):
             (String):                   Formatted string to display playlist entries in log
         """       
 
-        # Play function to be called by timer
-        def do_play(entry):
-            self.logger.info(SimpleUtil.cyan("=== play('%s') ===" % entry))
-            transition_type = TransitionType.INSTANT
-            if fade_in:
-                transition_type = TransitionType.FADE
-            self.soundsystem.play(entry, transition_type)
-            self.logger.info(self.get_ascii_programme())
-
-        
         # Mark entries which start after the end of their schedule or are cut
         clean_entries = self.preprocess_entries(entries, True)
 
         # Schedule function calls
         for entry in clean_entries:
-            planned_timer = self.is_something_planned_at_time(entry.start_unix)
-            now_unix = self.get_virtual_now()
-            diff = entry.start_unix - now_unix
-
-            if planned_timer:
-                # Check if the Playlist IDs are different
-                if planned_timer.entry.entry_id != entry.entry_id:
-                    # If not, stop and remove the old timer, create a new one
-                    self.stop_timer(planned_timer)                
-                else:
-                    # If the playlists do not differ => reuse the old timer and do nothing
-                    self.logger.info("Playlist Entry %s is already scheduled - no new timer created!" % entry)
-                    continue
-            
-            # If nothing is planned at given time, create a new timer
-            entry.switchtimer = self.create_timer(diff, do_play, [entry], switcher=True)
+
+            self.set_entry_timer(entry, fade_in, fade_out)
 
             # Check if it's the last item, which needs special handling
             if entry == clean_entries[-1]:
@@ -597,13 +586,52 @@ class AuraScheduler(threading.Thread):
 
 
 
+    def set_entry_timer(self, entry, fade_in, fade_out):
+        """
+        Creates timer for loading and playing an entry. Existing times are 
+        updated.
+        """
+        play_timer = self.is_something_planned_at_time(entry.start_unix)
+        now_unix = self.get_virtual_now()
+        diff = entry.start_unix - now_unix
+
+        # Play function to be called by timer
+        def do_play(entry):
+            self.logger.info(SimpleUtil.cyan("=== play('%s') ===" % entry))
+            transition_type = TransitionType.INSTANT
+            if fade_in:
+                transition_type = TransitionType.FADE
+
+            if entry.status != EntryPlayState.READY:
+                self.logger.critical(SimpleUtil.red("PLAY: For some reason the entry is not yet ready or could not be loaded (Entry: %s)" % str(entry)))
+                # TODO Pro-active fallback handling here
+
+            self.soundsystem.play(entry, transition_type)
+            self.logger.info(self.get_ascii_programme())
+
+
+        if play_timer:
+            # Check if the Playlist IDs are different
+            if play_timer.entry.entry_id != entry.entry_id:
+                # If not, stop and remove the old timer, create a new one
+                self.stop_timer(play_timer)                
+            else:
+                # If the playlists do not differ => reuse the old timer and do nothing
+                self.logger.info("Playlist Entry %s is already scheduled - no new timer created!" % entry)
+                return
+        
+        # If nothing is planned at given time, create a new timer
+        (entry.switchtimer, entry.loadtimer) = self.create_timer(diff, do_play, [entry], switcher=True)
+
+
+
     def preprocess_entries(self, entries, cut_oos):
         """
         Analyses and marks entries which are going to be cut or excluded.
 
         Args:
             entries ([PlaylistEntry]):  The playlist entries to be scheduled for playout
-            cut_oos (Bollean):  If `True` entries which are 'out of schedule' are not returned
+            cut_oos (Boolean):  If `True` entries which are 'out of schedule' are not returned
 
         Returns:
             ([PlaylistEntry]):  The list of processed playlist entries
@@ -721,7 +749,7 @@ class AuraScheduler(threading.Thread):
         Checks for existing timers at the given time.
         """
         for t in self.message_timer:
-            if t.entry.start_unix == given_time:
+            if t.entry.start_unix == given_time and (t.fade_in or t.switcher):
                 return t
         return False
 
@@ -743,7 +771,20 @@ class AuraScheduler(threading.Thread):
         t = CallFunctionTimer(diff, func, parameters, fadein, fadeout, switcher)
         self.message_timer.append(t)
         t.start()
-        return t
+
+        if switcher:
+            # Load function to be called by timer
+            def do_load(entry):
+                self.logger.info(SimpleUtil.cyan("=== load('%s') ===" % entry))
+                self.soundsystem.load(entry)
+
+            loader_diff = diff - self.config.get("preload_offset")
+            loader = CallFunctionTimer(loader_diff, do_load, parameters, fadein, fadeout, switcher)
+            self.message_timer.append(loader)
+            loader.start()
+            return (t, loader)
+        else:
+            return t
 
 
 
@@ -756,6 +797,10 @@ class AuraScheduler(threading.Thread):
         """
         timer.cancel()
 
+        if timer.entry.loadtimer is not None:
+            timer.entry.loadtimer.cancel()
+            self.message_timer.remove(timer.entry.loadtimer)
+
         if timer.entry.fadeintimer is not None:
             timer.entry.fadeintimer.cancel()
             self.message_timer.remove(timer.entry.fadeintimer)
@@ -839,13 +884,17 @@ class CallFunctionTimer(threading.Timer):
     fadeout = False
     switcher = False
 
-    def __init__(self, diff, func, param, fadein=False, fadeout=False, switcher=False):
+    def __init__(self, diff, func, param, fadein=False, fadeout=False, switcher=False, loader=False):
 
         self.logger = logging.getLogger("AuraEngine")
-        self.logger.debug("CallFunctionTimer: Executing LiquidSoap command '%s' in %s seconds..." % (str(func.__name__), str(diff)))
+        self.logger.debug("Executing soundsystem command '%s' in %s seconds..." % (str(func.__name__), str(diff)))
         threading.Timer.__init__(self, diff, func, args=param)
 
-        if not fadein and not fadeout and not switcher or fadein and fadeout or fadein and switcher or fadeout and switcher:
+        if not fadein and not fadeout and not switcher and not loader \
+            or fadein and fadeout \
+            or fadein and switcher \
+            or fadeout and switcher:
+            
             raise Exception("You have to create me with either fadein=true, fadeout=true or switcher=True")
 
         self.diff = diff
@@ -855,14 +904,20 @@ class CallFunctionTimer(threading.Timer):
         self.fadein = fadein
         self.fadeout = fadeout
         self.switcher = switcher
+        self.loader = loader
+
 
-    # ------------------------------------------------------------------------------------------ #
     def __str__(self):
+        """
+        String represenation of the timer.
+        """
         if self.fadein:
             return "CallFunctionTimer starting in " + str(self.diff) + "s fading in source '" + str(self.entry)
         elif self.fadeout:
             return "CallFunctionTimer starting in " + str(self.diff) + "s fading out source '" + str(self.entry)
         elif self.switcher:
             return "CallFunctionTimer starting in " + str(self.diff) + "s switching to source '" + str(self.entry)
+        elif self.loader:
+            return "CallFunctionTimer starting in " + str(self.diff) + "s loading source '" + str(self.entry)
         else:
             return "CORRUPTED CallFunctionTimer around! How can that be?"
-- 
GitLab