fallback.py 7.39 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
29
30
31
from src.base.config        import AuraConfig
from src.base.utils         import SimpleUtil as SU
from src.core.resources     import ResourceClass
from src.core.channels      import Channel
from src.core.control       import EngineExecutor
32

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


class FallbackType(Enum):
    """
    Types of playlists.
    """
39
40
41
42
    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
43
44
45
46
47
48
49
50

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

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

David Trattnig's avatar
David Trattnig committed
51

David Trattnig's avatar
David Trattnig committed
52

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

David Trattnig's avatar
David Trattnig committed
68
    
69
    def __init__(self, scheduler):
David Trattnig's avatar
David Trattnig committed
70
71
72
73
        """
        Constructor

        Args:
74

David Trattnig's avatar
David Trattnig committed
75
        """
76
77
        self.config = AuraConfig.config()
        self.logger = logging.getLogger("AuraEngine")
David Trattnig's avatar
David Trattnig committed
78
79
80
81
        self.scheduler = scheduler


    #
82
    #   METHODS
David Trattnig's avatar
David Trattnig committed
83
84
    #

85
86

    def queue_fallback_playlist(self, timeslot):
David Trattnig's avatar
David Trattnig committed
87
        """
David Trattnig's avatar
David Trattnig committed
88
        Evaluates the scheduled fallback and queues it using a timed thread.
David Trattnig's avatar
David Trattnig committed
89
        """
90
        (fallback_type, playlist) = self.get_fallback_playlist(timeslot)
David Trattnig's avatar
David Trattnig committed
91
92

        if playlist:
93
94
            self.logger.info(f"Resolved {fallback_type.value} fallback")         
            return FallbackCommand(timeslot, playlist.entries)
David Trattnig's avatar
David Trattnig committed
95
        else:
96
            msg = f"There is no timeslot- or show-fallback defined for timeslot#{timeslot.timeslot_id}. "
David Trattnig's avatar
David Trattnig committed
97
98
            msg += f"The station fallback will be used automatically."
            self.logger.info(msg)
David Trattnig's avatar
David Trattnig committed
99
100
101



102
    def resolve_playlist(self, timeslot):
David Trattnig's avatar
David Trattnig committed
103
        """
David Trattnig's avatar
David Trattnig committed
104
105
        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
106

David Trattnig's avatar
David Trattnig committed
107
        Args:
108
            timeslot (Timeslot)
David Trattnig's avatar
David Trattnig committed
109
        
David Trattnig's avatar
David Trattnig committed
110
        Returns:
David Trattnig's avatar
David Trattnig committed
111
            (FallbackType, Playlist)
David Trattnig's avatar
David Trattnig committed
112
        """
David Trattnig's avatar
David Trattnig committed
113
114
        planned_playlist = None
        fallback_type = None
David Trattnig's avatar
David Trattnig committed
115

116
        if self.validate_playlist(timeslot, "playlist"):
David Trattnig's avatar
David Trattnig committed
117
            planned_playlist = timeslot.playlist
David Trattnig's avatar
David Trattnig committed
118
            fallback_type = FallbackType.NONE
David Trattnig's avatar
David Trattnig committed
119
        else:
120
            (fallback_type, planned_playlist) = self.get_fallback_playlist(timeslot)            
David Trattnig's avatar
David Trattnig committed
121

David Trattnig's avatar
David Trattnig committed
122
        return (fallback_type, planned_playlist)
David Trattnig's avatar
David Trattnig committed
123
124
125



126
    def get_fallback_playlist(self, timeslot):
127
        """
David Trattnig's avatar
David Trattnig committed
128
        Retrieves the playlist to be used in a fallback scenario.
129

David Trattnig's avatar
David Trattnig committed
130
        Args: 
131
            timeslot (Timeslot)
132

David Trattnig's avatar
David Trattnig committed
133
        Returns:
David Trattnig's avatar
David Trattnig committed
134
            (Playlist) 
David Trattnig's avatar
David Trattnig committed
135
136
137
        """        
        playlist = None
        fallback_type = FallbackType.STATION
David Trattnig's avatar
David Trattnig committed
138

139
        if self.validate_playlist(timeslot, "schedule_fallback"):
David Trattnig's avatar
David Trattnig committed
140
            playlist = timeslot.schedule_fallback
David Trattnig's avatar
David Trattnig committed
141
            fallback_type = FallbackType.SCHEDULE
142
        elif self.validate_playlist(timeslot, "show_fallback"):
David Trattnig's avatar
David Trattnig committed
143
            playlist = timeslot.show_fallback
David Trattnig's avatar
David Trattnig committed
144
145
146
            fallback_type = FallbackType.SHOW

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

David Trattnig's avatar
David Trattnig committed
148
149


150
    def validate_playlist(self, timeslot, playlist_type):
David Trattnig's avatar
David Trattnig committed
151
152
        """
        Checks if a playlist is valid for play-out.
David Trattnig's avatar
David Trattnig committed
153
154
155
156
157
158
159
160
161
162
163
164

        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
165
        """
166
        playlist = getattr(timeslot, playlist_type)
David Trattnig's avatar
David Trattnig committed
167
        if playlist \
David Trattnig's avatar
David Trattnig committed
168
169
            and playlist.entries \
            and len(playlist.entries) > 0:
David Trattnig's avatar
David Trattnig committed
170

David Trattnig's avatar
David Trattnig committed
171
172
173
174
175
            # Default playlist
            if playlist_type == "playlist":
                return True

            # Fallback playlist
David Trattnig's avatar
David Trattnig committed
176
            elif playlist.entries:
David Trattnig's avatar
David Trattnig committed
177
                is_fs_only = True
David Trattnig's avatar
David Trattnig committed
178
                for entry in playlist.entries:
David Trattnig's avatar
David Trattnig committed
179
180
181
182
183
184
185
                    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
186
187
188
189
190
191
        return False





David Trattnig's avatar
David Trattnig committed
192

193
class FallbackCommand(EngineExecutor):
David Trattnig's avatar
David Trattnig committed
194
    """
195
196
197
198
    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
199
    """
200
201

    def __init__(self, timeslot, entries):
David Trattnig's avatar
David Trattnig committed
202
        """
David Trattnig's avatar
David Trattnig committed
203
        Constructor
204
205
206
207

        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
208
        """        
209
        from src.core.engine import Engine
210
211
212
213
214
215
216
217

        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
218

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