fallback_manager.py 8.94 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 logging
import random
David Trattnig's avatar
David Trattnig committed
26
import librosa
David Trattnig's avatar
David Trattnig committed
27

David Trattnig's avatar
David Trattnig committed
28
from accessify                  import private, protected
David Trattnig's avatar
David Trattnig committed
29
30
from modules.base.enum          import FallbackType
from modules.base.utils         import SimpleUtil
David Trattnig's avatar
David Trattnig committed
31
32
33
from modules.communication.mail import AuraMailer


David Trattnig's avatar
David Trattnig committed
34

David Trattnig's avatar
David Trattnig committed
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
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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
    def get_fallback(self, schedule, type):
        """
        Checks if the given schedule is valid and returns a valid fallback
        if required.
        """
        type = None
        playlist_id = schedule.playlist_id

        if not schedule.playlist_id:
            if not schedule.show_fallback_id:
                if not schedule.schedule_fallback_id:
                    if not schedule.station_fallback_id:
                        raise Exception
                    else:
                        type = FallbackType.STATION
                        playlist_id = schedule.station_fallback_id
                else:
                    type = FallbackType.TIMESLOT
                    playlist_id = schedule.schedule_fallback_id
            else:
                type = FallbackType.SHOW
                playlist_id = schedule.show_fallback_id

        if type:
            self.logger.warn("Detected fallback type '%s' required for schedule %s" % (type, str(schedule)))


        return (type, playlist_id)


    def validate_playlist(self, playlist_id):
        pass


David Trattnig's avatar
David Trattnig committed
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
    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.")
                    file = "../../testing/content/ernie_mayne_sugar.mp3"
                    media_type = "DEFAULT TRACK"
        else:
            file = ""
            self.logger.critical("Should set next fallback file for " + fallbackname + ", but this fallback is unknown!")

        if file:
157
            # Send admin email to notify about the fallback state
David Trattnig's avatar
David Trattnig committed
158
            if not active_playlist:
159
                active_playlist = "-"
David Trattnig's avatar
David Trattnig committed
160
161
162
163
164
165
            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)
166
            self.logger.warn("Providing fallback %s: '%s'. Sent admin email about fallback state" % (media_type, file))
David Trattnig's avatar
David Trattnig committed
167
168
169
170
171
172

        self.is_processing = False
        return file



173
174
175
176
177
178
179
    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
180
181
182
183
184
185
186
187
188

    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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
    #
    #   PRIVATE METHODS
    #


    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:
206
                        playlist_files += entry.source + "\n"
David Trattnig's avatar
David Trattnig committed
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230

        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)
        audio_files = list(filter(lambda f: self.is_audio_file(dir, f), files))
        
        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
231
            self.logger.info("Cleared fallback history.")
David Trattnig's avatar
David Trattnig committed
232
233
234
235
        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
236
237
238
        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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
        # 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



    def is_audio_file(self, dir, file):
        """
        Checks if the passed file is an audio file i.e. has a file-extension
        known for audio files.

        Args:
            (File): file:   the file object.

        Returns:
            (Boolean):      True, if it's an audio file.
        """
David Trattnig's avatar
David Trattnig committed
267
        audio_extensions = [".wav", ".flac", ".mp3", ".ogg", ".m4a"]
David Trattnig's avatar
David Trattnig committed
268
269
270
271
272
273
274
        ext = os.path.splitext(file)[1]
        abs_path = os.path.join(dir, file)

        if os.path.isfile(abs_path):
            if any(ext in s for s in audio_extensions):
                return True
        return False