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 aircox.programs.models as programs
|
||||
import aircox.controllers.models as controllers
|
||||
import aircox.cms.settings as settings
|
||||
|
||||
from aircox.cms.utils import image_url
|
||||
|
@ -649,7 +648,7 @@ class LogsPage(DatedListPage):
|
|||
template = 'cms/dated_list_page.html'
|
||||
|
||||
station = models.ForeignKey(
|
||||
controllers.Station,
|
||||
programs.Station,
|
||||
verbose_name = _('station'),
|
||||
null = True,
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
|
@ -34,7 +34,6 @@ from taggit.models import TaggedItemBase
|
|||
|
||||
# aircox
|
||||
import aircox.programs.models as programs
|
||||
import aircox.controllers.models as controllers
|
||||
|
||||
|
||||
def related_pages_filter(reset_cache=False):
|
||||
|
@ -856,7 +855,7 @@ class SectionList(ListBase, SectionRelativeItem):
|
|||
@register_snippet
|
||||
class SectionLogsList(SectionItem):
|
||||
station = models.ForeignKey(
|
||||
controllers.Station,
|
||||
programs.Station,
|
||||
verbose_name = _('station'),
|
||||
null = True,
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
import aircox.programs.models as programs
|
||||
import aircox.programs.models as models
|
||||
import aircox.programs.settings as settings
|
||||
|
||||
class Plugins(type):
|
||||
registry = {}
|
||||
|
||||
def __new__(cls, name, bases, attrs):
|
||||
cl = super().__new__(cls, name, bases, attrs)
|
||||
if name != 'Plugin':
|
||||
if not cl.name:
|
||||
cl.name = name.lower()
|
||||
cls.registry[cl.name] = cl
|
||||
return cl
|
||||
|
||||
@classmethod
|
||||
def discover(cls):
|
||||
"""
|
||||
Discover plugins -- needed because of the import traps
|
||||
"""
|
||||
import aircox.controllers.plugins.liquidsoap
|
||||
from aircox.programs.connector import Connector
|
||||
|
||||
|
||||
class Plugin(metaclass=Plugins):
|
||||
name = ''
|
||||
|
||||
def init_station(self, station):
|
||||
pass
|
||||
|
||||
def init_source(self, source):
|
||||
pass
|
||||
|
||||
|
||||
class StationController:
|
||||
class Streamer:
|
||||
"""
|
||||
Controller of a Station.
|
||||
Audio controller of a Station.
|
||||
"""
|
||||
station = None
|
||||
"""
|
||||
Related station
|
||||
"""
|
||||
template_name = ''
|
||||
template_name = 'aircox/controllers/liquidsoap.liq'
|
||||
"""
|
||||
If set, use this template in order to generated the configuration
|
||||
file in self.path file
|
||||
|
@ -62,10 +37,39 @@ class StationController:
|
|||
Current source object that is responsible of self.current_sound
|
||||
"""
|
||||
process = None
|
||||
"""
|
||||
Application's process if ran from Streamer
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
socket_path = ''
|
||||
"""
|
||||
Path to the connector's socket
|
||||
"""
|
||||
connector = None
|
||||
"""
|
||||
Connector to Liquidsoap server
|
||||
"""
|
||||
|
||||
def __init__(self, station, **kwargs):
|
||||
self.station = station
|
||||
self.path = os.path.join(station.path, 'station.liq')
|
||||
self.socket_path = os.path.join(station.path, 'station.sock')
|
||||
self.connector = Connector(self.socket_path)
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""
|
||||
Streamer identifier common in both external app and here
|
||||
"""
|
||||
return self.station.slug
|
||||
|
||||
#
|
||||
# RPC
|
||||
#
|
||||
def _send(self, *args, **kwargs):
|
||||
return self.connector.send(*args, **kwargs)
|
||||
|
||||
def fetch(self):
|
||||
"""
|
||||
Fetch data of the children and so on
|
||||
|
@ -73,21 +77,72 @@ class StationController:
|
|||
The base function just execute the function of all children
|
||||
sources. The plugin must implement the other extra part
|
||||
"""
|
||||
sources = self.station.get_sources(dealer = True)
|
||||
sources = self.station.sources
|
||||
for source in sources:
|
||||
source.prepare()
|
||||
if source.controller:
|
||||
source.controller.fetch()
|
||||
source.fetch()
|
||||
|
||||
rid = self._send('request.on_air').split(' ')[0]
|
||||
if ' ' in rid:
|
||||
rid = rid[:rid.index(' ')]
|
||||
if not rid:
|
||||
return
|
||||
|
||||
data = self._send('request.metadata ', rid, parse = True)
|
||||
if not data:
|
||||
return
|
||||
|
||||
self.current_sound = data.get('initial_uri')
|
||||
try:
|
||||
self.current_source = next(
|
||||
source for source in self.station.sources
|
||||
if source.rid == rid
|
||||
)
|
||||
except:
|
||||
self.current_source = None
|
||||
|
||||
def push(self, config = True):
|
||||
"""
|
||||
Update configuration and children's info.
|
||||
|
||||
The base function just execute the function of all children
|
||||
sources. The plugin must implement the other extra part
|
||||
"""
|
||||
sources = self.station.sources
|
||||
for source in sources:
|
||||
source.push()
|
||||
|
||||
if config and self.path and self.template_name:
|
||||
data = render_to_string(self.template_name, {
|
||||
'station': self.station,
|
||||
'streamer': self,
|
||||
'settings': settings,
|
||||
})
|
||||
data = re.sub('[\t ]+\n', '\n', data)
|
||||
data = re.sub('\n{3,}', '\n\n', data)
|
||||
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok = True)
|
||||
with open(self.path, 'w+') as file:
|
||||
file.write(data)
|
||||
|
||||
def skip(self):
|
||||
"""
|
||||
Skip a given source. If no source, use master.
|
||||
"""
|
||||
if self.current_source:
|
||||
self.current_source.skip()
|
||||
else:
|
||||
self._send(self.id, '.skip')
|
||||
|
||||
#
|
||||
# Process management
|
||||
#
|
||||
def __get_process_args(self):
|
||||
"""
|
||||
Get arguments for the executed application. Called by exec, to be
|
||||
used as subprocess.Popen(__get_process_args()).
|
||||
If no value is returned, abort the execution.
|
||||
|
||||
Must be implemented by the plugin
|
||||
"""
|
||||
return []
|
||||
return ['liquidsoap', '-v', self.path]
|
||||
|
||||
def process_run(self):
|
||||
"""
|
||||
|
@ -123,49 +178,15 @@ class StationController:
|
|||
"""
|
||||
If external program is ready to use, returns True
|
||||
"""
|
||||
|
||||
def push(self, config = True):
|
||||
"""
|
||||
Update configuration and children's info.
|
||||
|
||||
The base function just execute the function of all children
|
||||
sources. The plugin must implement the other extra part
|
||||
"""
|
||||
sources = self.station.get_sources(dealer = True)
|
||||
for source in sources:
|
||||
source.prepare()
|
||||
if source.controller:
|
||||
source.controller.push()
|
||||
|
||||
if config and self.path and self.template_name:
|
||||
import aircox.controllers.settings as settings
|
||||
|
||||
data = render_to_string(self.template_name, {
|
||||
'station': self.station,
|
||||
'settings': settings,
|
||||
})
|
||||
data = re.sub('[\t ]+\n', '\n', data)
|
||||
data = re.sub('\n{3,}', '\n\n', data)
|
||||
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok = True)
|
||||
with open(self.path, 'w+') as file:
|
||||
file.write(data)
|
||||
return self._send('var.list') != ''
|
||||
|
||||
|
||||
def skip(self):
|
||||
"""
|
||||
Skip the current sound on the station
|
||||
"""
|
||||
if self.current_source:
|
||||
self.current_source.controller.skip()
|
||||
|
||||
|
||||
class SourceController:
|
||||
class Source:
|
||||
"""
|
||||
Controller of a Source. Value are usually updated directly on the
|
||||
external side.
|
||||
"""
|
||||
source = None
|
||||
program = None
|
||||
"""
|
||||
Related source
|
||||
"""
|
||||
|
@ -187,8 +208,40 @@ class SourceController:
|
|||
Current source being responsible of the current sound
|
||||
"""
|
||||
|
||||
rid = None
|
||||
"""
|
||||
Current request id of the source in LiquidSoap
|
||||
"""
|
||||
connector = None
|
||||
"""
|
||||
Connector to Liquidsoap server
|
||||
"""
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.program.slug if self.program else 'dealer'
|
||||
|
||||
def __init__(self, station, **kwargs):
|
||||
self.station = station
|
||||
self.connector = self.station.streamer.connector
|
||||
self.__dict__.update(kwargs)
|
||||
self.__init_playlist()
|
||||
|
||||
#
|
||||
# Playlist
|
||||
#
|
||||
__playlist = None
|
||||
|
||||
def __init_playlist(self):
|
||||
self.__playlist = []
|
||||
if not self.path:
|
||||
self.path = os.path.join(self.station.path,
|
||||
self.id + '.m3u')
|
||||
self.from_file()
|
||||
|
||||
if not self.__playlist:
|
||||
self.from_db()
|
||||
|
||||
@property
|
||||
def playlist(self):
|
||||
"""
|
||||
|
@ -204,45 +257,6 @@ class SourceController:
|
|||
self.__playlist = value
|
||||
self.push()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
self.__playlist = []
|
||||
if not self.path:
|
||||
self.path = os.path.join(self.source.station.path,
|
||||
self.source.slug + '.m3u')
|
||||
self.from_file()
|
||||
|
||||
if not self.__playlist:
|
||||
self.from_db()
|
||||
|
||||
def skip(self):
|
||||
"""
|
||||
Skip the current sound in the source
|
||||
"""
|
||||
pass
|
||||
|
||||
def fetch(self):
|
||||
"""
|
||||
Get the source information
|
||||
"""
|
||||
pass
|
||||
|
||||
def push(self):
|
||||
"""
|
||||
Update data relative to the source on the external program.
|
||||
By default write the playlist.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok = True)
|
||||
with open(self.path, 'w') as file:
|
||||
file.write('\n'.join(self.__playlist or []))
|
||||
|
||||
def activate(self, value = True):
|
||||
"""
|
||||
Activate/Deactivate current source. May be different from the
|
||||
containing Source.
|
||||
"""
|
||||
pass
|
||||
|
||||
def from_db(self, diffusion = None, program = None):
|
||||
"""
|
||||
Load a playlist to the controller from the database. If diffusion or
|
||||
|
@ -255,21 +269,16 @@ class SourceController:
|
|||
self.playlist = diffusion.playlist
|
||||
return
|
||||
|
||||
source = self.source
|
||||
program = program or source.program
|
||||
program = program or self.program
|
||||
if program:
|
||||
self.playlist = [ sound.path for sound in
|
||||
programs.Sound.objects.filter(
|
||||
type = programs.Sound.Type.archive,
|
||||
models.Sound.objects.filter(
|
||||
type = models.Sound.Type.archive,
|
||||
program = program,
|
||||
)
|
||||
]
|
||||
return
|
||||
|
||||
if source.type == source.Type.file and source.url:
|
||||
self.playlist = [ source.url ]
|
||||
return
|
||||
|
||||
def from_file(self, path = None):
|
||||
"""
|
||||
Load a playlist from the given file (if not, use the
|
||||
|
@ -284,7 +293,63 @@ class SourceController:
|
|||
self.__playlist = self.__playlist.split('\n') \
|
||||
if self.__playlist else []
|
||||
|
||||
class Monitor:
|
||||
station = None
|
||||
#
|
||||
# RPC
|
||||
#
|
||||
def _send(self, *args, **kwargs):
|
||||
return self.connector.send(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
return self._send('var.get ', self.id, '_active') == 'true'
|
||||
|
||||
@active.setter
|
||||
def active(self, value):
|
||||
self._send('var.set ', self.id, '_active', '=',
|
||||
'true' if value else 'false')
|
||||
|
||||
def fetch(self):
|
||||
"""
|
||||
Get the source information
|
||||
"""
|
||||
data = self._send(self.id, '.get', parse = True)
|
||||
if not data or type(data) != dict:
|
||||
return
|
||||
|
||||
self.rid = data.get('rid')
|
||||
self.current_sound = data.get('initial_uri')
|
||||
|
||||
def push(self):
|
||||
"""
|
||||
Update data relative to the source on the external program.
|
||||
By default write the playlist.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok = True)
|
||||
with open(self.path, 'w') as file:
|
||||
file.write('\n'.join(self.__playlist or []))
|
||||
|
||||
def skip(self):
|
||||
"""
|
||||
Skip the current sound in the source
|
||||
"""
|
||||
self._send(self.id, '.skip')
|
||||
|
||||
def stream(self):
|
||||
"""
|
||||
Return a dict with stream info for a Stream program, or None if there
|
||||
is not. Used in the template.
|
||||
"""
|
||||
# TODO: multiple streams
|
||||
stream = self.program.stream_set.all().first()
|
||||
if not stream or (not stream.begin and not stream.delay):
|
||||
return
|
||||
|
||||
def to_seconds(time):
|
||||
return 3600 * time.hour + 60 * time.minute + time.second
|
||||
|
||||
return {
|
||||
'begin': stream.begin.strftime('%Hh%M') if stream.begin else None,
|
||||
'end': stream.end.strftime('%Hh%M') if stream.end else None,
|
||||
'delay': to_seconds(stream.delay) if stream.delay else 0
|
||||
}
|
||||
|
|
@ -46,6 +46,9 @@ def date_or_default(date, no_time = False):
|
|||
return date
|
||||
|
||||
|
||||
#
|
||||
# Abstracts
|
||||
#
|
||||
class RelatedManager(models.Manager):
|
||||
def get_for(self, object = None, model = None):
|
||||
"""
|
||||
|
@ -112,166 +115,344 @@ class Nameable(models.Model):
|
|||
abstract = True
|
||||
|
||||
|
||||
class Sound(Nameable):
|
||||
#
|
||||
# Station related classes
|
||||
#
|
||||
class Station(Nameable):
|
||||
"""
|
||||
A Sound is the representation of a sound file that can be either an excerpt
|
||||
or a complete archive of the related diffusion.
|
||||
Represents a radio station, to which multiple programs are attached
|
||||
and that is used as the top object for everything.
|
||||
|
||||
A Station holds controllers for the audio stream generation too.
|
||||
Theses are set up when needed (at the first access to these elements)
|
||||
then cached.
|
||||
"""
|
||||
class Type(IntEnum):
|
||||
other = 0x00,
|
||||
archive = 0x01,
|
||||
excerpt = 0x02,
|
||||
removed = 0x03,
|
||||
|
||||
program = models.ForeignKey(
|
||||
'Program',
|
||||
verbose_name = _('program'),
|
||||
blank = True, null = True,
|
||||
help_text = _('program related to it'),
|
||||
)
|
||||
diffusion = models.ForeignKey(
|
||||
'Diffusion',
|
||||
verbose_name = _('diffusion'),
|
||||
blank = True, null = True,
|
||||
help_text = _('initial diffusion related it')
|
||||
)
|
||||
type = models.SmallIntegerField(
|
||||
verbose_name = _('type'),
|
||||
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
|
||||
blank = True, null = True
|
||||
)
|
||||
path = models.FilePathField(
|
||||
_('file'),
|
||||
path = settings.AIRCOX_PROGRAMS_DIR,
|
||||
match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \
|
||||
.replace('.', r'\.') + ')$',
|
||||
recursive = True,
|
||||
blank = True, null = True,
|
||||
max_length = 256
|
||||
)
|
||||
embed = models.TextField(
|
||||
_('embed HTML code'),
|
||||
blank = True, null = True,
|
||||
help_text = _('HTML code used to embed a sound from external plateform'),
|
||||
)
|
||||
duration = models.TimeField(
|
||||
_('duration'),
|
||||
blank = True, null = True,
|
||||
help_text = _('duration of the sound'),
|
||||
)
|
||||
mtime = models.DateTimeField(
|
||||
_('modification time'),
|
||||
blank = True, null = True,
|
||||
help_text = _('last modification date and time'),
|
||||
)
|
||||
good_quality = models.BooleanField(
|
||||
_('good quality'),
|
||||
default = False,
|
||||
help_text = _('sound\'s quality is okay')
|
||||
)
|
||||
public = models.BooleanField(
|
||||
_('public'),
|
||||
default = False,
|
||||
help_text = _('the sound is accessible to the public')
|
||||
path = models.CharField(
|
||||
_('path'),
|
||||
help_text = _('path to the working directory'),
|
||||
max_length = 256,
|
||||
blank = True,
|
||||
)
|
||||
|
||||
def get_mtime(self):
|
||||
"""
|
||||
Get the last modification date from file
|
||||
"""
|
||||
mtime = os.stat(self.path).st_mtime
|
||||
mtime = tz.datetime.fromtimestamp(mtime)
|
||||
# db does not store microseconds
|
||||
mtime = mtime.replace(microsecond = 0)
|
||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
||||
#
|
||||
# Controllers
|
||||
#
|
||||
__sources = None
|
||||
__dealer = None
|
||||
__streamer = None
|
||||
|
||||
def url(self):
|
||||
@property
|
||||
def sources(self):
|
||||
"""
|
||||
Return an url to the stream
|
||||
Audio sources, dealer included
|
||||
"""
|
||||
# path = self._meta.get_field('path').path
|
||||
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
|
||||
#path = self.path.replace(path, '', 1)
|
||||
return main_settings.MEDIA_URL + '/' + path
|
||||
# force streamer creation
|
||||
streamer = self.streamer
|
||||
|
||||
def file_exists(self):
|
||||
"""
|
||||
Return true if the file still exists
|
||||
"""
|
||||
return os.path.exists(self.path)
|
||||
if not self.__sources:
|
||||
import aircox.programs.controllers as controllers
|
||||
self.__sources = [
|
||||
controllers.Source(station = self, program = program)
|
||||
for program in Program.objects.filter(stream__isnull = False)
|
||||
] + [ self.dealer ]
|
||||
return self.__sources
|
||||
|
||||
def check_on_file(self):
|
||||
"""
|
||||
Check sound file info again'st self, and update informations if
|
||||
needed (do not save). Return True if there was changes.
|
||||
"""
|
||||
if not self.file_exists():
|
||||
if self.type == self.Type.removed:
|
||||
return
|
||||
logger.info('sound %s: has been removed', self.path)
|
||||
self.type = self.Type.removed
|
||||
return True
|
||||
@property
|
||||
def dealer(self):
|
||||
# force streamer creation
|
||||
streamer = self.streamer
|
||||
|
||||
# not anymore removed
|
||||
changed = False
|
||||
if self.type == self.Type.removed and self.program:
|
||||
changed = True
|
||||
self.type = self.Type.archive \
|
||||
if self.path.startswith(self.program.archives_path) else \
|
||||
self.Type.excerpt
|
||||
if not self.__dealer:
|
||||
import aircox.programs.controllers as controllers
|
||||
self.__dealer = controllers.Source(station = self)
|
||||
return self.__dealer
|
||||
|
||||
# check mtime -> reset quality if changed (assume file changed)
|
||||
mtime = self.get_mtime()
|
||||
if self.mtime != mtime:
|
||||
self.mtime = mtime
|
||||
self.good_quality = False
|
||||
logger.info('sound %s: m_time has changed. Reset quality info',
|
||||
self.path)
|
||||
return True
|
||||
return changed
|
||||
|
||||
def check_perms(self):
|
||||
@property
|
||||
def streamer(self):
|
||||
"""
|
||||
Check permissions and update them if this is activated
|
||||
Audio controller for the station
|
||||
"""
|
||||
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
|
||||
self.removed or not os.path.exists(self.path):
|
||||
return
|
||||
if not self.__streamer:
|
||||
import aircox.programs.controllers as controllers
|
||||
self.__streamer = controllers.Streamer(station = self)
|
||||
return self.__streamer
|
||||
|
||||
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public]
|
||||
try:
|
||||
os.chmod(self.path, flags)
|
||||
except PermissionError as err:
|
||||
logger.error(
|
||||
'cannot set permissions {} to file {}: {}'.format(
|
||||
self.flags[self.public],
|
||||
self.path, err
|
||||
)
|
||||
def get_played(self, models, archives = True):
|
||||
"""
|
||||
Return a queryset with log of played elements on this station,
|
||||
of the given models, ordered by date ascending.
|
||||
|
||||
* models: a model or a list of models
|
||||
* archives: if false, exclude log of diffusion's archives from
|
||||
the queryset;
|
||||
"""
|
||||
qs = Log.objects.get_for(model = models) \
|
||||
.filter(station = self, type = Log.Type.play)
|
||||
if not archives and self.dealer:
|
||||
qs = qs.exclude(
|
||||
source = self.dealer.id,
|
||||
related_type = ContentType.objects.get_for_model(Sound)
|
||||
)
|
||||
return qs.order_by('date')
|
||||
|
||||
@staticmethod
|
||||
def __mix_logs_and_diff(diffs, logs, count = 0):
|
||||
"""
|
||||
Mix together logs and diffusion items of the same day,
|
||||
ordered by their date.
|
||||
|
||||
Diffs and Logs are assumed to be ordered by -date, and so is
|
||||
the resulting list
|
||||
"""
|
||||
# we fill a list with diff and retrieve logs that happened between
|
||||
# each to put them too there
|
||||
items = []
|
||||
diff_ = None
|
||||
for diff in diffs.order_by('-start'):
|
||||
logs_ = \
|
||||
logs.filter(date__gt = diff.end, date__lt = diff_.start) \
|
||||
if diff_ else \
|
||||
logs.filter(date__gt = diff.end)
|
||||
diff_ = diff
|
||||
items.extend(logs_)
|
||||
items.append(diff)
|
||||
if count and len(items) >= count:
|
||||
break
|
||||
|
||||
if diff_:
|
||||
if count and len(items) >= count:
|
||||
return items[:count]
|
||||
logs_ = logs.filter(date__lt = diff_.end)
|
||||
else:
|
||||
logs_ = logs.all()
|
||||
|
||||
items.extend(logs_)
|
||||
return items[:count] if count else items
|
||||
|
||||
def on_air(self, date = None, count = 0):
|
||||
"""
|
||||
Return a list of what happened on air, based on logs and
|
||||
diffusions informations. The list is sorted by -date.
|
||||
|
||||
* date: only for what happened on this date;
|
||||
* count: number of items to retrieve if not zero;
|
||||
|
||||
If date is not specified, count MUST be set to a non-zero value.
|
||||
Be careful with what you which for: the result is a plain list.
|
||||
|
||||
The list contains:
|
||||
* track logs: for the streamed programs;
|
||||
* diffusion: for the scheduled diffusions;
|
||||
"""
|
||||
# FIXME: as an iterator?
|
||||
# TODO argument to get sound instead of tracks
|
||||
if not date and not count:
|
||||
raise ValueError('at least one argument must be set')
|
||||
|
||||
if date and date > datetime.date.today():
|
||||
return []
|
||||
|
||||
logs = Log.objects.get_for(model = Track) \
|
||||
.filter(station = self) \
|
||||
.order_by('-date')
|
||||
|
||||
if date:
|
||||
logs = logs.filter(date__contains = date)
|
||||
diffs = Diffusion.objects.get_at(date)
|
||||
else:
|
||||
diffs = Diffusion.objects
|
||||
|
||||
diffs = diffs.filter(station = self) \
|
||||
.filter(type = Diffusion.Type.normal) \
|
||||
.filter(start__lte = tz.now())
|
||||
return self.__mix_logs_and_diff(diffs, logs, count)
|
||||
|
||||
def save(self, make_sources = True, *args, **kwargs):
|
||||
if not self.path:
|
||||
self.path = os.path.join(
|
||||
settings.AIRCOX_CONTROLLERS_WORKING_DIR,
|
||||
self.slug
|
||||
)
|
||||
|
||||
def __check_name(self):
|
||||
if not self.name and self.path:
|
||||
# FIXME: later, remove date?
|
||||
self.name = os.path.basename(self.path)
|
||||
self.name = os.path.splitext(self.name)[0]
|
||||
self.name = self.name.replace('_', ' ')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__check_name()
|
||||
|
||||
def save(self, check = True, *args, **kwargs):
|
||||
if check:
|
||||
self.check_on_file()
|
||||
self.__check_name()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return '/'.join(self.path.split('/')[-3:])
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Sound')
|
||||
verbose_name_plural = _('Sounds')
|
||||
class Program(Nameable):
|
||||
"""
|
||||
A Program can either be a Streamed or a Scheduled program.
|
||||
|
||||
A Streamed program is used to generate non-stop random playlists when there
|
||||
is not scheduled diffusion. In such a case, a Stream is used to describe
|
||||
diffusion informations.
|
||||
|
||||
A Scheduled program has a schedule and is the one with a normal use case.
|
||||
|
||||
Renaming a Program rename the corresponding directory to matches the new
|
||||
name if it does not exists.
|
||||
"""
|
||||
station = models.ForeignKey(
|
||||
Station,
|
||||
verbose_name = _('station'),
|
||||
)
|
||||
active = models.BooleanField(
|
||||
_('active'),
|
||||
default = True,
|
||||
help_text = _('if not set this program is no longer active')
|
||||
)
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""
|
||||
Return the path to the programs directory
|
||||
"""
|
||||
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
|
||||
self.slug + '_' + str(self.id) )
|
||||
|
||||
def ensure_dir(self, subdir = None):
|
||||
"""
|
||||
Make sur the program's dir exists (and optionally subdir). Return True
|
||||
if the dir (or subdir) exists.
|
||||
"""
|
||||
path = os.path.join(self.path, subdir) if subdir else \
|
||||
self.path
|
||||
os.makedirs(path, exist_ok = True)
|
||||
return os.path.exists(path)
|
||||
|
||||
@property
|
||||
def archives_path(self):
|
||||
return os.path.join(
|
||||
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
|
||||
)
|
||||
|
||||
@property
|
||||
def excerpts_path(self):
|
||||
return os.path.join(
|
||||
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
|
||||
)
|
||||
|
||||
def find_schedule(self, date):
|
||||
"""
|
||||
Return the first schedule that matches a given date.
|
||||
"""
|
||||
schedules = Schedule.objects.filter(program = self)
|
||||
for schedule in schedules:
|
||||
if schedule.match(date, check_time = False):
|
||||
return schedule
|
||||
|
||||
def __init__(self, *kargs, **kwargs):
|
||||
super().__init__(*kargs, **kwargs)
|
||||
if self.name:
|
||||
self.__original_path = self.path
|
||||
|
||||
def save(self, *kargs, **kwargs):
|
||||
super().save(*kargs, **kwargs)
|
||||
if hasattr(self, '__original_path') and \
|
||||
self.__original_path != self.path and \
|
||||
os.path.exists(self.__original_path) and \
|
||||
not os.path.exists(self.path):
|
||||
logger.info('program #%s\'s name changed to %s. Change dir name',
|
||||
self.id, self.name)
|
||||
shutil.move(self.__original_path, self.path)
|
||||
|
||||
sounds = Sounds.objects.filter(path__startswith = self.__original_path)
|
||||
for sound in sounds:
|
||||
sound.path.replace(self.__original_path, self.path)
|
||||
sound.save()
|
||||
|
||||
@classmethod
|
||||
def get_from_path(cl, path):
|
||||
"""
|
||||
Return a Program from the given path. We assume the path has been
|
||||
given in a previous time by this model (Program.path getter).
|
||||
"""
|
||||
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
|
||||
while path[0] == '/': path = path[1:]
|
||||
while path[-1] == '/': path = path[:-2]
|
||||
if '/' in path:
|
||||
path = path[:path.index('/')]
|
||||
|
||||
path = path.split('_')
|
||||
path = path[-1]
|
||||
qs = cl.objects.filter(id = int(path))
|
||||
return qs[0] if qs else None
|
||||
|
||||
|
||||
class DiffusionManager(models.Manager):
|
||||
def get_at(self, date = None, next = False):
|
||||
"""
|
||||
Return a queryset of diffusions that have the given date
|
||||
in their range.
|
||||
|
||||
If date is a datetime.date object, check only against the
|
||||
date.
|
||||
"""
|
||||
date = date or tz.now()
|
||||
if not issubclass(type(date), datetime.datetime):
|
||||
return self.filter(
|
||||
models.Q(start__contains = date) | \
|
||||
models.Q(end__contains = date)
|
||||
)
|
||||
|
||||
if not next:
|
||||
return self.filter(start__lte = date, end__gte = date) \
|
||||
.order_by('start')
|
||||
|
||||
return self.filter(
|
||||
models.Q(start__lte = date, end__gte = date) |
|
||||
models.Q(start__gte = date),
|
||||
).order_by('start')
|
||||
|
||||
def get_after(self, date = None):
|
||||
"""
|
||||
Return a queryset of diffusions that happen after the given
|
||||
date.
|
||||
"""
|
||||
date = date_or_default(date)
|
||||
return self.filter(
|
||||
start__gte = date,
|
||||
).order_by('start')
|
||||
|
||||
def get_before(self, date):
|
||||
"""
|
||||
Return a queryset of diffusions that finish before the given
|
||||
date.
|
||||
"""
|
||||
date = date_or_default(date)
|
||||
return self.filter(
|
||||
end__lte = date,
|
||||
).order_by('start')
|
||||
|
||||
|
||||
class Stream(models.Model):
|
||||
"""
|
||||
When there are no program scheduled, it is possible to play sounds
|
||||
in order to avoid blanks. A Stream is a Program that plays this role,
|
||||
and whose linked to a Stream.
|
||||
|
||||
All sounds that are marked as good and that are under the related
|
||||
program's archive dir are elligible for the sound's selection.
|
||||
"""
|
||||
program = models.ForeignKey(
|
||||
Program,
|
||||
verbose_name = _('related program'),
|
||||
)
|
||||
delay = models.TimeField(
|
||||
_('delay'),
|
||||
blank = True, null = True,
|
||||
help_text = _('delay between two sound plays')
|
||||
)
|
||||
begin = models.TimeField(
|
||||
_('begin'),
|
||||
blank = True, null = True,
|
||||
help_text = _('used to define a time range this stream is'
|
||||
'played')
|
||||
)
|
||||
end = models.TimeField(
|
||||
_('end'),
|
||||
blank = True, null = True,
|
||||
help_text = _('used to define a time range this stream is'
|
||||
'played')
|
||||
)
|
||||
|
||||
|
||||
class Schedule(models.Model):
|
||||
|
@ -296,7 +477,7 @@ class Schedule(models.Model):
|
|||
one_on_two = 0b100000
|
||||
|
||||
program = models.ForeignKey(
|
||||
'Program',
|
||||
Program,
|
||||
verbose_name = _('related program'),
|
||||
)
|
||||
date = models.DateTimeField(_('date'))
|
||||
|
@ -468,180 +649,6 @@ class Schedule(models.Model):
|
|||
verbose_name_plural = _('Schedules')
|
||||
|
||||
|
||||
class Program(Nameable):
|
||||
"""
|
||||
A Program can either be a Streamed or a Scheduled program.
|
||||
|
||||
A Streamed program is used to generate non-stop random playlists when there
|
||||
is not scheduled diffusion. In such a case, a Stream is used to describe
|
||||
diffusion informations.
|
||||
|
||||
A Scheduled program has a schedule and is the one with a normal use case.
|
||||
|
||||
Renaming a Program rename the corresponding directory to matches the new
|
||||
name if it does not exists.
|
||||
"""
|
||||
active = models.BooleanField(
|
||||
_('active'),
|
||||
default = True,
|
||||
help_text = _('if not set this program is no longer active')
|
||||
)
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""
|
||||
Return the path to the programs directory
|
||||
"""
|
||||
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
|
||||
self.slug + '_' + str(self.id) )
|
||||
|
||||
def ensure_dir(self, subdir = None):
|
||||
"""
|
||||
Make sur the program's dir exists (and optionally subdir). Return True
|
||||
if the dir (or subdir) exists.
|
||||
"""
|
||||
path = os.path.join(self.path, subdir) if subdir else \
|
||||
self.path
|
||||
os.makedirs(path, exist_ok = True)
|
||||
return os.path.exists(path)
|
||||
|
||||
@property
|
||||
def archives_path(self):
|
||||
return os.path.join(
|
||||
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
|
||||
)
|
||||
|
||||
@property
|
||||
def excerpts_path(self):
|
||||
return os.path.join(
|
||||
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
|
||||
)
|
||||
|
||||
def find_schedule(self, date):
|
||||
"""
|
||||
Return the first schedule that matches a given date.
|
||||
"""
|
||||
schedules = Schedule.objects.filter(program = self)
|
||||
for schedule in schedules:
|
||||
if schedule.match(date, check_time = False):
|
||||
return schedule
|
||||
|
||||
def __init__(self, *kargs, **kwargs):
|
||||
super().__init__(*kargs, **kwargs)
|
||||
if self.name:
|
||||
self.__original_path = self.path
|
||||
|
||||
def save(self, *kargs, **kwargs):
|
||||
super().save(*kargs, **kwargs)
|
||||
if hasattr(self, '__original_path') and \
|
||||
self.__original_path != self.path and \
|
||||
os.path.exists(self.__original_path) and \
|
||||
not os.path.exists(self.path):
|
||||
logger.info('program #%s\'s name changed to %s. Change dir name',
|
||||
self.id, self.name)
|
||||
shutil.move(self.__original_path, self.path)
|
||||
|
||||
sounds = Sounds.objects.filter(path__startswith = self.__original_path)
|
||||
for sound in sounds:
|
||||
sound.path.replace(self.__original_path, self.path)
|
||||
sound.save()
|
||||
|
||||
@classmethod
|
||||
def get_from_path(cl, path):
|
||||
"""
|
||||
Return a Program from the given path. We assume the path has been
|
||||
given in a previous time by this model (Program.path getter).
|
||||
"""
|
||||
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
|
||||
while path[0] == '/': path = path[1:]
|
||||
while path[-1] == '/': path = path[:-2]
|
||||
if '/' in path:
|
||||
path = path[:path.index('/')]
|
||||
|
||||
path = path.split('_')
|
||||
path = path[-1]
|
||||
qs = cl.objects.filter(id = int(path))
|
||||
return qs[0] if qs else None
|
||||
|
||||
|
||||
class DiffusionManager(models.Manager):
|
||||
def get_at(self, date = None, next = False):
|
||||
"""
|
||||
Return a queryset of diffusions that have the given date
|
||||
in their range.
|
||||
|
||||
If date is a datetime.date object, check only against the
|
||||
date.
|
||||
"""
|
||||
date = date or tz.now()
|
||||
if not issubclass(type(date), datetime.datetime):
|
||||
return self.filter(
|
||||
models.Q(start__contains = date) | \
|
||||
models.Q(end__contains = date)
|
||||
)
|
||||
|
||||
if not next:
|
||||
return self.filter(start__lte = date, end__gte = date) \
|
||||
.order_by('start')
|
||||
|
||||
return self.filter(
|
||||
models.Q(start__lte = date, end__gte = date) |
|
||||
models.Q(start__gte = date),
|
||||
).order_by('start')
|
||||
|
||||
def get_after(self, date = None):
|
||||
"""
|
||||
Return a queryset of diffusions that happen after the given
|
||||
date.
|
||||
"""
|
||||
date = date_or_default(date)
|
||||
return self.filter(
|
||||
start__gte = date,
|
||||
).order_by('start')
|
||||
|
||||
def get_before(self, date):
|
||||
"""
|
||||
Return a queryset of diffusions that finish before the given
|
||||
date.
|
||||
"""
|
||||
date = date_or_default(date)
|
||||
return self.filter(
|
||||
end__lte = date,
|
||||
).order_by('start')
|
||||
|
||||
|
||||
class Stream(models.Model):
|
||||
"""
|
||||
When there are no program scheduled, it is possible to play sounds
|
||||
in order to avoid blanks. A Stream is a Program that plays this role,
|
||||
and whose linked to a Stream.
|
||||
|
||||
All sounds that are marked as good and that are under the related
|
||||
program's archive dir are elligible for the sound's selection.
|
||||
"""
|
||||
program = models.ForeignKey(
|
||||
'Program',
|
||||
verbose_name = _('related program'),
|
||||
)
|
||||
delay = models.TimeField(
|
||||
_('delay'),
|
||||
blank = True, null = True,
|
||||
help_text = _('delay between two sound plays')
|
||||
)
|
||||
begin = models.TimeField(
|
||||
_('begin'),
|
||||
blank = True, null = True,
|
||||
help_text = _('used to define a time range this stream is'
|
||||
'played')
|
||||
)
|
||||
end = models.TimeField(
|
||||
_('end'),
|
||||
blank = True, null = True,
|
||||
help_text = _('used to define a time range this stream is'
|
||||
'played')
|
||||
)
|
||||
|
||||
|
||||
class Diffusion(models.Model):
|
||||
"""
|
||||
A Diffusion is an occurrence of a Program that is scheduled on the
|
||||
|
@ -669,7 +676,7 @@ class Diffusion(models.Model):
|
|||
|
||||
# common
|
||||
program = models.ForeignKey (
|
||||
'Program',
|
||||
Program,
|
||||
verbose_name = _('program'),
|
||||
)
|
||||
# specific
|
||||
|
@ -759,6 +766,168 @@ class Diffusion(models.Model):
|
|||
)
|
||||
|
||||
|
||||
class Sound(Nameable):
|
||||
"""
|
||||
A Sound is the representation of a sound file that can be either an excerpt
|
||||
or a complete archive of the related diffusion.
|
||||
"""
|
||||
class Type(IntEnum):
|
||||
other = 0x00,
|
||||
archive = 0x01,
|
||||
excerpt = 0x02,
|
||||
removed = 0x03,
|
||||
|
||||
program = models.ForeignKey(
|
||||
Program,
|
||||
verbose_name = _('program'),
|
||||
blank = True, null = True,
|
||||
help_text = _('program related to it'),
|
||||
)
|
||||
diffusion = models.ForeignKey(
|
||||
'Diffusion',
|
||||
verbose_name = _('diffusion'),
|
||||
blank = True, null = True,
|
||||
help_text = _('initial diffusion related it')
|
||||
)
|
||||
type = models.SmallIntegerField(
|
||||
verbose_name = _('type'),
|
||||
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
|
||||
blank = True, null = True
|
||||
)
|
||||
path = models.FilePathField(
|
||||
_('file'),
|
||||
path = settings.AIRCOX_PROGRAMS_DIR,
|
||||
match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \
|
||||
.replace('.', r'\.') + ')$',
|
||||
recursive = True,
|
||||
blank = True, null = True,
|
||||
max_length = 256
|
||||
)
|
||||
embed = models.TextField(
|
||||
_('embed HTML code'),
|
||||
blank = True, null = True,
|
||||
help_text = _('HTML code used to embed a sound from external plateform'),
|
||||
)
|
||||
duration = models.TimeField(
|
||||
_('duration'),
|
||||
blank = True, null = True,
|
||||
help_text = _('duration of the sound'),
|
||||
)
|
||||
mtime = models.DateTimeField(
|
||||
_('modification time'),
|
||||
blank = True, null = True,
|
||||
help_text = _('last modification date and time'),
|
||||
)
|
||||
good_quality = models.BooleanField(
|
||||
_('good quality'),
|
||||
default = False,
|
||||
help_text = _('sound\'s quality is okay')
|
||||
)
|
||||
public = models.BooleanField(
|
||||
_('public'),
|
||||
default = False,
|
||||
help_text = _('the sound is accessible to the public')
|
||||
)
|
||||
|
||||
def get_mtime(self):
|
||||
"""
|
||||
Get the last modification date from file
|
||||
"""
|
||||
mtime = os.stat(self.path).st_mtime
|
||||
mtime = tz.datetime.fromtimestamp(mtime)
|
||||
# db does not store microseconds
|
||||
mtime = mtime.replace(microsecond = 0)
|
||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Return an url to the stream
|
||||
"""
|
||||
# path = self._meta.get_field('path').path
|
||||
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
|
||||
#path = self.path.replace(path, '', 1)
|
||||
return main_settings.MEDIA_URL + '/' + path
|
||||
|
||||
def file_exists(self):
|
||||
"""
|
||||
Return true if the file still exists
|
||||
"""
|
||||
return os.path.exists(self.path)
|
||||
|
||||
def check_on_file(self):
|
||||
"""
|
||||
Check sound file info again'st self, and update informations if
|
||||
needed (do not save). Return True if there was changes.
|
||||
"""
|
||||
if not self.file_exists():
|
||||
if self.type == self.Type.removed:
|
||||
return
|
||||
logger.info('sound %s: has been removed', self.path)
|
||||
self.type = self.Type.removed
|
||||
return True
|
||||
|
||||
# not anymore removed
|
||||
changed = False
|
||||
if self.type == self.Type.removed and self.program:
|
||||
changed = True
|
||||
self.type = self.Type.archive \
|
||||
if self.path.startswith(self.program.archives_path) else \
|
||||
self.Type.excerpt
|
||||
|
||||
# check mtime -> reset quality if changed (assume file changed)
|
||||
mtime = self.get_mtime()
|
||||
if self.mtime != mtime:
|
||||
self.mtime = mtime
|
||||
self.good_quality = False
|
||||
logger.info('sound %s: m_time has changed. Reset quality info',
|
||||
self.path)
|
||||
return True
|
||||
return changed
|
||||
|
||||
def check_perms(self):
|
||||
"""
|
||||
Check file permissions and update it if the sound is public
|
||||
"""
|
||||
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
|
||||
self.removed or not os.path.exists(self.path):
|
||||
return
|
||||
|
||||
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public]
|
||||
try:
|
||||
os.chmod(self.path, flags)
|
||||
except PermissionError as err:
|
||||
logger.error(
|
||||
'cannot set permissions {} to file {}: {}'.format(
|
||||
self.flags[self.public],
|
||||
self.path, err
|
||||
)
|
||||
)
|
||||
|
||||
def __check_name(self):
|
||||
if not self.name and self.path:
|
||||
# FIXME: later, remove date?
|
||||
self.name = os.path.basename(self.path)
|
||||
self.name = os.path.splitext(self.name)[0]
|
||||
self.name = self.name.replace('_', ' ')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__check_name()
|
||||
|
||||
def save(self, check = True, *args, **kwargs):
|
||||
if check:
|
||||
self.check_on_file()
|
||||
self.__check_name()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return '/'.join(self.path.split('/')[-3:])
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Sound')
|
||||
verbose_name_plural = _('Sounds')
|
||||
|
||||
|
||||
class Track(Related):
|
||||
"""
|
||||
Track of a playlist of an object. The position can either be expressed
|
||||
|
@ -804,3 +973,133 @@ class Track(Related):
|
|||
verbose_name_plural = _('Tracks')
|
||||
|
||||
|
||||
#
|
||||
# Controls and audio output
|
||||
#
|
||||
|
||||
# FIXME HERE
|
||||
# + station -> played, on_air and others
|
||||
class Output (models.Model):
|
||||
"""
|
||||
Represent an audio output for the audio stream generation.
|
||||
You might want to take a look to LiquidSoap's documentation
|
||||
for the Jack, Alsa, and Icecast ouptuts.
|
||||
"""
|
||||
class Type(IntEnum):
|
||||
jack = 0x00
|
||||
alsa = 0x01
|
||||
icecast = 0x02
|
||||
|
||||
station = models.ForeignKey(
|
||||
Station,
|
||||
verbose_name = _('station'),
|
||||
)
|
||||
type = models.SmallIntegerField(
|
||||
_('type'),
|
||||
# we don't translate the names since it is project names.
|
||||
choices = [ (int(y), x) for x,y in Type.__members__.items() ],
|
||||
)
|
||||
active = models.BooleanField(
|
||||
_('active'),
|
||||
default = True,
|
||||
help_text = _('this output is active')
|
||||
)
|
||||
settings = models.TextField(
|
||||
_('output settings'),
|
||||
help_text = _('list of comma separated params available; '
|
||||
'this is put in the output config as raw code; '
|
||||
'plugin related'),
|
||||
blank = True, null = True
|
||||
)
|
||||
|
||||
|
||||
class Log(Related):
|
||||
"""
|
||||
Log sounds and diffusions that are played on the station.
|
||||
|
||||
This only remember what has been played on the outputs, not on each
|
||||
track; Source designate here which source is responsible of that.
|
||||
"""
|
||||
class Type(IntEnum):
|
||||
stop = 0x00
|
||||
"""
|
||||
Source has been stopped (only when there is no more sound)
|
||||
"""
|
||||
play = 0x01
|
||||
"""
|
||||
Source has been started/changed and is running related_object
|
||||
If no related_object is available, comment is used to designate
|
||||
the sound.
|
||||
"""
|
||||
load = 0x02
|
||||
"""
|
||||
Source starts to be preload related_object
|
||||
"""
|
||||
other = 0x03
|
||||
"""
|
||||
Other log
|
||||
"""
|
||||
|
||||
type = models.SmallIntegerField(
|
||||
verbose_name = _('type'),
|
||||
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
|
||||
blank = True, null = True,
|
||||
)
|
||||
station = models.ForeignKey(
|
||||
Station,
|
||||
verbose_name = _('station'),
|
||||
help_text = _('station on which the event occured'),
|
||||
)
|
||||
source = models.CharField(
|
||||
# we use a CharField to avoid loosing logs information if the
|
||||
# source is removed
|
||||
_('source'),
|
||||
max_length=64,
|
||||
help_text = _('source id that make it happen on the station'),
|
||||
blank = True, null = True,
|
||||
)
|
||||
date = models.DateTimeField(
|
||||
_('date'),
|
||||
default=tz.now,
|
||||
)
|
||||
comment = models.CharField(
|
||||
_('comment'),
|
||||
max_length = 512,
|
||||
blank = True, null = True,
|
||||
)
|
||||
|
||||
@property
|
||||
def end(self):
|
||||
"""
|
||||
Calculated end using self.related informations
|
||||
"""
|
||||
if self.related_type == Diffusion:
|
||||
return self.related.end
|
||||
if self.related_type == Sound:
|
||||
return self.date + to_timedelta(self.duration)
|
||||
return self.date
|
||||
|
||||
def is_expired(self, date = None):
|
||||
"""
|
||||
Return True if the log is expired. Note that it only check
|
||||
against the date, so it is still possible that the expiration
|
||||
occured because of a Stop or other source.
|
||||
"""
|
||||
date = date_or_default(date)
|
||||
return self.end < date
|
||||
|
||||
def print(self):
|
||||
logger.info('log #%s: %s%s',
|
||||
str(self),
|
||||
self.comment or '',
|
||||
' -- {} #{}'.format(self.related_type, self.related_id)
|
||||
if self.related else ''
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return '#{} ({}, {})'.format(
|
||||
self.pk, self.date.strftime('%Y/%m/%d %H:%M'), self.source
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -57,5 +57,7 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
|
|||
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
|
||||
|
||||
|
||||
# Controllers working directory
|
||||
ensure('AIRCOX_CONTROLLERS_WORKING_DIR', '/tmp/aircox')
|
||||
|
||||
|
||||
|
|
|
@ -55,8 +55,8 @@ end
|
|||
|
||||
{% block config %}
|
||||
set("server.socket", true)
|
||||
set("server.socket.path", "{{ station.controller.socket_path }}")
|
||||
set("log.file.path", "{{ station.controller.station.path }}/liquidsoap.log")
|
||||
set("server.socket.path", "{{ station.streamer.socket_path }}")
|
||||
set("log.file.path", "{{ station.path }}/liquidsoap.log")
|
||||
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
|
||||
set("{{ key|safe }}", {{ value|safe }})
|
||||
{% endfor %}
|
||||
|
@ -68,17 +68,9 @@ set("{{ key|safe }}", {{ value|safe }})
|
|||
|
||||
{% block sources %}
|
||||
live = fallback([
|
||||
{% for source in station.file_sources %}
|
||||
{% with controller=source.controller %}
|
||||
interactive_source(
|
||||
'{{ source.id_ }}', single("{{ source.url }}"), active=false
|
||||
),
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% with source=station.dealer controller=station.dealer.controller %}
|
||||
interactive_source('{{ source.id_ }}',
|
||||
playlist.once(reload_mode='watch', "{{ controller.path }}"),
|
||||
{% with source=station.dealer %}
|
||||
interactive_source('{{ source.id }}',
|
||||
playlist.once(reload_mode='watch', "{{ source.path }}"),
|
||||
active=false
|
||||
),
|
||||
{% endwith %}
|
||||
|
@ -87,28 +79,24 @@ live = fallback([
|
|||
|
||||
stream = fallback([
|
||||
rotate([
|
||||
{% for source in station.stream_sources %}
|
||||
{% with controller=source.controller stream=source.controller.stream %}
|
||||
{% for source in station.sources %}
|
||||
{% if source != station.dealer %}
|
||||
{% with stream=source.stream %}
|
||||
{% if stream.delay %}
|
||||
delay({{ stream.delay }}.,
|
||||
stream("{{ source.id_ }}", "{{ controller.path }}")),
|
||||
stream("{{ source.id }}", "{{ source.path }}")),
|
||||
{% elif stream.begin and stream.end %}
|
||||
at({ {{stream.begin}}-{{stream.end}} },
|
||||
stream("{{ source.id_ }}", "{{ controller.path }}")),
|
||||
stream("{{ source.id }}", "{{ source.path }}")),
|
||||
{% elif not stream %}
|
||||
stream("{{ source.id_ }}", "{{ controller.path }}"),
|
||||
stream("{{ source.id }}", "{{ source.path }}"),
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
]),
|
||||
|
||||
{% for source in station.fallback_sources %}
|
||||
{% with controller=source.controller %}
|
||||
single("{{ source.value }}"),
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
blank(id="blank_fallback", duration=0.1),
|
||||
blank(id="blank", duration=0.1),
|
||||
])
|
||||
|
||||
{% endblock %}
|
||||
|
@ -130,8 +118,8 @@ end
|
|||
|
||||
|
||||
{% block station %}
|
||||
{{ station.id_ }} = interactive_source (
|
||||
"{{ station.id_ }}",
|
||||
{{ station.streamer.id }} = interactive_source (
|
||||
"{{ station.streamer.id }}",
|
||||
fallback(
|
||||
track_sensitive=false,
|
||||
transitions=[to_live,to_stream],
|
||||
|
@ -147,9 +135,9 @@ end
|
|||
|
||||
|
||||
{% block outputs %}
|
||||
{% for output in station.outputs %}
|
||||
{% for output in station.output_set.all %}
|
||||
output.{{ output.get_type_display }}(
|
||||
{{ station.id_ }},
|
||||
{{ station.streamer.id }},
|
||||
{% if controller.settings %},
|
||||
{{ output.settings }}
|
||||
{% endif %}
|
Loading…
Reference in New Issue
Block a user