trackservice.py 15.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#
# Aura Engine (https://gitlab.servus.at/aura/engine)
#
# Copyright (C) 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/>.


20
import json
21
import logging
22
23
import requests

24
from collections                import deque
25

26
27
from src.base.config            import AuraConfig
from src.base.utils             import SimpleUtil as SU
David Trattnig's avatar
David Trattnig committed
28
29
from src.resources              import ResourceClass
from src.resources              import ResourceUtil
30
from src.scheduling.fallback    import FallbackType
31

32

33

David Trattnig's avatar
David Trattnig committed
34
class TrackServiceHandler():
35
    """
36
    Sends the trackservice entry and studio clock information to the `engine-api` REST endpoint.
37
38
39
    """
    logger = None
    config = None
40
41
    engine = None
    playlog = None
42
43


44
    def __init__(self, engine):
45
46
47
48
        """
        Initialize.
        """
        self.logger = logging.getLogger("AuraEngine")
49
50
51
        self.config = AuraConfig.config()
        self.engine = engine
        self.playlog = Playlog(engine)
52
53


54

55
    def on_timeslot_start(self, timeslot=None):
56
        """
57
        Some new timeslot has just started.
58
        """
59
60
61
        if timeslot:
            self.logger.info(f"Active timeslot used for trackservice '{timeslot}'")
        self.playlog.set_timeslot(timeslot)
62
63


64
65
66
67
68
69
70
71
72
73
74

    def on_timeslot_end(self, timeslot):
        """
        Called when a timeslot ends.
        """
        if timeslot:
            self.logger.info(f"Timeslot '{timeslot}' just ended")
        self.playlog.set_timeslot(None)



75
    def on_queue(self, entries):
76
        """
77
78
79
80
81
        Items have been queued. They are stored to the local playlog, allowing later
        matching and retrieval to augment meta-information.

        Args:
            entries ([PlaylistEntry]):  The entries which got queued
82
        """
83
84
85
86
87
88
89
        for entry in entries:
            self.playlog.add(entry)


    def on_play(self, entry):
        """
        Some `PlaylistEntry` started playing. This is likely only a LIVE or STREAM entry.
90
        """
91
92
93
        content_class = ResourceUtil.get_content_class(entry.get_content_type())
        if content_class == ResourceClass.FILE:
            # Files are handled by "on_metadata" called via Liquidsoap
94
            return
95

96
97
        diff = (entry.entry_start_actual - entry.entry_start).total_seconds()
        self.logger.info("There's a difference of %s seconds between planned and actual start of the entry" % diff)
98
99

        data = {}
100
        data["track_start"] = entry.entry_start_actual
101
102
103
104
        if entry.meta_data:
            data["track_artist"] = entry.meta_data.artist
            data["track_album"] = entry.meta_data.album
            data["track_title"] = entry.meta_data.title
105
        data["track_duration"] = entry.duration
106
        data["track_num"] = entry.entry_num
107
        data["track_type"] = content_class.numeric
108
        data["playlist_id"] = entry.playlist.playlist_id
109
110
111
        data["timeslot_id"] = entry.playlist.timeslot.timeslot_id
        data["show_id"] = entry.playlist.timeslot.show_id
        data["show_name"] = entry.playlist.timeslot.show_name
112
        data["log_source"] = self.config.get("api_engine_number")
113
114
115

        self.store_trackservice(data)
        self.store_clock_info(data)
116

117

118

119
120
121
122
123
124
125
126
    def on_metadata(self, meta):
        """
        Some metadata update was sent from Liquidsoap.
        """
        data = {}
        data["track_start"] = meta.get("on_air")
        data["track_artist"] = meta.get("artist")
        data["track_album"] = meta.get("album")
127
        data["track_title"] = meta.get("title")
128
        data["track_type"] = ResourceClass.FILE.numeric
129
        #lqs_source = meta["source"]
130

131
132
133
134
        if "duration" in meta:
            duration = float(meta.get("duration"))
            data["track_duration"] = int(duration)
        else:
135
            data["track_duration"] = 0
136

137
        entry = self.playlog.resolve_entry(meta["filename"])
David Trattnig's avatar
David Trattnig committed
138

139
140
141
142
        if entry:
            # This is a playlog according to the scheduled playlist (normal or fallback)
            data["track_num"] = entry.entry_num
            data["playlist_id"] = entry.playlist.playlist_id
143
144
145
            data["timeslot_id"] = entry.playlist.timeslot.timeslot_id
            data["show_id"] = entry.playlist.timeslot.show_id
            data["show_name"] = entry.playlist.timeslot.show_name
146
147
        else:
            # This is a fallback playlog which wasn't scheduled actually (e.g. station fallback)
148
            (past, timeslot, next) = self.playlog.get_timeslots()
149
            if timeslot:
150
151
                data = {**data, **timeslot}
                data["playlist_id"] = -1
152

153
154
        data["log_source"] = self.config.get("api_engine_number")
        data = SU.clean_dictionary(data)
155
156
157
        self.store_trackservice(data)
        self.store_clock_info(data)

158

159
160
161
162

    def store_trackservice(self, data):
        """
        Posts the given `PlaylistEntry` to the Engine API Playlog.
163
        """
164
        data = SU.clean_dictionary(data)
165

166
        self.logger.info("Posting playlog to Engine API...")
167
        url = self.config.get("api_engine_store_playlog")
168
169
        headers = {'content-type': 'application/json'}
        body = json.dumps(data, indent=4, sort_keys=True, default=str)
170
        self.logger.debug("Playlog Data: " + body)
171
        response = requests.post(url, data=body, headers=headers)
172
173
        if response.status_code != 204 or response.status_code != 204:
            msg = f"Error while posting playlog to Engine API: {response.reason} (Error {response.status_code})\n"
174
            self.logger.info(SU.red(msg) + response.content.decode("utf-8"))
175
176


177
    def store_clock_info(self, data):
178
        """
179
        Posts the current and next show information to the Engine API.
180
        """
181
        planned_playlist = None
182
        if self.engine.scheduler:
183
            (playlist_type, planned_playlist) = self.engine.scheduler.get_active_playlist()
184
        (past_timeslot, current_timeslot, next_timeslot) = self.playlog.get_timeslots()
185
186

        data = dict()
187
        data["engine_source"] = self.config.get("api_engine_number")
188

189
        if current_timeslot:
190
            data["current_timeslot"] = current_timeslot
191

192
193
194
195
196
197
198
199
200
            if planned_playlist:
                data["planned_playlist"] = dict()
                data["planned_playlist"]["playlist_id"] = planned_playlist.playlist_id
                data["planned_playlist"]["entries"] = []
                for e in planned_playlist.entries:
                    entry = dict()
                    entry["track_start"] = e.entry_start
                    if e.meta_data:
                        entry["track_artist"] = e.meta_data.artist
201
202
                        entry["track_album"] = e.meta_data.album
                        entry["track_title"] = e.meta_data.title
203
204
205
206
207
                    entry["track_num"] = e.entry_num
                    entry["track_duration"] = e.duration
                    content_class = ResourceUtil.get_content_class(e.get_content_type())
                    entry["track_type"] = content_class.numeric
                    entry = SU.clean_dictionary(entry)
208
                    data["planned_playlist"]["entries"].append(entry)
209

210
        if next_timeslot:
211
            data["next_timeslot"] = next_timeslot
212
213
214
215


        data = SU.clean_dictionary(data)

216
        self.logger.info("Posting clock info update to Engine API...")
217
218
219
        url = self.config.get("api_engine_store_clock")
        headers = {'content-type': 'application/json'}
        body = json.dumps(data, indent=4, sort_keys=True, default=str)
220
        self.logger.debug("Clock Data: " + body)
221
        response = requests.put(url, data=body, headers=headers)
222
223
224
        if response.status_code != 204 or response.status_code != 204:
            msg = f"Error while posting clock-info to Engine API: {response.reason} (Error {response.status_code})\n"
            self.logger.info(SU.red(msg) + response.content.decode("utf-8"))
225
226
227
228
229
230




class Playlog:
    """
231
    Playlog keeps a history of currently queued (and playing) entries. It stores the recent
David Trattnig's avatar
David Trattnig committed
232
    active entries to a local cache `history` being able to manage concurrently playing entries.
233
234
    It also is in charge of resolving relevant meta information of the currently playing entry for
    the TrackService handler.
David Trattnig's avatar
David Trattnig committed
235
236
237

    The records are stored in pre-formatted dictionary structure, allowing easy serialization when
    posting them to the Engine API.
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
    """
    config = None
    logger = None
    engine = None
    history = None
    previous_timeslot = None
    current_timeslot = None
    next_timeslot = None


    def __init__(self, engine):
        """
        Constructor
        """
        self.config = AuraConfig.config()
        self.logger = logging.getLogger("AuraEngine")
        self.engine = engine
255
        self.history = deque(maxlen=100)
256
        self.current_timeslot = {}
257
258
259
260
261
262
263
264
265
266
        self.init_timeslot(None)



    def init_timeslot(self, next_timeslot=None):
        """
        Initializes the timeslot.
        """
        data = {}
        self.assign_fallback_playlist(data, None)
267
        data["timeslot_id"] = -1
268
269
270
271
        data["show_id"] = -1
        data["show_name"] = ""

        if self.previous_timeslot:
272
            data["timeslot_start"] = self.previous_timeslot.get("timeslot_end")
273
        else:
274
            data["timeslot_start"] = None
275
276

        if next_timeslot:
277
            data["timeslot_end"] = next_timeslot.timeslot_start
278
        else:
279
            data["timeslot_end"] = None
280

David Trattnig's avatar
David Trattnig committed
281
        self.current_timeslot = data
282
283


284

285
286
    def set_timeslot(self, timeslot):
        """
287
288
289
290
291
292
293
294
        Sets the current timeslot and proper default values if no timeslot is available.
        Any previous timeslot is stored to `self.previous_timeslot` and the following one
        to `self.next_timeslot`.

        This method is protect by overwritting by multiple calls with the same timeslot.

        Args:
            timeslot (Timeslot):    The current timeslot
295
296
        """
        if timeslot and self.previous_timeslot:
297
            if self.previous_timeslot.get("timeslot_start") == timeslot.timeslot_start:
298
299
300
                return # Avoid overwrite by multiple calls in a row

        data = {}
David Trattnig's avatar
David Trattnig committed
301
        next_timeslot = self.engine.scheduler.get_programme().get_next_timeslots(1)
302
        if next_timeslot:
David Trattnig's avatar
David Trattnig committed
303
304
305
            next_timeslot = next_timeslot[0]
        else:
            next_timeslot = None
306

David Trattnig's avatar
David Trattnig committed
307
        # A valid timeslot from the scheduler is available
308
        if timeslot:
309
            self.assign_fallback_playlist(data, timeslot)
310
311
            data["timeslot_id"] = timeslot.timeslot_id
            data["timeslot_start"] = timeslot.timeslot_start
312
            data["timeslot_end"] = timeslot.timeslot_end
313
            data["show_id"] = timeslot.show_id
314
            data["show_name"] = timeslot.show_name
315
316
317
            data = SU.clean_dictionary(data)

            # Any previous (fake) timeslots should get the proper end now
318
            if not self.previous_timeslot:
319
                self.current_timeslot["timeslot_end"] = timeslot.timeslot_start
320
            self.previous_timeslot = self.current_timeslot
David Trattnig's avatar
David Trattnig committed
321
            self.current_timeslot = data
322

David Trattnig's avatar
David Trattnig committed
323
        # Defaults for a not existing timeslot
324
        else:
325
            self.init_timeslot(next_timeslot)
326

David Trattnig's avatar
David Trattnig committed
327
        # A valid following timeslot is available
328
        self.next_timeslot = None
329
        if next_timeslot:
330
331
            ns = {}
            self.assign_fallback_playlist(ns, next_timeslot)
332
333
334
            ns["timeslot_id"] = next_timeslot.timeslot_id
            ns["timeslot_start"] = next_timeslot.timeslot_start
            ns["timeslot_end"] = next_timeslot.timeslot_end
335
336
337
338
339
            ns["show_id"] = next_timeslot.show_id
            ns["show_name"] = next_timeslot.show_name
            ns["playlist_id"] = next_timeslot.playlist_id
            ns = SU.clean_dictionary(ns)
            self.next_timeslot = ns
David Trattnig's avatar
David Trattnig committed
340

341
342
343
344


    def assign_fallback_playlist(self, data, timeslot):
        """
345
346
347
348
349
        Assigns fallback info to the given timeslot.

        Args:
            data ({}):              The dictionary holding the (virtual) timeslot
            timeslot (Timeslot):    The actual timeslot object to retrieve fallback info from
350
        """
351
        playlist_type = None
352
353
354
        playlist = None

        if timeslot:
355
            playlist_type, playlist = self.engine.scheduler.resolve_playlist(timeslot)
356
357
358
359
360
361

        if playlist:
            data["playlist_id"] = playlist.playlist_id
        else:
            data["playlist_id"] = -1

362
363
364
365
366
367
368
369
370
371
372
373
        #FIXME "fallback_type" should be a more generic "playout_state"? (compare meta#42)
        #FIXME Add field for "playlist_type", which now differs from playout-state
        #FIXME Remove dependency to "scheduler" and "scheduler.fallback" module
        data["fallback_type"] = 0
        if self.engine.scheduler:
            playout_state = self.engine.scheduler.fallback.get_playout_state()
            data["fallback_type"] = playout_state.id

        # if playlist_type:
        #     data["fallback_type"] = playlist_type.id
        # else:
        #     data["fallback_type"] = FallbackType.STATION.id
374
375
376
377


    def get_timeslots(self):
        """
378
379
380
381
        Retrieves all available timeslots for the past, future and the current one.

        Returns:
            ({}, {}, {})
382
383
384
385
386
387
388
389
        """
        return (self.previous_timeslot, self.current_timeslot, self.next_timeslot)


    def add(self, entry):
        """
        Saves the currently preloaded [`Entry`] to the local cache.
        """
390
        self.history.append(entry)
391
392
393
394
395
396
397
398
399
400
401


    def get_recent_entries(self):
        """
        Retrieves the currently playing [`Entry`] from the local cache.
        """
        return self.history


    def resolve_entry(self, uri):
        """
402
        Retrieves the playlog matching the provided file URI.
403
404
405
406
407
408
409
410
411
412
413
414
415
416

        Args:
            path (String):    The URI of the resource
        """
        result = None
        entries = self.get_recent_entries()
        if not entries:
            return None

        for entry in entries:
            if entry:
                entry_source = entry.source

                if entry.get_content_type() in ResourceClass.FILE.types:
417
                    base_dir = self.config.abs_audio_store_path()
418
                    extension = self.config.get("audio_source_extension")
419
                    entry_source = ResourceUtil.source_to_filepath(base_dir, entry.source, extension)
420
421
422
423
424
425
426
                if entry_source == uri:
                    self.logger.info("Resolved '%s' entry '%s' for URI '%s'" % (entry.get_content_type(), entry, uri))
                    result = entry
                    break

        if not result:
            msg = "Found no entry in the recent history which matches the given source '%s'" % (uri)
David Trattnig's avatar
David Trattnig committed
427
            self.logger.info(msg)
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444

        return result


    def print_entry_history(self):
        """
        Prints all recents entries of the history.
        """
        msg = "Active entry history:\n"
        for entries in self.history:
            msg += "["
            for e in entries:
                msg += "\n" + str(e)
            msg += "]"
        self.logger.info(msg)