fallback_manager.py 12.6 KB
Newer Older
David Trattnig's avatar
David Trattnig committed
1
2

#
David Trattnig's avatar
David Trattnig committed
3
# Aura Engine (https://gitlab.servus.at/aura/engine)
David Trattnig's avatar
David Trattnig committed
4
#
David Trattnig's avatar
David Trattnig committed
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 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/>.

David Trattnig's avatar
David Trattnig committed
20
21
22
23



import os, os.path
David Trattnig's avatar
David Trattnig committed
24
import ntpath
David Trattnig's avatar
David Trattnig committed
25
26
import logging
import random
David Trattnig's avatar
David Trattnig committed
27
import librosa
David Trattnig's avatar
David Trattnig committed
28

David Trattnig's avatar
David Trattnig committed
29
from accessify                  import private, protected
David Trattnig's avatar
David Trattnig committed
30
from modules.scheduling.types   import PlaylistType
David Trattnig's avatar
David Trattnig committed
31
from modules.base.utils         import SimpleUtil, EngineUtil
32
from modules.base.mail          import AuraMailer
David Trattnig's avatar
David Trattnig committed
33
from modules.core.channels      import ChannelType
David Trattnig's avatar
David Trattnig committed
34

David Trattnig's avatar
David Trattnig committed
35

David Trattnig's avatar
David Trattnig committed
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class FallbackManager:
    """
    Handles all types of fallbacks in case there is an outage
    for the regular radio programme.

    Attributes:
        config (AuraConfig):        The engine configuration
        logger (AuraLogger):        The logger
        mail   (AuraMailer):        Mail service
        scheduler (AuraScheduler):  The scheduler
        fallback_history (Dict):    Holds a 24h history of played, local tracks to avoid re-play
        last_fallback (Integer):    Timestamp, when the last local file fallback was played
        is_processing (Boolean):    Flag to avoid race-conditions, as Liquidsoap sends plenty of requests at once
    """

    config = None
    logger = None
    mailer = None
    scheduler = None
    fallback_history = {}
    last_fallback = 0
    is_processing = False


    def __init__(self, config, logger, scheduler):
        """
        Constructor

        Args:
            config (AuraConfig):    Holds the engine configuration
        """
        self.config = config
        self.mailer = AuraMailer(self.config)
        self.scheduler = scheduler
        self.logger = logger


    #
    #   PUBLIC METHODS
    #

David Trattnig's avatar
David Trattnig committed
77
    def resolve_playlist(self, schedule):
David Trattnig's avatar
David Trattnig committed
78
        """
David Trattnig's avatar
David Trattnig committed
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
        Resolves the (fallback) playlist for the given schedule in case of pro-active fallback scenarios.
        
        A resolved playlist represents the state how it would currently  be aired. For example the `FallbackManager` 
        evaluated, that the actually planned playlist cannot be played for various reasons (e.g. entries n/a). 
        Instead one of the fallback playlists should be played. If the method is called some time later,
        it actually planned playlist might be valid, thus returned as the resolved playlist.

        As long the adressed schedule is still within the scheduling window, the resolved playlist can
        always change.

        This method also updates `schedule.fallback_state` to the current fallback type (`PlaylistType`).

        Args:
            schedule (Schedule):    The schedule to resolve the playlist for
        
        Returns:
            (Playlist):             The resolved playlist
David Trattnig's avatar
David Trattnig committed
96
        """
David Trattnig's avatar
David Trattnig committed
97
        playlist = None
David Trattnig's avatar
David Trattnig committed
98
        type = None
David Trattnig's avatar
David Trattnig committed
99
        self.logger.info("Resolving playlist for schedule #%s ..." % schedule.schedule_id)
David Trattnig's avatar
David Trattnig committed
100

David Trattnig's avatar
David Trattnig committed
101
        if not self.validate_playlist(schedule, "playlist"):
102
103
            if not self.validate_playlist(schedule, "schedule_fallback"):
                if not self.validate_playlist(schedule, "show_fallback"):
David Trattnig's avatar
David Trattnig committed
104
                    if not self.validate_playlist(schedule, "station_fallback"):
105
106
                        self.logger.error(SimpleUtil.red("No (fallback) playlists for schedule #%s available - not even a single one!" % schedule.schedule_id))
                        return None
David Trattnig's avatar
David Trattnig committed
107
                    else:
David Trattnig's avatar
David Trattnig committed
108
109
                        type = PlaylistType.STATION
                        playlist = schedule.station_fallback
David Trattnig's avatar
David Trattnig committed
110
                else:
David Trattnig's avatar
David Trattnig committed
111
112
                    type = PlaylistType.TIMESLOT
                    playlist = schedule.schedule_fallback
David Trattnig's avatar
David Trattnig committed
113
            else:
David Trattnig's avatar
David Trattnig committed
114
115
116
117
118
                type = PlaylistType.SHOW
                playlist = schedule.show_fallback
        else:
            type = PlaylistType.DEFAULT
            playlist = schedule.playlist
David Trattnig's avatar
David Trattnig committed
119

David Trattnig's avatar
David Trattnig committed
120
121
122
123
124
125
126
127
128
        if type and type != PlaylistType.DEFAULT:
            previous_type = schedule.fallback_state
            if type == previous_type:
                self.logger.info("Fallback state for schedule #%s is still '%s'" % (schedule.schedule_id, type))
            else:
                self.logger.warn("Detected fallback type switch from '%s' to '%s' is required for schedule %s." % (previous_type, type, str(schedule)))
        
        schedule.fallback_state = type
        return playlist[0]
David Trattnig's avatar
David Trattnig committed
129
130
131



David Trattnig's avatar
David Trattnig committed
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
    def handle_proactive_fallback(self, scheduler, playlist):
        """
        This is the 1st level strategy for fallback handling. When playlist entries are pre-rolled their 
        state is validated. If any of them doesn't become "ready to play" in time, some fallback entries
        are queued.
        """
        resolved_playlist = self.resolve_playlist(playlist.schedule)
        if playlist != resolved_playlist:
            self.logger.info("Switching from playlist #%s to fallback playlist #%s ..." % (playlist.playlist_id, resolved_playlist.playlist_id))
            
            # Destroy any existing queue timers
            for entry in playlist.entries:
                scheduler.stop_timer(entry.switchtimer)
            self.logger.info("Stopped existing timers for entries")

            # Queue the fallback playlist
            scheduler.queue_playlist_entries(resolved_playlist.schedule, resolved_playlist.entries, False, True)
            self.logger.info("Queued fallback playlist entries (Fallback type: %s)" % playlist.type)
        else:
            self.logger.critical(SimpleUtil.red("For some strange reason the fallback playlist equals the currently failed one?!"))
David Trattnig's avatar
David Trattnig committed
152

David Trattnig's avatar
David Trattnig committed
153
        
David Trattnig's avatar
David Trattnig committed
154
155


David Trattnig's avatar
David Trattnig committed
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
    def get_fallback_for(self, fallbackname):
        """
        Retrieves a random fallback audio source for any of the types:
            - timeslot/schedule
            - show
            - station
        
        Args:
            fallbackname (String):      Fallback type

        Returns:
            (String):                   Absolute path to the file
        """
        file = ""
        media_type = "PLAYLIST"
        active_schedule, active_playlist = self.scheduler.get_active_playlist()

        # Block access to avoid race-conditions
        if self.is_processing:
            return None
        else:
            self.is_processing = True

        # Get fallback track(s) by fallback-type
        if fallbackname == "timeslot":
            file = self.get_playlist_items(active_schedule, "schedule_fallback")

        elif fallbackname == "show":
            file = self.get_playlist_items(active_schedule, "show_fallback")

        elif fallbackname == "station":
            file = self.get_playlist_items(active_schedule, "station_fallback")

            if not file:
                media_type = "TRACK"
                file = self.get_random_local_track()

                if not file:
                    self.logger.critical("Got no file for station fallback! Playing default test track, to play anything at all.")
David Trattnig's avatar
David Trattnig committed
195
                    file = "../../test/content/ernie_mayne_sugar.mp3"
David Trattnig's avatar
David Trattnig committed
196
197
198
199
200
201
                    media_type = "DEFAULT TRACK"
        else:
            file = ""
            self.logger.critical("Should set next fallback file for " + fallbackname + ", but this fallback is unknown!")

        if file:
202
            # Send admin email to notify about the fallback state
David Trattnig's avatar
David Trattnig committed
203
            if not active_playlist:
204
                active_playlist = "-"
David Trattnig's avatar
David Trattnig committed
205
206
207
208
209
210
            msg = "AURA ENGINE %s FALLBACK DETECTED!\n\n" % fallbackname
            msg += "Expected, active Schedule: %s \n" % active_schedule
            msg += "Expected, active Playlist: %s \n\n" % active_playlist
            msg += "Providing FALLBACK-%s for %s '%s'\n\n" % (media_type, fallbackname, file)
            msg += "Please review the schedules or contact your Aura Engine administrator."
            self.mailer.send_admin_mail("CRITICAL - Detected fallback for %s" % fallbackname, msg)
211
            self.logger.warn("Providing fallback %s: '%s'. Sent admin email about fallback state" % (media_type, file))
David Trattnig's avatar
David Trattnig committed
212
213
214
215
216
217

        self.is_processing = False
        return file



218
219
220
221
222
223
224
    def fallback_has_started(self, artist, title):
        """
        Called when a fallback track has actually started playing
        """
        self.logger.info("Now playing: fallback track '%s - %s'." % (artist, title))


David Trattnig's avatar
David Trattnig committed
225
226
227
228
229
230
231
232
233

    def get_track_duration(self, file):
        """
        Returns the length of the given audio file.
        """
        y, sr = librosa.load(file)
        duration = librosa.get_duration(y=y, sr=sr)
        return duration

David Trattnig's avatar
David Trattnig committed
234
235


David Trattnig's avatar
David Trattnig committed
236
237
238
239
240
    #
    #   PRIVATE METHODS
    #


David Trattnig's avatar
David Trattnig committed
241
242
243
244
245
246
247
248
249
250
251

    def validate_playlist(self, schedule, playlist_type):
        """
        Checks if a playlist is valid for play-out.
        """
        playlist = getattr(schedule, playlist_type)
        if playlist \
            and isinstance(playlist, list) \
            and playlist[0].entries \
            and len(playlist[0].entries) > 0:

252
            return True
David Trattnig's avatar
David Trattnig committed
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
        return False



    def validate_entries(self, entries):
        """
        Checks if playlist entries are valid for play-out.
        """
        for entry in entries:
            if entry.get_type() == ChannelType.FILESYSTEM:
                audio_store = self.config.get("audiofolder")
                filepath = EngineUtil.uri_to_filepath(audio_store, entry.source)

                if not self.is_audio_file(filepath):
                    self.logger.warn("Invalid filesystem path '%s' in entry '%s'" % (filepath, str(entry)))
                    return False
        return True



David Trattnig's avatar
David Trattnig committed
273
274
275
276
277
278
279
280
281
282
283
284
    def get_playlist_items(self, schedule, fallback_key):
        """
        Retrieves the list of tracks from a playlist defined by `fallback_key`.
        """
        playlist_files = ""

        if hasattr(schedule, fallback_key):
            playlist = getattr(schedule, fallback_key)
            if len(playlist) > 0:
                playlist = playlist[0]
                if playlist and playlist.entries:
                    for entry in playlist.entries:
285
                        playlist_files += entry.source + "\n"
David Trattnig's avatar
David Trattnig committed
286
287
288
289
290
291
292
293
294
295
296
297
298
299

        return playlist_files



    def get_random_local_track(self):
        """
        Retrieves a random audio track from the local station-fallback directory.

        Returns:
            (String):   Absolute path to an audio file
        """
        dir = self.config.fallback_music_folder
        files = os.listdir(dir)
David Trattnig's avatar
David Trattnig committed
300
        audio_files = list(filter(lambda f: self.is_audio_file(os.path.join(dir, f)), files))
David Trattnig's avatar
David Trattnig committed
301
302
303
304
305
306
307
308
309
        
        if not dir or not audio_files:
            self.logger.error("Folder 'fallback_music_folder = %s' is empty!" % dir)
            return None

        # If last played fallback is > 24 hours ago, ignore play history
        # This should save used memory if the engine runs for a long time
        if self.last_fallback < SimpleUtil.timestamp() - (60*60*24):
            self.fallback_history = {}
David Trattnig's avatar
David Trattnig committed
310
            self.logger.info("Cleared fallback history.")
David Trattnig's avatar
David Trattnig committed
311
312
313
314
        self.last_fallback = SimpleUtil.timestamp()
        
        # Retrieve files which haven't been played yet
        history = set(self.fallback_history.keys())
David Trattnig's avatar
David Trattnig committed
315
316
317
        left_audio_files = list( set(audio_files) - (history) )
        self.logger.info("Left fallback audio-files: %d/%d" % (len(left_audio_files), len(audio_files)))
        
David Trattnig's avatar
David Trattnig committed
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
        # If nothing left, clear history and start with all files again
        if not len(left_audio_files):
            self.fallback_history = {}
            left_audio_files = audio_files

        # Select random track from directory
        i = random.randint(0, len(left_audio_files)-1)
        file = os.path.join(dir, left_audio_files[i])

        # Store track in history, to avoid playing it multiple times
        if file:
            self.fallback_history[left_audio_files[i]] = SimpleUtil.timestamp()

        return file



David Trattnig's avatar
David Trattnig committed
335
    def is_audio_file(self, file):
David Trattnig's avatar
David Trattnig committed
336
337
338
339
340
        """
        Checks if the passed file is an audio file i.e. has a file-extension
        known for audio files.

        Args:
David Trattnig's avatar
David Trattnig committed
341
342
            dir (String):  
            file (File):   the file object.
David Trattnig's avatar
David Trattnig committed
343
344
345
346

        Returns:
            (Boolean):      True, if it's an audio file.
        """
David Trattnig's avatar
David Trattnig committed
347
        audio_extensions = [".wav", ".flac", ".mp3", ".ogg", ".m4a"]
David Trattnig's avatar
David Trattnig committed
348
349
        ext = os.path.splitext(file)[1]

David Trattnig's avatar
David Trattnig committed
350
        if os.path.isfile(file):
David Trattnig's avatar
David Trattnig committed
351
352
353
            if any(ext in s for s in audio_extensions):
                return True
        return False