engine.py 40.1 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
249
        if entry.type == ChannelType.LIVE:
            entry.channel = "linein_" + entry.source.split("line://")[1]
250
            is_ready = True
251
        else:
David Trattnig's avatar
David Trattnig committed
252
253
            # Choose and save the input channel
            entry.previous_channel, entry.channel = self.channel_swap(entry.type)
254

David Trattnig's avatar
David Trattnig committed
255
256
        # PLAYLIST
        if entry.type == ChannelType.FILESYSTEM:
257
            is_ready = self.playlist_push(entry.channel, entry.source)
David Trattnig's avatar
David Trattnig committed
258
259
            
        # STREAM
David Trattnig's avatar
David Trattnig committed
260
        elif entry.type == ChannelType.HTTP or entry.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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
        # 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:
            if entry.type != ChannelType.FILESYSTEM:
                raise InvalidChannelException
        
        # Determine channel
        channel = self.channel_swap(entry.type)

        # 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
339

            # Update active channel and type
            self.active_channel[entry.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
385
386
387
388
389
390
391
            if not entry.channel:
                self.logger.warn("Trying to stop entry %s, but it has no channel assigned" % entry)
                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
    # FIXME
    # def playlist_activate(self, playlist, cue_in=0.0):
    #     """
    #     Activates a new Playlist.
665

666
667
668
669
670
671
672
673
674
675
    #     Args:
    #         new_entry (Playlist):       The playlist to be played
    #         cue_in (Float):             Start/cue-time of track (For some reason Liquidsoap doesn't acknowledge this yet)
        
    #     Raises:
    #         (LQConnectionError): In case connecting to LiquidSoap isn't possible
    #     """
        
    #     # Grab the actual active entry
    #     # active_entry = self.scheduler.get_active_entry()
676

677
678
    #     # Set default channel, if no previous track is available
    #     current_channel = self.active_channel[ChannelType.FILESYSTEM]
679

680
681
    #     # if active_entry:
    #     #     current_channel = active_entry.channel
682

683
684
685
686
687
688
    #     try:
    #         # FIXME clearing creates some serious timing issues
    #         # To activate this feature we'd need some more sophisticated
    #         # Liquidsoap logic, such as >= 2 filesystem channels and
    #         # possiblities to pause pre-queued channels or cleaning them
    #         # after each completed schedule.
689

690
691
692
693
694
695
    #         # self.enable_transaction()
    #         # #if active_entry:
    #         #     #self.fade_out(active_entry)
    #         # res = self.playlist_clear(current_channel)
    #         # self.logger.info("Clear Queue Response: "+res)
    #         # self.disable_transaction()
696
697


698
    #         self.enable_transaction()
David Trattnig's avatar
David Trattnig committed
699
    #         self.mixer_channels_reload()
700
701
702
703
704
705
706
707
    #         # self.fade_in(playlist.entries[0])
    #         # FIXME rework
    #         for new_entry in playlist.entries:
    #             if current_channel == new_entry.channel:
    #                 self.activate_same_channel(new_entry, cue_in)
    #             else:
    #                 self.activate_different_channel(new_entry, cue_in, current_channel)
    #                 current_channel = new_entry.channel
708

709
    #         self.disable_transaction()
710

711
712
713
714
715
716
    #         # self.logger.critical("FIXME: Implement TrackService")
    #         #self.scheduler.update_track_service(new_entry)
    #     except LQConnectionError:
    #         # we already caught and handled this error in __send_lqc_command__, 
    #         # but we do not want to execute this function further and pass the exception
    #         pass
717
718
719



720
721
722
    def playlist_push(self, channel, uri):
        """
        Adds an filesystem URI to the given `ChannelType.FILESYSTEM` channel.
723

724
        Args:
David Trattnig's avatar
David Trattnig committed
725
726
727
            channel (Channel): The channel to push the file to
            uri (String):      The URI of the file

728
        Returns:
David Trattnig's avatar
David Trattnig committed
729
            (Boolean):  `True` if successful
730
731
732
733
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
        self.logger.info(SimpleUtil.pink("playlist.push('%s', '%s'" % (channel, uri)))
734
735
736
737
738
739

        self.enable_transaction()
        result = self.__send_lqc_command__(self.client, channel, "playlist_push", uri)
        self.logger.info("%s.playlist_push result: %s" % (channel, result))
        self.disable_transaction()

David Trattnig's avatar
David Trattnig committed
740
741
        # If successful, Liquidsoap returns a resource ID of the queued track
        return int(result) >= 0
742
743
744



745
746
747
    def playlist_seek(self, channel, seconds_to_seek):
        """
        Forwards the player of the given `ChannelType.FILESYSTEM` channel by (n) seconds.
748

749
        Args:
David Trattnig's avatar
David Trattnig committed
750
            channel (Channel): The channel to push the file to
751
            seconds_to_seeks (Float):   The seconds to skip
David Trattnig's avatar
David Trattnig committed
752
753
754

        Returns:
            (String):   Liquidsoap response
755
756
757
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
758

759
760
761
762
763
764
        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
765
766


David Trattnig's avatar
David Trattnig committed
767

768
769
770
    def playlist_clear(self, channel):
        """
        Removes all tracks currently queued in the given `ChannelType.FILESYSTEM` channel.
David Trattnig's avatar
David Trattnig committed
771
772
773
774
775
776

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

        Returns:
            (String):   Liquidsoap response
777
778
779
780
781
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException

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

783
784
785
786
        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()
787

788
        return result
789

David Trattnig's avatar
David Trattnig committed
790
791


792
793
794
795
    #
    #   Fading 
    #

796
797
798
799
800

    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
801
802
803
804
805
806

        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
807
        """
808
809
810
811
812
        try:
            fade_in_time = float(self.config.get("fade_in_time"))

            if fade_in_time > 0:
                self.fade_in_active = True
813
                target_volume = entry.volume
814
815
816

                step = fade_in_time / target_volume

817
818
819
                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))
820

821
                # Enable logging, which might have been disabled in a previous fade-out
822
823
824
825
                self.disable_logging = True
                self.client.disable_logging = True

                for i in range(target_volume):
826
                    self.channel_volume(entry.channel.value, i + 1)
827
828
                    time.sleep(step)

829
830
                msg = "Finished with fading-in '%s'." % entry.channel
                self.logger.info(SimpleUtil.pink(msg))
831
832
833
834
835

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

837
838
839
840
841
        except LQConnectionError as e:
            self.logger.critical(str(e))

        return True

842
843
844
845
846


    def fade_out(self, entry):
        """
        Performs a fade-out for the given `entry` at channel `entry.channel`.
David Trattnig's avatar
David Trattnig committed
847
848
849
850
851
852
        
        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
853
        """
854
855
856
857
        try:
            fade_out_time = float(self.config.get("fade_out_time"))

            if fade_out_time > 0:
858
                step = abs(fade_out_time) / entry.volume
859

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

863
                # Disable logging... it is going to be enabled again after fadein and -out is finished
864
865
866
                self.disable_logging = True
                self.client.disable_logging = True

867
868
                for i in range(entry.volume):
                    self.channel_volume(entry.channel.value, entry.volume-i-1)
869
870
                    time.sleep(step)

871
872
                msg = "Finished with fading-out '%s'" % entry.channel
                self.logger.info(SimpleUtil.pink(msg))
873

874
                # Enable logging again
875
876
877
878
                self.fade_out_active = False
                if not self.fade_in_active:
                    self.disable_logging = False
                    self.client.disable_logging = False
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
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
        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):
959
        """
960
961
962
963
        Initializes the LiquidSoap Player after startup of the engine.

        Returns:
            (String):   Message that the player is started.
964
        """
965
        t = StartupThread(self)
966
967
        t.start()

968
        return "Engine Core startup done!"
969

970
971

    # ------------------------------------------------------------------------------------------ #
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
972
    def __send_lqc_command__(self, lqs_instance, namespace, command, *args):
973
974
975
976
977
978
979
980
981
982
983
984
985
986
        """
        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:
987
988
            if not self.disable_logging:
                if namespace == "recorder":
989
                    self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + "_" + str(command) + "." + str(args))
990
                else:
991
                    if command == "":
992
                        self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + str(args))
993
                    else:
994
                        self.logger.debug("LiquidSoapCommunicator is calling " + str(namespace) + "." + str(command) + str(args))
995

996
            # call wanted function ...
997
998

            # FIXME REFACTOR all calls in a common way
David Trattnig's avatar
David Trattnig committed
999
            if command in  ["playlist_push", "playlist_seek", "playlist_clear", "stream_set_url", "stream_start", "stream_stop", "stream_status"]:
1000
                func = getattr(lqs_instance, command)
For faster browsing, not all history is shown. View entire blame