diff --git a/aircox_liquidsoap/management/commands/liquidsoap.py b/aircox_liquidsoap/management/commands/liquidsoap.py
new file mode 100644
index 0000000..e312dd8
--- /dev/null
+++ b/aircox_liquidsoap/management/commands/liquidsoap.py
@@ -0,0 +1,31 @@
+"""
+Control Liquidsoap
+"""
+import os
+import re
+from argparse import RawTextHelpFormatter
+
+from django.core.management.base import BaseCommand, CommandError
+from django.views.generic.base import View
+from django.template.loader import render_to_string
+
+import aircox_liquidsoap.settings as settings
+import aircox_liquidsoap.utils as utils
+
+
+
+class Command (BaseCommand):
+ help= __doc__
+ output_dir = settings.AIRCOX_LIQUIDSOAP_MEDIA
+
+ def add_arguments (self, parser):
+ parser.formatter_class=RawTextHelpFormatter
+ parser.add_argument(
+ '-o', '--on_air', action='store_true',
+ help='Print what is on air'
+ )
+
+ def handle (self, *args, **options):
+ controller = utils.Controller()
+ controller.get()
+
diff --git a/aircox_liquidsoap/management/commands/liquidsoap_files.py b/aircox_liquidsoap/management/commands/liquidsoap_files.py
index 6b80311..2cbb59b 100644
--- a/aircox_liquidsoap/management/commands/liquidsoap_files.py
+++ b/aircox_liquidsoap/management/commands/liquidsoap_files.py
@@ -11,6 +11,7 @@ from django.views.generic.base import View
from django.template.loader import render_to_string
import aircox_liquidsoap.settings as settings
+import aircox_liquidsoap.utils as utils
import aircox_programs.settings as programs_settings
import aircox_programs.models as models
@@ -89,36 +90,9 @@ class Command (BaseCommand):
with open(path, 'w+') as file:
file.write(data)
- @staticmethod
- def liquid_stream (stream):
- def to_seconds (time):
- return 3600 * time.hour + 60 * time.minute + time.second
-
- return {
- 'name': stream.program.get_slug_name(),
- 'begin': stream.begin.strftime('%Hh%M') if stream.begin else None,
- 'end': stream.end.strftime('%Hh%M') if stream.end else None,
- 'delay': to_seconds(stream.delay) if stream.delay else None,
- 'file': '{}/stream_{}.m3u'.format(
- settings.AIRCOX_LIQUIDSOAP_MEDIA,
- stream.pk,
- )
- }
-
- def liquid_station (self, station):
- station.streams = [
- self.liquid_stream(stream)
- for stream in models.Stream.objects.filter(
- program__active = True, program__station = station
- )
- ]
- return station
-
def get_config (self, output = None):
context = {
- 'stations': [ self.liquid_station(station)
- for station in models.Station.objects.filter(active = True)
- ],
+ 'monitor': utils.Monitor(),
'settings': settings,
}
@@ -129,16 +103,18 @@ class Command (BaseCommand):
self.print(data, output, 'aircox.liq')
def get_playlist (self, stream = None, output = None):
- path = os.path.join(
- programs_settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
- stream.program.path
- )
+ program = stream.program
+ source = utils.Source(program = program)
+
sounds = models.Sound.objects.filter(
- # good_quality = True,
- type = models.Sound.Type['archive'],
- path__startswith = path
+ # good_quality = True,
+ type = models.Sound.Type['archive'],
+ path__startswith = os.path.join(
+ programs_settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
+ program.path
+ )
)
data = '\n'.join(sound.path for sound in sounds)
- self.print(data, output, 'stream_{}.m3u'.format(stream.pk))
+ self.print(data, output, utils.Source(program = program).path)
diff --git a/aircox_liquidsoap/templates/aircox_liquidsoap/base_source.html b/aircox_liquidsoap/templates/aircox_liquidsoap/base_source.html
new file mode 100644
index 0000000..d48166f
--- /dev/null
+++ b/aircox_liquidsoap/templates/aircox_liquidsoap/base_source.html
@@ -0,0 +1,14 @@
+{% with metadata=source.metadata %}
+
+
{{ source.name }}
+
+ {{ metadata.initial_uri }}
+ {{ metadata.status }}
+
+
+
+{% endwith %}
+
+
diff --git a/aircox_liquidsoap/templates/aircox_liquidsoap/config.liq b/aircox_liquidsoap/templates/aircox_liquidsoap/config.liq
new file mode 100644
index 0000000..986fee4
--- /dev/null
+++ b/aircox_liquidsoap/templates/aircox_liquidsoap/config.liq
@@ -0,0 +1,31 @@
+{# Utilities #}
+def interactive_source (id, s, ) = \
+ def apply_metadata(m) = \
+ m = json_of(compact=true, m) \
+ ignore(interactive.string('#{id}_meta', m)) \
+ end \
+ \
+ s = on_metadata(id = id, apply_metadata, s) \
+ add_skip_command(s) \
+ s \
+end \
+\
+def stream (id, file) = \
+ s = playlist(id = '#{id}_playlist', mode = "random", file) \
+ interactive_source(id, s) \
+end \
+\
+{# Config #}
+set("server.socket", true) \
+set("server.socket.path", "{{ settings.AIRCOX_LIQUIDSOAP_SOCKET }}") \
+{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
+set("{{ key|safe }}", {{ value|safe }}) \
+{% endfor %}
+\
+\
+{% for controller in monitor.controllers.values %}
+{% include 'aircox_liquidsoap/station.liq' %} \
+{{ controller.id }} = make_station_{{ controller.id }}() \
+output.alsa({{ controller.id }}) \
+{% endfor %}
+
diff --git a/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html b/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html
index 7f89c0a..dac4539 100644
--- a/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html
+++ b/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html
@@ -1,11 +1,129 @@
+{% if not embed %}
+
+
+
+
+
+
+
+
+{% endif %}
+ {% for c_id, controller in monitor.controllers.items %}
+ {% with on_air=controller.on_air %}
+
+
+ {% if not controller.connector.available %}
+ disconnected
+ {% endif %}
+
+ {{ controller.station.name }}
+
+
+
+ {% with source=controller.master %}
+ {% include 'aircox_liquidsoap/base_source.html' %}
+ {% endwith %}
+
+ {% with source=controller.dealer %}
+ {% include 'aircox_liquidsoap/base_source.html' %}
+ {% endwith %}
+
+ {% for source in controller.streams.values %}
+ {% include 'aircox_liquidsoap/base_source.html' %}
+ {% endfor %}
+
+
+ {% endwith %}
+ {% endfor %}
+
+{% if not embed %}
+
+
+
+{% endif %}
diff --git a/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq b/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq
index 04c0706..3c7713c 100644
--- a/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq
+++ b/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq
@@ -5,54 +5,50 @@ A station has multiple sources:
- streams: a rotate source with all playlists
- single: security song
{% endcomment %}
-{% with name=station.name streams=station.streams %}
-def make_station_{{ name }} () = \
+def make_station_{{ controller.id }} () = \
{# dealer #}
- dealer = interactive_source('{{ name }}_dealer', playlist.once( \
+ {% with source=controller.dealer %}
+ {% if source %}
+ dealer = interactive_source('{{ source.id }}', playlist.once( \
reload_mode='watch', \
- '/tmp/dealer.m3u', \
+ "{{ source.path }}", \
)) \
\
- dealer_on = interactive.bool("{{ name }}_dealer_on", false) \
+ dealer_on = interactive.bool("{{ source.id }}_on", false) \
+ {% endif %}
+ {% endwith %}
\
{# streams #}
streams = interactive_source("streams", rotate([ \
- {% for stream in streams %}
- {% if stream.delay %}
- delay({{ stream.delay }}., stream("{{ name }}_{{ stream.name }}", "{{ stream.file }}")), \
+ {% for source in controller.streams.values %}
+ {% with info=source.stream_info %}
+ {% if info.delay %}
+ delay({{ info.delay }}., stream("{{ source.id }}", "{{ source.path }}")), \
+ {% elif info.begin and info.end %}
+ at({ {{info.begin}}-{{info.end}} }, stream("{{ source.id }}", "{{ source.path }}")), \
{% endif %}
+ {% endwith %}
{% endfor %}
- switch([ \
- {% for stream in streams %}
- {% if stream.begin and stream.end %}
- ({ stream.begin, stream.end }, stream("{{ name }}_{{ stream.name }}", "{{ stream.file }}")), \
- {% endif %}
- {% endfor %}
- ]), \
-
- {% for stream in streams %}
- {% if not stream.delay %}
- {% if not stream.begin or not stream.end %}
- stream("{{ name }}_{{ stream.name }}", "{{ stream.file }}"), \
- {% endif %}
+ {% for source in controller.streams.values %}
+ {% if not source.stream_info %}
+ stream("{{ source.id }}", "{{ source.path }}"), \
{% endif %}
{% endfor %}
])) \
\
{# station #}
interactive_source ( \
- "{{ name }}", \
+ "{{ controller.id }}", \
fallback(track_sensitive = false, [ \
at(dealer_on, dealer), \
- {% if station.fallback %}
streams, \
- single("{{ station.fallback }}") \
+ {% if controller.station.fallback %}
+ single("{{ controller.station.fallback }}"), \
{% else %}
- mksafe(streams) \
+ blank(), \
{% endif %}
]) \
) \
end \
-{% endwith %}
diff --git a/aircox_liquidsoap/utils.py b/aircox_liquidsoap/utils.py
index 7f8a194..091a97d 100644
--- a/aircox_liquidsoap/utils.py
+++ b/aircox_liquidsoap/utils.py
@@ -1,34 +1,72 @@
+import os
import socket
import re
+import json
+from django.utils.translation import ugettext as _, ugettext_lazy
+
+import aircox_programs.models as models
import aircox_liquidsoap.settings as settings
-class Controller:
- socket = None
- available = False
- def __init__ (self):
- self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+class Connector:
+ """
+ Telnet connector utility.
+
+ address: a string to the unix domain socket file, or a tuple
+ (host, port) for TCP/IP connection
+ """
+ __socket = None
+ __available = False
+ address = settings.AIRCOX_LIQUIDSOAP_SOCKET
+
+ @property
+ def available (self):
+ return self.__available
+
+ def __init__ (self, address = None):
+ self.address = address
def open (self):
- if self.available:
+ if self.__available:
return
- address = settings.AIRCOX_LIQUIDSOAP_SOCKET
try:
- self.socket.connect(address)
- self.available = True
+ family = socket.AF_INET if type(self.address) in (tuple, list) else \
+ socket.AF_UNIX
+ self.__socket = socket.socket(family, socket.SOCK_STREAM)
+ self.__socket.connect(self.address)
+ self.__available = True
except:
print('can not connect to liquidsoap socket {}'.format(address))
- self.available = False
+ self.__available = False
return -1
- def send (self, data):
+ def send (self, *data, try_count = 1, parse = False, parse_json = False):
if self.open():
return ''
- data = bytes(data + '\n', encoding='utf-8')
- self.socket.sendall(data)
- return self.socket.recv(10240).decode('utf-8')
+ data = bytes(''.join([str(d) for d in data]) + '\n', encoding='utf-8')
+
+ try:
+ reg = re.compile('(.*)[\n\r]+END[\n\r]*$')
+ self.__socket.sendall(data)
+ data = ''
+ while not reg.match(data):
+ data += self.__socket.recv(1024).decode('unicode_escape')
+
+ if data:
+ data = reg.sub(r'\1', data)
+ data = data.strip()
+
+ if parse:
+ data = self.parse(data)
+ elif parse_json:
+ data = self.parse_json(data)
+ return data
+ except:
+ self.__available = False
+ if try_count > 0:
+ return self.send(data, try_count - 1)
def parse (self, string):
string = string.split('\n')
@@ -41,10 +79,241 @@ class Controller:
data[line['key']] = line['value']
return data
- def get (self, station, source = None):
- if source:
- r = self.send('{}_{}.get'.format(station.get_slug_name(), source))
- else:
- r = self.send('{}.get'.format(station.get_slug_name()))
- return self.parse(r) if r else None
+ def parse_json (self, string):
+ try:
+ if string[0] == '"' and string[-1] == '"':
+ string = string[1:-1]
+ return json.loads(string) if string else None
+ except:
+ return None
+
+
+class Source:
+ """
+ A structure that holds informations about a LiquidSoap source.
+ """
+ controller = None
+ program = None
+ metadata = None
+
+ def __init__ (self, controller = None, program = None):
+ self.controller = controller
+ self.program = program
+
+ @property
+ def station (self):
+ """
+ Proxy to self.(program|controller).station
+ """
+ return self.program.station if self.program else \
+ self.controller.station
+
+ @property
+ def connector (self):
+ """
+ Proxy to self.controller.connector
+ """
+ return self.controller.connector
+
+ @property
+ def id (self):
+ """
+ Identifier for the source, scoped in the station's one
+ """
+ postfix = ('_stream_' + str(self.program.id)) if self.program else ''
+ return self.station.slug + postfix
+
+ @property
+ def name (self):
+ """
+ Name of the related object (program or station)
+ """
+ if self.program:
+ return self.program.name
+ return self.station.name
+
+ @property
+ def path (self):
+ """
+ Path to the playlist
+ """
+ return os.path.join(
+ settings.AIRCOX_LIQUIDSOAP_MEDIA,
+ self.id + '.m3u'
+ )
+
+ @property
+ def playlist (self):
+ """
+ The playlist as an array
+ """
+ try:
+ with open(self.path, 'r') as file:
+ return file.readlines()
+ except:
+ return []
+
+ @playlist.setter
+ def playlist (self, sounds):
+ with open(self.path, 'w') as file:
+ file.write('\n'.join(sounds))
+ self.connector.send(self.name, '_playlist.reload')
+
+ def stream_info (self):
+ """
+ Return a dict with info related to the program's stream
+ """
+ if not self.program:
+ return
+
+ stream = models.Stream.objects.get(program = self.program)
+ if not stream.begin and not stream.delay:
+ return
+
+ def to_seconds (time):
+ return 3600 * time.hour + 60 * time.minute + time.second
+
+ return {
+ 'begin': stream.begin.strftime('%Hh%M') if stream.begin else None,
+ 'end': stream.end.strftime('%Hh%M') if stream.end else None,
+ 'delay': to_seconds(stream.delay) if stream.delay else None
+ }
+
+ def skip (self):
+ """
+ Skip a given source. If no source, use master.
+ """
+ self.connector.send(self.id, '.skip')
+
+ def update (self, metadata = None):
+ """
+ Update metadata with the given metadata dict or request them to
+ liquidsoap if nothing is given.
+
+ Return -1 in case no update happened
+ """
+ if metadata:
+ source = metadata.get('source') or ''
+ if self.program and not source.startswith(self.id):
+ return -1
+ self.metadata = metadata
+ return
+
+ r = self.connector.send('var.get ', self.id + '_meta', parse_json=True)
+ return self.update(metadata = r) if r else -1
+
+
+class Dealer (Source):
+ """
+ The Dealer source is a source that is used for scheduled diffusions and
+ manual sound diffusion.
+ """
+ name = _('Dealer')
+
+ @property
+ def id (self):
+ return self.station.name + '_dealer'
+
+ def stream_info (self):
+ pass
+
+ def get_next_diffusion (self):
+ pass
+
+ def on_air (self, value = True):
+ pass
+
+ @property
+ def playlist (self):
+ try:
+ with open(self.path, 'r') as file:
+ return file.readlines()
+ except:
+ return []
+
+ @playlist.setter
+ def playlist (self, sounds):
+ with open(self.path, 'w') as file:
+ file.write('\n'.join(sounds))
+
+
+class Controller:
+ connector = None
+ station = None # the related station
+ master = None # master source (station's source)
+ dealer = None # dealer source
+ streams = None # streams sources
+
+ @property
+ def on_air (self):
+ return self.master
+
+ @property
+ def id (self):
+ return self.master and self.master.id
+
+ @property
+ def name (self):
+ return self.master and self.master.name
+
+ def __init__ (self, station, connector = None):
+ """
+ use_connector: avoids the creation of a Connector, in case it is not needed
+ """
+ self.connector = connector
+ self.station = station
+ self.station.controller = self
+
+ self.master = Source(self)
+ self.dealer = Dealer(self)
+ self.streams = {
+ source.id : source
+ for source in [
+ Source(self, program)
+ for program in models.Program.objects.filter(station = station,
+ active = True)
+ if program.stream_set.count()
+ ]
+ }
+
+ def get (self, source_id):
+ """
+ Get a source by its id
+ """
+ if source_id == self.master.id:
+ return self.master
+ if source_id == self.dealer.id:
+ return self.dealer
+ return self.streams.get(source_id)
+
+ def update_all (self):
+ """
+ Fetch and update all sources metadata.
+ """
+ self.master.update()
+ self.dealer.update()
+ for source in self.streams.values():
+ source.update()
+
+
+class Monitor:
+ """
+ Monitor multiple controllers.
+ """
+ controllers = None
+
+ def __init__ (self, connector = None):
+ self.controllers = {
+ controller.id : controller
+ for controller in [
+ Controller(station, connector)
+ for station in models.Station.objects.filter(active = True)
+ ]
+ }
+
+ def update (self):
+ for controller in self.controllers.values():
+ controller.update_all()
+
+
diff --git a/aircox_liquidsoap/views.py b/aircox_liquidsoap/views.py
index 7bf8a7f..c0a8da8 100644
--- a/aircox_liquidsoap/views.py
+++ b/aircox_liquidsoap/views.py
@@ -9,36 +9,50 @@ import aircox_liquidsoap.settings as settings
import aircox_liquidsoap.utils as utils
import aircox_programs.models as models
+
+view_monitor = utils.Monitor(
+ utils.Connector(address = settings.AIRCOX_LIQUIDSOAP_SOCKET)
+)
+
+class Actions:
+ @classmethod
+ def exec (cl, monitor, controller, source, action):
+ controller = monitor.controllers.get(controller)
+ source = controller and controller.get(source)
+
+ if not controller or not source or \
+ action.startswith('__') or \
+ action not in cl.__dict__:
+ return -1
+
+ action = getattr(Actions, action)
+ return action(monitor, controller, source)
+
+ @classmethod
+ def skip (cl, monitor, controller, source):
+ source.skip()
+
+
class LiquidControl (View):
template_name = 'aircox_liquidsoap/controller.html'
def get_context_data (self, **kwargs):
- stations = models.Station.objects.all()
- controller = utils.Controller()
-
- for station in stations:
- name = station.get_slug_name()
- streams = models.Stream.objects.filter(
- program__active = True,
- program__station = station
- )
-
- # list sources
- sources = [ 'dealer' ] + \
- [ stream.program.get_slug_name() for stream in streams]
-
- # sources status
- station.sources = { name: controller.get(station) }
- station.sources.update({
- source: controller.get(station, source)
- for source in sources
- })
-
+ view_monitor.update()
return {
'request': self.request,
- 'stations': stations,
+ 'monitor': view_monitor,
+ 'embed': 'embed' in self.request.GET,
}
+ def post (self, request = None, **kwargs):
+ if 'action' in request.POST:
+ POST = request.POST
+ controller = POST.get('controller')
+ source = POST.get('source')
+ action = POST.get('action')
+ Actions.exec(view_monitor, controller, source, action)
+ return HttpResponse('')
+
def get (self, request = None, **kwargs):
self.request = request
context = self.get_context_data(**kwargs)