fallback_manager.py 12.4 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
24
25



import os, os.path
import random

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

David Trattnig's avatar
David Trattnig committed
32

David Trattnig's avatar
David Trattnig committed
33
34
35
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
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
David Trattnig's avatar
David Trattnig committed
65
        self.logger = logger
David Trattnig's avatar
David Trattnig committed
66
67
68
69
70
71
72
73
74
        self.mailer = AuraMailer(self.config)
        self.scheduler = scheduler
        self.logger = logger


    #
    #   PUBLIC METHODS
    #

David Trattnig's avatar
David Trattnig committed
75
    def resolve_playlist(self, schedule):
David Trattnig's avatar
David Trattnig committed
76
        """
David Trattnig's avatar
David Trattnig committed
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
        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
94
        """
David Trattnig's avatar
David Trattnig committed
95
        playlist = None
David Trattnig's avatar
David Trattnig committed
96
        type = None
David Trattnig's avatar
David Trattnig committed
97
        self.logger.info("Resolving playlist for schedule #%s ..." % schedule.schedule_id)
David Trattnig's avatar
David Trattnig committed
98

David Trattnig's avatar
David Trattnig committed
99
        if not self.validate_playlist(schedule, "playlist"):
100
101
            if not self.validate_playlist(schedule, "schedule_fallback"):
                if not self.validate_playlist(schedule, "show_fallback"):
David Trattnig's avatar
David Trattnig committed
102
                    if not self.validate_playlist(schedule, "station_fallback"):
103
104
                        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
105
                    else:
David Trattnig's avatar
David Trattnig committed
106
107
                        type = PlaylistType.STATION
                        playlist = schedule.station_fallback
David Trattnig's avatar
David Trattnig committed
108
                else:
David Trattnig's avatar
David Trattnig committed
109
110
                    type = PlaylistType.TIMESLOT
                    playlist = schedule.schedule_fallback
David Trattnig's avatar
David Trattnig committed
111
            else:
David Trattnig's avatar
David Trattnig committed
112
113
114
115
116
                type = PlaylistType.SHOW
                playlist = schedule.show_fallback
        else:
            type = PlaylistType.DEFAULT
            playlist = schedule.playlist
David Trattnig's avatar
David Trattnig committed
117

David Trattnig's avatar
David Trattnig committed
118
119
120
121
122
123
124
125
126
        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
127
128
129



David Trattnig's avatar
David Trattnig committed
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
    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
150

David Trattnig's avatar
David Trattnig committed
151
        
David Trattnig's avatar
David Trattnig committed
152
153


David Trattnig's avatar
David Trattnig committed
154
155
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
    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
193
                    file = "../../test/content/ernie_mayne_sugar.mp3"
David Trattnig's avatar
David Trattnig committed
194
195
196
197
198
199
                    media_type = "DEFAULT TRACK"
        else:
            file = ""
            self.logger.critical("Should set next fallback file for " + fallbackname + ", but this fallback is unknown!")

        if file:
200
            # Send admin email to notify about the fallback state
David Trattnig's avatar
David Trattnig committed
201
            if not active_playlist:
202
                active_playlist = "-"
David Trattnig's avatar
David Trattnig committed
203
204
205
206
207
208
            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)
209
            self.logger.warn("Providing fallback %s: '%s'. Sent admin email about fallback state" % (media_type, file))
David Trattnig's avatar
David Trattnig committed
210
211
212
213
214
215

        self.is_processing = False
        return file



216
217
218
219
220
221
222
    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
223
224


David Trattnig's avatar
David Trattnig committed
225
226


David Trattnig's avatar
David Trattnig committed
227
228
229
230
231
    #
    #   PRIVATE METHODS
    #


David Trattnig's avatar
David Trattnig committed
232
233
234
235
236
237
238
239
240
241
242

    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:

243
            return True
David Trattnig's avatar
David Trattnig committed
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
        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
264
265
266
267
268
269
270
271
272
273
274
275
    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:
276
                        playlist_files += entry.source + "\n"
David Trattnig's avatar
David Trattnig committed
277
278
279
280
281
282
283
284
285
286
287
288
289
290

        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
291
        audio_files = list(filter(lambda f: self.is_audio_file(os.path.join(dir, f)), files))
David Trattnig's avatar
David Trattnig committed
292
293
294
295
296
297
298
299
300
        
        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
301
            self.logger.info("Cleared fallback history.")
David Trattnig's avatar
David Trattnig committed
302
303
304
305
        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
306
307
308
        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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
        # 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
326
    def is_audio_file(self, file):
David Trattnig's avatar
David Trattnig committed
327
328
329
330
331
        """
        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
332
333
            dir (String):  
            file (File):   the file object.
David Trattnig's avatar
David Trattnig committed
334
335
336
337

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

David Trattnig's avatar
David Trattnig committed
341
        if os.path.isfile(file):
David Trattnig's avatar
David Trattnig committed
342
343
344
            if any(ext in s for s in audio_extensions):
                return True
        return False