fallback.py 10.6 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
142
                    with DB.Session() as session:
                        session.merge(timeslot)
143
                self.engine.event_dispatcher.on_fallback_active(timeslot, fallback_type)
144

David Trattnig's avatar
David Trattnig committed
145
146
147


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

151

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


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


185
186
187
188
189
    # 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
190

191
192
193
194
195
196
197
    #     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
198
199
200



201
202
203
204
    # 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
205

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

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

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

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


222

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

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

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

236
237
238
239
240
241
    #     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
242

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


David Trattnig's avatar
David Trattnig committed
246

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

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

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

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

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

260
261
262
263
264
265
266
    #     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
267

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

272
273
274
275
276
277
278
279
280
281
    #         # 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
282

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


David Trattnig's avatar
David Trattnig committed
286

287
288


289

290
291
292
# class FallbackCommand(EngineExecutor):
#     """
#     Command composition for executing timed scheduling and unscheduling of fallback playlists.
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
322
#     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)