diff --git a/cms/models.py b/cms/models.py index 67f47ad..a782f86 100644 --- a/cms/models.py +++ b/cms/models.py @@ -28,7 +28,6 @@ from taggit.models import TaggedItemBase import bleach import aircox.programs.models as programs -import aircox.controllers.models as controllers import aircox.cms.settings as settings from aircox.cms.utils import image_url @@ -649,7 +648,7 @@ class LogsPage(DatedListPage): template = 'cms/dated_list_page.html' station = models.ForeignKey( - controllers.Station, + programs.Station, verbose_name = _('station'), null = True, on_delete=models.SET_NULL, diff --git a/cms/sections.py b/cms/sections.py index 64968a6..0bc7480 100644 --- a/cms/sections.py +++ b/cms/sections.py @@ -34,7 +34,6 @@ from taggit.models import TaggedItemBase # aircox import aircox.programs.models as programs -import aircox.controllers.models as controllers def related_pages_filter(reset_cache=False): @@ -856,7 +855,7 @@ class SectionList(ListBase, SectionRelativeItem): @register_snippet class SectionLogsList(SectionItem): station = models.ForeignKey( - controllers.Station, + programs.Station, verbose_name = _('station'), null = True, on_delete=models.SET_NULL, diff --git a/controllers/__init__.py b/controllers/__init__.py deleted file mode 100644 index 139597f..0000000 --- a/controllers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/controllers/admin.py b/controllers/admin.py deleted file mode 100644 index aa3c4e8..0000000 --- a/controllers/admin.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.contrib import admin - -import aircox.controllers.models as models - - -class SourceInline(admin.StackedInline): - model = models.Source - extra = 0 - -class OutputInline(admin.StackedInline): - model = models.Output - extra = 0 - - -@admin.register(models.Station) -class StationAdmin(admin.ModelAdmin): - inlines = [ SourceInline, OutputInline ] - -@admin.register(models.Log) -class LogAdmin(admin.ModelAdmin): - list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'related'] - list_filter = ['date', 'source', 'related_type'] - -admin.site.register(models.Source) -admin.site.register(models.Output) - - - - diff --git a/controllers/locale/fr/LC_MESSAGES/django.po b/controllers/locale/fr/LC_MESSAGES/django.po deleted file mode 100644 index ea7e7cd..0000000 --- a/controllers/locale/fr/LC_MESSAGES/django.po +++ /dev/null @@ -1,112 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-08-11 17:07+0200\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" - -#: controllers/models.py:38 -msgid "path" -msgstr "" - -#: controllers/models.py:39 -msgid "path to the working directory" -msgstr "" - -#: controllers/models.py:44 -msgid "plugin" -msgstr "" - -#: controllers/models.py:49 controllers/models.py:301 controllers/models.py:397 -msgid "active" -msgstr "" - -#: controllers/models.py:51 -msgid "this station is active" -msgstr "" - -#: controllers/models.py:142 -msgid "Dealer" -msgstr "" - -#: controllers/models.py:294 controllers/models.py:389 -#: controllers/models.py:444 -msgid "station" -msgstr "" - -#: controllers/models.py:297 controllers/models.py:392 -#: controllers/models.py:438 -msgid "type" -msgstr "" - -#: controllers/models.py:303 -msgid "this source is active" -msgstr "" - -#: controllers/models.py:307 -msgid "related program" -msgstr "" - -#: controllers/models.py:311 -msgid "url" -msgstr "" - -#: controllers/models.py:313 -msgid "url related to a file local or distant" -msgstr "" - -#: controllers/models.py:399 -msgid "this output is active" -msgstr "" - -#: controllers/models.py:402 -msgid "output settings" -msgstr "" - -#: controllers/models.py:403 -msgid "" -"list of comma separated params available; this is put in the output config " -"as raw code; plugin related" -msgstr "" - -#: controllers/models.py:445 -msgid "station on which the event occured" -msgstr "" - -#: controllers/models.py:450 -msgid "source" -msgstr "" - -#: controllers/models.py:452 -msgid "source id that make it happen on the station" -msgstr "" - -#: controllers/models.py:456 -msgid "date" -msgstr "" - -#: controllers/models.py:460 -msgid "comment" -msgstr "" - -#: controllers/templates/aircox/controllers/monitor.html:107 -#: controllers/templates/aircox/controllers/monitor.html:117 -msgid "skip" -msgstr "" - -#: controllers/templates/aircox/controllers/monitor.html:108 -msgid "update" -msgstr "" diff --git a/controllers/management/commands/controllers.py b/controllers/management/commands/controllers.py deleted file mode 100644 index a1f990e..0000000 --- a/controllers/management/commands/controllers.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Main tool to work with liquidsoap. We can: -- monitor Liquidsoap's sources and do logs, print what's on air. -- generate configuration files and playlists for a given station -""" -import os -import time -import re - -from argparse import RawTextHelpFormatter - -from django.conf import settings as main_settings -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone as tz - -import aircox.programs.models as programs -from aircox.controllers.models import Log, Station -from aircox.controllers.monitor import Monitor - - -class Command (BaseCommand): - help= __doc__ - - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter - group = parser.add_argument_group('actions') - group.add_argument( - '-c', '--config', action='store_true', - help='generate configuration files for the stations' - ) - group.add_argument( - '-m', '--monitor', action='store_true', - help='monitor the scheduled diffusions and log what happens' - ) - group.add_argument( - '-r', '--run', action='store_true', - help='run the required applications for the stations' - ) - - group = parser.add_argument_group('options') - group.add_argument( - '-d', '--delay', type=int, - default=1000, - help='time to sleep in MILLISECONDS between two updates when we ' - 'monitor' - ) - group.add_argument( - '-s', '--station', type=str, action='append', - help='name of the station to monitor instead of monitoring ' - 'all stations' - ) - group.add_argument( - '-t', '--timeout', type=int, - default=600, - help='time to wait in SECONDS before canceling a diffusion that ' - 'has not been ran but should have been. If 0, does not ' - 'check' - ) - - def handle (self, *args, - config = None, run = None, monitor = None, - station = [], delay = 1000, timeout = 600, - **options): - - stations = Station.objects.filter(name__in = station)[:] \ - if station else \ - Station.objects.all()[:] - - for station in stations: - station.prepare() - if config and not run: # no need to write it twice - station.controller.push() - if run: - station.controller.process_run() - - if monitor: - monitors = [ - Monitor(station, cancel_timeout = timeout) - for station in stations - ] - delay = delay / 1000 - while True: - for monitor in monitors: - monitor.monitor() - time.sleep(delay) - - if run: - for station in stations: - station.controller.process_wait() - diff --git a/controllers/models.py b/controllers/models.py deleted file mode 100644 index ab4d663..0000000 --- a/controllers/models.py +++ /dev/null @@ -1,482 +0,0 @@ -""" -Classes that define common interfaces in order to control external -software that generate the audio streams for us, such as Liquidsoap. - -It must be implemented per program in order to work. - - -Basically, we follow the follow the idea that a Station has different -sources that are used to generate the audio stream: -- **stream**: one source per Streamed program; -- **dealer**: one source for all Scheduled programs; -- **master**: main output -""" -import os -import datetime -import logging -from enum import IntEnum - -from django.db import models -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils import timezone as tz - -import aircox.programs.models as programs -from aircox.programs.utils import to_timedelta -import aircox.controllers.settings as settings -from aircox.controllers.plugins.plugins import Plugins - - -logger = logging.getLogger('aircox.core') - -Plugins.discover() - - -class Station(programs.Nameable): - path = models.CharField( - _('path'), - help_text = _('path to the working directory'), - max_length = 256, - blank = True, - ) - plugin_name = models.CharField( - _('plugin'), - max_length = 32, - choices = [ (name, name) for name in Plugins.registry.keys() ], - ) - active = models.BooleanField( - _('active'), - default = True, - help_text = _('this station is active') - ) - - plugin = None - """ - The plugin used for this station. This is initialized at __init__, - based on self.plugin_name and should not be changed. - """ - controller = None - """ - Controllers over the station. It is implemented by the plugin using - plugin.StationController - """ - - @property - def id_(self): - return self.slug - - def get_sources(self, type = None, prepare = True, dealer = False): - """ - Return a list of active sources that can have their controllers - initialized. - """ - qs = self.source_set.filter(active = True) - if type: - qs = qs.filter(type = type) - - sources = [ source.prepare() or source for source in qs ] - if dealer == True: - sources.append(self.dealer) - return sources - - @property - def all_sources(self): - return self.get_sources(dealer = True) - - @property - def stream_sources(self): - return self.get_sources(type = Source.Type.stream) - - @property - def file_sources(self): - return self.get_sources(type = Source.Type.file) - - @property - def fallback_sources(self): - return self.get_sources(type = Source.Type.fallback) - - @property - def outputs(self): - """ - List of active outputs - """ - return [ output for output in self.output_set.filter(active = True) ] - - def prepare(self, fetch = True): - """ - Initialize station's controller. Does not initialize sources' - controllers. - - Note that the Station must have been saved first, in order to - have correct informations such as the working path. - """ - if not self.pk: - raise ValueError('station be must saved first') - - self.controller = self.plugin.init_station(self) - self.dealer.prepare() - for source in self.source_set.all(): - source.prepare() - if fetch: - self.controller.fetch() - - def make_sources(self): - """ - Generate default sources for the station and save them. - """ - streams = programs.Program.objects.filter( - active = True, stream__isnull = False - ) - for stream in streams: - Source(station = self, - type = Source.Type.stream, - name = stream.name, - program = stream).save() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.dealer = Source( - station = self, - name = _('Dealer'), - type = Source.Type.dealer - ) - - if self.plugin_name: - self.plugin = Plugins.registry.get(self.plugin_name) - - def get_played(self, models, archives = True): - """ - Return a queryset with log of played elements on this station, - of the given models, ordered by date ascending. - - * model: a model or a list of models - * archive: if false, exclude log of diffusion's archives from - the queryset; - """ - qs = Log.objects.get_for(model = models) \ - .filter(station = self, type = Log.Type.play) - if not archives and self.dealer: - qs = qs.exclude( - source = self.dealer.id_, - related_type = ContentType.objects.get_for_model( - programs.Sound - ) - ) - return qs.order_by('date') - - - @staticmethod - def __mix_logs_and_diff(diffs, logs, count = 0): - """ - Mix together logs and diffusion items of the same day, - ordered by their date. - - Diffs and Logs are assumed to be ordered by -date, and so is - the resulting list - """ - # we fill a list with diff and retrieve logs that happened between - # each to put them too there - items = [] - diff_ = None - for diff in diffs.order_by('-start'): - logs_ = \ - logs.filter(date__gt = diff.end, date__lt = diff_.start) \ - if diff_ else \ - logs.filter(date__gt = diff.end) - diff_ = diff - items.extend(logs_) - items.append(diff) - if count and len(items) >= count: - break - - if diff_: - if count and len(items) >= count: - return items[:count] - logs_ = logs.filter(date__lt = diff_.end) - else: - logs_ = logs.all() - - items.extend(logs_) - return items[:count] if count else items - - - def on_air(self, date = None, count = 0): - """ - Return a list of what happened on air, based on logs and - diffusions informations. The list is sorted by -date. - - * date: only for what happened on this date; - * count: number of items to retrieve if not zero; - - If date is not specified, count MUST be set to a non-zero value. - Be careful with what you which for: the result is a plain list. - - The list contains: - * track logs: for the streamed programs; - * diffusion: for the scheduled diffusions; - """ - # FIXME: as an iterator? - # TODO argument to get sound instead of tracks - # TODO #Station - if not date and not count: - raise ValueError('at least one argument must be set') - - if date and date > datetime.date.today(): - return [] - - logs = Log.objects.get_for(model = programs.Track) \ - .filter(station = self) \ - .order_by('-date') - - if date: - logs = logs.filter(date__contains = date) - diffs = programs.Diffusion.objects.get_at(date) - else: - diffs = programs.Diffusion.objects - - diffs = diffs.filter(type = programs.Diffusion.Type.normal) \ - .filter(start__lte = tz.now()) - return self.__mix_logs_and_diff(diffs, logs, count) - - - def save(self, make_sources = True, *args, **kwargs): - """ - * make_sources: if the model has not been yet saved, generate - sources for it. - """ - if not self.path: - self.path = os.path.join( - settings.AIRCOX_CONTROLLERS_MEDIA, - self.slug - ) - - super().save(*args, **kwargs) - - if make_sources and not self.source_set.count(): - self.make_sources() - self.prepare() - - # test - self.prepare() - self.controller.push() - -class Source(programs.Nameable): - """ - Source designate a source for the audio stream. - - A Source can have different types, that are declared here by order - of priority. A lower value priority means that the source has a higher - priority. - """ - class Type(IntEnum): - file = 0x01 - """ - Use a file as input, that can either be a local or distant file. - Path must be set. - - Should be used only for exceptional cases, such as streaming from - distant place. - """ - dealer = 0x02 - """ - This source is used for scheduled programs. It is automatically - added to the station, and may not be saved. - """ - stream = 0x03 - """ - Source related to a streamed programs (one for each). programs.Program - must be set in this case. - It uses program's stream information in order to generate correct - delays or time ranges. - """ - fallback = 0x05 - """ - Same as file, but declared with a lower priority than streams. - Their goal is to be used when no other source is available, so - it is NOT interactive. - """ - - station = models.ForeignKey( - Station, - verbose_name = _('station'), - ) - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - ) - active = models.BooleanField( - _('active'), - default = True, - help_text = _('this source is active') - ) - program = models.ForeignKey( - programs.Program, - verbose_name = _('related program'), - blank = True, null = True, - limit_choices_to = { 'stream__isnull': False }, - ) - url = models.TextField( - _('url'), - blank = True, null = True, - help_text = _('url related to a file local or distant') - ) - - controller = None - """ - Implement controls over a Source. This is done by the plugin, that - implements plugin.SourceController; - """ - - @property - def id_(self): - return self.slug - - @property - def stream(self): - if self.type != self.Type.stream or not self.program: - return - return self.program.stream_set and self.program.stream_set.first() - - def prepare(self, fetch = True): - """ - Create a controller - """ - self.controller = self.station.plugin.init_source(self) - if fetch: - self.controller.fetch() - - def save(self, *args, **kwargs): - if self.type in (self.Type.file, self.Type.fallback) and \ - not self.url: - raise ValueError('url is missing but required') - if self.type == self.Type.stream and \ - (not self.program or not self.program.stream_set.count()): - raise ValueError('missing related stream program; program must be ' - 'a streamed program') - if self.type == self.Type.dealer: - raise ValueError('can not save a dealer source') - - super().save(*args, **kwargs) - - -class Output (models.Model): - class Type(IntEnum): - jack = 0x00 - alsa = 0x01 - icecast = 0x02 - - station = models.ForeignKey( - Station, - verbose_name = _('station'), - ) - type = models.SmallIntegerField( - _('type'), - # we don't translate the names since it is project names. - choices = [ (int(y), x) for x,y in Type.__members__.items() ], - ) - active = models.BooleanField( - _('active'), - default = True, - help_text = _('this output is active') - ) - settings = models.TextField( - _('output settings'), - help_text = _('list of comma separated params available; ' - 'this is put in the output config as raw code; ' - 'plugin related'), - blank = True, null = True - ) - - -class Log(programs.Related): - """ - Log sounds and diffusions that are played on the station. - - This only remember what has been played on the outputs, not on each - track; Source designate here which source is responsible of that. - """ - class Type(IntEnum): - stop = 0x00 - """ - Source has been stopped (only when there is no more sound) - """ - play = 0x01 - """ - Source has been started/changed and is running related_object - If no related_object is available, comment is used to designate - the sound. - """ - load = 0x02 - """ - Source starts to be preload related_object - """ - other = 0x03 - """ - Other log - """ - - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - blank = True, null = True, - ) - station = models.ForeignKey( - Station, - verbose_name = _('station'), - help_text = _('station on which the event occured'), - ) - source = models.CharField( - # we use a CharField to avoid loosing logs information if the - # source is removed - _('source'), - max_length=64, - help_text = _('source id that make it happen on the station'), - blank = True, null = True, - ) - date = models.DateTimeField( - _('date'), - default=tz.now, - ) - comment = models.CharField( - _('comment'), - max_length = 512, - blank = True, null = True, - ) - - @property - def end(self): - """ - Calculated end using self.related informations - """ - if self.related_type == programs.Diffusion: - return self.related.end - if self.related_type == programs.Sound: - return self.date + to_timedelta(self.duration) - return self.date - - def is_expired(self, date = None): - """ - Return True if the log is expired. Note that it only check - against the date, so it is still possible that the expiration - occured because of a Stop or other source. - """ - date = programs.date_or_default(date) - return self.end < date - - def print(self): - logger.info('log #%s: %s%s', - str(self), - self.comment or '', - ' -- {} #{}'.format(self.related_type, self.related_id) - if self.related else '' - ) - - def __str__(self): - return '#{} ({}, {})'.format( - self.pk, self.date.strftime('%Y/%m/%d %H:%M'), self.source - ) - - - diff --git a/controllers/monitor.py b/controllers/monitor.py deleted file mode 100644 index 5019aad..0000000 --- a/controllers/monitor.py +++ /dev/null @@ -1,261 +0,0 @@ -import time - -from django.utils import timezone as tz - -import aircox.programs.models as programs -from aircox.controllers.models import Log - -class Monitor: - """ - Log and launch diffusions for the given station. - - Monitor should be able to be used after a crash a go back - where it was playing, so we heavily use logs to be able to - do that. - - We keep trace of played items on the generated stream: - - sounds played on this stream; - - scheduled diffusions - - tracks for sounds of streamed programs - """ - station = None - controller = None - cancel_timeout = 60*10 - """ - Time in seconds before a diffusion that have archives is cancelled - because it has not been played. - """ - sync_timeout = 60*10 - """ - Time in minuts before all stream playlists are checked and updated - """ - sync_next = None - """ - Datetime of the next sync - """ - - def __init__(self, station, **kwargs): - self.station = station - self.__dict__.update(kwargs) - - def monitor(self): - """ - Run all monitoring functions. Ensure that station has controllers - """ - if not self.controller: - self.station.prepare() - self.controller = self.station.controller - - if not self.controller.ready(): - return - - self.trace() - self.sync_playlists() - self.handle() - - def log(self, **kwargs): - """ - Create a log using **kwargs, and print info - """ - log = Log(station = self.station, **kwargs) - log.save() - log.print() - - def trace(self): - """ - Check the current_sound of the station and update logs if - needed. - """ - self.controller.fetch() - current_sound = self.controller.current_sound - current_source = self.controller.current_source - if not current_sound or not current_source: - return - - log = Log.objects.get_for(model = programs.Sound) \ - .filter(station = self.station).order_by('date').last() - - # only streamed - if log and (log.related and not log.related.diffusion): - self.trace_sound_tracks(log) - - # TODO: expiration - if log and (log.source == current_source.id_ and \ - log.related and - log.related.path == current_sound): - return - - sound = programs.Sound.objects.filter(path = current_sound) - self.log( - type = Log.Type.play, - source = current_source.id_, - date = tz.now(), - related = sound[0] if sound else None, - comment = None if sound else current_sound, - ) - - def trace_sound_tracks(self, log): - """ - Log tracks for the given sound (for streamed programs); Called by - self.trace - """ - logs = Log.objects.get_for(model = programs.Track) \ - .filter(pk__gt = log.pk) - logs = [ log.related_id for log in logs ] - - tracks = programs.Track.objects.get_for(object = log.related) \ - .filter(in_seconds = True) - if tracks and len(tracks) == len(logs): - return - - tracks = tracks.exclude(pk__in = logs).order_by('position') - now = tz.now() - for track in tracks: - pos = log.date + tz.timedelta(seconds = track.position) - if pos < now: - self.log( - type = Log.Type.play, - source = log.source, - date = pos, - related = track - ) - - def sync_playlists(self): - """ - Synchronize updated playlists - """ - now = tz.now() - if self.sync_next and self.sync_next < now: - return - - self.sync_next = now + tz.timedelta(seconds = self.sync_timeout) - - for source in self.station.stream_sources: - playlist = [ sound.path for sound in - source.program.sound_set.all() ] - source.controller.playlist = playlist - - def trace_canceled(self): - """ - Check diffusions that should have been played but did not start, - and cancel them - """ - if not self.cancel_timeout: - return - - diffs = programs.objects.get_at().filter( - type = programs.Diffusion.Type.normal, - sound__type = programs.Sound.Type.archive, - ) - logs = station.get_played(models = programs.Diffusion) - - date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout) - for diff in diffs: - if logs.filter(related = diff): - continue - if diff.start < now: - diff.type = programs.Diffusion.Type.canceled - diff.save() - self.log( - type = Log.Type.other, - related = diff, - comment = 'Diffusion canceled after {} seconds' \ - .format(self.cancel_timeout) - ) - - def __current_diff(self): - """ - Return a tuple with the currently running diffusion and the items - that still have to be played. If there is not, return None - """ - station = self.station - now = tz.make_aware(tz.datetime.now()) - - diff_log = station.get_played(models = programs.Diffusion) \ - .order_by('date').last() - if not diff_log or \ - not diff_log.related.is_date_in_range(now): - return None, [] - - # sound has switched? assume it has been (forced to) stopped - sounds = station.get_played(models = programs.Sound) \ - .filter(date__gte = diff_log.date) \ - .order_by('date') - - if sounds.last() and sounds.last().source != diff_log.source: - return diff_log, [] - - # last diff is still playing: get the remaining playlist - sounds = sounds.filter( - source = diff_log.source, pk__gt = diff_log.pk - ) - sounds = [ - sound.related.path for sound in sounds - if sound.related.type != programs.Sound.Type.removed - ] - - return ( - diff_log.related, - [ path for path in diff_log.related.playlist - if path not in sounds ] - ) - - def __next_diff(self, diff): - """ - Return the tuple with the next diff that should be played and - the playlist - - Note: diff is a log - """ - station = self.station - now = tz.now() - - args = {'start__gt': diff.date } if diff else {} - diff = programs.Diffusion.objects.get_at(now).filter( - type = programs.Diffusion.Type.normal, - sound__type = programs.Sound.Type.archive, - **args - ).distinct().order_by('start').first() - return (diff, diff and diff.playlist or []) - - def handle(self): - """ - Handle scheduled diffusion, trigger if needed, preload playlists - and so on. - """ - station = self.station - dealer = station.dealer - if not dealer: - return - now = tz.now() - - # current and next diffs - diff, playlist = self.__current_diff() - dealer.controller.active = bool(playlist) - - next_diff, next_playlist = self.__next_diff(diff) - playlist += next_playlist - - # playlist update - if dealer.controller.playlist != playlist: - dealer.controller.playlist = playlist - if next_diff: - self.log( - type = Log.Type.load, - source = dealer.id_, - date = now, - related = next_diff - ) - - # dealer.on when next_diff start <= now - if next_diff and not dealer.controller.active and \ - next_diff.start <= now: - dealer.controller.active = True - self.log( - type = Log.Type.play, - source = dealer.id_, - date = now, - related = next_diff, - ) - - diff --git a/controllers/plugins/liquidsoap.py b/controllers/plugins/liquidsoap.py deleted file mode 100644 index d681eec..0000000 --- a/controllers/plugins/liquidsoap.py +++ /dev/null @@ -1,123 +0,0 @@ -import os -import subprocess -import atexit - -import aircox.controllers.plugins.plugins as plugins -from aircox.controllers.plugins.connector import Connector - - -class LiquidSoap(plugins.Plugin): - @staticmethod - def init_station(station): - return StationController(station = station) - - @staticmethod - def init_source(source): - return SourceController(source = source) - - -class StationController(plugins.StationController): - template_name = 'aircox/controllers/liquidsoap.liq' - socket_path = '' - connector = None - - def __init__(self, station, **kwargs): - super().__init__( - station = station, - path = os.path.join(station.path, 'station.liq'), - socket_path = os.path.join(station.path, 'station.sock'), - **kwargs - ) - self.connector = Connector(self.socket_path) - - def _send(self, *args, **kwargs): - return self.connector.send(*args, **kwargs) - - def __get_process_args(self): - return ['liquidsoap', '-v', self.path] - - def ready(self): - return self._send('var.list') != '' - - def fetch(self): - super().fetch() - - rid = self._send('request.on_air').split(' ')[0] - if ' ' in rid: - rid = rid[:rid.index(' ')] - if not rid: - return - - data = self._send('request.metadata ', rid, parse = True) - if not data: - return - - self.current_sound = data.get('initial_uri') - try: - self.current_source = next( - source for source in self.station.get_sources(dealer = True) - if source.controller.rid == rid - ) - except: - self.current_source = None - - def skip(self): - """ - Skip a given source. If no source, use master. - """ - self._send(self.station.id_, '.skip') - - -class SourceController(plugins.SourceController): - rid = None - connector = None - - def __init__(self, *args, **kwargs): - super().__init__(**kwargs) - self.connector = self.source.station.controller.connector - - def _send(self, *args, **kwargs): - return self.connector.send(*args, **kwargs) - - @property - def active(self): - return self._send('var.get ', self.source.id_, '_active') == 'true' - - @active.setter - def active(self, value): - self._send('var.set ', self.source.id_, '_active', '=', - 'true' if value else 'false') - - def skip(self): - """ - Skip a given source. If no source, use master. - """ - self._send(self.source.id_, '.skip') - - def fetch(self): - data = self._send(self.source.id_, '.get', parse = True) - if not data or type(data) != dict: - return - - self.rid = data.get('rid') - self.current_sound = data.get('initial_uri') - - def stream(self): - """ - Return a dict with stream info for a Stream program, or None if there - is not. Used in the template. - """ - stream = self.source.stream - if not stream or (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 0 - } - - diff --git a/controllers/settings.py b/controllers/settings.py deleted file mode 100755 index 69f1728..0000000 --- a/controllers/settings.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -import stat - -from django.conf import settings - -def ensure (key, default): - globals()[key] = getattr(settings, key, default) - - -# Working directory for the controllers -ensure('AIRCOX_CONTROLLERS_MEDIA', '/tmp/aircox') - - diff --git a/controllers/urls.py b/controllers/urls.py deleted file mode 100644 index 26fb30b..0000000 --- a/controllers/urls.py +++ /dev/null @@ -1,9 +0,0 @@ - -from django.conf.urls import include, url -import aircox.controllers.views as views - -urls = [ - url(r'^monitor', views.Monitor.as_view(), name='controllers.monitor'), - url(r'^on_air', views.on_air, name='controllers.on_air'), -] - diff --git a/controllers/views.py b/controllers/views.py deleted file mode 100644 index 5280aca..0000000 --- a/controllers/views.py +++ /dev/null @@ -1,130 +0,0 @@ -import json - -from django.views.generic.base import View, TemplateResponseMixin -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, Http404 -from django.shortcuts import render -from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils import timezone as tz - -import aircox.controllers.models as models - - -class Stations: - stations = models.Station.objects.all() - update_timeout = None - fetch_timeout = None - - def fetch(self): - if self.fetch_timeout and self.fetch_timeout > tz.now(): - return - - self.fetch_timeout = tz.now() + tz.timedelta(seconds = 5) - for station in self.stations: - station.prepare(fetch = True) - -stations = Stations() - - -def on_air(request): - try: - import aircox.cms.models as cms - except: - cms = None - - station = request.GET.get('station'); - if station: - station = stations.stations.filter(name = station) - else: - station = stations.stations.first() - - last = station.on_air(count = 1) - if not last: - return HttpResponse('') - - last = last[0] - if type(last) == models.Log: - last = { - 'type': 'track', - 'artist': last.related.artist, - 'title': last.related.title, - 'date': last.date, - } - else: - try: - publication = None - if cms: - publication = \ - cms.DiffusionPage.objects.filter( - diffusion = last.initial or last).first() or \ - cms.ProgramPage.objects.filter( - program = last.program).first() - except: - pass - - last = { - 'type': 'diffusion', - 'title': last.program.name, - 'date': last.start, - 'url': publication.specific.url if publication else None, - } - - last['date'] = str(last['date']) - return HttpResponse(json.dumps(last)) - - -# TODO: -# - login url -class Monitor(View,TemplateResponseMixin,LoginRequiredMixin): - template_name = 'aircox/controllers/monitor.html' - - def get_context_data(self, **kwargs): - stations.fetch() - return { 'stations': stations.stations } - - def get (self, request = None, **kwargs): - if not request.user.is_active: - return Http404() - - self.request = request - context = self.get_context_data(**kwargs) - return render(request, self.template_name, context) - - def post (self, request = None, **kwargs): - if not request.user.is_active: - return Http404() - - if not ('action' or 'station') in request.POST: - return HttpResponse('') - - POST = request.POST - controller = POST.get('controller') - action = POST.get('action') - - station = stations.stations.filter(name = POST.get('station')) \ - .first() - if not station: - return HttpResponse('') - station.prepare(fetch=True) - - source = None - if 'source' in POST: - source = station.source_set.filter(name = POST['source']) \ - .first() - if not source and POST['source'] == station.dealer.name: - source = station.dealer - - if source: - source.prepare() - - if station and action == 'skip': - if source: - print('skip ', source) - source.controller.skip() - else: - station.controller.skip() - - return HttpResponse('') - - - diff --git a/docs/technicians.md b/docs/technicians.md new file mode 100644 index 0000000..de8270a --- /dev/null +++ b/docs/technicians.md @@ -0,0 +1,28 @@ + +# General information +Aircox is a set of Django applications that aims to provide a radio management solution, and is +written in Python 3.5. + +Running Aircox on production involves: +* Aircox modules and a running Django project; +* a supervisor for common tasks (sounds monitoring, stream control, etc.) -- `supervisord`; +* a wsgi and an HTTP server -- `gunicorn`, `nginx`; +* a database supported by Django (MySQL, SQLite, PostGresSQL); + +# Architecture and concepts +Aircox is divided in three main modules: +* `programs`: basics of Aircox (programs, diffusions, sounds, etc. management); +* `controllers`: interact with application to generate audio stream (LiquidSoap); +* `cms`: create a website with Aircox elements (playlists, timetable, players on the website); + + + + + +# Installation + + +# Configuration + + + diff --git a/programs/admin.py b/programs/admin.py index f157224..3ede9e0 100755 --- a/programs/admin.py +++ b/programs/admin.py @@ -171,3 +171,23 @@ class TrackAdmin(admin.ModelAdmin): list_display = ['id', 'title', 'artist', 'position', 'in_seconds', 'related'] + +# TODO: sort & redo +class OutputInline(admin.StackedInline): + model = Output + extra = 0 + +@admin.register(Station) +class StationAdmin(admin.ModelAdmin): + inlines = [ OutputInline ] + +@admin.register(Log) +class LogAdmin(admin.ModelAdmin): + list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'related'] + list_filter = ['date', 'source', 'related_type'] + +admin.site.register(Output) + + + + diff --git a/controllers/plugins/connector.py b/programs/connector.py similarity index 100% rename from controllers/plugins/connector.py rename to programs/connector.py diff --git a/controllers/plugins/plugins.py b/programs/controllers.py similarity index 58% rename from controllers/plugins/plugins.py rename to programs/controllers.py index f9d7b81..6092bbd 100644 --- a/controllers/plugins/plugins.py +++ b/programs/controllers.py @@ -5,46 +5,21 @@ import atexit from django.template.loader import render_to_string -import aircox.programs.models as programs +import aircox.programs.models as models +import aircox.programs.settings as settings -class Plugins(type): - registry = {} - - def __new__(cls, name, bases, attrs): - cl = super().__new__(cls, name, bases, attrs) - if name != 'Plugin': - if not cl.name: - cl.name = name.lower() - cls.registry[cl.name] = cl - return cl - - @classmethod - def discover(cls): - """ - Discover plugins -- needed because of the import traps - """ - import aircox.controllers.plugins.liquidsoap +from aircox.programs.connector import Connector -class Plugin(metaclass=Plugins): - name = '' - - def init_station(self, station): - pass - - def init_source(self, source): - pass - - -class StationController: +class Streamer: """ - Controller of a Station. + Audio controller of a Station. """ station = None """ Related station """ - template_name = '' + template_name = 'aircox/controllers/liquidsoap.liq' """ If set, use this template in order to generated the configuration file in self.path file @@ -62,10 +37,39 @@ class StationController: Current source object that is responsible of self.current_sound """ process = None + """ + Application's process if ran from Streamer + """ - def __init__(self, **kwargs): + socket_path = '' + """ + Path to the connector's socket + """ + connector = None + """ + Connector to Liquidsoap server + """ + + def __init__(self, station, **kwargs): + self.station = station + self.path = os.path.join(station.path, 'station.liq') + self.socket_path = os.path.join(station.path, 'station.sock') + self.connector = Connector(self.socket_path) self.__dict__.update(kwargs) + @property + def id(self): + """ + Streamer identifier common in both external app and here + """ + return self.station.slug + + # + # RPC + # + def _send(self, *args, **kwargs): + return self.connector.send(*args, **kwargs) + def fetch(self): """ Fetch data of the children and so on @@ -73,21 +77,72 @@ class StationController: The base function just execute the function of all children sources. The plugin must implement the other extra part """ - sources = self.station.get_sources(dealer = True) + sources = self.station.sources for source in sources: - source.prepare() - if source.controller: - source.controller.fetch() + source.fetch() + rid = self._send('request.on_air').split(' ')[0] + if ' ' in rid: + rid = rid[:rid.index(' ')] + if not rid: + return + + data = self._send('request.metadata ', rid, parse = True) + if not data: + return + + self.current_sound = data.get('initial_uri') + try: + self.current_source = next( + source for source in self.station.sources + if source.rid == rid + ) + except: + self.current_source = None + + def push(self, config = True): + """ + Update configuration and children's info. + + The base function just execute the function of all children + sources. The plugin must implement the other extra part + """ + sources = self.station.sources + for source in sources: + source.push() + + if config and self.path and self.template_name: + data = render_to_string(self.template_name, { + 'station': self.station, + 'streamer': self, + 'settings': settings, + }) + data = re.sub('[\t ]+\n', '\n', data) + data = re.sub('\n{3,}', '\n\n', data) + + os.makedirs(os.path.dirname(self.path), exist_ok = True) + with open(self.path, 'w+') as file: + file.write(data) + + def skip(self): + """ + Skip a given source. If no source, use master. + """ + if self.current_source: + self.current_source.skip() + else: + self._send(self.id, '.skip') + + # + # Process management + # def __get_process_args(self): """ Get arguments for the executed application. Called by exec, to be used as subprocess.Popen(__get_process_args()). If no value is returned, abort the execution. - - Must be implemented by the plugin """ - return [] + return ['liquidsoap', '-v', self.path] def process_run(self): """ @@ -123,49 +178,15 @@ class StationController: """ If external program is ready to use, returns True """ - - def push(self, config = True): - """ - Update configuration and children's info. - - The base function just execute the function of all children - sources. The plugin must implement the other extra part - """ - sources = self.station.get_sources(dealer = True) - for source in sources: - source.prepare() - if source.controller: - source.controller.push() - - if config and self.path and self.template_name: - import aircox.controllers.settings as settings - - data = render_to_string(self.template_name, { - 'station': self.station, - 'settings': settings, - }) - data = re.sub('[\t ]+\n', '\n', data) - data = re.sub('\n{3,}', '\n\n', data) - - os.makedirs(os.path.dirname(self.path), exist_ok = True) - with open(self.path, 'w+') as file: - file.write(data) + return self._send('var.list') != '' - def skip(self): - """ - Skip the current sound on the station - """ - if self.current_source: - self.current_source.controller.skip() - - -class SourceController: +class Source: """ Controller of a Source. Value are usually updated directly on the external side. """ - source = None + program = None """ Related source """ @@ -187,8 +208,40 @@ class SourceController: Current source being responsible of the current sound """ + rid = None + """ + Current request id of the source in LiquidSoap + """ + connector = None + """ + Connector to Liquidsoap server + """ + + @property + def id(self): + return self.program.slug if self.program else 'dealer' + + def __init__(self, station, **kwargs): + self.station = station + self.connector = self.station.streamer.connector + self.__dict__.update(kwargs) + self.__init_playlist() + + # + # Playlist + # __playlist = None + def __init_playlist(self): + self.__playlist = [] + if not self.path: + self.path = os.path.join(self.station.path, + self.id + '.m3u') + self.from_file() + + if not self.__playlist: + self.from_db() + @property def playlist(self): """ @@ -204,45 +257,6 @@ class SourceController: self.__playlist = value self.push() - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - self.__playlist = [] - if not self.path: - self.path = os.path.join(self.source.station.path, - self.source.slug + '.m3u') - self.from_file() - - if not self.__playlist: - self.from_db() - - def skip(self): - """ - Skip the current sound in the source - """ - pass - - def fetch(self): - """ - Get the source information - """ - pass - - def push(self): - """ - Update data relative to the source on the external program. - By default write the playlist. - """ - os.makedirs(os.path.dirname(self.path), exist_ok = True) - with open(self.path, 'w') as file: - file.write('\n'.join(self.__playlist or [])) - - def activate(self, value = True): - """ - Activate/Deactivate current source. May be different from the - containing Source. - """ - pass - def from_db(self, diffusion = None, program = None): """ Load a playlist to the controller from the database. If diffusion or @@ -255,21 +269,16 @@ class SourceController: self.playlist = diffusion.playlist return - source = self.source - program = program or source.program + program = program or self.program if program: self.playlist = [ sound.path for sound in - programs.Sound.objects.filter( - type = programs.Sound.Type.archive, + models.Sound.objects.filter( + type = models.Sound.Type.archive, program = program, ) ] return - if source.type == source.Type.file and source.url: - self.playlist = [ source.url ] - return - def from_file(self, path = None): """ Load a playlist from the given file (if not, use the @@ -284,7 +293,63 @@ class SourceController: self.__playlist = self.__playlist.split('\n') \ if self.__playlist else [] -class Monitor: - station = None + # + # RPC + # + def _send(self, *args, **kwargs): + return self.connector.send(*args, **kwargs) + @property + def active(self): + return self._send('var.get ', self.id, '_active') == 'true' + + @active.setter + def active(self, value): + self._send('var.set ', self.id, '_active', '=', + 'true' if value else 'false') + + def fetch(self): + """ + Get the source information + """ + data = self._send(self.id, '.get', parse = True) + if not data or type(data) != dict: + return + + self.rid = data.get('rid') + self.current_sound = data.get('initial_uri') + + def push(self): + """ + Update data relative to the source on the external program. + By default write the playlist. + """ + os.makedirs(os.path.dirname(self.path), exist_ok = True) + with open(self.path, 'w') as file: + file.write('\n'.join(self.__playlist or [])) + + def skip(self): + """ + Skip the current sound in the source + """ + self._send(self.id, '.skip') + + def stream(self): + """ + Return a dict with stream info for a Stream program, or None if there + is not. Used in the template. + """ + # TODO: multiple streams + stream = self.program.stream_set.all().first() + if not stream or (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 0 + } diff --git a/programs/models.py b/programs/models.py index 0e3b7ea..3799f69 100755 --- a/programs/models.py +++ b/programs/models.py @@ -46,6 +46,9 @@ def date_or_default(date, no_time = False): return date +# +# Abstracts +# class RelatedManager(models.Manager): def get_for(self, object = None, model = None): """ @@ -112,166 +115,344 @@ class Nameable(models.Model): abstract = True -class Sound(Nameable): +# +# Station related classes +# +class Station(Nameable): """ - A Sound is the representation of a sound file that can be either an excerpt - or a complete archive of the related diffusion. + Represents a radio station, to which multiple programs are attached + and that is used as the top object for everything. + + A Station holds controllers for the audio stream generation too. + Theses are set up when needed (at the first access to these elements) + then cached. """ - class Type(IntEnum): - other = 0x00, - archive = 0x01, - excerpt = 0x02, - removed = 0x03, - - program = models.ForeignKey( - 'Program', - verbose_name = _('program'), - blank = True, null = True, - help_text = _('program related to it'), - ) - diffusion = models.ForeignKey( - 'Diffusion', - verbose_name = _('diffusion'), - blank = True, null = True, - help_text = _('initial diffusion related it') - ) - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - blank = True, null = True - ) - path = models.FilePathField( - _('file'), - path = settings.AIRCOX_PROGRAMS_DIR, - match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \ - .replace('.', r'\.') + ')$', - recursive = True, - blank = True, null = True, - max_length = 256 - ) - embed = models.TextField( - _('embed HTML code'), - blank = True, null = True, - help_text = _('HTML code used to embed a sound from external plateform'), - ) - duration = models.TimeField( - _('duration'), - blank = True, null = True, - help_text = _('duration of the sound'), - ) - mtime = models.DateTimeField( - _('modification time'), - blank = True, null = True, - help_text = _('last modification date and time'), - ) - good_quality = models.BooleanField( - _('good quality'), - default = False, - help_text = _('sound\'s quality is okay') - ) - public = models.BooleanField( - _('public'), - default = False, - help_text = _('the sound is accessible to the public') + path = models.CharField( + _('path'), + help_text = _('path to the working directory'), + max_length = 256, + blank = True, ) - def get_mtime(self): - """ - Get the last modification date from file - """ - mtime = os.stat(self.path).st_mtime - mtime = tz.datetime.fromtimestamp(mtime) - # db does not store microseconds - mtime = mtime.replace(microsecond = 0) - return tz.make_aware(mtime, tz.get_current_timezone()) + # + # Controllers + # + __sources = None + __dealer = None + __streamer = None - def url(self): + @property + def sources(self): """ - Return an url to the stream + Audio sources, dealer included """ - # path = self._meta.get_field('path').path - path = self.path.replace(main_settings.MEDIA_ROOT, '', 1) - #path = self.path.replace(path, '', 1) - return main_settings.MEDIA_URL + '/' + path + # force streamer creation + streamer = self.streamer - def file_exists(self): - """ - Return true if the file still exists - """ - return os.path.exists(self.path) + if not self.__sources: + import aircox.programs.controllers as controllers + self.__sources = [ + controllers.Source(station = self, program = program) + for program in Program.objects.filter(stream__isnull = False) + ] + [ self.dealer ] + return self.__sources - def check_on_file(self): - """ - Check sound file info again'st self, and update informations if - needed (do not save). Return True if there was changes. - """ - if not self.file_exists(): - if self.type == self.Type.removed: - return - logger.info('sound %s: has been removed', self.path) - self.type = self.Type.removed - return True + @property + def dealer(self): + # force streamer creation + streamer = self.streamer - # not anymore removed - changed = False - if self.type == self.Type.removed and self.program: - changed = True - self.type = self.Type.archive \ - if self.path.startswith(self.program.archives_path) else \ - self.Type.excerpt + if not self.__dealer: + import aircox.programs.controllers as controllers + self.__dealer = controllers.Source(station = self) + return self.__dealer - # check mtime -> reset quality if changed (assume file changed) - mtime = self.get_mtime() - if self.mtime != mtime: - self.mtime = mtime - self.good_quality = False - logger.info('sound %s: m_time has changed. Reset quality info', - self.path) - return True - return changed - - def check_perms(self): + @property + def streamer(self): """ - Check permissions and update them if this is activated + Audio controller for the station """ - if not settings.AIRCOX_SOUND_AUTO_CHMOD or \ - self.removed or not os.path.exists(self.path): - return + if not self.__streamer: + import aircox.programs.controllers as controllers + self.__streamer = controllers.Streamer(station = self) + return self.__streamer - flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public] - try: - os.chmod(self.path, flags) - except PermissionError as err: - logger.error( - 'cannot set permissions {} to file {}: {}'.format( - self.flags[self.public], - self.path, err - ) + def get_played(self, models, archives = True): + """ + Return a queryset with log of played elements on this station, + of the given models, ordered by date ascending. + + * models: a model or a list of models + * archives: if false, exclude log of diffusion's archives from + the queryset; + """ + qs = Log.objects.get_for(model = models) \ + .filter(station = self, type = Log.Type.play) + if not archives and self.dealer: + qs = qs.exclude( + source = self.dealer.id, + related_type = ContentType.objects.get_for_model(Sound) + ) + return qs.order_by('date') + + @staticmethod + def __mix_logs_and_diff(diffs, logs, count = 0): + """ + Mix together logs and diffusion items of the same day, + ordered by their date. + + Diffs and Logs are assumed to be ordered by -date, and so is + the resulting list + """ + # we fill a list with diff and retrieve logs that happened between + # each to put them too there + items = [] + diff_ = None + for diff in diffs.order_by('-start'): + logs_ = \ + logs.filter(date__gt = diff.end, date__lt = diff_.start) \ + if diff_ else \ + logs.filter(date__gt = diff.end) + diff_ = diff + items.extend(logs_) + items.append(diff) + if count and len(items) >= count: + break + + if diff_: + if count and len(items) >= count: + return items[:count] + logs_ = logs.filter(date__lt = diff_.end) + else: + logs_ = logs.all() + + items.extend(logs_) + return items[:count] if count else items + + def on_air(self, date = None, count = 0): + """ + Return a list of what happened on air, based on logs and + diffusions informations. The list is sorted by -date. + + * date: only for what happened on this date; + * count: number of items to retrieve if not zero; + + If date is not specified, count MUST be set to a non-zero value. + Be careful with what you which for: the result is a plain list. + + The list contains: + * track logs: for the streamed programs; + * diffusion: for the scheduled diffusions; + """ + # FIXME: as an iterator? + # TODO argument to get sound instead of tracks + if not date and not count: + raise ValueError('at least one argument must be set') + + if date and date > datetime.date.today(): + return [] + + logs = Log.objects.get_for(model = Track) \ + .filter(station = self) \ + .order_by('-date') + + if date: + logs = logs.filter(date__contains = date) + diffs = Diffusion.objects.get_at(date) + else: + diffs = Diffusion.objects + + diffs = diffs.filter(station = self) \ + .filter(type = Diffusion.Type.normal) \ + .filter(start__lte = tz.now()) + return self.__mix_logs_and_diff(diffs, logs, count) + + def save(self, make_sources = True, *args, **kwargs): + if not self.path: + self.path = os.path.join( + settings.AIRCOX_CONTROLLERS_WORKING_DIR, + self.slug ) - def __check_name(self): - if not self.name and self.path: - # FIXME: later, remove date? - self.name = os.path.basename(self.path) - self.name = os.path.splitext(self.name)[0] - self.name = self.name.replace('_', ' ') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__check_name() - - def save(self, check = True, *args, **kwargs): - if check: - self.check_on_file() - self.__check_name() super().save(*args, **kwargs) - def __str__(self): - return '/'.join(self.path.split('/')[-3:]) - class Meta: - verbose_name = _('Sound') - verbose_name_plural = _('Sounds') +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. + + Renaming a Program rename the corresponding directory to matches the new + name if it does not exists. + """ + station = models.ForeignKey( + Station, + verbose_name = _('station'), + ) + active = models.BooleanField( + _('active'), + default = True, + help_text = _('if not set this program is no longer active') + ) + + @property + def path(self): + """ + Return the path to the programs directory + """ + return os.path.join(settings.AIRCOX_PROGRAMS_DIR, + self.slug + '_' + str(self.id) ) + + def ensure_dir(self, subdir = None): + """ + Make sur the program's dir exists (and optionally subdir). Return True + if the dir (or subdir) exists. + """ + path = os.path.join(self.path, subdir) if subdir else \ + self.path + os.makedirs(path, exist_ok = True) + return os.path.exists(path) + + @property + def archives_path(self): + return os.path.join( + self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR + ) + + @property + def excerpts_path(self): + return os.path.join( + self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR + ) + + def find_schedule(self, date): + """ + Return the first schedule that matches a given date. + """ + schedules = Schedule.objects.filter(program = self) + for schedule in schedules: + if schedule.match(date, check_time = False): + return schedule + + def __init__(self, *kargs, **kwargs): + super().__init__(*kargs, **kwargs) + if self.name: + self.__original_path = self.path + + def save(self, *kargs, **kwargs): + super().save(*kargs, **kwargs) + if hasattr(self, '__original_path') and \ + self.__original_path != self.path and \ + os.path.exists(self.__original_path) and \ + not os.path.exists(self.path): + logger.info('program #%s\'s name changed to %s. Change dir name', + self.id, self.name) + shutil.move(self.__original_path, self.path) + + sounds = Sounds.objects.filter(path__startswith = self.__original_path) + for sound in sounds: + sound.path.replace(self.__original_path, self.path) + sound.save() + + @classmethod + def get_from_path(cl, path): + """ + Return a Program from the given path. We assume the path has been + given in a previous time by this model (Program.path getter). + """ + path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') + while path[0] == '/': path = path[1:] + while path[-1] == '/': path = path[:-2] + if '/' in path: + path = path[:path.index('/')] + + path = path.split('_') + path = path[-1] + qs = cl.objects.filter(id = int(path)) + return qs[0] if qs else None + + +class DiffusionManager(models.Manager): + def get_at(self, date = None, next = False): + """ + Return a queryset of diffusions that have the given date + in their range. + + If date is a datetime.date object, check only against the + date. + """ + date = date or tz.now() + if not issubclass(type(date), datetime.datetime): + return self.filter( + models.Q(start__contains = date) | \ + models.Q(end__contains = date) + ) + + if not next: + return self.filter(start__lte = date, end__gte = date) \ + .order_by('start') + + return self.filter( + models.Q(start__lte = date, end__gte = date) | + models.Q(start__gte = date), + ).order_by('start') + + def get_after(self, date = None): + """ + Return a queryset of diffusions that happen after the given + date. + """ + date = date_or_default(date) + return self.filter( + start__gte = date, + ).order_by('start') + + def get_before(self, date): + """ + Return a queryset of diffusions that finish before the given + date. + """ + date = date_or_default(date) + return self.filter( + end__lte = date, + ).order_by('start') + + +class Stream(models.Model): + """ + When there are no program scheduled, it is possible to play sounds + in order to avoid blanks. A Stream is a Program that plays this role, + and whose linked to a Stream. + + All sounds that are marked as good and that are under the related + program's archive dir are elligible for the sound's selection. + """ + program = models.ForeignKey( + Program, + verbose_name = _('related program'), + ) + delay = models.TimeField( + _('delay'), + blank = True, null = True, + help_text = _('delay between two sound plays') + ) + begin = models.TimeField( + _('begin'), + blank = True, null = True, + help_text = _('used to define a time range this stream is' + 'played') + ) + end = models.TimeField( + _('end'), + blank = True, null = True, + help_text = _('used to define a time range this stream is' + 'played') + ) class Schedule(models.Model): @@ -296,7 +477,7 @@ class Schedule(models.Model): one_on_two = 0b100000 program = models.ForeignKey( - 'Program', + Program, verbose_name = _('related program'), ) date = models.DateTimeField(_('date')) @@ -468,180 +649,6 @@ class Schedule(models.Model): verbose_name_plural = _('Schedules') -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. - - Renaming a Program rename the corresponding directory to matches the new - name if it does not exists. - """ - active = models.BooleanField( - _('active'), - default = True, - help_text = _('if not set this program is no longer active') - ) - - @property - def path(self): - """ - Return the path to the programs directory - """ - return os.path.join(settings.AIRCOX_PROGRAMS_DIR, - self.slug + '_' + str(self.id) ) - - def ensure_dir(self, subdir = None): - """ - Make sur the program's dir exists (and optionally subdir). Return True - if the dir (or subdir) exists. - """ - path = os.path.join(self.path, subdir) if subdir else \ - self.path - os.makedirs(path, exist_ok = True) - return os.path.exists(path) - - @property - def archives_path(self): - return os.path.join( - self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - - @property - def excerpts_path(self): - return os.path.join( - self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - - def find_schedule(self, date): - """ - Return the first schedule that matches a given date. - """ - schedules = Schedule.objects.filter(program = self) - for schedule in schedules: - if schedule.match(date, check_time = False): - return schedule - - def __init__(self, *kargs, **kwargs): - super().__init__(*kargs, **kwargs) - if self.name: - self.__original_path = self.path - - def save(self, *kargs, **kwargs): - super().save(*kargs, **kwargs) - if hasattr(self, '__original_path') and \ - self.__original_path != self.path and \ - os.path.exists(self.__original_path) and \ - not os.path.exists(self.path): - logger.info('program #%s\'s name changed to %s. Change dir name', - self.id, self.name) - shutil.move(self.__original_path, self.path) - - sounds = Sounds.objects.filter(path__startswith = self.__original_path) - for sound in sounds: - sound.path.replace(self.__original_path, self.path) - sound.save() - - @classmethod - def get_from_path(cl, path): - """ - Return a Program from the given path. We assume the path has been - given in a previous time by this model (Program.path getter). - """ - path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') - while path[0] == '/': path = path[1:] - while path[-1] == '/': path = path[:-2] - if '/' in path: - path = path[:path.index('/')] - - path = path.split('_') - path = path[-1] - qs = cl.objects.filter(id = int(path)) - return qs[0] if qs else None - - -class DiffusionManager(models.Manager): - def get_at(self, date = None, next = False): - """ - Return a queryset of diffusions that have the given date - in their range. - - If date is a datetime.date object, check only against the - date. - """ - date = date or tz.now() - if not issubclass(type(date), datetime.datetime): - return self.filter( - models.Q(start__contains = date) | \ - models.Q(end__contains = date) - ) - - if not next: - return self.filter(start__lte = date, end__gte = date) \ - .order_by('start') - - return self.filter( - models.Q(start__lte = date, end__gte = date) | - models.Q(start__gte = date), - ).order_by('start') - - def get_after(self, date = None): - """ - Return a queryset of diffusions that happen after the given - date. - """ - date = date_or_default(date) - return self.filter( - start__gte = date, - ).order_by('start') - - def get_before(self, date): - """ - Return a queryset of diffusions that finish before the given - date. - """ - date = date_or_default(date) - return self.filter( - end__lte = date, - ).order_by('start') - - -class Stream(models.Model): - """ - When there are no program scheduled, it is possible to play sounds - in order to avoid blanks. A Stream is a Program that plays this role, - and whose linked to a Stream. - - All sounds that are marked as good and that are under the related - program's archive dir are elligible for the sound's selection. - """ - program = models.ForeignKey( - 'Program', - verbose_name = _('related program'), - ) - delay = models.TimeField( - _('delay'), - blank = True, null = True, - help_text = _('delay between two sound plays') - ) - begin = models.TimeField( - _('begin'), - blank = True, null = True, - help_text = _('used to define a time range this stream is' - 'played') - ) - end = models.TimeField( - _('end'), - blank = True, null = True, - help_text = _('used to define a time range this stream is' - 'played') - ) - - class Diffusion(models.Model): """ A Diffusion is an occurrence of a Program that is scheduled on the @@ -669,7 +676,7 @@ class Diffusion(models.Model): # common program = models.ForeignKey ( - 'Program', + Program, verbose_name = _('program'), ) # specific @@ -759,6 +766,168 @@ class Diffusion(models.Model): ) +class Sound(Nameable): + """ + A Sound is the representation of a sound file that can be either an excerpt + or a complete archive of the related diffusion. + """ + class Type(IntEnum): + other = 0x00, + archive = 0x01, + excerpt = 0x02, + removed = 0x03, + + program = models.ForeignKey( + Program, + verbose_name = _('program'), + blank = True, null = True, + help_text = _('program related to it'), + ) + diffusion = models.ForeignKey( + 'Diffusion', + verbose_name = _('diffusion'), + blank = True, null = True, + help_text = _('initial diffusion related it') + ) + type = models.SmallIntegerField( + verbose_name = _('type'), + choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], + blank = True, null = True + ) + path = models.FilePathField( + _('file'), + path = settings.AIRCOX_PROGRAMS_DIR, + match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \ + .replace('.', r'\.') + ')$', + recursive = True, + blank = True, null = True, + max_length = 256 + ) + embed = models.TextField( + _('embed HTML code'), + blank = True, null = True, + help_text = _('HTML code used to embed a sound from external plateform'), + ) + duration = models.TimeField( + _('duration'), + blank = True, null = True, + help_text = _('duration of the sound'), + ) + mtime = models.DateTimeField( + _('modification time'), + blank = True, null = True, + help_text = _('last modification date and time'), + ) + good_quality = models.BooleanField( + _('good quality'), + default = False, + help_text = _('sound\'s quality is okay') + ) + public = models.BooleanField( + _('public'), + default = False, + help_text = _('the sound is accessible to the public') + ) + + def get_mtime(self): + """ + Get the last modification date from file + """ + mtime = os.stat(self.path).st_mtime + mtime = tz.datetime.fromtimestamp(mtime) + # db does not store microseconds + mtime = mtime.replace(microsecond = 0) + return tz.make_aware(mtime, tz.get_current_timezone()) + + def url(self): + """ + Return an url to the stream + """ + # path = self._meta.get_field('path').path + path = self.path.replace(main_settings.MEDIA_ROOT, '', 1) + #path = self.path.replace(path, '', 1) + return main_settings.MEDIA_URL + '/' + path + + def file_exists(self): + """ + Return true if the file still exists + """ + return os.path.exists(self.path) + + def check_on_file(self): + """ + Check sound file info again'st self, and update informations if + needed (do not save). Return True if there was changes. + """ + if not self.file_exists(): + if self.type == self.Type.removed: + return + logger.info('sound %s: has been removed', self.path) + self.type = self.Type.removed + return True + + # not anymore removed + changed = False + if self.type == self.Type.removed and self.program: + changed = True + self.type = self.Type.archive \ + if self.path.startswith(self.program.archives_path) else \ + self.Type.excerpt + + # check mtime -> reset quality if changed (assume file changed) + mtime = self.get_mtime() + if self.mtime != mtime: + self.mtime = mtime + self.good_quality = False + logger.info('sound %s: m_time has changed. Reset quality info', + self.path) + return True + return changed + + def check_perms(self): + """ + Check file permissions and update it if the sound is public + """ + if not settings.AIRCOX_SOUND_AUTO_CHMOD or \ + self.removed or not os.path.exists(self.path): + return + + flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public] + try: + os.chmod(self.path, flags) + except PermissionError as err: + logger.error( + 'cannot set permissions {} to file {}: {}'.format( + self.flags[self.public], + self.path, err + ) + ) + + def __check_name(self): + if not self.name and self.path: + # FIXME: later, remove date? + self.name = os.path.basename(self.path) + self.name = os.path.splitext(self.name)[0] + self.name = self.name.replace('_', ' ') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__check_name() + + def save(self, check = True, *args, **kwargs): + if check: + self.check_on_file() + self.__check_name() + super().save(*args, **kwargs) + + def __str__(self): + return '/'.join(self.path.split('/')[-3:]) + + class Meta: + verbose_name = _('Sound') + verbose_name_plural = _('Sounds') + + class Track(Related): """ Track of a playlist of an object. The position can either be expressed @@ -804,3 +973,133 @@ class Track(Related): verbose_name_plural = _('Tracks') +# +# Controls and audio output +# + +# FIXME HERE +# + station -> played, on_air and others +class Output (models.Model): + """ + Represent an audio output for the audio stream generation. + You might want to take a look to LiquidSoap's documentation + for the Jack, Alsa, and Icecast ouptuts. + """ + class Type(IntEnum): + jack = 0x00 + alsa = 0x01 + icecast = 0x02 + + station = models.ForeignKey( + Station, + verbose_name = _('station'), + ) + type = models.SmallIntegerField( + _('type'), + # we don't translate the names since it is project names. + choices = [ (int(y), x) for x,y in Type.__members__.items() ], + ) + active = models.BooleanField( + _('active'), + default = True, + help_text = _('this output is active') + ) + settings = models.TextField( + _('output settings'), + help_text = _('list of comma separated params available; ' + 'this is put in the output config as raw code; ' + 'plugin related'), + blank = True, null = True + ) + + +class Log(Related): + """ + Log sounds and diffusions that are played on the station. + + This only remember what has been played on the outputs, not on each + track; Source designate here which source is responsible of that. + """ + class Type(IntEnum): + stop = 0x00 + """ + Source has been stopped (only when there is no more sound) + """ + play = 0x01 + """ + Source has been started/changed and is running related_object + If no related_object is available, comment is used to designate + the sound. + """ + load = 0x02 + """ + Source starts to be preload related_object + """ + other = 0x03 + """ + Other log + """ + + type = models.SmallIntegerField( + verbose_name = _('type'), + choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], + blank = True, null = True, + ) + station = models.ForeignKey( + Station, + verbose_name = _('station'), + help_text = _('station on which the event occured'), + ) + source = models.CharField( + # we use a CharField to avoid loosing logs information if the + # source is removed + _('source'), + max_length=64, + help_text = _('source id that make it happen on the station'), + blank = True, null = True, + ) + date = models.DateTimeField( + _('date'), + default=tz.now, + ) + comment = models.CharField( + _('comment'), + max_length = 512, + blank = True, null = True, + ) + + @property + def end(self): + """ + Calculated end using self.related informations + """ + if self.related_type == Diffusion: + return self.related.end + if self.related_type == Sound: + return self.date + to_timedelta(self.duration) + return self.date + + def is_expired(self, date = None): + """ + Return True if the log is expired. Note that it only check + against the date, so it is still possible that the expiration + occured because of a Stop or other source. + """ + date = date_or_default(date) + return self.end < date + + def print(self): + logger.info('log #%s: %s%s', + str(self), + self.comment or '', + ' -- {} #{}'.format(self.related_type, self.related_id) + if self.related else '' + ) + + def __str__(self): + return '#{} ({}, {})'.format( + self.pk, self.date.strftime('%Y/%m/%d %H:%M'), self.source + ) + + + diff --git a/programs/settings.py b/programs/settings.py index e2467b0..0cf7e63 100755 --- a/programs/settings.py +++ b/programs/settings.py @@ -57,5 +57,7 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';') ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"') +# Controllers working directory +ensure('AIRCOX_CONTROLLERS_WORKING_DIR', '/tmp/aircox') diff --git a/controllers/templates/aircox/controllers/liquidsoap.liq b/programs/templates/aircox/controllers/liquidsoap.liq similarity index 69% rename from controllers/templates/aircox/controllers/liquidsoap.liq rename to programs/templates/aircox/controllers/liquidsoap.liq index d958b69..014bd6a 100644 --- a/controllers/templates/aircox/controllers/liquidsoap.liq +++ b/programs/templates/aircox/controllers/liquidsoap.liq @@ -55,8 +55,8 @@ end {% block config %} set("server.socket", true) -set("server.socket.path", "{{ station.controller.socket_path }}") -set("log.file.path", "{{ station.controller.station.path }}/liquidsoap.log") +set("server.socket.path", "{{ station.streamer.socket_path }}") +set("log.file.path", "{{ station.path }}/liquidsoap.log") {% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %} set("{{ key|safe }}", {{ value|safe }}) {% endfor %} @@ -68,17 +68,9 @@ set("{{ key|safe }}", {{ value|safe }}) {% block sources %} live = fallback([ - {% for source in station.file_sources %} - {% with controller=source.controller %} - interactive_source( - '{{ source.id_ }}', single("{{ source.url }}"), active=false - ), - {% endwith %} - {% endfor %} - - {% with source=station.dealer controller=station.dealer.controller %} - interactive_source('{{ source.id_ }}', - playlist.once(reload_mode='watch', "{{ controller.path }}"), + {% with source=station.dealer %} + interactive_source('{{ source.id }}', + playlist.once(reload_mode='watch', "{{ source.path }}"), active=false ), {% endwith %} @@ -87,28 +79,24 @@ live = fallback([ stream = fallback([ rotate([ - {% for source in station.stream_sources %} - {% with controller=source.controller stream=source.controller.stream %} + {% for source in station.sources %} + {% if source != station.dealer %} + {% with stream=source.stream %} {% if stream.delay %} delay({{ stream.delay }}., - stream("{{ source.id_ }}", "{{ controller.path }}")), + stream("{{ source.id }}", "{{ source.path }}")), {% elif stream.begin and stream.end %} at({ {{stream.begin}}-{{stream.end}} }, - stream("{{ source.id_ }}", "{{ controller.path }}")), + stream("{{ source.id }}", "{{ source.path }}")), {% elif not stream %} - stream("{{ source.id_ }}", "{{ controller.path }}"), + stream("{{ source.id }}", "{{ source.path }}"), {% endif %} {% endwith %} + {% endif %} {% endfor %} ]), - {% for source in station.fallback_sources %} - {% with controller=source.controller %} - single("{{ source.value }}"), - {% endwith %} - {% endfor %} - - blank(id="blank_fallback", duration=0.1), + blank(id="blank", duration=0.1), ]) {% endblock %} @@ -130,8 +118,8 @@ end {% block station %} -{{ station.id_ }} = interactive_source ( - "{{ station.id_ }}", +{{ station.streamer.id }} = interactive_source ( + "{{ station.streamer.id }}", fallback( track_sensitive=false, transitions=[to_live,to_stream], @@ -147,9 +135,9 @@ end {% block outputs %} -{% for output in station.outputs %} +{% for output in station.output_set.all %} output.{{ output.get_type_display }}( - {{ station.id_ }}, + {{ station.streamer.id }}, {% if controller.settings %}, {{ output.settings }} {% endif %} diff --git a/controllers/templates/aircox/controllers/monitor.html b/programs/templates/aircox/controllers/monitor.html similarity index 100% rename from controllers/templates/aircox/controllers/monitor.html rename to programs/templates/aircox/controllers/monitor.html