fallback_manager.py 10.2 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


David Trattnig's avatar
David Trattnig committed
22
import logging
David Trattnig's avatar
David Trattnig committed
23

David Trattnig's avatar
David Trattnig committed
24
25
26
from enum                       import Enum
from threading                  import Thread, Timer
from datetime                   import datetime, timedelta
David Trattnig's avatar
David Trattnig committed
27

David Trattnig's avatar
David Trattnig committed
28
from modules.base.utils         import SimpleUtil as SU
29
from modules.base.mail          import AuraMailer
David Trattnig's avatar
David Trattnig committed
30
from modules.core.resources     import ResourceClass
31
32
33
from modules.core.channels      import Channel


David Trattnig's avatar
David Trattnig committed
34
35
36
37
38
39


class FallbackType(Enum):
    """
    Types of playlists.
    """
40
41
42
43
    NONE        = { "id": 0, "name": "default", "lqs_sources": [ Channel.QUEUE_A, Channel.QUEUE_A] }    # No fallback active, default playout
    SCHEDULE    = { "id": 1, "name": "schedule", "lqs_sources": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B]}   # The first played when some default playlist fails
    SHOW        = { "id": 2, "name": "show", "lqs_sources": [ "station_folder", "station_playlist"]}       # The second played when the timeslot fallback fails    
    STATION     = { "id": 3, "name": "station", "lqs_sources": [ "station_folder", "station_playlist"] }    # The last played when everything else fails
David Trattnig's avatar
David Trattnig committed
44
45
46
47
48
49
50
51

    @property
    def id(self):
        return self.value["id"]

    def __str__(self):
        return str(self.value["name"])

David Trattnig's avatar
David Trattnig committed
52

David Trattnig's avatar
David Trattnig committed
53

David Trattnig's avatar
David Trattnig committed
54
55
56
57
58
59
60
61
62
63
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
David Trattnig's avatar
David Trattnig committed
64
    """    
David Trattnig's avatar
David Trattnig committed
65
66
67
68
    config = None
    logger = None
    mailer = None
    scheduler = None
David Trattnig's avatar
David Trattnig committed
69
    message_timer = None
David Trattnig's avatar
David Trattnig committed
70
71


David Trattnig's avatar
David Trattnig committed
72
73
    
    def __init__(self, config, logger, scheduler, message_timer):
David Trattnig's avatar
David Trattnig committed
74
75
76
77
78
79
80
        """
        Constructor

        Args:
            config (AuraConfig):    Holds the engine configuration
        """
        self.config = config
David Trattnig's avatar
David Trattnig committed
81
        self.logger = logger
David Trattnig's avatar
David Trattnig committed
82
83
        self.mailer = AuraMailer(self.config)
        self.scheduler = scheduler
David Trattnig's avatar
David Trattnig committed
84
85
        # self.message_timer = message_timer
        self.message_timer = []
David Trattnig's avatar
David Trattnig committed
86
87
88
89
90
91


    #
    #   PUBLIC METHODS
    #

David Trattnig's avatar
David Trattnig committed
92
    def schedule_fallback_playlist(self, schedule, schedule_now=False):
David Trattnig's avatar
David Trattnig committed
93
        """
David Trattnig's avatar
David Trattnig committed
94
        Evaluates the scheduled fallback and queues it using a timed thread.
David Trattnig's avatar
David Trattnig committed
95
96

        Args:
David Trattnig's avatar
David Trattnig committed
97
            schedule_now (Boolean): If `True` it is executed immediately
David Trattnig's avatar
David Trattnig committed
98
        """
David Trattnig's avatar
David Trattnig committed
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
        timer_start = None
        timer_end = None
        (fallback_type, playlist) = self.get_fallback_playlist(schedule)

        if playlist:
            self.logger.info(f"Resolved {fallback_type.value} fallback")

            def do_schedule(entries):
                self.logger.info(SU.cyan(f"=== set_fallback_playlist('{entries}') ==="))
                self.scheduler.engine.player.start_fallback_playlist(entries)
            def do_unschedule():
                self.logger.info(SU.cyan("=== clear_fallback_playlist() ==="))
                self.scheduler.engine.player.stop_fallback_playlist()

            if schedule_now == True:
                # Update queue immediately
                thread = Thread(target = do_schedule, args = (playlist.entries,))
                thread.start()
            else:          
                # Update queue at the beginning of the timeslot  
                timer_start = FallbackCommandTimer(schedule.start_unix, do_schedule, playlist.entries)
                self.message_timer.append(timer_start)
                timer_start.start()

            # Update fallback channel to be cleared at the end of the timeslot
            timer_end = FallbackCommandTimer(schedule.end_unix, do_unschedule)
            self.message_timer.append(timer_end)
            timer_end.start()
            return (timer_start, timer_end)
David Trattnig's avatar
David Trattnig committed
128

David Trattnig's avatar
David Trattnig committed
129
        else:
David Trattnig's avatar
David Trattnig committed
130
131
132
            msg = f"There is no schedule- or show-fallback defined for timeslot#{schedule.schedule_id}. "
            msg += f"The station fallback will be used automatically."
            self.logger.info(msg)
David Trattnig's avatar
David Trattnig committed
133
134
135



David Trattnig's avatar
David Trattnig committed
136
    def resolve_playlist(self, schedule):
David Trattnig's avatar
David Trattnig committed
137
        """
David Trattnig's avatar
David Trattnig committed
138
139
        Retrieves the currently planned (fallback) playlist. If a normal playlist is available,
        this one is returned. In case of station fallback no playlist is returned.
David Trattnig's avatar
David Trattnig committed
140

David Trattnig's avatar
David Trattnig committed
141
142
143
        Args:
            schedule (Schedule)
        
David Trattnig's avatar
David Trattnig committed
144
        Returns:
David Trattnig's avatar
David Trattnig committed
145
            (FallbackType, Playlist)
David Trattnig's avatar
David Trattnig committed
146
        """
David Trattnig's avatar
David Trattnig committed
147
148
        planned_playlist = None
        fallback_type = None
David Trattnig's avatar
David Trattnig committed
149

David Trattnig's avatar
David Trattnig committed
150
151
152
        if self.validate_playlist(schedule, "playlist"):
            planned_playlist = schedule.get_playlist()
            fallback_type = FallbackType.NONE
David Trattnig's avatar
David Trattnig committed
153
        else:
David Trattnig's avatar
David Trattnig committed
154
            (fallback_type, planned_playlist) = self.get_fallback_playlist(schedule)            
David Trattnig's avatar
David Trattnig committed
155

David Trattnig's avatar
David Trattnig committed
156
        return (fallback_type, planned_playlist)
David Trattnig's avatar
David Trattnig committed
157
158
159



David Trattnig's avatar
David Trattnig committed
160
    def get_fallback_playlist(self, schedule):
161
        """
David Trattnig's avatar
David Trattnig committed
162
        Retrieves the playlist to be used in a fallback scenario.
163

David Trattnig's avatar
David Trattnig committed
164
165
        Args: 
            schedule (Schedule)
166

David Trattnig's avatar
David Trattnig committed
167
168
169
170
171
        Returns:
            (Playlist)
        """        
        playlist = None
        fallback_type = FallbackType.STATION
David Trattnig's avatar
David Trattnig committed
172

David Trattnig's avatar
David Trattnig committed
173
174
175
176
177
178
179
180
        if self.validate_playlist(schedule, "schedule_fallback"):
            playlist = schedule.schedule_fallback[0]
            fallback_type = FallbackType.SCHEDULE
        elif self.validate_playlist(schedule, "show_fallback"):
            playlist = schedule.show_fallback[0]
            fallback_type = FallbackType.SHOW

        return (fallback_type, playlist)
David Trattnig's avatar
David Trattnig committed
181

David Trattnig's avatar
David Trattnig committed
182
183


David Trattnig's avatar
David Trattnig committed
184
185
186
187
188
    #
    #   PRIVATE METHODS
    #


David Trattnig's avatar
David Trattnig committed
189
190
191
    def validate_playlist(self, schedule, playlist_type):
        """
        Checks if a playlist is valid for play-out.
David Trattnig's avatar
David Trattnig committed
192
193
194
195
196
197
198
199
200
201
202
203

        Following checks are done for all playlists:

            - has one or more entries

        Fallback playlists should either:
                
            - have filesystem entries only
            - reference a recording of a previous playout of a show (also filesystem)
        
        Otherwise, if a fallback playlist contains Live or Stream entries,
        the exact playout behaviour can hardly be predicted.
David Trattnig's avatar
David Trattnig committed
204
205
206
207
208
209
210
        """
        playlist = getattr(schedule, playlist_type)
        if playlist \
            and isinstance(playlist, list) \
            and playlist[0].entries \
            and len(playlist[0].entries) > 0:

David Trattnig's avatar
David Trattnig committed
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
            # Default playlist
            if playlist_type == "playlist":
                return True

            # Fallback playlist
            elif playlist[0].entries:
                is_fs_only = True
                for entry in playlist[0].entries:
                    if entry.get_content_type() not in ResourceClass.FILE.types:
                        self.logger.error(SU.red("Fallback playlist of type '%s' contains not only file-system entries! \
                            Skipping fallback level..." % playlist_type))
                        is_fs_only = False
                        break                
                return is_fs_only

David Trattnig's avatar
David Trattnig committed
226
227
228
229
230
231
        return False





David Trattnig's avatar
David Trattnig committed
232
233
234
235
236
237
238
239
240
241
242
class EngineCommandTimer(Timer):
    """
    Timer for timed executing of Engine commands.
    """
    timer_store = {}    
    logger = logging.getLogger("AuraEngine")
    timer_id = None
    timer_type = None
    param = None
    diff = None
    dt = None
David Trattnig's avatar
David Trattnig committed
243
244


David Trattnig's avatar
David Trattnig committed
245
    def __init__(self, timer_type="BASE", due_time=None, func=None, param=None):
David Trattnig's avatar
David Trattnig committed
246
        """
David Trattnig's avatar
David Trattnig committed
247
        Constructor
David Trattnig's avatar
David Trattnig committed
248
        """
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
        now_unix = Engine.engine_time()        
        self.timer_type = timer_type       
        self.timer_id = f"{timer_type}:{func.__name__}:{due_time}"
                
        diff = due_time - now_unix
        if diff < 0.0:
            msg = f"Trying to create timer in the past: {self.timer_id}"
            self.logger.error(SU.red(msg))
            raise Exception(msg)

        self.diff = diff
        self.dt = datetime.now() + timedelta(seconds=diff)
        self.func = func
        self.param = param

        def wrapper_func(param=None):

            # Remove from cache
            self.logger.info(SU.green(f"Removing old timer with ID: {self.timer_id}"))
            del EngineCommandTimer.timer_store[self.timer_id]            
            # Call actual function
            if param: func(param,) 
            else: func()

        Timer.__init__(self, diff, wrapper_func, (param,))       
        self.update_cache()
        self.logger.info(SU.green(f"Created command timer with ID: {self.timer_id}"))
        
David Trattnig's avatar
David Trattnig committed
277

David Trattnig's avatar
David Trattnig committed
278
279
280
281
282
283
284
285
286
287
288
289
    
    def update_cache(self):
        """
        Adds the instance to the cache and cancels any previously existing commands.
        """
        existing_command = None
        if self.timer_id in EngineCommandTimer.timer_store:
            existing_command = EngineCommandTimer.timer_store[self.timer_id]    
        if existing_command:
            self.logger.info(SU.green(f"Cancelling previous timer with ID: {self.timer_id}"))
            existing_command.cancel()
        EngineCommandTimer.timer_store[self.timer_id] = self
David Trattnig's avatar
David Trattnig committed
290
291
292



David Trattnig's avatar
David Trattnig committed
293
    def print_active_timers(self):
David Trattnig's avatar
David Trattnig committed
294
        """
David Trattnig's avatar
David Trattnig committed
295
        Prints a list of active timers to the log.
David Trattnig's avatar
David Trattnig committed
296
        """
David Trattnig's avatar
David Trattnig committed
297
298
        for id, timer in EngineCommandTimer.timer_store.values():
            EngineCommandTimer.logger.info(str(timer))
David Trattnig's avatar
David Trattnig committed
299
300
301



David Trattnig's avatar
David Trattnig committed
302
303
304
305
306
    def __str__(self):
        """
        String represenation of the timer.
        """
        return f"[{self.timer_id}] COMMAND TIMER due at {str(self.dt)} (alive: {self.is_alive()})"
David Trattnig's avatar
David Trattnig committed
307
308
309
310
311





David Trattnig's avatar
David Trattnig committed
312
313
314
315
316
class FallbackCommandTimer(EngineCommandTimer):
    """
    Timer for executing timed scheduling of fallback playlists.
    """
    def __init__(self, diff=None, func=None, param=None):
David Trattnig's avatar
David Trattnig committed
317
        """
David Trattnig's avatar
David Trattnig committed
318
319
320
321
322
        Constructor
        """        
        super().__init__("FALLBACK", diff, func, param)
        self.logger.info("Executing scheduled fallback playlist update '%s' in %s seconds..." % \
            (str(func.__name__), str(diff)))
David Trattnig's avatar
David Trattnig committed
323