fallback.py 10.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


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

24
25
from enum                   import Enum
from datetime               import timedelta
David Trattnig's avatar
David Trattnig committed
26

27
28
from src.base.config        import AuraConfig
from src.base.utils         import SimpleUtil as SU
David Trattnig's avatar
David Trattnig committed
29
30
31
from src.resources          import ResourceClass, ResourceUtil
from src.channels           import Channel
from src.control            import EngineExecutor
32
from src.scheduling.models  import DB
David Trattnig's avatar
David Trattnig committed
33
34
35
36


class FallbackType(Enum):
    """
37
38
39
40
41
42
    Types of fallbacks.

        NONE:       No fallback active, default playout
        SCHEDULE:   The first played when some default playlist fails
        SHOW:       The second played when the timeslot fallback fails
        STATION:    The last played when everything else fails
David Trattnig's avatar
David Trattnig committed
43
    """
44
45
46
47
    NONE        = { "id": 0, "name": "default", "channels": [ Channel.QUEUE_A, Channel.QUEUE_B ] }
    SCHEDULE    = { "id": 1, "name": "schedule", "channels": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B ] }   
    SHOW        = { "id": 2, "name": "show", "channels": [ Channel.FALLBACK_QUEUE_A, Channel.FALLBACK_QUEUE_B ] }
    STATION     = { "id": 3, "name": "station", "channels": [ Channel.FALLBACK_STATION_FOLDER, Channel.FALLBACK_STATION_PLAYLIST ] }
David Trattnig's avatar
David Trattnig committed
48
49
50
51
52

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

53
54
55
56
    @property
    def channels(self):
        return self.value["channels"]

David Trattnig's avatar
David Trattnig committed
57
58
59
    def __str__(self):
        return str(self.value["name"])

David Trattnig's avatar
David Trattnig committed
60

David Trattnig's avatar
David Trattnig committed
61

David Trattnig's avatar
David Trattnig committed
62
63
class FallbackManager:
    """
64
65
    Handles all types of fallbacks in case there is an outage or missing schedules
    for the radio programme.
David Trattnig's avatar
David Trattnig committed
66
    """    
David Trattnig's avatar
David Trattnig committed
67
68
    config = None
    logger = None
69
70
    engine = None
    state = None
David Trattnig's avatar
David Trattnig committed
71
    
72
73

    def __init__(self, engine):
David Trattnig's avatar
David Trattnig committed
74
75
76
77
        """
        Constructor

        Args:
78
            scheduler (Scheduler):  The scheduler
David Trattnig's avatar
David Trattnig committed
79
        """
80
81
        self.config = AuraConfig.config()
        self.logger = logging.getLogger("AuraEngine")
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
        self.engine = engine
        self.state = {            
            "fallback_type": FallbackType.NONE,
            "previous_fallback_type": None, 
            "timeslot": None
        }


    #
    #   EVENTS
    #


    def on_timeslot_start(self, timeslot=None):
        """
        Some new timeslot has just started.
98
        """
99
100
101
102
103
        self.state["timeslot"] = timeslot


    def on_timeslot_end(self, timeslot):
        """
David Trattnig's avatar
David Trattnig committed
104
105
        The timeslot has ended and the state is updated. The method ensures that any intermediate state 
        update doesn't get overwritten.
106
        """
107
108
109
110
111
112
113
114
115
116
        if self.state["timeslot"] == timeslot:
            self.state["timeslot"] = None


    def on_play(self, entry):
        """
        Event Handler which is called by the engine when some entry is actually playing. 

        Args:
            source (String):    The `PlaylistEntry` object
117
118
119
120
121
122
        """     
        content_class = ResourceUtil.get_content_class(entry.get_content_type())
        if content_class == ResourceClass.FILE:
            # Files are handled by "on_metadata" called via Liquidsoap
            return

123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
        self.update_fallback_state(entry.channel)


    def on_metadata(self, data):
        """
        Event called by the soundsystem implementation (i.e. Liquidsoap) when some entry is actually playing. 
        This does not include live or stream sources, since they ain't have metadata and are triggered from 
        engine core (see `on_play(..)`).

        Args:
            data (dict):    A collection of metadata related to the current track
        """
        channel = data.get("source")
        fallback_type = self.update_fallback_state(channel)

        # If we turned into a fallback state we issue an event
        if fallback_type is not FallbackType.NONE:
            # Only trigger the event the upon first state change
            if fallback_type != self.state.get("previous_fallback_type"):
142
143
144
145
                timeslot = self.state["timeslot"]
                if timeslot:
                    DB.session.merge(timeslot)
                self.engine.event_dispatcher.on_fallback_active(timeslot, fallback_type)
146

David Trattnig's avatar
David Trattnig committed
147
148
149


    #
150
    #   METHODS
David Trattnig's avatar
David Trattnig committed
151
152
    #

153

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
    def update_fallback_state(self, channel):
        """
        Update the current and previously active fallback state.

        Returns:
            (FallbackType): The current fallback
        """
        fallback_type = self.type_for_channel(channel)
        self.state["previous_fallback_type"] = self.state["fallback_type"]
        self.state["fallback_type"] = fallback_type
        return fallback_type


    def type_for_channel(self, source):
        """
        Retrieves the matching fallback type for the given source.
        """
        if source in [str(i) for i in FallbackType.SCHEDULE.channels]:
            return FallbackType.SCHEDULE
        if source in [str(i) for i in FallbackType.SHOW.channels]:
            return FallbackType.SHOW
        if source in [str(i) for i in FallbackType.STATION.channels]:
            return FallbackType.STATION
        return FallbackType.NONE


180
    def queue_fallback_playlist(self, timeslot):
David Trattnig's avatar
David Trattnig committed
181
        """
David Trattnig's avatar
David Trattnig committed
182
        Evaluates the scheduled fallback and queues it using a timed thread.
David Trattnig's avatar
David Trattnig committed
183
        """
184
        (fallback_type, playlist) = self.get_fallback_playlist(timeslot)
David Trattnig's avatar
David Trattnig committed
185
186

        if playlist:
187
188
            self.logger.info(f"Resolved {fallback_type.value} fallback")         
            return FallbackCommand(timeslot, playlist.entries)
David Trattnig's avatar
David Trattnig committed
189
        else:
190
            msg = f"There is no timeslot- or show-fallback defined for timeslot#{timeslot.timeslot_id}. "
David Trattnig's avatar
David Trattnig committed
191
192
            msg += f"The station fallback will be used automatically."
            self.logger.info(msg)
David Trattnig's avatar
David Trattnig committed
193
194
195



196
    def resolve_playlist(self, timeslot):
David Trattnig's avatar
David Trattnig committed
197
        """
David Trattnig's avatar
David Trattnig committed
198
199
        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
200

David Trattnig's avatar
David Trattnig committed
201
        Args:
202
            timeslot (Timeslot)
David Trattnig's avatar
David Trattnig committed
203
        
David Trattnig's avatar
David Trattnig committed
204
        Returns:
David Trattnig's avatar
David Trattnig committed
205
            (FallbackType, Playlist)
David Trattnig's avatar
David Trattnig committed
206
        """
David Trattnig's avatar
David Trattnig committed
207
        fallback_type = None
208
        planned_playlist = self.engine.scheduler.programme.get_current_playlist(timeslot)
David Trattnig's avatar
David Trattnig committed
209

210
        if planned_playlist:        
David Trattnig's avatar
David Trattnig committed
211
            fallback_type = FallbackType.NONE
David Trattnig's avatar
David Trattnig committed
212
        else:
213
            (fallback_type, planned_playlist) = self.get_fallback_playlist(timeslot)            
David Trattnig's avatar
David Trattnig committed
214

David Trattnig's avatar
David Trattnig committed
215
        return (fallback_type, planned_playlist)
David Trattnig's avatar
David Trattnig committed
216
217
218



219
    def get_fallback_playlist(self, timeslot):
220
        """
David Trattnig's avatar
David Trattnig committed
221
        Retrieves the playlist to be used in a fallback scenario.
222

David Trattnig's avatar
David Trattnig committed
223
        Args: 
224
            timeslot (Timeslot)
225

David Trattnig's avatar
David Trattnig committed
226
        Returns:
David Trattnig's avatar
David Trattnig committed
227
            (Playlist) 
David Trattnig's avatar
David Trattnig committed
228
229
230
        """        
        playlist = None
        fallback_type = FallbackType.STATION
David Trattnig's avatar
David Trattnig committed
231

232
        if self.validate_playlist(timeslot, "schedule_fallback"):
David Trattnig's avatar
David Trattnig committed
233
            playlist = timeslot.schedule_fallback
David Trattnig's avatar
David Trattnig committed
234
            fallback_type = FallbackType.SCHEDULE
235
        elif self.validate_playlist(timeslot, "show_fallback"):
David Trattnig's avatar
David Trattnig committed
236
            playlist = timeslot.show_fallback
David Trattnig's avatar
David Trattnig committed
237
238
239
            fallback_type = FallbackType.SHOW

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

David Trattnig's avatar
David Trattnig committed
241
242


243
    def validate_playlist(self, timeslot, playlist_type):
David Trattnig's avatar
David Trattnig committed
244
245
        """
        Checks if a playlist is valid for play-out.
David Trattnig's avatar
David Trattnig committed
246
247
248
249
250
251
252
253
254
255
256
257

        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
258
        """
259
        playlist = getattr(timeslot, playlist_type)
David Trattnig's avatar
David Trattnig committed
260
        if playlist \
David Trattnig's avatar
David Trattnig committed
261
262
            and playlist.entries \
            and len(playlist.entries) > 0:
David Trattnig's avatar
David Trattnig committed
263

David Trattnig's avatar
David Trattnig committed
264
265
266
267
268
            # Default playlist
            if playlist_type == "playlist":
                return True

            # Fallback playlist
David Trattnig's avatar
David Trattnig committed
269
            elif playlist.entries:
David Trattnig's avatar
David Trattnig committed
270
                is_fs_only = True
David Trattnig's avatar
David Trattnig committed
271
                for entry in playlist.entries:
David Trattnig's avatar
David Trattnig committed
272
273
274
275
276
277
278
                    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
279
280
281
282
283
284
        return False





David Trattnig's avatar
David Trattnig committed
285

286
class FallbackCommand(EngineExecutor):
David Trattnig's avatar
David Trattnig committed
287
    """
288
289
290
291
    Command composition for executing timed scheduling and unscheduling of fallback playlists.

    Based on the `timeslot.start_date` and `timeslot.end_date` two `EngineExecutor commands
    are created.
David Trattnig's avatar
David Trattnig committed
292
    """
293

294

295
    def __init__(self, timeslot, entries):
David Trattnig's avatar
David Trattnig committed
296
        """
David Trattnig's avatar
David Trattnig committed
297
        Constructor
298
299
300
301

        Args:
            timeslot (Timeslot):    The timeslot any fallback entries should be scheduled for
            entries (List):         List of entries to be scheduled as fallback
David Trattnig's avatar
David Trattnig committed
302
        """        
David Trattnig's avatar
David Trattnig committed
303
        from src.engine import Engine
304
305
306
307
308
309
310
311

        def do_play(entries):
            self.logger.info(SU.cyan(f"=== start_fallback_playlist('{entries}') ==="))
            Engine.get_instance().player.start_fallback_playlist(entries)

        def do_stop():
            self.logger.info(SU.cyan("=== stop_fallback_playlist() ==="))
            Engine.get_instance().player.stop_fallback_playlist()
David Trattnig's avatar
David Trattnig committed
312

313
314
        # Start fade-out 50% before the end of the timeslot for a more smooth transition
        end_time_offset = int(float(AuraConfig.config().get("fade_out_time")) / 2 * 1000 * -1)
315
        end_time = SU.timestamp(timeslot.timeslot_end + timedelta(milliseconds=end_time_offset))
316
        self.logger.info(f"Starting fade-out of scheduled fallback with an offset of {end_time_offset} milliseconds at {end_time}")
317
318
        super().__init__("FALLBACK", None, timeslot.start_unix, do_play, entries)
        EngineExecutor("FALLBACK", self, end_time, do_stop, None)