fallback.py 10.5 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
    Types of fallbacks.

39
40
        NONE:       No fallback active, default playout as planned
        STATION:    The station fallback is played when everything else fails
David Trattnig's avatar
David Trattnig committed
41
    """
42
    NONE        = { "id": 0, "name": "default", "channels": [ Channel.QUEUE_A, Channel.QUEUE_B ] }
43
44
    # 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 ] }
45
    STATION     = { "id": 3, "name": "station", "channels": [ Channel.FALLBACK_STATION_FOLDER, Channel.FALLBACK_STATION_PLAYLIST ] }
David Trattnig's avatar
David Trattnig committed
46
47
48
49
50

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

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

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

David Trattnig's avatar
David Trattnig committed
58

David Trattnig's avatar
David Trattnig committed
59

David Trattnig's avatar
David Trattnig committed
60
61
class FallbackManager:
    """
62
63
    Manages if engine is in normal or fallback play-state.
    """
David Trattnig's avatar
David Trattnig committed
64
65
    config = None
    logger = None
66
67
    engine = None
    state = None
68

69
70

    def __init__(self, engine):
David Trattnig's avatar
David Trattnig committed
71
72
73
74
        """
        Constructor

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


    #
    #   EVENTS
    #


    def on_timeslot_start(self, timeslot=None):
        """
        Some new timeslot has just started.
95
        """
96
97
98
99
100
        self.state["timeslot"] = timeslot


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


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

        Args:
            source (String):    The `PlaylistEntry` object
114
        """
115
116
117
118
119
        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

120
121
122
123
124
        self.update_fallback_state(entry.channel)


    def on_metadata(self, data):
        """
125
126
        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
127
128
129
130
131
132
133
134
135
136
137
138
        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"):
139
140
                timeslot = self.state["timeslot"]
                if timeslot:
141
                    DB.session.merge(timeslot)
142
                self.engine.event_dispatcher.on_fallback_active(timeslot, fallback_type)
143

David Trattnig's avatar
David Trattnig committed
144
145
146


    #
147
    #   METHODS
David Trattnig's avatar
David Trattnig committed
148
149
    #

150

151
152
153
154
155
156
157
    def get_playout_state(self):
        """
        Returns the current playout state, like normal or fallback.
        """
        return self.state["fallback_type"]


158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
    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.
        """
175
176
177
178
        # 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
179
180
181
182
183
        if source in [str(i) for i in FallbackType.STATION.channels]:
            return FallbackType.STATION
        return FallbackType.NONE


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

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



200
201
202
203
    # def resolve_playlist(self, timeslot):
    #     """
    #     Retrieves the currently planned, default or fallback playlist. If a normal playlist is available,
    #     this one is returned. In case of a station fallback to be triggered, no playlist is returned.
David Trattnig's avatar
David Trattnig committed
204

205
206
    #     Args:
    #         timeslot (Timeslot)
David Trattnig's avatar
David Trattnig committed
207

208
209
210
211
    #     Returns:
    #         (FallbackType, Playlist)
    #     """
    #     (playlist_type, planned_playlist) = self.engine.scheduler.programme.get_current_playlist(timeslot)
David Trattnig's avatar
David Trattnig committed
212

213
214
215
216
    #     if planned_playlist:
    #         playlist_type = FallbackType.NONE
    #     else:
    #         (playlist_type, planned_playlist) = self.get_fallback_playlist(timeslot)
David Trattnig's avatar
David Trattnig committed
217

218
    #     return (playlist_type, planned_playlist)
David Trattnig's avatar
David Trattnig committed
219
220


221

222
223
224
    # def get_fallback_playlist(self, timeslot):
    #     """
    #     Retrieves the playlist to be used in a fallback scenario.
225

226
227
    #     Args:
    #         timeslot (Timeslot)
David Trattnig's avatar
David Trattnig committed
228

229
230
231
232
233
    #     Returns:
    #         (Playlist)
    #     """
    #     playlist = None
    #     fallback_type = FallbackType.STATION
David Trattnig's avatar
David Trattnig committed
234

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

242
    #     return (fallback_type, playlist)
David Trattnig's avatar
David Trattnig committed
243
244


David Trattnig's avatar
David Trattnig committed
245

246
247
248
    # def validate_playlist(self, timeslot, playlist_type):
    #     """
    #     Checks if a playlist is valid for play-out.
David Trattnig's avatar
David Trattnig committed
249

250
    #     Following checks are done for all playlists:
David Trattnig's avatar
David Trattnig committed
251

252
    #         - has one or more entries
David Trattnig's avatar
David Trattnig committed
253

254
    #     Fallback playlists should either:
David Trattnig's avatar
David Trattnig committed
255

256
257
    #         - have filesystem entries only
    #         - reference a recording of a previous playout of a show (also filesystem)
David Trattnig's avatar
David Trattnig committed
258

259
260
261
262
263
264
265
    #     Otherwise, if a fallback playlist contains Live or Stream entries,
    #     the exact playout behaviour can hardly be predicted.
    #     """
    #     playlist = getattr(timeslot, playlist_type)
    #     if playlist \
    #         and playlist.entries \
    #         and len(playlist.entries) > 0:
David Trattnig's avatar
David Trattnig committed
266

267
268
269
    #         # Default playlist
    #         if playlist_type == "playlist":
    #             return True
David Trattnig's avatar
David Trattnig committed
270

271
272
273
274
275
276
277
278
279
280
    #         # Fallback playlist
    #         elif playlist.entries:
    #             is_fs_only = True
    #             for entry in playlist.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
281

282
    #     return False
David Trattnig's avatar
David Trattnig committed
283
284


David Trattnig's avatar
David Trattnig committed
285

286
287


288

289
290
291
# class FallbackCommand(EngineExecutor):
#     """
#     Command composition for executing timed scheduling and unscheduling of fallback playlists.
292

293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
#     Based on the `timeslot.start_date` and `timeslot.end_date` two `EngineExecutor commands
#     are created.
#     """


#     def __init__(self, timeslot, entries):
#         """
#         Constructor

#         Args:
#             timeslot (Timeslot):    The timeslot any fallback entries should be scheduled for
#             entries (List):         List of entries to be scheduled as fallback
#         """
#         from src.engine import Engine

#         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()

#         # 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)
#         end_time = SU.timestamp(timeslot.timeslot_end + timedelta(milliseconds=end_time_offset))
#         self.logger.info(f"Starting fade-out of scheduled fallback with an offset of {end_time_offset} milliseconds at {end_time}")
#         super().__init__("FALLBACK", None, timeslot.start_unix, do_play, entries)
#         EngineExecutor("FALLBACK", self, end_time, do_stop, None)