diff --git a/aircox_liquidsoap/management/commands/liquidsoap_files.py b/aircox_liquidsoap/management/commands/liquidsoap_files.py index 3fba17e..6b80311 100644 --- a/aircox_liquidsoap/management/commands/liquidsoap_files.py +++ b/aircox_liquidsoap/management/commands/liquidsoap_files.py @@ -105,11 +105,19 @@ class Command (BaseCommand): ) } + 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 = { - 'streams': [ - self.liquid_stream(stream) - for stream in models.Stream.objects.filter(program__active = True) + 'stations': [ self.liquid_station(station) + for station in models.Station.objects.filter(active = True) ], 'settings': settings, } diff --git a/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html b/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html new file mode 100644 index 0000000..7f89c0a --- /dev/null +++ b/aircox_liquidsoap/templates/aircox_liquidsoap/controller.html @@ -0,0 +1,11 @@ + +{% for station in stations %} +{% for name, source in station.sources.items %} +
+ {{ name }}: + {{ source.initial_uri }} + +
+{% endfor %} +{% endfor %} + diff --git a/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq b/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq new file mode 100644 index 0000000..04c0706 --- /dev/null +++ b/aircox_liquidsoap/templates/aircox_liquidsoap/station.liq @@ -0,0 +1,58 @@ +{% comment %} +A station has multiple sources: +- dealer: a controlled playlist playing once each track, that reloads on file + change. This is used for scheduled sounds. +- streams: a rotate source with all playlists +- single: security song +{% endcomment %} +{% with name=station.name streams=station.streams %} +def make_station_{{ name }} () = \ + {# dealer #} + dealer = interactive_source('{{ name }}_dealer', playlist.once( \ + reload_mode='watch', \ + '/tmp/dealer.m3u', \ + )) \ + \ + dealer_on = interactive.bool("{{ name }}_dealer_on", false) \ + \ + {# streams #} + streams = interactive_source("streams", rotate([ \ + {% for stream in streams %} + {% if stream.delay %} + delay({{ stream.delay }}., stream("{{ name }}_{{ stream.name }}", "{{ stream.file }}")), \ + {% endif %} + {% 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 %} + {% endif %} + {% endfor %} + ])) \ + \ + {# station #} + interactive_source ( \ + "{{ name }}", \ + fallback(track_sensitive = false, [ \ + at(dealer_on, dealer), \ + {% if station.fallback %} + streams, \ + single("{{ station.fallback }}") \ + {% else %} + mksafe(streams) \ + {% endif %} + ]) \ + ) \ +end \ +{% endwith %} + diff --git a/aircox_liquidsoap/urls.py b/aircox_liquidsoap/urls.py new file mode 100644 index 0000000..4305218 --- /dev/null +++ b/aircox_liquidsoap/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url + +import aircox_liquidsoap.views as views + +urlpatterns = [ + url('^controller/', views.LiquidControl.as_view(), name = 'liquid-controller'), +] + + diff --git a/aircox_liquidsoap/utils.py b/aircox_liquidsoap/utils.py index cd75ad8..7f8a194 100644 --- a/aircox_liquidsoap/utils.py +++ b/aircox_liquidsoap/utils.py @@ -1,4 +1,6 @@ import socket +import re + import aircox_liquidsoap.settings as settings class Controller: @@ -23,12 +25,26 @@ class Controller: def send (self, data): if self.open(): - return -1 + return '' data = bytes(data + '\n', encoding='utf-8') self.socket.sendall(data) return self.socket.recv(10240).decode('utf-8') - def get (self, stream = None): - print(self.send('station.get')) + def parse (self, string): + string = string.split('\n') + data = {} + for line in string: + line = re.search(r'(?P[^=]+)="?(?P([^"]|\\")+)"?', line) + if not line: + continue + line = line.groupdict() + 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 diff --git a/aircox_liquidsoap/views.py b/aircox_liquidsoap/views.py new file mode 100644 index 0000000..7bf8a7f --- /dev/null +++ b/aircox_liquidsoap/views.py @@ -0,0 +1,47 @@ +import re + +from django.views.generic.base import View, TemplateResponseMixin +from django.template.loader import render_to_string +from django.shortcuts import render +from django.http import HttpResponse + +import aircox_liquidsoap.settings as settings +import aircox_liquidsoap.utils as utils +import aircox_programs.models as models + +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 + }) + + return { + 'request': self.request, + 'stations': stations, + } + + def get (self, request = None, **kwargs): + self.request = request + context = self.get_context_data(**kwargs) + return render(request, self.template_name, context) + + diff --git a/aircox_programs/admin.py b/aircox_programs/admin.py index e4b15c1..4e9a960 100755 --- a/aircox_programs/admin.py +++ b/aircox_programs/admin.py @@ -67,9 +67,13 @@ class StreamAdmin (admin.ModelAdmin): list_display = ('id', 'program', 'delay', 'begin', 'end') +@admin.register(Station) +class StationAdmin (NameableAdmin): + fields = NameableAdmin.fields + [ 'active', 'public', 'fallback' ] + @admin.register(Program) class ProgramAdmin (NameableAdmin): - fields = NameableAdmin.fields + fields = NameableAdmin.fields + [ 'stations', 'active' ] inlines = [ ScheduleInline, StreamInline ] def get_form (self, request, obj=None, **kwargs): diff --git a/aircox_programs/models.py b/aircox_programs/models.py index b033fe6..e7765ca 100755 --- a/aircox_programs/models.py +++ b/aircox_programs/models.py @@ -43,6 +43,10 @@ class Nameable (models.Model): class Track (Nameable): + """ + Track of a playlist of an episode. The position can either be expressed + as the position in the playlist or as the moment in seconds it started. + """ # There are no nice solution for M2M relations ship (even without # through) in django-admin. So we unfortunately need to make one- # to-one relations and add a position argument @@ -96,7 +100,8 @@ class Sound (Nameable): path = models.FilePathField( _('file'), path = settings.AIRCOX_PROGRAMS_DIR, - match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT).replace('.', r'\.') + ')$', + match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \ + .replace('.', r'\.') + ')$', recursive = True, blank = True, null = True, ) @@ -216,6 +221,10 @@ class Stream (models.Model): class Schedule (models.Model): + """ + A Schedule defines time slots of programs' diffusions. It can be a run or + a rerun (in such case it is linked to the related schedule). + """ # Frequency for schedules. Basically, it is a mask of bits where each bit is # a week. Bits > rank 5 are used for special schedules. # Important: the first week is always the first week where the weekday of @@ -377,6 +386,18 @@ class Schedule (models.Model): class Diffusion (models.Model): + """ + A Diffusion is a cell in the timetable that is linked to an episode. A + diffusion can have different status that tells us what happens / did + happened or not. + + A Diffusion can have different types: + - default: simple diffusion that is planified / did occurred + - unconfirmed: a generated diffusion that has not been confirmed and thus + is not yet planified + - cancel: the diffusion has been canceled + - stop: the diffusion has been manually stopped + """ Type = { 'default': 0x00, # simple diffusion (done/planed) 'unconfirmed': 0x01, # scheduled by the generator but not confirmed for diffusion @@ -416,12 +437,56 @@ class Diffusion (models.Model): verbose_name_plural = _('Diffusions') -class Program (Nameable): +class Station (Nameable): + """ + A Station regroup one or more programs (stream and normal), and is the top + element used to generate streams outputs and configuration. + """ active = models.BooleanField( - _('inactive'), + _('active'), + default = True, + help_text = _('this station is active') + ) + public = models.BooleanField( + _('public'), + default = True, + help_text = _('information are available to the public'), + ) + fallback = models.FilePathField( + _('fallback song'), + match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \ + .replace('.', r'\.') + ')$', + recursive = True, + blank = True, null = True, + help_text = _('use this song file if there is a problem and nothing is ' + 'played') + ) + + +class Program (Nameable): + """ + A Program can either be a Streamed or a Scheduled program. + + A Streamed program is used to generate non-stop random playlists when there + is not scheduled diffusion. In such a case, a Stream is used to describe + diffusion informations. + + A Scheduled program has a schedule and is the one with a normal use case. + """ + station = models.ForeignKey( + Station, + verbose_name = _('station') + ) + active = models.BooleanField( + _('active'), default = True, help_text = _('if not set this program is no longer active') ) + public = models.BooleanField( + _('public'), + default = True, + help_text = _('information are available to the public') + ) @property def path (self): @@ -454,6 +519,10 @@ class Program (Nameable): return schedule class Episode (Nameable): + """ + Occurrence of a program, can have multiple sounds (archive/excerpt) and + a playlist (with assigned tracks) + """ program = models.ForeignKey( Program, verbose_name = _('program'),