engine.py 38 KB
Newer Older
1
#
David Trattnig's avatar
David Trattnig committed
2
# Aura Engine (https://gitlab.servus.at/aura/engine)
3
#
David Trattnig's avatar
David Trattnig committed
4
# Copyright (C) 2017-2020 - The Aura Engine Team.
5
#
David Trattnig's avatar
David Trattnig committed
6
7
8
9
# 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.
10
#
David Trattnig's avatar
David Trattnig committed
11
12
13
14
# 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.
15
#
David Trattnig's avatar
David Trattnig committed
16
17
18
# 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/>.

19

20
21
import time
import logging
22
import json
23

David Trattnig's avatar
David Trattnig committed
24
25
26
from urllib.parse               import urlparse, ParseResult
from contextlib                 import suppress
from threading                  import Thread
David Trattnig's avatar
David Trattnig committed
27
28
29
30

from modules.base.enum          import ChannelType, Channel, TransitionType, LiquidsoapResponse, EntryPlayState
from modules.base.utils         import TerminalColors, SimpleUtil, EngineUtil
from modules.base.exceptions    import LQConnectionError, InvalidChannelException, NoActiveEntryException, EngineMalfunctionException, LQStreamException, LoadSourceException
31
from modules.communication.liquidsoap.playerclient import LiquidSoapPlayerClient
32
# from modules.communication.liquidsoap.recorderclient import LiquidSoapRecorderClient
David Trattnig's avatar
David Trattnig committed
33
34
35
from modules.core.startup       import StartupThread
from modules.core.state         import PlayerStateService
from modules.core.monitor       import Monitoring
36
from modules.communication.mail import AuraMailer
37

38

David Trattnig's avatar
David Trattnig committed
39
class SoundSystem():
40
    """ 
David Trattnig's avatar
David Trattnig committed
41
    SoundSystem Class
42
43

    Uses LiquidSoapClient, but introduces more complex commands, transactions and error handling.
44
    """
45
    client = None
46
    logger = None
47
    transaction = 0
48
    channels = None
49
    scheduler = None
David Trattnig's avatar
David Trattnig committed
50
    monitoring = None
51
    auramailer = None
52
    is_liquidsoap_running = False
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
53
    connection_attempts = 0
54
    disable_logging = False
55
56
    fade_in_active = False
    fade_out_active = False
57

58
59
60
61
62
63
    # Active Channel & Entry Handling
    active_channel_type = None
    active_channel = None
    player_state = None


64
    def __init__(self, config):
65
        """
David Trattnig's avatar
David Trattnig committed
66
        Initializes the sound-system by establishing a Socket connection
67
68
69
70
        to Liquidsoap.

        Args:
            config (AuraConfig):    The configuration
71
        """
72
        self.config = config
73
74
        self.logger = logging.getLogger("AuraEngine")

75
76
        self.client = LiquidSoapPlayerClient(config, "engine.sock")
        # self.lqcr = LiquidSoapRecorderClient(config, "record.sock")
77
        self.auramailer = AuraMailer(self.config)
78
79
        self.monitoring = Monitoring(config, self, self.auramailer)
        
David Trattnig's avatar
David Trattnig committed
80
        self.is_active()
81

82
83
84
        # Initialize Default Channels
        self.active_channel = {
            ChannelType.FILESYSTEM: Channel.FILESYSTEM_A,
David Trattnig's avatar
David Trattnig committed
85
            ChannelType.HTTP: Channel.HTTP_A,
David Trattnig's avatar
David Trattnig committed
86
            ChannelType.HTTPS: Channel.HTTPS_A,
87
88
89
90
91
92
93
94
95
            ChannelType.LIVE: Channel.LIVE_0
        }
        # self.active_entries = {}
        self.player_state = PlayerStateService(config)



    def start(self):
        """
David Trattnig's avatar
David Trattnig committed
96
        Starts the sound-system.
97
98
99
100
101
102
        """
        # Sleep needed, because the socket is created too slowly by Liquidsoap
        time.sleep(1)
        self.enable_transaction()
        time.sleep(1)

David Trattnig's avatar
David Trattnig committed
103
104
105
106
        # Initialize all channels
        channels = self.mixer_channels_reload()
        for c in channels:
            self.channel_volume(c, "0")
107
108

        # Setting init params like a blank file
David Trattnig's avatar
David Trattnig committed
109
110
111
        # install_dir = self.config.get("install_dir")
        # channel = self.active_channel[ChannelType.FILESYSTEM]
        # self.playlist_push(channel, install_dir + "/configuration/blank.flac")
112
113
114
115
116

        self.disable_transaction()
        self.is_liquidsoap_running = True
        self.logger.info(SimpleUtil.green("Engine Core ------[ connected ]-------- Liquidsoap"))

David Trattnig's avatar
David Trattnig committed
117
118
119
120
121
122
123
124
125
126
127
        # Start Monitoring
        is_valid = self.monitoring.has_valid_status(False)
        status = self.monitoring.get_status()
        self.logger.info("Status Monitor:\n%s" % json.dumps(status, indent=4))
        if not is_valid:
            self.logger.info("Engine Status: " + SimpleUtil.red(status["engine"]["status"]))
            raise EngineMalfunctionException
        else:
            self.logger.info("Engine Status: " + SimpleUtil.green("OK"))


128
129
130

    def is_ready(self):
        """
131
        Returns `True` if the soundsystem is connected to Liquidsoap and is ready to be used.
132
133
134
        """
        return self.is_liquidsoap_running

135

David Trattnig's avatar
David Trattnig committed
136

137
    #
138
139
140
141
    #   MIXER : GENERAL
    # 


David Trattnig's avatar
David Trattnig committed
142
143
144
145
146
147
    def mixer_status(self):
        """
        Returns the state of all mixer channels
        """
        cnt = 0
        inputstate = {}
148

David Trattnig's avatar
David Trattnig committed
149
150
        self.enable_transaction()
        inputs = self.mixer_channels()
151

David Trattnig's avatar
David Trattnig committed
152
153
154
        for input in inputs:
            inputstate[input] = self.channel_status(cnt)
            cnt = cnt + 1
155

David Trattnig's avatar
David Trattnig committed
156
157
        self.disable_transaction()
        return inputstate
David Trattnig's avatar
David Trattnig committed
158

David Trattnig's avatar
David Trattnig committed
159
160

    def mixer_channels(self):
161
        """
David Trattnig's avatar
David Trattnig committed
162
        Retrieves all mixer channels
163
        """
David Trattnig's avatar
David Trattnig committed
164
165
        if self.channels is None or len(self.channels) == 0:
            self.channels = self.__send_lqc_command__(self.client, "mixer", "inputs")
166

David Trattnig's avatar
David Trattnig committed
167
        return self.channels
168
169


David Trattnig's avatar
David Trattnig committed
170
171
172
173
    def mixer_channels_selected(self):
        """
        Retrieves all selected channels of the mixer.
        """
174
        cnt = 0
David Trattnig's avatar
David Trattnig committed
175
        activeinputs = []
176

David Trattnig's avatar
David Trattnig committed
177
178
179
180
181
        self.enable_transaction()
        inputs = self.mixer_channels()

        for input in inputs:
            status = self.channel_status(cnt)
182
183
184
185
186
187
188
            if "selected=true" in status:
                activeinputs.append(input)
            cnt = cnt + 1

        self.disable_transaction()

        return activeinputs
189

190

David Trattnig's avatar
David Trattnig committed
191
192
193
194
195
196
197
198
199
200
201
    def mixer_channels_except(self, input_type):
        """
        Retrieves all mixer channels except the ones of the given type.
        """
        try:
            activemixer_copy = self.mixer_channels().copy()
            activemixer_copy.remove(input_type)
        except ValueError as e:
            self.logger.error("Requested channel (" + input_type + ") not in channellist. Reason: " + str(e))
        except AttributeError:
            self.logger.critical("Channellist is None")
202

David Trattnig's avatar
David Trattnig committed
203
        return activemixer_copy
204
205


David Trattnig's avatar
David Trattnig committed
206
207
208
209
210
211
    def mixer_channels_reload(self):
        """
        Reloads all mixer channels. 
        """
        self.channels = None
        return self.mixer_channels()
212
213
214
215
216



    # ------------------------------------------------------------------------------------------ #
    def get_mixer_volume(self, channel):
David Trattnig's avatar
David Trattnig committed
217
        # FIXME Is this needed; even possible?
218
219
        return False

David Trattnig's avatar
David Trattnig committed
220

221
222
223


    #
David Trattnig's avatar
David Trattnig committed
224
    #   MIXER : CONTROL SECTION
225
226
227
    #


David Trattnig's avatar
David Trattnig committed
228
    def preroll(self, entry):
David Trattnig's avatar
David Trattnig committed
229
        """
David Trattnig's avatar
David Trattnig committed
230
231
232
233
234
        Pre-Rolls/Pre-Loads the entry. This is required before the actual `play(..)` can happen.

        Be aware when using this method to queue a very short entry (shorter than ``) this may
        result in sitations with incorrect timing. In this case bundle multiple short entries as
        one queue using `preroll_playlist(self, entries)`.
235

David Trattnig's avatar
David Trattnig committed
236
237
238
239
240
241
        It's important to note, that his method is blocking until loading has finished. If this 
        method is called asynchronously, the progress on the preloading state can be looked up in 
        `entry.state`.

        Args:
            entries ([Entry]):    An array holding filesystem entries
David Trattnig's avatar
David Trattnig committed
242
243
244
        """
        entry.status = EntryPlayState.LOADING
        self.logger.info("Loading entry '%s'" % entry)
245
        is_ready = False
246

David Trattnig's avatar
David Trattnig committed
247
        # LIVE
248
        if entry.get_type() == ChannelType.LIVE:
249
            entry.channel = "linein_" + entry.source.split("line://")[1]
250
            is_ready = True
251
        else:
David Trattnig's avatar
David Trattnig committed
252
            # Choose and save the input channel
253
            entry.previous_channel, entry.channel = self.channel_swap(entry.get_type())
254

David Trattnig's avatar
David Trattnig committed
255
        # PLAYLIST
256
        if entry.get_type() == ChannelType.FILESYSTEM:
257
            is_ready = self.playlist_push(entry.channel, entry.source)
David Trattnig's avatar
David Trattnig committed
258
259
            
        # STREAM
260
        elif entry.get_type() == ChannelType.HTTP or entry.get_type() == ChannelType.HTTPS:
261
            is_ready = self.stream_load_entry(entry)
David Trattnig's avatar
David Trattnig committed
262

263
264
        if is_ready == True:
            entry.status = EntryPlayState.READY
265

David Trattnig's avatar
David Trattnig committed
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
        # Store in play-log cache for later reference
        self.player_state.add_to_history([entry])



    def preroll_group(self, entries):
        """
        Pre-Rolls/Pre-Loads multiple filesystem entries at once. This call is required before the 
        actual `play(..)` can happen. Due to their nature, non-filesystem entries cannot be queued
        using this method. In this case use `preroll(self, entry)` instead. This method also allows
        queuing of very short files, such as jingles.

        It's important to note, that his method is blocking until loading has finished. If this 
        method is called asynchronously, the progress on the preloading state can be looked up in 
        `entry.state`.

        Args:
            entries ([Entry]):    An array holding filesystem entries
        """
        channel = None

        # Validate entry type
        for entry in entries:
289
            if entry.get_type() != ChannelType.FILESYSTEM:
David Trattnig's avatar
David Trattnig committed
290
291
292
                raise InvalidChannelException
        
        # Determine channel
293
        channel = self.channel_swap(entry.get_type())
David Trattnig's avatar
David Trattnig committed
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309

        # Queue entries
        for entry in entries:
            entry.status = EntryPlayState.LOADING
            self.logger.info("Loading entry '%s'" % entry)

            # Choose and save the input channel
            entry.previous_channel, entry.channel = channel

            if self.playlist_push(entry.channel, entry.source) == True:
                entry.status = EntryPlayState.READY
        
        # Store in play-log cache for later reference
        self.player_state.add_to_history(entries)


310
311

    def play(self, entry, transition):
David Trattnig's avatar
David Trattnig committed
312
        """
313
314
        Plays a new `Entry`. In case of a new schedule (or some intented, immediate transition),
        a clean channel is selected and transitions between old and new channel is performed.
David Trattnig's avatar
David Trattnig committed
315

David Trattnig's avatar
David Trattnig committed
316
317
318
        This method expects that the entry is pre-loaded using `preroll(..)` or `preroll_group(self, entries)`
        before being played. In case the pre-roll has happened for a group of entries, only the 
        first entry of the group needs to be passed.
David Trattnig's avatar
David Trattnig committed
319

David Trattnig's avatar
David Trattnig committed
320
        Args:
David Trattnig's avatar
David Trattnig committed
321
            entry (PlaylistEntry):          The audio source to be played
David Trattnig's avatar
David Trattnig committed
322
            transition (TransitionType):    The type of transition to use e.g. fade-in or instant volume level.
David Trattnig's avatar
David Trattnig committed
323
            queue (Boolean):                If `True` the entry is queued if the `ChannelType` does allow so; 
324
                otherwise a new channel of the same type is activated
David Trattnig's avatar
David Trattnig committed
325
326
        
        """
David Trattnig's avatar
David Trattnig committed
327
328
329
        with suppress(LQConnectionError):

            # Instant activation or fade-in
David Trattnig's avatar
David Trattnig committed
330
            self.enable_transaction()
331
            if transition == TransitionType.FADE:
David Trattnig's avatar
David Trattnig committed
332
                self.channel_select(entry.channel.value, True)
333
334
                self.fade_in(entry)
            else:
David Trattnig's avatar
David Trattnig committed
335
                self.channel_activate(entry.channel.value, True)
David Trattnig's avatar
David Trattnig committed
336
            self.disable_transaction()
337
338

            # Update active channel and type
339
            self.active_channel[entry.get_type()] = entry.channel     
David Trattnig's avatar
David Trattnig committed
340
341
342
343
344
345
346
347
348
349
350
351
352

            # Dear filesystem channels, please leave the room as you would like to find it!
            if entry.previous_channel and entry.previous_channel in ChannelType.FILESYSTEM.channels:
                def clean_up():
                    # Wait a little, if there is some long fade-out. Note, this also means,
                    # this channel should not be used for at least some seconds (including clearing time).
                    time.sleep(2)
                    self.enable_transaction()
                    self.channel_activate(entry.previous_channel.value, False)
                    res = self.playlist_clear(entry.previous_channel)
                    self.logger.info("Clear Queue Response: "+res)
                    self.disable_transaction()
                Thread(target=clean_up).start()
353

David Trattnig's avatar
David Trattnig committed
354

355
356
357

    def on_play(self, source):
        """
David Trattnig's avatar
David Trattnig committed
358
        Event Handler which is called by the soundsystem implementation (i.e. Liquidsoap) 
359
        when some entry is actually playing.
David Trattnig's avatar
David Trattnig committed
360
361
362

        Args:
            source (String):    The URI of the media source currently being played
363
364
365
366
367
368
369
370
371
372
373
        """
        self.logger.info(SimpleUtil.pink("Source '%s' started playing" % source))

        try:
            self.player_state.store_trackservice_entry(source)
        except NoActiveEntryException:
            self.logger.warn(SimpleUtil.red("Currently there's nothing playing!"))



    def stop(self, entry, transition):
David Trattnig's avatar
David Trattnig committed
374
        """
375
        Stops the currently playing entry. 
David Trattnig's avatar
David Trattnig committed
376
377

        Args:
378
379
            entry (Entry):                  The entry to stop playing
            transition (TransitionType):    The type of transition to use e.g. fade-out.
David Trattnig's avatar
David Trattnig committed
380
        """
David Trattnig's avatar
David Trattnig committed
381
        with suppress(LQConnectionError):
382
            self.enable_transaction()
383

384
            if not entry.channel:
David Trattnig's avatar
David Trattnig committed
385
                self.logger.warn(SimpleUtil.red("Trying to stop entry %s, but it has no channel assigned" % entry))
386
387
388
389
390
391
                return
            
            if transition == TransitionType.FADE:
                self.fade_out(entry)
            else:
                self.channel_volume(entry.channel, 0)
392

393
394
            self.logger.info(SimpleUtil.pink("Stopped channel '%s' for entry %s" % (entry.channel, entry)))
            self.disable_transaction()
395

396

David Trattnig's avatar
David Trattnig committed
397

398

David Trattnig's avatar
David Trattnig committed
399
400
401
402
403
    #
    #   MIXER : CHANNEL
    #


404
    def channel_swap(self, channel_type):
David Trattnig's avatar
David Trattnig committed
405
406
407
408
409
410
411
412
        """
        Returns the currently in-active channel for a given type. For example if the currently some
        file on channel FILESYSTEM_A is playing, the channel FILESYSTEM B is returned for being used
        to queue new entries.

        Args:
            channel_type (ChannelType): The channel type such es filesystem, stream or live channel
        """
David Trattnig's avatar
David Trattnig committed
413
414
        previous_channel = self.active_channel[channel_type]
        new_channel = None
415
416
417
        msg = None

        if channel_type == ChannelType.FILESYSTEM:
David Trattnig's avatar
David Trattnig committed
418
419
            if previous_channel == Channel.FILESYSTEM_A:
                new_channel = Channel.FILESYSTEM_B
420
421
                msg = "Swapped filesystem channel from A > B"
            else:
David Trattnig's avatar
David Trattnig committed
422
                new_channel = Channel.FILESYSTEM_A
423
424
                msg = "Swapped filesystem channel from B > A"
            
David Trattnig's avatar
David Trattnig committed
425
        elif channel_type == ChannelType.HTTP:
David Trattnig's avatar
David Trattnig committed
426
427
            if previous_channel == Channel.HTTP_A:
                new_channel = Channel.HTTP_B
David Trattnig's avatar
David Trattnig committed
428
                msg = "Swapped HTTP Stream channel from A > B"
429
            else:
David Trattnig's avatar
David Trattnig committed
430
                new_channel = Channel.HTTP_A
David Trattnig's avatar
David Trattnig committed
431
432
433
                msg = "Swapped HTTP Stream channel from B > A"

        elif channel_type == ChannelType.HTTPS:
David Trattnig's avatar
David Trattnig committed
434
435
            if previous_channel == Channel.HTTPS_A:
                new_channel = Channel.HTTPS_B
David Trattnig's avatar
David Trattnig committed
436
437
                msg = "Swapped HTTPS Stream channel from A > B"
            else:
David Trattnig's avatar
David Trattnig committed
438
                new_channel = Channel.HTTPS_A
David Trattnig's avatar
David Trattnig committed
439
                msg = "Swapped HTTPS Stream channel from B > A"
David Trattnig's avatar
David Trattnig committed
440
            
David Trattnig's avatar
David Trattnig committed
441
442
        if msg: self.logger.info(SimpleUtil.pink(msg))
        return (previous_channel, new_channel)
443

David Trattnig's avatar
David Trattnig committed
444

445

David Trattnig's avatar
David Trattnig committed
446
447
448
449
450
    def channel_status(self, channel_number):
        """
        Retrieves the status of a channel identified by the channel number.
        """
        return self.__send_lqc_command__(self.client, "mixer", "status", channel_number)
451
452


453

David Trattnig's avatar
David Trattnig committed
454
455
456
    def channel_select(self, channel, select):
        """
        Selects/deselects some mixer channel
457

David Trattnig's avatar
David Trattnig committed
458
459
460
        Args:
            pos (Integer): The channel number
            select (Boolean): Select or deselect
461

David Trattnig's avatar
David Trattnig committed
462
463
464
465
        Returns:
            (String):   Liquidsoap server response
        """
        channels = self.mixer_channels()
466

David Trattnig's avatar
David Trattnig committed
467
468
469
470
471
472
473
474
475
        try:
            index = channels.index(channel)
            if len(channel) < 1:
                self.logger.critical("Cannot select channel. There are no channels!")
            else:
                message = self.__send_lqc_command__(self.client, "mixer", "select", index, select)
                return message
        except Exception as e:
            self.logger.critical("Ran into exception when selecting channel. Reason: " + str(e))
476

477

478

479
    def channel_activate(self, channel, activate):
David Trattnig's avatar
David Trattnig committed
480
481
482
483
484
485
486
487
488
489
490
491
492
        """
        Combined call of following to save execution time:
          - Select some mixer channel
          - Increase the volume to 100, 

        Args:
            pos (Integer):  The channel number
            activate (Boolean): Activate or deactivate

        Returns:
            (String):   Liquidsoap server response
        """
        channels = self.mixer_channels()
493
494
495
496

        try:
            index = channels.index(channel)
            if len(channel) < 1:
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
497
498
                self.logger.critical("Cannot activate channel. There are no channels!")
            else:
David Trattnig's avatar
David Trattnig committed
499
                message = self.__send_lqc_command__(self.client, "mixer", "activate", index, activate)
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
500
501
502
                return message
        except Exception as e:
            self.logger.critical("Ran into exception when activating channel. Reason: " + str(e))
503

David Trattnig's avatar
David Trattnig committed
504
505


506
507
    def channel_volume(self, channel, volume):
        """
508
509
510
511
512
        Set volume of a channel

        Args:
            channel (Channel):      The channel
            volume  (Integer)       Volume between 0 and 100
513
        """
514
        channel = str(channel)
515
        try:
516
            if str(volume) == "100":
David Trattnig's avatar
David Trattnig committed
517
                channels = self.mixer_channels()
518
519
                index = channels.index(channel)
            else:
David Trattnig's avatar
David Trattnig committed
520
                channels = self.mixer_channels()
521
                index = channels.index(channel)
522
        except ValueError as e:
523
524
            msg = SimpleUtil.red("Cannot set volume of channel " + channel + " to " + str(volume) + "!. Reason: " + str(e))
            self.logger.error(msg)
David Trattnig's avatar
David Trattnig committed
525
            self.logger.info("Available channels: %s" % str(channels))
526
527
528
            return

        try:
529
            if len(channel) < 1:
530
531
                msg = SimpleUtil.red("Cannot set volume of channel " + channel + " to " + str(volume) + "! There are no channels.")
                self.logger.warning(msg)
532
            else:
533
                message = self.__send_lqc_command__(self.client, "mixer", "volume", str(index), str(int(volume)))
534

535
536
                if not self.disable_logging:
                    if message.find('volume=' + str(volume) + '%'):
537
                        self.logger.info(SimpleUtil.pink("Set volume of channel '%s' to %s" % (channel, str(volume))))
538
                    else:
539
540
                        msg = SimpleUtil.red("Setting volume of channel " + channel + " gone wrong! Liquidsoap message: " + message)
                        self.logger.warning(msg)
541

542
                return message
543
        except AttributeError as e: #(LQConnectionError, AttributeError):
544
            self.disable_transaction(force=True)
545
546
            msg = SimpleUtil.red("Ran into exception when setting volume of channel " + channel + ". Reason: " + str(e))
            self.logger.error(msg)
547

548
549

    #
550
    #   Channel Type - Stream 
551
552
    #

David Trattnig's avatar
David Trattnig committed
553
554
555
556

    def stream_load_entry(self, entry):
        """
        Loads the given stream entry and updates the entries's status codes.
David Trattnig's avatar
Docs.    
David Trattnig committed
557
558
559

        Args:
            entry (Entry):  The entry to be pre-loaded
David Trattnig's avatar
David Trattnig committed
560

David Trattnig's avatar
Docs.    
David Trattnig committed
561
562
        Returns:
            (Boolean):  `True` if successfull
David Trattnig's avatar
David Trattnig committed
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
        """
        self.stream_load(entry.channel, entry.source)
        time.sleep(1)

        retry_delay = self.config.get("input_stream_retry_delay") 
        max_retries =  self.config.get("input_stream_max_retries")
        retries = 0

        while not self.stream_is_ready(entry.channel, entry.source):
            self.logger.info("Loading Stream ...")
            if retries >= max_retries:
                raise LoadSourceException("Could not connect to stream while waiting for %s seconds!" % retries*retry_delay)
            time.sleep(retry_delay)
            retries += 1

578
        return True
David Trattnig's avatar
David Trattnig committed
579
580
581
582



    def stream_load(self, channel, url):
David Trattnig's avatar
David Trattnig committed
583
        """
David Trattnig's avatar
David Trattnig committed
584
585
        Preloads the stream URL on the given channel. Note this method is blocking
        some serious amount of time; hence it's worth being called asynchroneously.
David Trattnig's avatar
David Trattnig committed
586
587
588
589
590
591
592

        Args:
            channel (Channel): The stream channel
            uri (String):      The stream URL

        Returns:
            (Boolean):  `True` if successful
David Trattnig's avatar
David Trattnig committed
593
594
        """
        result = None
595

David Trattnig's avatar
David Trattnig committed
596
        self.enable_transaction()
David Trattnig's avatar
David Trattnig committed
597
        result = self.__send_lqc_command__(self.client, channel, "stream_stop")
David Trattnig's avatar
David Trattnig committed
598
599
        
        if result != LiquidsoapResponse.SUCCESS.value:
David Trattnig's avatar
David Trattnig committed
600
            self.logger.error("%s.stop result: %s" % (channel, result))
David Trattnig's avatar
David Trattnig committed
601
            raise LQStreamException("Error while stopping stream!")
602

David Trattnig's avatar
David Trattnig committed
603
        result = self.__send_lqc_command__(self.client, channel, "stream_set_url", url)
604

David Trattnig's avatar
David Trattnig committed
605
        if result != LiquidsoapResponse.SUCCESS.value:
David Trattnig's avatar
David Trattnig committed
606
            self.logger.error("%s.set_url result: %s" % (channel, result))
David Trattnig's avatar
David Trattnig committed
607
            raise LQStreamException("Error while setting stream URL!")
608

David Trattnig's avatar
David Trattnig committed
609
610
        # Liquidsoap ignores commands sent without a certain timeout
        time.sleep(2)
611

David Trattnig's avatar
David Trattnig committed
612
613
        result = self.__send_lqc_command__(self.client, channel, "stream_start")
        self.logger.info("%s.start result: %s" % (channel, result))
614

David Trattnig's avatar
David Trattnig committed
615
616
617
618
619
        self.disable_transaction()
        return result



David Trattnig's avatar
David Trattnig committed
620
    def stream_is_ready(self, channel, url):
David Trattnig's avatar
David Trattnig committed
621
        """
David Trattnig's avatar
David Trattnig committed
622
623
        Checks if the stream on the given channel is ready to play. Note this method is blocking
        some serious amount of time even when successfull; hence it's worth being called asynchroneously.
David Trattnig's avatar
David Trattnig committed
624
625
626
627
628
629
630

        Args:
            channel (Channel): The stream channel
            uri (String):      The stream URL

        Returns:
            (Boolean):  `True` if successful
David Trattnig's avatar
David Trattnig committed
631
632
633
634
635
        """
        result = None

        self.enable_transaction()

David Trattnig's avatar
David Trattnig committed
636
637
        result = self.__send_lqc_command__(self.client, channel, "stream_status")
        self.logger.info("%s.status result: %s" % (channel, result))
David Trattnig's avatar
David Trattnig committed
638
639
640
641
642
643
644
645
646
647
648

        if not result.startswith(LiquidsoapResponse.STREAM_STATUS_CONNECTED.value):
            return False

        lqs_url = result.split(" ")[1]
        if not url == lqs_url:
            self.logger.error("Wrong URL '%s' set for channel '%s', expected: '%s'." % (lqs_url, channel, url))
            return False

        self.disable_transaction()

David Trattnig's avatar
David Trattnig committed
649
650
651
        stream_buffer = self.config.get("input_stream_buffer")
        self.logger.info("Ready to play stream, but wait %s seconds until the buffer is filled..." % str(stream_buffer))
        time.sleep(round(float(stream_buffer)))
David Trattnig's avatar
David Trattnig committed
652
        return True
653
654


David Trattnig's avatar
David Trattnig committed
655

656

657
    #
David Trattnig's avatar
David Trattnig committed
658
    #   Channel Type - Filesystem 
659
    #
David Trattnig's avatar
David Trattnig committed
660

661
662


663
664
665
    def playlist_push(self, channel, uri):
        """
        Adds an filesystem URI to the given `ChannelType.FILESYSTEM` channel.
666

667
        Args:
David Trattnig's avatar
David Trattnig committed
668
669
670
            channel (Channel): The channel to push the file to
            uri (String):      The URI of the file

671
        Returns:
David Trattnig's avatar
David Trattnig committed
672
            (Boolean):  `True` if successful
673
674
675
676
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
        self.logger.info(SimpleUtil.pink("playlist.push('%s', '%s'" % (channel, uri)))
677
678

        self.enable_transaction()
679
680
681
        audio_store = self.config.get("audiofolder")
        filepath = EngineUtil.uri_to_filepath(audio_store, uri)
        result = self.__send_lqc_command__(self.client, channel, "playlist_push", filepath)
682
683
684
        self.logger.info("%s.playlist_push result: %s" % (channel, result))
        self.disable_transaction()

David Trattnig's avatar
David Trattnig committed
685
686
        # If successful, Liquidsoap returns a resource ID of the queued track
        return int(result) >= 0
687
688
689



690
691
692
    def playlist_seek(self, channel, seconds_to_seek):
        """
        Forwards the player of the given `ChannelType.FILESYSTEM` channel by (n) seconds.
693

694
        Args:
David Trattnig's avatar
David Trattnig committed
695
            channel (Channel): The channel to push the file to
696
            seconds_to_seeks (Float):   The seconds to skip
David Trattnig's avatar
David Trattnig committed
697
698
699

        Returns:
            (String):   Liquidsoap response
700
701
702
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
703

704
705
706
707
708
709
        self.enable_transaction()
        result = self.__send_lqc_command__(self.client, channel, "playlist_seek", str(seconds_to_seek))
        self.logger.info("%s.playlist_seek result: %s" % (channel, result))
        self.disable_transaction()

        return result
710
711


David Trattnig's avatar
David Trattnig committed
712

713
714
715
    def playlist_clear(self, channel):
        """
        Removes all tracks currently queued in the given `ChannelType.FILESYSTEM` channel.
David Trattnig's avatar
David Trattnig committed
716
717
718
719
720
721

        Args:
            channel (Channel): The channel to push the file to

        Returns:
            (String):   Liquidsoap response
722
723
724
725
726
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException

        self.logger.info(SimpleUtil.pink("Clearing filesystem queue '%s'!" % channel))
727

728
729
730
731
        self.enable_transaction()
        result = self.__send_lqc_command__(self.client, channel, "playlist_clear")
        self.logger.info("%s.playlist_clear result: %s" % (channel, result))
        self.disable_transaction()
732

733
        return result
734

David Trattnig's avatar
David Trattnig committed
735
736


737
738
739
740
    #
    #   Fading 
    #

741
742
743
744
745

    def fade_in(self, entry):
        """
        Performs a fade-in for the given `entry` to the `entry.volume` loudness
        at channel `entry.channel`.
David Trattnig's avatar
David Trattnig committed
746
747
748
749
750
751

        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
752
        """
753
754
755
756
757
        try:
            fade_in_time = float(self.config.get("fade_in_time"))

            if fade_in_time > 0:
                self.fade_in_active = True
758
                target_volume = entry.volume
759
760
761

                step = fade_in_time / target_volume

762
763
764
                msg = "Starting to fading-in '%s'. Step is %ss and target volume is %s." % \
                    (entry.channel, str(step), str(target_volume))
                self.logger.info(SimpleUtil.pink(msg))
765

766
                # Enable logging, which might have been disabled in a previous fade-out
767
768
769
770
                self.disable_logging = True
                self.client.disable_logging = True

                for i in range(target_volume):
771
                    self.channel_volume(entry.channel.value, i + 1)
772
773
                    time.sleep(step)

774
775
                msg = "Finished with fading-in '%s'." % entry.channel
                self.logger.info(SimpleUtil.pink(msg))
776
777
778
779
780

                self.fade_in_active = False
                if not self.fade_out_active:
                    self.disable_logging = False
                    self.client.disable_logging = False
781

782
783
784
785
786
        except LQConnectionError as e:
            self.logger.critical(str(e))

        return True

787
788
789
790
791


    def fade_out(self, entry):
        """
        Performs a fade-out for the given `entry` at channel `entry.channel`.
David Trattnig's avatar
David Trattnig committed
792
793
794
795
796
797
        
        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
798
        """
799
800
801
802
        try:
            fade_out_time = float(self.config.get("fade_out_time"))

            if fade_out_time > 0:
803
                step = abs(fade_out_time) / entry.volume
804

805
806
                msg = "Starting to fading-out '%s'. Step is %ss." % (entry.channel, str(step))
                self.logger.info(SimpleUtil.pink(msg))
807

808
                # Disable logging... it is going to be enabled again after fadein and -out is finished
809
810
811
                self.disable_logging = True
                self.client.disable_logging = True

812
813
                for i in range(entry.volume):
                    self.channel_volume(entry.channel.value, entry.volume-i-1)
814
815
                    time.sleep(step)

816
817
                msg = "Finished with fading-out '%s'" % entry.channel
                self.logger.info(SimpleUtil.pink(msg))
818

819
                # Enable logging again
820
821
822
823
                self.fade_out_active = False
                if not self.fade_in_active:
                    self.disable_logging = False
                    self.client.disable_logging = False
824

825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
        except LQConnectionError as e:
            self.logger.critical(str(e))

        return True



    #
    #   Recording
    #


    # ------------------------------------------------------------------------------------------ #
    def recorder_stop(self):
        self.enable_transaction()

        for i in range(5):
            if self.config.get("rec_" + str(i)) == "y":
                self.__send_lqc_command__(self.client, "recorder_" + str(i), "stop")

        self.disable_transaction()

    # ------------------------------------------------------------------------------------------ #
    def recorder_start(self, num=-1):
        if not self.is_liquidsoap_running:
            if num==-1:
                msg = "Want to start recorder, but LiquidSoap is not running"
            else:
                msg = "Want to start recorder " + str(num) + ", but LiquidSoap is not running"
            self.logger.warning(msg)
            return False

        self.enable_transaction()

        if num == -1:
            self.recorder_start_all()
        else:
            self.recorder_start_one(num)

        self.disable_transaction()

    # ------------------------------------------------------------------------------------------ #
    def recorder_start_all(self):
        if not self.is_liquidsoap_running:
            self.logger.warning("Want to start all recorder, but LiquidSoap is not running")
            return False

        self.enable_transaction()
        for i in range(5):
            self.recorder_start_one(i)
        self.disable_transaction()

    # ------------------------------------------------------------------------------------------ #
    def recorder_start_one(self, num):
        if not self.is_liquidsoap_running:
            return False

        if self.config.get("rec_" + str(num)) == "y":
            returnvalue = self.__send_lqc_command__(self.client, "recorder", str(num), "status")

            if returnvalue == "off":
                self.__send_lqc_command__(self.client, "recorder", str(num), "start")

    # ------------------------------------------------------------------------------------------ #
    def get_recorder_status(self):
        self.enable_transaction(self.client)
        recorder_state = self.__send_lqc_command__(self.client, "record", "status")
        self.disable_transaction(self.client)

        return recorder_state



    #
    #   Basic Methods
    #


    def init_player(self):
904
        """
905
906
907
908
        Initializes the LiquidSoap Player after startup of the engine.

        Returns:
            (String):   Message that the player is started.
909
        """
910
        t = StartupThread(self)
911
912
        t.start()

913
        return "Engine Core startup done!"
914

915
916

    # ------------------------------------------------------------------------------------------ #
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
917
    def __send_lqc_command__(self, lqs_instance, namespace, command, *args):
918
919
920
921
922
923
924
925
926
927
928
929
930
931
        """
        Ein Kommando an Liquidsoap senden
        @type  lqs_instance: object
        @param lqs_instance: Instance of LiquidSoap Client
        @type  namespace:    string
        @param namespace:    Namespace of function
        @type  command:      string
        @param command:      Function name
        @type args:          list
        @param args:         List of parameters
        @rtype:              string
        @return:             Response from LiquidSoap
        """
        try:
932
933
            if not self.disable_logging:
                if namespace == "recorder":
934
                    self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + "_" + str(command) + "." + str(args))
935
                else:
936
                    if command == "":
937
                        self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + str(args))
938
                    else:
939
                        self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + "." + str(command) + str(args))
940

941
            # call wanted function ...
942
943

            # FIXME REFACTOR all calls in a common way
David Trattnig's avatar
David Trattnig committed
944
            if command in  ["playlist_push", "playlist_seek", "playlist_clear", "stream_set_url", "stream_start", "stream_stop", "stream_status"]:
945
946
947
948
949
950
                func = getattr(lqs_instance, command)
                result = func(str(namespace), *args)
            else:
                func = getattr(lqs_instance, namespace)
                result = func(command, *args)            

951

952
953
            if not self.disable_logging:
                self.logger.debug("LiquidSoapCommunicator got response " + str(result))
954

Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
955
            self.connection_attempts = 0
956

957
958
            return result

959
        except LQConnectionError as e:
960
            self.logger.error("Connection Error when sending " + str(namespace) + "." + str(command) + str(args))
961
            if self.try_to_reconnect():
962
                time.sleep(0.2)
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
963
964
                self.connection_attempts += 1
                if self.connection_attempts < 5:
965
966
967
968
969
970
971
972
973
                    # reconnect
                    self.__open_conn(self.client)
                    self.logger.info("Trying to resend " + str(namespace) + "." + str(command) + str(args))
                    # grab return value
                    retval = self.__send_lqc_command__(lqs_instance, namespace, command, *args)
                    # disconnect
                    self.__close_conn(self.client)
                    # return the val
                    return retval
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
974
                else:
975
976
977
978
979
980
                    if command == "":
                        msg = "Rethrowing Exception while trying to send " + str(namespace) + str(args)
                    else:
                        msg = "Rethrowing Exception while trying to send " + str(namespace) + "." + str(command) + str(args)

                    self.logger.info(msg)
981
                    self.disable_transaction(socket=self.client, force=True)
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
982
                    raise e
983
            else:
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
984
                # also store when was last admin mail sent with which content...
David Trattnig's avatar
David Trattnig committed
985
                # FIXME implement admin mail sending
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
986
                self.logger.critical("SEND ADMIN MAIL AT THIS POINT")
987
988
                raise e

David Trattnig's avatar
David Trattnig committed
989
990
991
992
993

    def is_active(self):
        """
        Checks if Liquidsoap is running
        """
994
995
996
997
998
999
1000
        try:
            self.uptime()
            self.is_liquidsoap_running = True
        except LQConnectionError as e:
            self.logger.info("Liquidsoap is not running so far")
            self.is_liquidsoap_running = False
        except Exception as e: