engine.py 40.1 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#
#  engine
#
#  Playout Daemon for autoradio project
#
#
#  Copyright (C) 2017-2018 Gottfried Gaisbauer <gottfried.gaisbauer@servus.at>
#
#  This file is part of engine.
#
#  engine is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  any later version.
#
#  engine 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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with engine. If not, see <http://www.gnu.org/licenses/>.
#

25
26
import time
import logging
27
import json
28

David Trattnig's avatar
David Trattnig committed
29
30
31
from urllib.parse               import urlparse, ParseResult
from contextlib                 import suppress
from threading                  import Thread
David Trattnig's avatar
David Trattnig committed
32
33
34
35

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
36
from modules.communication.liquidsoap.playerclient import LiquidSoapPlayerClient
37
# from modules.communication.liquidsoap.recorderclient import LiquidSoapRecorderClient
David Trattnig's avatar
David Trattnig committed
38
39
40
from modules.core.startup       import StartupThread
from modules.core.state         import PlayerStateService
from modules.core.monitor       import Monitoring
41
from modules.communication.mail import AuraMailer
42

43

David Trattnig's avatar
David Trattnig committed
44
class SoundSystem():
45
    """ 
David Trattnig's avatar
David Trattnig committed
46
    SoundSystem Class
47
48

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

63
64
65
66
67
68
    # Active Channel & Entry Handling
    active_channel_type = None
    active_channel = None
    player_state = None


69
    def __init__(self, config):
70
        """
David Trattnig's avatar
David Trattnig committed
71
        Initializes the sound-system by establishing a Socket connection
72
73
74
75
        to Liquidsoap.

        Args:
            config (AuraConfig):    The configuration
76
        """
77
        self.config = config
78
79
        self.logger = logging.getLogger("AuraEngine")

80
81
        self.client = LiquidSoapPlayerClient(config, "engine.sock")
        # self.lqcr = LiquidSoapRecorderClient(config, "record.sock")
82
        self.auramailer = AuraMailer(self.config)
83
84
        self.monitoring = Monitoring(config, self, self.auramailer)
        
David Trattnig's avatar
David Trattnig committed
85
        self.is_active()
86

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



    def start(self):
        """
David Trattnig's avatar
David Trattnig committed
101
        Starts the sound-system.
102
103
104
105
106
107
        """
        # 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
108
109
110
111
        # Initialize all channels
        channels = self.mixer_channels_reload()
        for c in channels:
            self.channel_volume(c, "0")
112
113

        # Setting init params like a blank file
David Trattnig's avatar
David Trattnig committed
114
115
116
        # install_dir = self.config.get("install_dir")
        # channel = self.active_channel[ChannelType.FILESYSTEM]
        # self.playlist_push(channel, install_dir + "/configuration/blank.flac")
117
118
119
120
121

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

David Trattnig's avatar
David Trattnig committed
122
123
124
125
126
127
128
129
130
131
132
        # 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"))


133
134
135

    def is_ready(self):
        """
136
        Returns `True` if the soundsystem is connected to Liquidsoap and is ready to be used.
137
138
139
        """
        return self.is_liquidsoap_running

140

David Trattnig's avatar
David Trattnig committed
141

142
    #
143
144
145
146
    #   MIXER : GENERAL
    # 


David Trattnig's avatar
David Trattnig committed
147
148
149
150
151
152
    def mixer_status(self):
        """
        Returns the state of all mixer channels
        """
        cnt = 0
        inputstate = {}
153

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

David Trattnig's avatar
David Trattnig committed
157
158
159
        for input in inputs:
            inputstate[input] = self.channel_status(cnt)
            cnt = cnt + 1
160

David Trattnig's avatar
David Trattnig committed
161
162
        self.disable_transaction()
        return inputstate
David Trattnig's avatar
David Trattnig committed
163

David Trattnig's avatar
David Trattnig committed
164
165

    def mixer_channels(self):
166
        """
David Trattnig's avatar
David Trattnig committed
167
        Retrieves all mixer channels
168
        """
David Trattnig's avatar
David Trattnig committed
169
170
        if self.channels is None or len(self.channels) == 0:
            self.channels = self.__send_lqc_command__(self.client, "mixer", "inputs")
171

David Trattnig's avatar
David Trattnig committed
172
        return self.channels
173
174


David Trattnig's avatar
David Trattnig committed
175
176
177
178
    def mixer_channels_selected(self):
        """
        Retrieves all selected channels of the mixer.
        """
179
        cnt = 0
David Trattnig's avatar
David Trattnig committed
180
        activeinputs = []
181

David Trattnig's avatar
David Trattnig committed
182
183
184
185
186
        self.enable_transaction()
        inputs = self.mixer_channels()

        for input in inputs:
            status = self.channel_status(cnt)
187
188
189
190
191
192
193
            if "selected=true" in status:
                activeinputs.append(input)
            cnt = cnt + 1

        self.disable_transaction()

        return activeinputs
194

195

David Trattnig's avatar
David Trattnig committed
196
197
198
199
200
201
202
203
204
205
206
    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")
207

David Trattnig's avatar
David Trattnig committed
208
        return activemixer_copy
209
210


David Trattnig's avatar
David Trattnig committed
211
212
213
214
215
216
    def mixer_channels_reload(self):
        """
        Reloads all mixer channels. 
        """
        self.channels = None
        return self.mixer_channels()
217
218
219
220
221



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

David Trattnig's avatar
David Trattnig committed
225

226
227
228


    #
David Trattnig's avatar
David Trattnig committed
229
    #   MIXER : CONTROL SECTION
230
231
232
    #


David Trattnig's avatar
David Trattnig committed
233
    def preroll(self, entry):
David Trattnig's avatar
David Trattnig committed
234
        """
David Trattnig's avatar
David Trattnig committed
235
236
237
238
239
        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)`.
240

David Trattnig's avatar
David Trattnig committed
241
242
243
244
245
246
        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
247
248
249
        """
        entry.status = EntryPlayState.LOADING
        self.logger.info("Loading entry '%s'" % entry)
250
        is_ready = False
251

David Trattnig's avatar
David Trattnig committed
252
        # LIVE
253
254
        if entry.type == ChannelType.LIVE:
            entry.channel = "linein_" + entry.source.split("line://")[1]
255
            is_ready = True
256
        else:
David Trattnig's avatar
David Trattnig committed
257
258
            # Choose and save the input channel
            entry.previous_channel, entry.channel = self.channel_swap(entry.type)
259

David Trattnig's avatar
David Trattnig committed
260
261
        # PLAYLIST
        if entry.type == ChannelType.FILESYSTEM:
262
            is_ready = self.playlist_push(entry.channel, entry.source)
David Trattnig's avatar
David Trattnig committed
263
264
            
        # STREAM
David Trattnig's avatar
David Trattnig committed
265
        elif entry.type == ChannelType.HTTP or entry.type == ChannelType.HTTPS:
266
            is_ready = self.stream_load_entry(entry)
David Trattnig's avatar
David Trattnig committed
267

268
269
        if is_ready == True:
            entry.status = EntryPlayState.READY
270

David Trattnig's avatar
David Trattnig committed
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
310
311
312
313
314
        # 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)


315
316

    def play(self, entry, transition):
David Trattnig's avatar
David Trattnig committed
317
        """
318
319
        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
320

David Trattnig's avatar
David Trattnig committed
321
322
323
        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
324

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

            # Instant activation or fade-in
David Trattnig's avatar
David Trattnig committed
335
            self.enable_transaction()
336
            if transition == TransitionType.FADE:
David Trattnig's avatar
David Trattnig committed
337
                self.channel_select(entry.channel.value, True)
338
339
                self.fade_in(entry)
            else:
David Trattnig's avatar
David Trattnig committed
340
                self.channel_activate(entry.channel.value, True)
David Trattnig's avatar
David Trattnig committed
341
            self.disable_transaction()
342
343
344

            # Update active channel and type
            self.active_channel[entry.type] = entry.channel     
David Trattnig's avatar
David Trattnig committed
345
346
347
348
349
350
351
352
353
354
355
356
357

            # 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()
358

David Trattnig's avatar
David Trattnig committed
359

360
361
362

    def on_play(self, source):
        """
David Trattnig's avatar
David Trattnig committed
363
        Event Handler which is called by the soundsystem implementation (i.e. Liquidsoap) 
364
        when some entry is actually playing.
David Trattnig's avatar
David Trattnig committed
365
366
367

        Args:
            source (String):    The URI of the media source currently being played
368
369
370
371
372
373
374
375
376
377
378
        """
        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
379
        """
380
        Stops the currently playing entry. 
David Trattnig's avatar
David Trattnig committed
381
382

        Args:
383
384
            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
385
        """
David Trattnig's avatar
David Trattnig committed
386
        with suppress(LQConnectionError):
387
            self.enable_transaction()
388

389
390
391
392
393
394
395
396
            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)
397

398
399
            self.logger.info(SimpleUtil.pink("Stopped channel '%s' for entry %s" % (entry.channel, entry)))
            self.disable_transaction()
400

401

David Trattnig's avatar
David Trattnig committed
402

403

David Trattnig's avatar
David Trattnig committed
404
405
406
407
408
    #
    #   MIXER : CHANNEL
    #


409
    def channel_swap(self, channel_type):
David Trattnig's avatar
David Trattnig committed
410
411
412
413
414
415
416
417
        """
        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
418
419
        previous_channel = self.active_channel[channel_type]
        new_channel = None
420
421
422
        msg = None

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

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

David Trattnig's avatar
David Trattnig committed
449

450

David Trattnig's avatar
David Trattnig committed
451
452
453
454
455
    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)
456
457


458

David Trattnig's avatar
David Trattnig committed
459
460
461
    def channel_select(self, channel, select):
        """
        Selects/deselects some mixer channel
462

David Trattnig's avatar
David Trattnig committed
463
464
465
        Args:
            pos (Integer): The channel number
            select (Boolean): Select or deselect
466

David Trattnig's avatar
David Trattnig committed
467
468
469
470
        Returns:
            (String):   Liquidsoap server response
        """
        channels = self.mixer_channels()
471

David Trattnig's avatar
David Trattnig committed
472
473
474
475
476
477
478
479
480
        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))
481

482

483

484
    def channel_activate(self, channel, activate):
David Trattnig's avatar
David Trattnig committed
485
486
487
488
489
490
491
492
493
494
495
496
497
        """
        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()
498
499
500
501

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

David Trattnig's avatar
David Trattnig committed
509
510


511
512
    def channel_volume(self, channel, volume):
        """
513
514
515
516
517
        Set volume of a channel

        Args:
            channel (Channel):      The channel
            volume  (Integer)       Volume between 0 and 100
518
        """
519
        channel = str(channel)
520
        try:
521
            if str(volume) == "100":
David Trattnig's avatar
David Trattnig committed
522
                channels = self.mixer_channels()
523
524
                index = channels.index(channel)
            else:
David Trattnig's avatar
David Trattnig committed
525
                channels = self.mixer_channels()
526
                index = channels.index(channel)
527
        except ValueError as e:
528
529
            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
530
            self.logger.info("Available channels: %s" % str(channels))
531
532
533
            return

        try:
534
            if len(channel) < 1:
535
536
                msg = SimpleUtil.red("Cannot set volume of channel " + channel + " to " + str(volume) + "! There are no channels.")
                self.logger.warning(msg)
537
            else:
538
                message = self.__send_lqc_command__(self.client, "mixer", "volume", str(index), str(int(volume)))
539

540
541
                if not self.disable_logging:
                    if message.find('volume=' + str(volume) + '%'):
542
                        self.logger.info(SimpleUtil.pink("Set volume of channel '%s' to %s" % (channel, str(volume))))
543
                    else:
544
545
                        msg = SimpleUtil.red("Setting volume of channel " + channel + " gone wrong! Liquidsoap message: " + message)
                        self.logger.warning(msg)
546

547
                return message
548
        except AttributeError as e: #(LQConnectionError, AttributeError):
549
            self.disable_transaction(force=True)
550
551
            msg = SimpleUtil.red("Ran into exception when setting volume of channel " + channel + ". Reason: " + str(e))
            self.logger.error(msg)
552

553
554

    #
555
    #   Channel Type - Stream 
556
557
    #

David Trattnig's avatar
David Trattnig committed
558
559
560
561

    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
562
563
564

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

David Trattnig's avatar
Docs.    
David Trattnig committed
566
567
        Returns:
            (Boolean):  `True` if successfull
David Trattnig's avatar
David Trattnig committed
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
        """
        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

583
        return True
David Trattnig's avatar
David Trattnig committed
584
585
586
587



    def stream_load(self, channel, url):
David Trattnig's avatar
David Trattnig committed
588
        """
David Trattnig's avatar
David Trattnig committed
589
590
        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
591
592
593
594
595
596
597

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

        Returns:
            (Boolean):  `True` if successful
David Trattnig's avatar
David Trattnig committed
598
599
        """
        result = None
600

David Trattnig's avatar
David Trattnig committed
601
        self.enable_transaction()
David Trattnig's avatar
David Trattnig committed
602
        result = self.__send_lqc_command__(self.client, channel, "stream_stop")
David Trattnig's avatar
David Trattnig committed
603
604
        
        if result != LiquidsoapResponse.SUCCESS.value:
David Trattnig's avatar
David Trattnig committed
605
            self.logger.error("%s.stop result: %s" % (channel, result))
David Trattnig's avatar
David Trattnig committed
606
            raise LQStreamException("Error while stopping stream!")
607

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

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

David Trattnig's avatar
David Trattnig committed
614
615
        # Liquidsoap ignores commands sent without a certain timeout
        time.sleep(2)
616

David Trattnig's avatar
David Trattnig committed
617
618
        result = self.__send_lqc_command__(self.client, channel, "stream_start")
        self.logger.info("%s.start result: %s" % (channel, result))
619

David Trattnig's avatar
David Trattnig committed
620
621
622
623
624
        self.disable_transaction()
        return result



David Trattnig's avatar
David Trattnig committed
625
    def stream_is_ready(self, channel, url):
David Trattnig's avatar
David Trattnig committed
626
        """
David Trattnig's avatar
David Trattnig committed
627
628
        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
629
630
631
632
633
634
635

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

        Returns:
            (Boolean):  `True` if successful
David Trattnig's avatar
David Trattnig committed
636
637
638
639
640
        """
        result = None

        self.enable_transaction()

David Trattnig's avatar
David Trattnig committed
641
642
        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
643
644
645
646
647
648
649
650
651
652
653

        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
654
655
656
        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
657
        return True
658
659


David Trattnig's avatar
David Trattnig committed
660

661

662
    #
David Trattnig's avatar
David Trattnig committed
663
    #   Channel Type - Filesystem 
664
    #
David Trattnig's avatar
David Trattnig committed
665

666
667
668
669
    # FIXME
    # def playlist_activate(self, playlist, cue_in=0.0):
    #     """
    #     Activates a new Playlist.
670

671
672
673
674
675
676
677
678
679
680
    #     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()
681

682
683
    #     # Set default channel, if no previous track is available
    #     current_channel = self.active_channel[ChannelType.FILESYSTEM]
684

685
686
    #     # if active_entry:
    #     #     current_channel = active_entry.channel
687

688
689
690
691
692
693
    #     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.
694

695
696
697
698
699
700
    #         # 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()
701
702


703
    #         self.enable_transaction()
David Trattnig's avatar
David Trattnig committed
704
    #         self.mixer_channels_reload()
705
706
707
708
709
710
711
712
    #         # 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
713

714
    #         self.disable_transaction()
715

716
717
718
719
720
721
    #         # 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
722
723
724



725
726
727
    def playlist_push(self, channel, uri):
        """
        Adds an filesystem URI to the given `ChannelType.FILESYSTEM` channel.
728

729
        Args:
David Trattnig's avatar
David Trattnig committed
730
731
732
            channel (Channel): The channel to push the file to
            uri (String):      The URI of the file

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

        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
745
746
        # If successful, Liquidsoap returns a resource ID of the queued track
        return int(result) >= 0
747
748
749



750
751
752
    def playlist_seek(self, channel, seconds_to_seek):
        """
        Forwards the player of the given `ChannelType.FILESYSTEM` channel by (n) seconds.
753

754
        Args:
David Trattnig's avatar
David Trattnig committed
755
            channel (Channel): The channel to push the file to
756
            seconds_to_seeks (Float):   The seconds to skip
David Trattnig's avatar
David Trattnig committed
757
758
759

        Returns:
            (String):   Liquidsoap response
760
761
762
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException
763

764
765
766
767
768
769
        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
770
771


David Trattnig's avatar
David Trattnig committed
772

773
774
775
    def playlist_clear(self, channel):
        """
        Removes all tracks currently queued in the given `ChannelType.FILESYSTEM` channel.
David Trattnig's avatar
David Trattnig committed
776
777
778
779
780
781

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

        Returns:
            (String):   Liquidsoap response
782
783
784
785
786
        """
        if channel not in ChannelType.FILESYSTEM.channels:
            raise InvalidChannelException

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

788
789
790
791
        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()
792

793
        return result
794

David Trattnig's avatar
David Trattnig committed
795
796


797
798
799
800
    #
    #   Fading 
    #

801
802
803
804
805

    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
806
807
808
809
810
811

        Args:
            entry (Entry):  The entry to fade
        
        Returns:
            (Boolean):  `True` if successful
812
        """
813
814
815
816
817
        try:
            fade_in_time = float(self.config.get("fade_in_time"))

            if fade_in_time > 0:
                self.fade_in_active = True
818
                target_volume = entry.volume
819
820
821

                step = fade_in_time / target_volume

822
823
824
                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))
825

826
                # Enable logging, which might have been disabled in a previous fade-out
827
828
829
830
                self.disable_logging = True
                self.client.disable_logging = True

                for i in range(target_volume):
831
                    self.channel_volume(entry.channel.value, i + 1)
832
833
                    time.sleep(step)

834
835
                msg = "Finished with fading-in '%s'." % entry.channel
                self.logger.info(SimpleUtil.pink(msg))
836
837
838
839
840

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

842
843
844
845
846
        except LQConnectionError as e:
            self.logger.critical(str(e))

        return True

847
848
849
850
851


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

            if fade_out_time > 0:
863
                step = abs(fade_out_time) / entry.volume
864

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

868
                # Disable logging... it is going to be enabled again after fadein and -out is finished
869
870
871
                self.disable_logging = True
                self.client.disable_logging = True

872
873
                for i in range(entry.volume):
                    self.channel_volume(entry.channel.value, entry.volume-i-1)
874
875
                    time.sleep(step)

876
877
                msg = "Finished with fading-out '%s'" % entry.channel
                self.logger.info(SimpleUtil.pink(msg))
878

879
                # Enable logging again
880
881
882
883
                self.fade_out_active = False
                if not self.fade_in_active:
                    self.disable_logging = False
                    self.client.disable_logging = False
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
959
960
961
962
963
        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):
964
        """
965
966
967
968
        Initializes the LiquidSoap Player after startup of the engine.

        Returns:
            (String):   Message that the player is started.
969
        """
970
        t = StartupThread(self)
971
972
        t.start()

973
        return "Engine Core startup done!"
974

975
976

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