engine.py 37 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

David Trattnig's avatar
David Trattnig committed
23
24
from contextlib                 import suppress
from threading                  import Thread
David Trattnig's avatar
David Trattnig committed
25

26
27
28
import meta

from modules.base.utils         import TerminalColors, SimpleUtil as SU, EngineUtil
David Trattnig's avatar
David Trattnig committed
29
30
from modules.base.exceptions    import LQConnectionError, InvalidChannelException, LQStreamException, LoadSourceException
from modules.core.channels      import ChannelType, Channel, TransitionType, LiquidsoapResponse, EntryPlayState
David Trattnig's avatar
David Trattnig committed
31
from modules.core.startup       import StartupThread
32
from modules.core.events        import EngineEventDispatcher
David Trattnig's avatar
David Trattnig committed
33
34
from modules.core.liquidsoap.playerclient import LiquidSoapPlayerClient
# from modules.core.liquidsoap.recorderclient import LiquidSoapRecorderClient
35

36

David Trattnig's avatar
David Trattnig committed
37
class SoundSystem():
38
39
    """
    The Soundsystem Mixer Control.
40

41
42
43
44
    This class represents a virtual mixer as an abstraction layer to the actual audio hardware.
    It uses LiquidSoapClient, but introduces more complex commands, transactions and error handling.

    From one layer above it is used by `AuraScheduler` as if a virtual DJ is remote controling the mixer.
45
    """
46
    client = None
47
    logger = None
48
    transaction = 0
49
    channels = None
50
    scheduler = None
51
    event_dispatcher = 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
    active_channel = None
58
    plugins=None
59

60
    def __init__(self, config):
61
        """
David Trattnig's avatar
David Trattnig committed
62
        Initializes the sound-system by establishing a Socket connection
63
64
65
66
        to Liquidsoap.

        Args:
            config (AuraConfig):    The configuration
67
        """
68
        self.config = config
69
        self.logger = logging.getLogger("AuraEngine")
70
71
        self.client = LiquidSoapPlayerClient(config, "engine.sock")
        # self.lqcr = LiquidSoapRecorderClient(config, "record.sock")
72
73
        self.is_active() # TODO Check if it makes sense to move it to the boot-phase
        self.plugins = dict()
74
        
75
76
77

    def start(self):
        """
78
79
        Starts the engine. Called when the connection to the sound-system implementation
        has been established.
80
        """
81
82
        self.event_dispatcher = EngineEventDispatcher(self, self.scheduler)

83
84
        # Sleep needed, because the socket is created too slowly by Liquidsoap
        time.sleep(1)
85
        self.mixer_initialize()
86
        self.is_liquidsoap_running = True
87
88
89
90
91
        self.event_dispatcher.on_initialized()
        self.logger.info(SU.green("Engine Core ------[ connected ]-------- Liquidsoap"))
        self.event_dispatcher.on_boot()
        self.logger.info(EngineUtil.engine_info("Engine Core", meta.__version__))
        self.event_dispatcher.on_ready()
David Trattnig's avatar
David Trattnig committed
92
93


94
95
96
    #
    #   MIXER : GENERAL
    # 
97

98
99

    def mixer_initialize(self):
100
        """
101
102
            - Pull all faders down to volume 0.
            - Initialize default channels per type
103
        """
104
105
106
107
108
109
        self.enable_transaction()
        time.sleep(1) # TODO Check is this is still required
        channels = self.mixer_channels_reload()
        for channel in channels:
            self.channel_volume(channel, "0")
        self.disable_transaction()
David Trattnig's avatar
David Trattnig committed
110

111
112
113
114
115
116
        self.active_channel = {
            ChannelType.FILESYSTEM: Channel.FILESYSTEM_A,
            ChannelType.HTTP: Channel.HTTP_A,
            ChannelType.HTTPS: Channel.HTTPS_A,
            ChannelType.LIVE: Channel.LIVE_0
        }
117
118


David Trattnig's avatar
David Trattnig committed
119
120
121
122
123
124
    def mixer_status(self):
        """
        Returns the state of all mixer channels
        """
        cnt = 0
        inputstate = {}
125

David Trattnig's avatar
David Trattnig committed
126
127
        self.enable_transaction()
        inputs = self.mixer_channels()
128

129
130
        for channel in inputs:
            inputstate[channel] = self.channel_status(cnt)
David Trattnig's avatar
David Trattnig committed
131
            cnt = cnt + 1
132

David Trattnig's avatar
David Trattnig committed
133
134
        self.disable_transaction()
        return inputstate
David Trattnig's avatar
David Trattnig committed
135

David Trattnig's avatar
David Trattnig committed
136
137

    def mixer_channels(self):
138
        """
David Trattnig's avatar
David Trattnig committed
139
        Retrieves all mixer channels
140
        """
David Trattnig's avatar
David Trattnig committed
141
142
143
        if self.channels is None or len(self.channels) == 0:
            self.channels = self.__send_lqc_command__(self.client, "mixer", "inputs")
        return self.channels
144
145


David Trattnig's avatar
David Trattnig committed
146
147
148
149
    def mixer_channels_selected(self):
        """
        Retrieves all selected channels of the mixer.
        """
150
        cnt = 0
David Trattnig's avatar
David Trattnig committed
151
        activeinputs = []
152

David Trattnig's avatar
David Trattnig committed
153
154
155
        self.enable_transaction()
        inputs = self.mixer_channels()

156
        for channel in inputs:
David Trattnig's avatar
David Trattnig committed
157
            status = self.channel_status(cnt)
158
            if "selected=true" in status:
159
                activeinputs.append(channel)
160
161
162
163
            cnt = cnt + 1

        self.disable_transaction()
        return activeinputs
164

165

David Trattnig's avatar
David Trattnig committed
166
167
168
169
170
171
172
173
    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:
174
            self.logger.error("Requested channel (%s) not in channel-list. Reason: %s" % (input_type, str(e)))
David Trattnig's avatar
David Trattnig committed
175
        except AttributeError:
176
            self.logger.critical("Empty channel list")
177

David Trattnig's avatar
David Trattnig committed
178
        return activemixer_copy
179
180


David Trattnig's avatar
David Trattnig committed
181
182
183
184
185
186
    def mixer_channels_reload(self):
        """
        Reloads all mixer channels. 
        """
        self.channels = None
        return self.mixer_channels()
187
188
189


    #
David Trattnig's avatar
David Trattnig committed
190
    #   MIXER : CONTROL SECTION
191
192
193
    #


David Trattnig's avatar
David Trattnig committed
194
    def preroll(self, entry):
David Trattnig's avatar
David Trattnig committed
195
        """
David Trattnig's avatar
David Trattnig committed
196
197
198
199
200
        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)`.
201

David Trattnig's avatar
David Trattnig committed
202
203
204
205
206
207
        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
208
209
210
        """
        entry.status = EntryPlayState.LOADING
        self.logger.info("Loading entry '%s'" % entry)
211
        is_ready = False
212

David Trattnig's avatar
David Trattnig committed
213
        # LIVE
214
        if entry.get_type() == ChannelType.LIVE:
215
            entry.channel = "linein_" + entry.source.split("line://")[1]
216
            is_ready = True
217
        else:
David Trattnig's avatar
David Trattnig committed
218
            # Choose and save the input channel
219
            entry.previous_channel, entry.channel = self.channel_swap(entry.get_type())
220

David Trattnig's avatar
David Trattnig committed
221
        # PLAYLIST
222
        if entry.get_type() == ChannelType.FILESYSTEM:
223
            is_ready = self.playlist_push(entry.channel, entry.source)
David Trattnig's avatar
David Trattnig committed
224
225
            
        # STREAM
226
        elif entry.get_type() == ChannelType.HTTP or entry.get_type() == ChannelType.HTTPS:
227
            is_ready = self.stream_load_entry(entry)
David Trattnig's avatar
David Trattnig committed
228

229
        if is_ready:
230
            entry.status = EntryPlayState.READY
231

232
        self.event_dispatcher.on_queue([entry])
David Trattnig's avatar
David Trattnig committed
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253



    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:
254
            if entry.get_type() != ChannelType.FILESYSTEM:
David Trattnig's avatar
David Trattnig committed
255
256
257
                raise InvalidChannelException
        
        # Determine channel
258
        channel = self.channel_swap(entries[0].get_type())
David Trattnig's avatar
David Trattnig committed
259
260
261
262
263
264
265
266
267
268
269
270

        # 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
        
271
        self.event_dispatcher.on_queue(entries)
David Trattnig's avatar
David Trattnig committed
272
273


274
275

    def play(self, entry, transition):
David Trattnig's avatar
David Trattnig committed
276
        """
277
278
        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
279

David Trattnig's avatar
David Trattnig committed
280
281
282
        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
283

David Trattnig's avatar
David Trattnig committed
284
        Args:
David Trattnig's avatar
David Trattnig committed
285
            entry (PlaylistEntry):          The audio source to be played
David Trattnig's avatar
David Trattnig committed
286
            transition (TransitionType):    The type of transition to use e.g. fade-in or instant volume level.
David Trattnig's avatar
David Trattnig committed
287
            queue (Boolean):                If `True` the entry is queued if the `ChannelType` does allow so; 
288
                otherwise a new channel of the same type is activated
David Trattnig's avatar
David Trattnig committed
289
290
        
        """
David Trattnig's avatar
David Trattnig committed
291
292
293
        with suppress(LQConnectionError):

            # Instant activation or fade-in
David Trattnig's avatar
David Trattnig committed
294
            self.enable_transaction()
295
            if transition == TransitionType.FADE:
David Trattnig's avatar
David Trattnig committed
296
                self.channel_select(entry.channel.value, True)
297
298
                self.fade_in(entry)
            else:
David Trattnig's avatar
David Trattnig committed
299
                self.channel_activate(entry.channel.value, True)
David Trattnig's avatar
David Trattnig committed
300
            self.disable_transaction()
301
302

            # Update active channel and type
303
            self.active_channel[entry.get_type()] = entry.channel
David Trattnig's avatar
David Trattnig committed
304
305
306
307
308
309
310
311
312
313

            # 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)
314
                    self.logger.info("Clear Queue Response: " + res)
David Trattnig's avatar
David Trattnig committed
315
316
                    self.disable_transaction()
                Thread(target=clean_up).start()
317
318
319
320
            
            # Filesystem meta-changes trigger the event via Liquidsoap
            if not entry.channel in ChannelType.FILESYSTEM.channels:
                self.on_play(entry)
321

David Trattnig's avatar
David Trattnig committed
322

323
324
325

    def on_play(self, source):
        """
326
        Event Handler which is called by the soundsystem implementation (i.e. Liquidsoap)
327
        when some entry is actually playing.
David Trattnig's avatar
David Trattnig committed
328
329

        Args:
330
            source (String):    The `Entry` or URI or of the media source currently being played
331
        """
332
        self.event_dispatcher.on_play(source)
333
334
335
336



    def stop(self, entry, transition):
David Trattnig's avatar
David Trattnig committed
337
        """
338
        Stops the currently playing entry.
David Trattnig's avatar
David Trattnig committed
339
340

        Args:
341
342
            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
343
        """
David Trattnig's avatar
David Trattnig committed
344
        with suppress(LQConnectionError):
345
            self.enable_transaction()
346

347
            if not entry.channel:
348
                self.logger.warn(SU.red("Trying to stop entry %s, but it has no channel assigned" % entry))
349
350
351
352
353
354
                return
            
            if transition == TransitionType.FADE:
                self.fade_out(entry)
            else:
                self.channel_volume(entry.channel, 0)
355

356
            self.logger.info(SU.pink("Stopped channel '%s' for entry %s" % (entry.channel, entry)))
357
            self.disable_transaction()
358
            self.event_dispatcher.on_stop(entry)
359

David Trattnig's avatar
David Trattnig committed
360

361

David Trattnig's avatar
David Trattnig committed
362
363
364
365
366
    #
    #   MIXER : CHANNEL
    #


367
    def channel_swap(self, channel_type):
David Trattnig's avatar
David Trattnig committed
368
369
370
371
372
373
374
375
        """
        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
376
377
        previous_channel = self.active_channel[channel_type]
        new_channel = None
378
379
380
        msg = None

        if channel_type == ChannelType.FILESYSTEM:
David Trattnig's avatar
David Trattnig committed
381
382
            if previous_channel == Channel.FILESYSTEM_A:
                new_channel = Channel.FILESYSTEM_B
383
384
                msg = "Swapped filesystem channel from A > B"
            else:
David Trattnig's avatar
David Trattnig committed
385
                new_channel = Channel.FILESYSTEM_A
386
                msg = "Swapped filesystem channel from B > A"
387

David Trattnig's avatar
David Trattnig committed
388
        elif channel_type == ChannelType.HTTP:
David Trattnig's avatar
David Trattnig committed
389
390
            if previous_channel == Channel.HTTP_A:
                new_channel = Channel.HTTP_B
David Trattnig's avatar
David Trattnig committed
391
                msg = "Swapped HTTP Stream channel from A > B"
392
            else:
David Trattnig's avatar
David Trattnig committed
393
                new_channel = Channel.HTTP_A
David Trattnig's avatar
David Trattnig committed
394
395
396
                msg = "Swapped HTTP Stream channel from B > A"

        elif channel_type == ChannelType.HTTPS:
David Trattnig's avatar
David Trattnig committed
397
398
            if previous_channel == Channel.HTTPS_A:
                new_channel = Channel.HTTPS_B
David Trattnig's avatar
David Trattnig committed
399
400
                msg = "Swapped HTTPS Stream channel from A > B"
            else:
David Trattnig's avatar
David Trattnig committed
401
                new_channel = Channel.HTTPS_A
David Trattnig's avatar
David Trattnig committed
402
                msg = "Swapped HTTPS Stream channel from B > A"
David Trattnig's avatar
David Trattnig committed
403
            
404
        if msg: self.logger.info(SU.pink(msg))
David Trattnig's avatar
David Trattnig committed
405
        return (previous_channel, new_channel)
406

David Trattnig's avatar
David Trattnig committed
407

408

David Trattnig's avatar
David Trattnig committed
409
410
411
412
413
    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)
414
415


416

David Trattnig's avatar
David Trattnig committed
417
418
419
    def channel_select(self, channel, select):
        """
        Selects/deselects some mixer channel
420

David Trattnig's avatar
David Trattnig committed
421
422
423
        Args:
            pos (Integer): The channel number
            select (Boolean): Select or deselect
424

David Trattnig's avatar
David Trattnig committed
425
426
427
428
        Returns:
            (String):   Liquidsoap server response
        """
        channels = self.mixer_channels()
429

David Trattnig's avatar
David Trattnig committed
430
431
432
433
434
435
436
437
438
        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))
439

440

441

442
    def channel_activate(self, channel, activate):
David Trattnig's avatar
David Trattnig committed
443
444
445
446
447
448
449
450
451
452
453
454
455
        """
        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()
456
457
458
459

        try:
            index = channels.index(channel)
            if len(channel) < 1:
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
460
461
                self.logger.critical("Cannot activate channel. There are no channels!")
            else:
David Trattnig's avatar
David Trattnig committed
462
                message = self.__send_lqc_command__(self.client, "mixer", "activate", index, activate)
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
463
464
465
                return message
        except Exception as e:
            self.logger.critical("Ran into exception when activating channel. Reason: " + str(e))
466

David Trattnig's avatar
David Trattnig committed
467
468


469
470
    def channel_volume(self, channel, volume):
        """
471
472
473
474
475
        Set volume of a channel

        Args:
            channel (Channel):      The channel
            volume  (Integer)       Volume between 0 and 100
476
        """
477
        channel = str(channel)
478
        try:
479
            if str(volume) == "100":
David Trattnig's avatar
David Trattnig committed
480
                channels = self.mixer_channels()
481
482
                index = channels.index(channel)
            else:
David Trattnig's avatar
David Trattnig committed
483
                channels = self.mixer_channels()
484
                index = channels.index(channel)
485
        except ValueError as e:
486
            msg = SU.red("Cannot set volume of channel " + channel + " to " + str(volume) + "!. Reason: " + str(e))
487
            self.logger.error(msg)
David Trattnig's avatar
David Trattnig committed
488
            self.logger.info("Available channels: %s" % str(channels))
489
490
491
            return

        try:
492
            if len(channel) < 1:
493
                msg = SU.red("Cannot set volume of channel " + channel + " to " + str(volume) + "! There are no channels.")
494
                self.logger.warning(msg)
495
            else:
496
                message = self.__send_lqc_command__(self.client, "mixer", "volume", str(index), str(int(volume)))
497

498
499
                if not self.disable_logging:
                    if message.find('volume=' + str(volume) + '%'):
500
                        self.logger.info(SU.pink("Set volume of channel '%s' to %s" % (channel, str(volume))))
501
                    else:
502
                        msg = SU.red("Setting volume of channel " + channel + " gone wrong! Liquidsoap message: " + message)
503
                        self.logger.warning(msg)
504

505
                return message
506
        except AttributeError as e: #(LQConnectionError, AttributeError):
507
            self.disable_transaction(force=True)
508
            msg = SU.red("Ran into exception when setting volume of channel " + channel + ". Reason: " + str(e))
509
            self.logger.error(msg)
510

511
512

    #
513
    #   Channel Type - Stream
514
515
    #

David Trattnig's avatar
David Trattnig committed
516
517
518
519

    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
520
521
522

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

David Trattnig's avatar
Docs.    
David Trattnig committed
524
525
        Returns:
            (Boolean):  `True` if successfull
David Trattnig's avatar
David Trattnig committed
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
        """
        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

541
        return True
David Trattnig's avatar
David Trattnig committed
542
543
544
545



    def stream_load(self, channel, url):
David Trattnig's avatar
David Trattnig committed
546
        """
David Trattnig's avatar
David Trattnig committed
547
548
        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
549
550
551
552
553
554
555

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

        Returns:
            (Boolean):  `True` if successful
David Trattnig's avatar
David Trattnig committed
556
557
        """
        result = None
558

David Trattnig's avatar
David Trattnig committed
559
        self.enable_transaction()
David Trattnig's avatar
David Trattnig committed
560
        result = self.__send_lqc_command__(self.client, channel, "stream_stop")
David Trattnig's avatar
David Trattnig committed
561
562
        
        if result != LiquidsoapResponse.SUCCESS.value:
David Trattnig's avatar
David Trattnig committed
563
            self.logger.error("%s.stop result: %s" % (channel, result))
David Trattnig's avatar
David Trattnig committed
564
            raise LQStreamException("Error while stopping stream!")
565

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

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

David Trattnig's avatar
David Trattnig committed
572
573
        # Liquidsoap ignores commands sent without a certain timeout
        time.sleep(2)
574

David Trattnig's avatar
David Trattnig committed
575
576
        result = self.__send_lqc_command__(self.client, channel, "stream_start")
        self.logger.info("%s.start result: %s" % (channel, result))
577

David Trattnig's avatar
David Trattnig committed
578
579
580
581
582
        self.disable_transaction()
        return result



David Trattnig's avatar
David Trattnig committed
583
    def stream_is_ready(self, channel, url):
David Trattnig's avatar
David Trattnig committed
584
        """
David Trattnig's avatar
David Trattnig committed
585
586
        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
587
588
589
590
591
592
593

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

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

        self.enable_transaction()

David Trattnig's avatar
David Trattnig committed
599
600
        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
601
602
603
604
605
606
607
608
609
610
611

        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
612
613
614
        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
615
        return True
616
617


David Trattnig's avatar
David Trattnig committed
618

619

620
    #
David Trattnig's avatar
David Trattnig committed
621
    #   Channel Type - Filesystem 
622
    #
David Trattnig's avatar
David Trattnig committed
623

624
625


626
627
628
    def playlist_push(self, channel, uri):
        """
        Adds an filesystem URI to the given `ChannelType.FILESYSTEM` channel.
629

630
        Args:
David Trattnig's avatar
David Trattnig committed
631
632
633
            channel (Channel): The channel to push the file to
            uri (String):      The URI of the file

634
        Returns:
David Trattnig's avatar
David Trattnig committed
635
            (Boolean):  `True` if successful
636
637
638
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
639
        self.logger.info(SU.pink("playlist.push('%s', '%s'" % (channel, uri)))
640
641

        self.enable_transaction()
642
643
644
        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)
645
646
647
        self.logger.info("%s.playlist_push result: %s" % (channel, result))
        self.disable_transaction()

David Trattnig's avatar
David Trattnig committed
648
649
        # If successful, Liquidsoap returns a resource ID of the queued track
        return int(result) >= 0
650
651
652



653
654
655
    def playlist_seek(self, channel, seconds_to_seek):
        """
        Forwards the player of the given `ChannelType.FILESYSTEM` channel by (n) seconds.
656

657
        Args:
David Trattnig's avatar
David Trattnig committed
658
            channel (Channel): The channel to push the file to
659
            seconds_to_seeks (Float):   The seconds to skip
David Trattnig's avatar
David Trattnig committed
660
661
662

        Returns:
            (String):   Liquidsoap response
663
664
665
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
666

667
668
669
670
671
672
        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
673
674


David Trattnig's avatar
David Trattnig committed
675

676
677
678
    def playlist_clear(self, channel):
        """
        Removes all tracks currently queued in the given `ChannelType.FILESYSTEM` channel.
David Trattnig's avatar
David Trattnig committed
679
680
681
682
683
684

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

        Returns:
            (String):   Liquidsoap response
685
686
687
688
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException

689
        self.logger.info(SU.pink("Clearing filesystem queue '%s'!" % channel))
690

691
692
693
694
        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()
695

696
        return result
697

David Trattnig's avatar
David Trattnig committed
698
699


700
    #
701
    #   Fading
702
703
    #

704
705
706
707
708

    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
709
710
711
712
713
714

        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
715
        """
716
717
718
719
720
        try:
            fade_in_time = float(self.config.get("fade_in_time"))

            if fade_in_time > 0:
                self.fade_in_active = True
721
                target_volume = entry.volume
722
723
724

                step = fade_in_time / target_volume

725
726
                msg = "Starting to fading-in '%s'. Step is %ss and target volume is %s." % \
                    (entry.channel, str(step), str(target_volume))
727
                self.logger.info(SU.pink(msg))
728

729
                # Enable logging, which might have been disabled in a previous fade-out
730
731
732
733
                self.disable_logging = True
                self.client.disable_logging = True

                for i in range(target_volume):
734
                    self.channel_volume(entry.channel.value, i + 1)
735
736
                    time.sleep(step)

737
                msg = "Finished with fading-in '%s'." % entry.channel
738
                self.logger.info(SU.pink(msg))
739
740
741
742
743

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

745
746
747
748
749
        except LQConnectionError as e:
            self.logger.critical(str(e))

        return True

750
751
752
753
754


    def fade_out(self, entry):
        """
        Performs a fade-out for the given `entry` at channel `entry.channel`.
David Trattnig's avatar
David Trattnig committed
755
756
757
758
759
760
        
        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
761
        """
762
763
764
765
        try:
            fade_out_time = float(self.config.get("fade_out_time"))

            if fade_out_time > 0:
766
                step = abs(fade_out_time) / entry.volume
767

768
                msg = "Starting to fading-out '%s'. Step is %ss." % (entry.channel, str(step))
769
                self.logger.info(SU.pink(msg))
770

771
                # Disable logging... it is going to be enabled again after fadein and -out is finished
772
773
774
                self.disable_logging = True
                self.client.disable_logging = True

775
776
                for i in range(entry.volume):
                    self.channel_volume(entry.channel.value, entry.volume-i-1)
777
778
                    time.sleep(step)

779
                msg = "Finished with fading-out '%s'" % entry.channel
780
                self.logger.info(SU.pink(msg))
781

782
                # Enable logging again
783
784
785
786
                self.fade_out_active = False
                if not self.fade_in_active:
                    self.disable_logging = False
                    self.client.disable_logging = False
787

788
789
790
791
792
793
794
795
796
797
798
799
        except LQConnectionError as e:
            self.logger.critical(str(e))

        return True



    #
    #   Recording
    #


800
801
802
    # # ------------------------------------------------------------------------------------------ #
    # def recorder_stop(self):
    #     self.enable_transaction()
803

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

808
    #     self.disable_transaction()
809

810
811
812
813
814
815
816
817
818
    # # ------------------------------------------------------------------------------------------ #
    # 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
819

820
    #     self.enable_transaction()
821

822
823
824
825
    #     if num == -1:
    #         self.recorder_start_all()
    #     else:
    #         self.recorder_start_one(num)
826

827
    #     self.disable_transaction()
828

829
830
831
832
833
    # # ------------------------------------------------------------------------------------------ #
    # 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
834

835
836
837
838
    #     self.enable_transaction()
    #     for i in range(5):
    #         self.recorder_start_one(i)
    #     self.disable_transaction()
839

840
841
842
843
    # # ------------------------------------------------------------------------------------------ #
    # def recorder_start_one(self, num):
    #     if not self.is_liquidsoap_running:
    #         return False
844

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

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

851
852
853
854
855
    # # ------------------------------------------------------------------------------------------ #
    # 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)
856

857
    #     return recorder_state
858
859
860
861
862
863
864
865
866



    #
    #   Basic Methods
    #


    def init_player(self):
867
        """
868
869
870
871
        Initializes the LiquidSoap Player after startup of the engine.

        Returns:
            (String):   Message that the player is started.
872
        """
873
        t = StartupThread(self)
874
875
        t.start()

876
        return "Engine Core startup done!"
877

878
879

    # ------------------------------------------------------------------------------------------ #
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
880
    def __send_lqc_command__(self, lqs_instance, namespace, command, *args):
881
882
883
884
885
886
887
888
889
890
891
892
893
894
        """
        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:
895
896
            if not self.disable_logging:
                if namespace == "recorder":
897
                    self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + "_" + str(command) + "." + str(args))
898
                else:
899
                    if command == "":
900
                        self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + str(args))
901
                    else:
902
                        self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + "." + str(command) + str(args))
903

904
            # call wanted function ...
905
906

            # FIXME REFACTOR all calls in a common way
David Trattnig's avatar
David Trattnig committed
907
            if command in  ["playlist_push", "playlist_seek", "playlist_clear", "stream_set_url", "stream_start", "stream_stop", "stream_status"]:
908
909
910
911
912
913
                func = getattr(lqs_instance, command)
                result = func(str(namespace), *args)
            else:
                func = getattr(lqs_instance, namespace)
                result = func(command, *args)            

914

915
916
            if not self.disable_logging:
                self.logger.debug("LiquidSoapCommunicator got response " + str(result))
917

Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
918
            self.connection_attempts = 0
919

920
921
            return result

922
        except LQConnectionError as e:
923
            self.logger.error("Connection Error when sending " + str(namespace) + "." + str(command) + str(args))
924
            if self.try_to_reconnect():
925
                time.sleep(0.2)
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
926