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)