issue #3: merge controllers into programs; missing: views
This commit is contained in:
parent
f5ec634e3c
commit
edd4c7ec87
|
@ -28,7 +28,6 @@ from taggit.models import TaggedItemBase
|
||||||
import bleach
|
import bleach
|
||||||
|
|
||||||
import aircox.programs.models as programs
|
import aircox.programs.models as programs
|
||||||
import aircox.controllers.models as controllers
|
|
||||||
import aircox.cms.settings as settings
|
import aircox.cms.settings as settings
|
||||||
|
|
||||||
from aircox.cms.utils import image_url
|
from aircox.cms.utils import image_url
|
||||||
|
@ -649,7 +648,7 @@ class LogsPage(DatedListPage):
|
||||||
template = 'cms/dated_list_page.html'
|
template = 'cms/dated_list_page.html'
|
||||||
|
|
||||||
station = models.ForeignKey(
|
station = models.ForeignKey(
|
||||||
controllers.Station,
|
programs.Station,
|
||||||
verbose_name = _('station'),
|
verbose_name = _('station'),
|
||||||
null = True,
|
null = True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
|
|
|
@ -34,7 +34,6 @@ from taggit.models import TaggedItemBase
|
||||||
|
|
||||||
# aircox
|
# aircox
|
||||||
import aircox.programs.models as programs
|
import aircox.programs.models as programs
|
||||||
import aircox.controllers.models as controllers
|
|
||||||
|
|
||||||
|
|
||||||
def related_pages_filter(reset_cache=False):
|
def related_pages_filter(reset_cache=False):
|
||||||
|
@ -856,7 +855,7 @@ class SectionList(ListBase, SectionRelativeItem):
|
||||||
@register_snippet
|
@register_snippet
|
||||||
class SectionLogsList(SectionItem):
|
class SectionLogsList(SectionItem):
|
||||||
station = models.ForeignKey(
|
station = models.ForeignKey(
|
||||||
controllers.Station,
|
programs.Station,
|
||||||
verbose_name = _('station'),
|
verbose_name = _('station'),
|
||||||
null = True,
|
null = True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 ""
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -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')
|
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
|
||||||
]
|
|
||||||
|
|
|
@ -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
28
docs/technicians.md
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -171,3 +171,23 @@ class TrackAdmin(admin.ModelAdmin):
|
||||||
list_display = ['id', 'title', 'artist', 'position', 'in_seconds', 'related']
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,46 +5,21 @@ import atexit
|
||||||
|
|
||||||
from django.template.loader import render_to_string
|
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):
|
from aircox.programs.connector import Connector
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Plugin(metaclass=Plugins):
|
class Streamer:
|
||||||
name = ''
|
|
||||||
|
|
||||||
def init_station(self, station):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def init_source(self, source):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class StationController:
|
|
||||||
"""
|
"""
|
||||||
Controller of a Station.
|
Audio controller of a Station.
|
||||||
"""
|
"""
|
||||||
station = None
|
station = None
|
||||||
"""
|
"""
|
||||||
Related station
|
Related station
|
||||||
"""
|
"""
|
||||||
template_name = ''
|
template_name = 'aircox/controllers/liquidsoap.liq'
|
||||||
"""
|
"""
|
||||||
If set, use this template in order to generated the configuration
|
If set, use this template in order to generated the configuration
|
||||||
file in self.path file
|
file in self.path file
|
||||||
|
@ -62,10 +37,39 @@ class StationController:
|
||||||
Current source object that is responsible of self.current_sound
|
Current source object that is responsible of self.current_sound
|
||||||
"""
|
"""
|
||||||
process = None
|
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)
|
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):
|
def fetch(self):
|
||||||
"""
|
"""
|
||||||
Fetch data of the children and so on
|
Fetch data of the children and so on
|
||||||
|
@ -73,21 +77,72 @@ class StationController:
|
||||||
The base function just execute the function of all children
|
The base function just execute the function of all children
|
||||||
sources. The plugin must implement the other extra part
|
sources. The plugin must implement the other extra part
|
||||||
"""
|
"""
|
||||||
sources = self.station.get_sources(dealer = True)
|
sources = self.station.sources
|
||||||
for source in sources:
|
for source in sources:
|
||||||
source.prepare()
|
source.fetch()
|
||||||
if source.controller:
|
|
||||||
source.controller.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):
|
def __get_process_args(self):
|
||||||
"""
|
"""
|
||||||
Get arguments for the executed application. Called by exec, to be
|
Get arguments for the executed application. Called by exec, to be
|
||||||
used as subprocess.Popen(__get_process_args()).
|
used as subprocess.Popen(__get_process_args()).
|
||||||
If no value is returned, abort the execution.
|
If no value is returned, abort the execution.
|
||||||
|
|
||||||
Must be implemented by the plugin
|
|
||||||
"""
|
"""
|
||||||
return []
|
return ['liquidsoap', '-v', self.path]
|
||||||
|
|
||||||
def process_run(self):
|
def process_run(self):
|
||||||
"""
|
"""
|
||||||
|
@ -123,49 +178,15 @@ class StationController:
|
||||||
"""
|
"""
|
||||||
If external program is ready to use, returns True
|
If external program is ready to use, returns True
|
||||||
"""
|
"""
|
||||||
|
return self._send('var.list') != ''
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def skip(self):
|
class Source:
|
||||||
"""
|
|
||||||
Skip the current sound on the station
|
|
||||||
"""
|
|
||||||
if self.current_source:
|
|
||||||
self.current_source.controller.skip()
|
|
||||||
|
|
||||||
|
|
||||||
class SourceController:
|
|
||||||
"""
|
"""
|
||||||
Controller of a Source. Value are usually updated directly on the
|
Controller of a Source. Value are usually updated directly on the
|
||||||
external side.
|
external side.
|
||||||
"""
|
"""
|
||||||
source = None
|
program = None
|
||||||
"""
|
"""
|
||||||
Related source
|
Related source
|
||||||
"""
|
"""
|
||||||
|
@ -187,8 +208,40 @@ class SourceController:
|
||||||
Current source being responsible of the current sound
|
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
|
__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
|
@property
|
||||||
def playlist(self):
|
def playlist(self):
|
||||||
"""
|
"""
|
||||||
|
@ -204,45 +257,6 @@ class SourceController:
|
||||||
self.__playlist = value
|
self.__playlist = value
|
||||||
self.push()
|
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):
|
def from_db(self, diffusion = None, program = None):
|
||||||
"""
|
"""
|
||||||
Load a playlist to the controller from the database. If diffusion or
|
Load a playlist to the controller from the database. If diffusion or
|
||||||
|
@ -255,21 +269,16 @@ class SourceController:
|
||||||
self.playlist = diffusion.playlist
|
self.playlist = diffusion.playlist
|
||||||
return
|
return
|
||||||
|
|
||||||
source = self.source
|
program = program or self.program
|
||||||
program = program or source.program
|
|
||||||
if program:
|
if program:
|
||||||
self.playlist = [ sound.path for sound in
|
self.playlist = [ sound.path for sound in
|
||||||
programs.Sound.objects.filter(
|
models.Sound.objects.filter(
|
||||||
type = programs.Sound.Type.archive,
|
type = models.Sound.Type.archive,
|
||||||
program = program,
|
program = program,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
return
|
return
|
||||||
|
|
||||||
if source.type == source.Type.file and source.url:
|
|
||||||
self.playlist = [ source.url ]
|
|
||||||
return
|
|
||||||
|
|
||||||
def from_file(self, path = None):
|
def from_file(self, path = None):
|
||||||
"""
|
"""
|
||||||
Load a playlist from the given file (if not, use the
|
Load a playlist from the given file (if not, use the
|
||||||
|
@ -284,7 +293,63 @@ class SourceController:
|
||||||
self.__playlist = self.__playlist.split('\n') \
|
self.__playlist = self.__playlist.split('\n') \
|
||||||
if self.__playlist else []
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -46,6 +46,9 @@ def date_or_default(date, no_time = False):
|
||||||
return date
|
return date
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Abstracts
|
||||||
|
#
|
||||||
class RelatedManager(models.Manager):
|
class RelatedManager(models.Manager):
|
||||||
def get_for(self, object = None, model = None):
|
def get_for(self, object = None, model = None):
|
||||||
"""
|
"""
|
||||||
|
@ -112,166 +115,344 @@ class Nameable(models.Model):
|
||||||
abstract = True
|
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
|
Represents a radio station, to which multiple programs are attached
|
||||||
or a complete archive of the related diffusion.
|
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):
|
path = models.CharField(
|
||||||
other = 0x00,
|
_('path'),
|
||||||
archive = 0x01,
|
help_text = _('path to the working directory'),
|
||||||
excerpt = 0x02,
|
max_length = 256,
|
||||||
removed = 0x03,
|
blank = True,
|
||||||
|
|
||||||
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):
|
#
|
||||||
"""
|
# Controllers
|
||||||
Get the last modification date from file
|
#
|
||||||
"""
|
__sources = None
|
||||||
mtime = os.stat(self.path).st_mtime
|
__dealer = None
|
||||||
mtime = tz.datetime.fromtimestamp(mtime)
|
__streamer = None
|
||||||
# db does not store microseconds
|
|
||||||
mtime = mtime.replace(microsecond = 0)
|
|
||||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
|
||||||
|
|
||||||
def url(self):
|
@property
|
||||||
|
def sources(self):
|
||||||
"""
|
"""
|
||||||
Return an url to the stream
|
Audio sources, dealer included
|
||||||
"""
|
"""
|
||||||
# path = self._meta.get_field('path').path
|
# force streamer creation
|
||||||
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
|
streamer = self.streamer
|
||||||
#path = self.path.replace(path, '', 1)
|
|
||||||
return main_settings.MEDIA_URL + '/' + path
|
|
||||||
|
|
||||||
def file_exists(self):
|
if not self.__sources:
|
||||||
"""
|
import aircox.programs.controllers as controllers
|
||||||
Return true if the file still exists
|
self.__sources = [
|
||||||
"""
|
controllers.Source(station = self, program = program)
|
||||||
return os.path.exists(self.path)
|
for program in Program.objects.filter(stream__isnull = False)
|
||||||
|
] + [ self.dealer ]
|
||||||
|
return self.__sources
|
||||||
|
|
||||||
def check_on_file(self):
|
@property
|
||||||
"""
|
def dealer(self):
|
||||||
Check sound file info again'st self, and update informations if
|
# force streamer creation
|
||||||
needed (do not save). Return True if there was changes.
|
streamer = self.streamer
|
||||||
"""
|
|
||||||
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
|
if not self.__dealer:
|
||||||
changed = False
|
import aircox.programs.controllers as controllers
|
||||||
if self.type == self.Type.removed and self.program:
|
self.__dealer = controllers.Source(station = self)
|
||||||
changed = True
|
return self.__dealer
|
||||||
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)
|
@property
|
||||||
mtime = self.get_mtime()
|
def streamer(self):
|
||||||
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 permissions and update them if this is activated
|
Audio controller for the station
|
||||||
"""
|
"""
|
||||||
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
|
if not self.__streamer:
|
||||||
self.removed or not os.path.exists(self.path):
|
import aircox.programs.controllers as controllers
|
||||||
return
|
self.__streamer = controllers.Streamer(station = self)
|
||||||
|
return self.__streamer
|
||||||
|
|
||||||
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public]
|
def get_played(self, models, archives = True):
|
||||||
try:
|
"""
|
||||||
os.chmod(self.path, flags)
|
Return a queryset with log of played elements on this station,
|
||||||
except PermissionError as err:
|
of the given models, ordered by date ascending.
|
||||||
logger.error(
|
|
||||||
'cannot set permissions {} to file {}: {}'.format(
|
* models: a model or a list of models
|
||||||
self.flags[self.public],
|
* archives: if false, exclude log of diffusion's archives from
|
||||||
self.path, err
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '/'.join(self.path.split('/')[-3:])
|
|
||||||
|
|
||||||
class Meta:
|
class Program(Nameable):
|
||||||
verbose_name = _('Sound')
|
"""
|
||||||
verbose_name_plural = _('Sounds')
|
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):
|
class Schedule(models.Model):
|
||||||
|
@ -296,7 +477,7 @@ class Schedule(models.Model):
|
||||||
one_on_two = 0b100000
|
one_on_two = 0b100000
|
||||||
|
|
||||||
program = models.ForeignKey(
|
program = models.ForeignKey(
|
||||||
'Program',
|
Program,
|
||||||
verbose_name = _('related program'),
|
verbose_name = _('related program'),
|
||||||
)
|
)
|
||||||
date = models.DateTimeField(_('date'))
|
date = models.DateTimeField(_('date'))
|
||||||
|
@ -468,180 +649,6 @@ class Schedule(models.Model):
|
||||||
verbose_name_plural = _('Schedules')
|
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):
|
class Diffusion(models.Model):
|
||||||
"""
|
"""
|
||||||
A Diffusion is an occurrence of a Program that is scheduled on the
|
A Diffusion is an occurrence of a Program that is scheduled on the
|
||||||
|
@ -669,7 +676,7 @@ class Diffusion(models.Model):
|
||||||
|
|
||||||
# common
|
# common
|
||||||
program = models.ForeignKey (
|
program = models.ForeignKey (
|
||||||
'Program',
|
Program,
|
||||||
verbose_name = _('program'),
|
verbose_name = _('program'),
|
||||||
)
|
)
|
||||||
# specific
|
# 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):
|
class Track(Related):
|
||||||
"""
|
"""
|
||||||
Track of a playlist of an object. The position can either be expressed
|
Track of a playlist of an object. The position can either be expressed
|
||||||
|
@ -804,3 +973,133 @@ class Track(Related):
|
||||||
verbose_name_plural = _('Tracks')
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -57,5 +57,7 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
|
||||||
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
|
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
|
||||||
|
|
||||||
|
|
||||||
|
# Controllers working directory
|
||||||
|
ensure('AIRCOX_CONTROLLERS_WORKING_DIR', '/tmp/aircox')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -55,8 +55,8 @@ end
|
||||||
|
|
||||||
{% block config %}
|
{% block config %}
|
||||||
set("server.socket", true)
|
set("server.socket", true)
|
||||||
set("server.socket.path", "{{ station.controller.socket_path }}")
|
set("server.socket.path", "{{ station.streamer.socket_path }}")
|
||||||
set("log.file.path", "{{ station.controller.station.path }}/liquidsoap.log")
|
set("log.file.path", "{{ station.path }}/liquidsoap.log")
|
||||||
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
|
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
|
||||||
set("{{ key|safe }}", {{ value|safe }})
|
set("{{ key|safe }}", {{ value|safe }})
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -68,17 +68,9 @@ set("{{ key|safe }}", {{ value|safe }})
|
||||||
|
|
||||||
{% block sources %}
|
{% block sources %}
|
||||||
live = fallback([
|
live = fallback([
|
||||||
{% for source in station.file_sources %}
|
{% with source=station.dealer %}
|
||||||
{% with controller=source.controller %}
|
interactive_source('{{ source.id }}',
|
||||||
interactive_source(
|
playlist.once(reload_mode='watch', "{{ source.path }}"),
|
||||||
'{{ 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 }}"),
|
|
||||||
active=false
|
active=false
|
||||||
),
|
),
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -87,28 +79,24 @@ live = fallback([
|
||||||
|
|
||||||
stream = fallback([
|
stream = fallback([
|
||||||
rotate([
|
rotate([
|
||||||
{% for source in station.stream_sources %}
|
{% for source in station.sources %}
|
||||||
{% with controller=source.controller stream=source.controller.stream %}
|
{% if source != station.dealer %}
|
||||||
|
{% with stream=source.stream %}
|
||||||
{% if stream.delay %}
|
{% if stream.delay %}
|
||||||
delay({{ stream.delay }}.,
|
delay({{ stream.delay }}.,
|
||||||
stream("{{ source.id_ }}", "{{ controller.path }}")),
|
stream("{{ source.id }}", "{{ source.path }}")),
|
||||||
{% elif stream.begin and stream.end %}
|
{% elif stream.begin and stream.end %}
|
||||||
at({ {{stream.begin}}-{{stream.end}} },
|
at({ {{stream.begin}}-{{stream.end}} },
|
||||||
stream("{{ source.id_ }}", "{{ controller.path }}")),
|
stream("{{ source.id }}", "{{ source.path }}")),
|
||||||
{% elif not stream %}
|
{% elif not stream %}
|
||||||
stream("{{ source.id_ }}", "{{ controller.path }}"),
|
stream("{{ source.id }}", "{{ source.path }}"),
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
]),
|
]),
|
||||||
|
|
||||||
{% for source in station.fallback_sources %}
|
blank(id="blank", duration=0.1),
|
||||||
{% with controller=source.controller %}
|
|
||||||
single("{{ source.value }}"),
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
blank(id="blank_fallback", duration=0.1),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -130,8 +118,8 @@ end
|
||||||
|
|
||||||
|
|
||||||
{% block station %}
|
{% block station %}
|
||||||
{{ station.id_ }} = interactive_source (
|
{{ station.streamer.id }} = interactive_source (
|
||||||
"{{ station.id_ }}",
|
"{{ station.streamer.id }}",
|
||||||
fallback(
|
fallback(
|
||||||
track_sensitive=false,
|
track_sensitive=false,
|
||||||
transitions=[to_live,to_stream],
|
transitions=[to_live,to_stream],
|
||||||
|
@ -147,9 +135,9 @@ end
|
||||||
|
|
||||||
|
|
||||||
{% block outputs %}
|
{% block outputs %}
|
||||||
{% for output in station.outputs %}
|
{% for output in station.output_set.all %}
|
||||||
output.{{ output.get_type_display }}(
|
output.{{ output.get_type_display }}(
|
||||||
{{ station.id_ }},
|
{{ station.streamer.id }},
|
||||||
{% if controller.settings %},
|
{% if controller.settings %},
|
||||||
{{ output.settings }}
|
{{ output.settings }}
|
||||||
{% endif %}
|
{% endif %}
|
Loading…
Reference in New Issue
Block a user