issue #3: merge controllers into programs; missing: views

This commit is contained in:
bkfox 2016-08-29 15:45:17 +02:00
parent f5ec634e3c
commit edd4c7ec87
20 changed files with 875 additions and 1726 deletions

View File

@ -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,

View File

@ -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,

View File

@ -1,2 +0,0 @@

View File

@ -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)

View File

@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

View File

@ -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()

View File

@ -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
)

View File

@ -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,
)

View File

@ -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
}

View File

@ -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')

View File

@ -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'),
]

View File

@ -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('')

28
docs/technicians.md Normal file
View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -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
)

View File

@ -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')

View File

@ -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 %}