fallback_manager.py 8.02 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
39
from accessify                  import private, protected
from modules.base.simpleutil    import SimpleUtil
David Trattnig's avatar
David Trattnig committed
40
41
42
from modules.communication.mail import AuraMailer


David Trattnig's avatar
David Trattnig committed
43

David Trattnig's avatar
David Trattnig committed
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
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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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
    #


    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:
132
            # Send admin email to notify about the fallback state
David Trattnig's avatar
David Trattnig committed
133
            if not active_playlist:
134
                active_playlist = "-"
David Trattnig's avatar
David Trattnig committed
135
136
137
138
139
140
            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)
141
            self.logger.warn("Providing fallback %s: '%s'. Sent admin email about fallback state" % (media_type, file))
David Trattnig's avatar
David Trattnig committed
142
143
144
145
146
147

        self.is_processing = False
        return file



148
149
150
151
152
153
154
    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
155
156
157
158
159
160
161
162
163

    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
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
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:
                        playlist_files += entry.filename + "\n"

        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
206
            self.logger.info("Cleared fallback history.")
David Trattnig's avatar
David Trattnig committed
207
208
209
210
        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
211
212
213
        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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
        # 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
242
        audio_extensions = [".wav", ".flac", ".mp3", ".ogg", ".m4a"]
David Trattnig's avatar
David Trattnig committed
243
244
245
246
247
248
249
        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