fallback_manager.py 9.12 KB
Newer Older
David Trattnig's avatar
David Trattnig committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

#
#  Aura Engine
#
#  Playout Daemon for autoradio project
#
#
#  Copyright (C) 2020 David Trattnig <david.trattnig@subsquare.at>
#
#  This file is part of engine.
#
#  engine is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  any later version.
#
#  engine 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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with engine. If not, see <http://www.gnu.org/licenses/>.
#

# Meta
__version__ = '0.0.1'
__license__ = "GNU General Public License (GPL) Version 3"
__version_info__ = (0, 0, 1)
__author__ = 'David Trattnig <david.trattnig@subsquare.at>'


import os, os.path
import logging
import random
David Trattnig's avatar
David Trattnig committed
36
import librosa
David Trattnig's avatar
David Trattnig committed
37

David Trattnig's avatar
David Trattnig committed
38
from accessify                  import private, protected
David Trattnig's avatar
David Trattnig committed
39
40
from modules.base.enum          import FallbackType
from modules.base.utils         import SimpleUtil
David Trattnig's avatar
David Trattnig committed
41
42
43
from modules.communication.mail import AuraMailer


David Trattnig's avatar
David Trattnig committed
44

David Trattnig's avatar
David Trattnig committed
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
77
78
79
80
81
82
83
84
85
86
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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
    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
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
157
158
159
160
161
162
163
164
165
166
    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:
167
            # Send admin email to notify about the fallback state
David Trattnig's avatar
David Trattnig committed
168
            if not active_playlist:
169
                active_playlist = "-"
David Trattnig's avatar
David Trattnig committed
170
171
172
173
174
175
            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)
176
            self.logger.warn("Providing fallback %s: '%s'. Sent admin email about fallback state" % (media_type, file))
David Trattnig's avatar
David Trattnig committed
177
178
179
180
181
182

        self.is_processing = False
        return file



183
184
185
186
187
188
189
    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
190
191
192
193
194
195
196
197
198

    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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
    #
    #   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:
216
                        playlist_files += entry.source + "\n"
David Trattnig's avatar
David Trattnig committed
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240

        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
241
            self.logger.info("Cleared fallback history.")
David Trattnig's avatar
David Trattnig committed
242
243
244
245
        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
246
247
248
        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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
        # 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
277
        audio_extensions = [".wav", ".flac", ".mp3", ".ogg", ".m4a"]
David Trattnig's avatar
David Trattnig committed
278
279
280
281
282
283
284
        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