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

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

View File

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

90
programs/connector.py Normal file
View File

@ -0,0 +1,90 @@
import os
import socket
import re
import json
class Connector:
"""
Simple connector class that retrieve/send data through a unix
domain socket file or a TCP/IP connection
It is able to parse list of `key=value`, and JSON data.
"""
__socket = None
__available = False
address = None
"""
a string to the unix domain socket file, or a tuple (host, port) for
TCP/IP connection
"""
@property
def available(self):
return self.__available
def __init__(self, address = None):
if address:
self.address = address
def open(self):
if self.__available:
return
try:
family = socket.AF_INET if type(self.address) in (tuple, list) else \
socket.AF_UNIX
self.__socket = socket.socket(family, socket.SOCK_STREAM)
self.__socket.connect(self.address)
self.__available = True
except:
self.__available = False
return -1
def send(self, *data, try_count = 1, parse = False, parse_json = False):
if self.open():
return ''
data = bytes(''.join([str(d) for d in data]) + '\n', encoding='utf-8')
try:
reg = re.compile(r'(.*)\s+END\s*$')
self.__socket.sendall(data)
data = ''
while not reg.search(data):
data += self.__socket.recv(1024).decode('utf-8')
if data:
data = reg.sub(r'\1', data)
data = data.strip()
if parse:
data = self.parse(data)
elif parse_json:
data = self.parse_json(data)
return data
except:
self.__available = False
if try_count > 0:
return self.send(data, try_count - 1)
def parse(self, string):
string = string.split('\n')
data = {}
for line in string:
line = re.search(r'(?P<key>[^=]+)="?(?P<value>([^"]|\\")+)"?', line)
if not line:
continue
line = line.groupdict()
data[line['key']] = line['value']
return data
def parse_json(self, string):
try:
if string[0] == '"' and string[-1] == '"':
string = string[1:-1]
return json.loads(string) if string else None
except:
return None

355
programs/controllers.py Normal file
View File

@ -0,0 +1,355 @@
import os
import re
import subprocess
import atexit
from django.template.loader import render_to_string
import aircox.programs.models as models
import aircox.programs.settings as settings
from aircox.programs.connector import Connector
class Streamer:
"""
Audio controller of a Station.
"""
station = None
"""
Related station
"""
template_name = 'aircox/controllers/liquidsoap.liq'
"""
If set, use this template in order to generated the configuration
file in self.path file
"""
path = None
"""
Path of the configuration file.
"""
current_sound = ''
"""
Current sound being played (retrieved by fetch)
"""
current_source = None
"""
Current source object that is responsible of self.current_sound
"""
process = None
"""
Application's process if ran from Streamer
"""
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
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.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.
"""
return ['liquidsoap', '-v', self.path]
def process_run(self):
"""
Execute the external application with corresponding informations.
This function must make sure that all needed files have been generated.
"""
if self.process:
return
self.push()
args = self.__get_process_args()
if not args:
return
self.process = subprocess.Popen(args, stderr=subprocess.STDOUT)
atexit.register(self.process.terminate)
def process_terminate(self):
if self.process:
self.process.terminate()
self.process = None
def process_wait(self):
"""
Wait for the process to terminate if there is a process
"""
if self.process:
self.process.wait()
self.process = None
def ready(self):
"""
If external program is ready to use, returns True
"""
return self._send('var.list') != ''
class Source:
"""
Controller of a Source. Value are usually updated directly on the
external side.
"""
program = None
"""
Related source
"""
path = ''
"""
Path to the Source's playlist file. Optional.
"""
active = True
"""
Source is available. May be different from the containing Source,
e.g. dealer and liquidsoap.
"""
current_sound = ''
"""
Current sound being played (retrieved by fetch)
"""
current_source = None
"""
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):
"""
Current playlist on the Source, list of paths to play
"""
self.fetch()
return self.__playlist
@playlist.setter
def playlist(self, value):
value = sorted(value)
if value != self.__playlist:
self.__playlist = value
self.push()
def from_db(self, diffusion = None, program = None):
"""
Load a playlist to the controller from the database. If diffusion or
program is given use it, otherwise, try with self.program if exists, or
(if URI, self.url).
A playlist from a program uses all its available archives.
"""
if diffusion:
self.playlist = diffusion.playlist
return
program = program or self.program
if program:
self.playlist = [ sound.path for sound in
models.Sound.objects.filter(
type = models.Sound.Type.archive,
program = program,
)
]
return
def from_file(self, path = None):
"""
Load a playlist from the given file (if not, use the
controller's one
"""
path = path or self.path
if not os.path.exists(path):
return
with open(path, 'r') as file:
self.__playlist = file.read()
self.__playlist = self.__playlist.split('\n') \
if self.__playlist else []
#
# RPC
#
def _send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs)
@property
def active(self):
return self._send('var.get ', self.id, '_active') == 'true'
@active.setter
def active(self, value):
self._send('var.set ', self.id, '_active', '=',
'true' if value else 'false')
def fetch(self):
"""
Get the source information
"""
data = self._send(self.id, '.get', parse = True)
if not data or type(data) != dict:
return
self.rid = data.get('rid')
self.current_sound = data.get('initial_uri')
def push(self):
"""
Update data relative to the source on the external program.
By default write the playlist.
"""
os.makedirs(os.path.dirname(self.path), exist_ok = True)
with open(self.path, 'w') as file:
file.write('\n'.join(self.__playlist or []))
def skip(self):
"""
Skip the current sound in the source
"""
self._send(self.id, '.skip')
def stream(self):
"""
Return a dict with stream info for a Stream program, or None if there
is not. Used in the template.
"""
# TODO: multiple streams
stream = self.program.stream_set.all().first()
if not stream or (not stream.begin and not stream.delay):
return
def to_seconds(time):
return 3600 * time.hour + 60 * time.minute + time.second
return {
'begin': stream.begin.strftime('%Hh%M') if stream.begin else None,
'end': stream.end.strftime('%Hh%M') if stream.end else None,
'delay': to_seconds(stream.delay) if stream.delay else 0
}

View File

@ -46,6 +46,9 @@ def date_or_default(date, no_time = False):
return date
#
# Abstracts
#
class RelatedManager(models.Manager):
def get_for(self, object = None, model = None):
"""
@ -112,166 +115,344 @@ class Nameable(models.Model):
abstract = True
class Sound(Nameable):
#
# Station related classes
#
class Station(Nameable):
"""
A Sound is the representation of a sound file that can be either an excerpt
or a complete archive of the related diffusion.
Represents a radio station, to which multiple programs are attached
and that is used as the top object for everything.
A Station holds controllers for the audio stream generation too.
Theses are set up when needed (at the first access to these elements)
then cached.
"""
class Type(IntEnum):
other = 0x00,
archive = 0x01,
excerpt = 0x02,
removed = 0x03,
program = models.ForeignKey(
'Program',
verbose_name = _('program'),
blank = True, null = True,
help_text = _('program related to it'),
)
diffusion = models.ForeignKey(
'Diffusion',
verbose_name = _('diffusion'),
blank = True, null = True,
help_text = _('initial diffusion related it')
)
type = models.SmallIntegerField(
verbose_name = _('type'),
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
blank = True, null = True
)
path = models.FilePathField(
_('file'),
path = settings.AIRCOX_PROGRAMS_DIR,
match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \
.replace('.', r'\.') + ')$',
recursive = True,
blank = True, null = True,
max_length = 256
)
embed = models.TextField(
_('embed HTML code'),
blank = True, null = True,
help_text = _('HTML code used to embed a sound from external plateform'),
)
duration = models.TimeField(
_('duration'),
blank = True, null = True,
help_text = _('duration of the sound'),
)
mtime = models.DateTimeField(
_('modification time'),
blank = True, null = True,
help_text = _('last modification date and time'),
)
good_quality = models.BooleanField(
_('good quality'),
default = False,
help_text = _('sound\'s quality is okay')
)
public = models.BooleanField(
_('public'),
default = False,
help_text = _('the sound is accessible to the public')
path = models.CharField(
_('path'),
help_text = _('path to the working directory'),
max_length = 256,
blank = True,
)
def get_mtime(self):
"""
Get the last modification date from file
"""
mtime = os.stat(self.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
# db does not store microseconds
mtime = mtime.replace(microsecond = 0)
return tz.make_aware(mtime, tz.get_current_timezone())
#
# Controllers
#
__sources = None
__dealer = None
__streamer = None
def url(self):
@property
def sources(self):
"""
Return an url to the stream
Audio sources, dealer included
"""
# path = self._meta.get_field('path').path
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
#path = self.path.replace(path, '', 1)
return main_settings.MEDIA_URL + '/' + path
# force streamer creation
streamer = self.streamer
def file_exists(self):
"""
Return true if the file still exists
"""
return os.path.exists(self.path)
if not self.__sources:
import aircox.programs.controllers as controllers
self.__sources = [
controllers.Source(station = self, program = program)
for program in Program.objects.filter(stream__isnull = False)
] + [ self.dealer ]
return self.__sources
def check_on_file(self):
"""
Check sound file info again'st self, and update informations if
needed (do not save). Return True if there was changes.
"""
if not self.file_exists():
if self.type == self.Type.removed:
return
logger.info('sound %s: has been removed', self.path)
self.type = self.Type.removed
return True
@property
def dealer(self):
# force streamer creation
streamer = self.streamer
# not anymore removed
changed = False
if self.type == self.Type.removed and self.program:
changed = True
self.type = self.Type.archive \
if self.path.startswith(self.program.archives_path) else \
self.Type.excerpt
if not self.__dealer:
import aircox.programs.controllers as controllers
self.__dealer = controllers.Source(station = self)
return self.__dealer
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()
if self.mtime != mtime:
self.mtime = mtime
self.good_quality = False
logger.info('sound %s: m_time has changed. Reset quality info',
self.path)
return True
return changed
def check_perms(self):
@property
def streamer(self):
"""
Check permissions and update them if this is activated
Audio controller for the station
"""
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
self.removed or not os.path.exists(self.path):
return
if not self.__streamer:
import aircox.programs.controllers as controllers
self.__streamer = controllers.Streamer(station = self)
return self.__streamer
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public]
try:
os.chmod(self.path, flags)
except PermissionError as err:
logger.error(
'cannot set permissions {} to file {}: {}'.format(
self.flags[self.public],
self.path, err
)
def get_played(self, models, archives = True):
"""
Return a queryset with log of played elements on this station,
of the given models, ordered by date ascending.
* models: a model or a list of models
* archives: if false, exclude log of diffusion's archives from
the queryset;
"""
qs = Log.objects.get_for(model = models) \
.filter(station = self, type = Log.Type.play)
if not archives and self.dealer:
qs = qs.exclude(
source = self.dealer.id,
related_type = ContentType.objects.get_for_model(Sound)
)
return qs.order_by('date')
@staticmethod
def __mix_logs_and_diff(diffs, logs, count = 0):
"""
Mix together logs and diffusion items of the same day,
ordered by their date.
Diffs and Logs are assumed to be ordered by -date, and so is
the resulting list
"""
# we fill a list with diff and retrieve logs that happened between
# each to put them too there
items = []
diff_ = None
for diff in diffs.order_by('-start'):
logs_ = \
logs.filter(date__gt = diff.end, date__lt = diff_.start) \
if diff_ else \
logs.filter(date__gt = diff.end)
diff_ = diff
items.extend(logs_)
items.append(diff)
if count and len(items) >= count:
break
if diff_:
if count and len(items) >= count:
return items[:count]
logs_ = logs.filter(date__lt = diff_.end)
else:
logs_ = logs.all()
items.extend(logs_)
return items[:count] if count else items
def on_air(self, date = None, count = 0):
"""
Return a list of what happened on air, based on logs and
diffusions informations. The list is sorted by -date.
* date: only for what happened on this date;
* count: number of items to retrieve if not zero;
If date is not specified, count MUST be set to a non-zero value.
Be careful with what you which for: the result is a plain list.
The list contains:
* track logs: for the streamed programs;
* diffusion: for the scheduled diffusions;
"""
# FIXME: as an iterator?
# TODO argument to get sound instead of tracks
if not date and not count:
raise ValueError('at least one argument must be set')
if date and date > datetime.date.today():
return []
logs = Log.objects.get_for(model = Track) \
.filter(station = self) \
.order_by('-date')
if date:
logs = logs.filter(date__contains = date)
diffs = Diffusion.objects.get_at(date)
else:
diffs = Diffusion.objects
diffs = diffs.filter(station = self) \
.filter(type = Diffusion.Type.normal) \
.filter(start__lte = tz.now())
return self.__mix_logs_and_diff(diffs, logs, count)
def save(self, make_sources = True, *args, **kwargs):
if not self.path:
self.path = os.path.join(
settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug
)
def __check_name(self):
if not self.name and self.path:
# FIXME: later, remove date?
self.name = os.path.basename(self.path)
self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__check_name()
def save(self, check = True, *args, **kwargs):
if check:
self.check_on_file()
self.__check_name()
super().save(*args, **kwargs)
def __str__(self):
return '/'.join(self.path.split('/')[-3:])
class Meta:
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
class Program(Nameable):
"""
A Program can either be a Streamed or a Scheduled program.
A Streamed program is used to generate non-stop random playlists when there
is not scheduled diffusion. In such a case, a Stream is used to describe
diffusion informations.
A Scheduled program has a schedule and is the one with a normal use case.
Renaming a Program rename the corresponding directory to matches the new
name if it does not exists.
"""
station = models.ForeignKey(
Station,
verbose_name = _('station'),
)
active = models.BooleanField(
_('active'),
default = True,
help_text = _('if not set this program is no longer active')
)
@property
def path(self):
"""
Return the path to the programs directory
"""
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
self.slug + '_' + str(self.id) )
def ensure_dir(self, subdir = None):
"""
Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists.
"""
path = os.path.join(self.path, subdir) if subdir else \
self.path
os.makedirs(path, exist_ok = True)
return os.path.exists(path)
@property
def archives_path(self):
return os.path.join(
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
@property
def excerpts_path(self):
return os.path.join(
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
def find_schedule(self, date):
"""
Return the first schedule that matches a given date.
"""
schedules = Schedule.objects.filter(program = self)
for schedule in schedules:
if schedule.match(date, check_time = False):
return schedule
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
if self.name:
self.__original_path = self.path
def save(self, *kargs, **kwargs):
super().save(*kargs, **kwargs)
if hasattr(self, '__original_path') and \
self.__original_path != self.path and \
os.path.exists(self.__original_path) and \
not os.path.exists(self.path):
logger.info('program #%s\'s name changed to %s. Change dir name',
self.id, self.name)
shutil.move(self.__original_path, self.path)
sounds = Sounds.objects.filter(path__startswith = self.__original_path)
for sound in sounds:
sound.path.replace(self.__original_path, self.path)
sound.save()
@classmethod
def get_from_path(cl, path):
"""
Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter).
"""
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/': path = path[1:]
while path[-1] == '/': path = path[:-2]
if '/' in path:
path = path[:path.index('/')]
path = path.split('_')
path = path[-1]
qs = cl.objects.filter(id = int(path))
return qs[0] if qs else None
class DiffusionManager(models.Manager):
def get_at(self, date = None, next = False):
"""
Return a queryset of diffusions that have the given date
in their range.
If date is a datetime.date object, check only against the
date.
"""
date = date or tz.now()
if not issubclass(type(date), datetime.datetime):
return self.filter(
models.Q(start__contains = date) | \
models.Q(end__contains = date)
)
if not next:
return self.filter(start__lte = date, end__gte = date) \
.order_by('start')
return self.filter(
models.Q(start__lte = date, end__gte = date) |
models.Q(start__gte = date),
).order_by('start')
def get_after(self, date = None):
"""
Return a queryset of diffusions that happen after the given
date.
"""
date = date_or_default(date)
return self.filter(
start__gte = date,
).order_by('start')
def get_before(self, date):
"""
Return a queryset of diffusions that finish before the given
date.
"""
date = date_or_default(date)
return self.filter(
end__lte = date,
).order_by('start')
class Stream(models.Model):
"""
When there are no program scheduled, it is possible to play sounds
in order to avoid blanks. A Stream is a Program that plays this role,
and whose linked to a Stream.
All sounds that are marked as good and that are under the related
program's archive dir are elligible for the sound's selection.
"""
program = models.ForeignKey(
Program,
verbose_name = _('related program'),
)
delay = models.TimeField(
_('delay'),
blank = True, null = True,
help_text = _('delay between two sound plays')
)
begin = models.TimeField(
_('begin'),
blank = True, null = True,
help_text = _('used to define a time range this stream is'
'played')
)
end = models.TimeField(
_('end'),
blank = True, null = True,
help_text = _('used to define a time range this stream is'
'played')
)
class Schedule(models.Model):
@ -296,7 +477,7 @@ class Schedule(models.Model):
one_on_two = 0b100000
program = models.ForeignKey(
'Program',
Program,
verbose_name = _('related program'),
)
date = models.DateTimeField(_('date'))
@ -468,180 +649,6 @@ class Schedule(models.Model):
verbose_name_plural = _('Schedules')
class Program(Nameable):
"""
A Program can either be a Streamed or a Scheduled program.
A Streamed program is used to generate non-stop random playlists when there
is not scheduled diffusion. In such a case, a Stream is used to describe
diffusion informations.
A Scheduled program has a schedule and is the one with a normal use case.
Renaming a Program rename the corresponding directory to matches the new
name if it does not exists.
"""
active = models.BooleanField(
_('active'),
default = True,
help_text = _('if not set this program is no longer active')
)
@property
def path(self):
"""
Return the path to the programs directory
"""
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
self.slug + '_' + str(self.id) )
def ensure_dir(self, subdir = None):
"""
Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists.
"""
path = os.path.join(self.path, subdir) if subdir else \
self.path
os.makedirs(path, exist_ok = True)
return os.path.exists(path)
@property
def archives_path(self):
return os.path.join(
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
@property
def excerpts_path(self):
return os.path.join(
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
def find_schedule(self, date):
"""
Return the first schedule that matches a given date.
"""
schedules = Schedule.objects.filter(program = self)
for schedule in schedules:
if schedule.match(date, check_time = False):
return schedule
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
if self.name:
self.__original_path = self.path
def save(self, *kargs, **kwargs):
super().save(*kargs, **kwargs)
if hasattr(self, '__original_path') and \
self.__original_path != self.path and \
os.path.exists(self.__original_path) and \
not os.path.exists(self.path):
logger.info('program #%s\'s name changed to %s. Change dir name',
self.id, self.name)
shutil.move(self.__original_path, self.path)
sounds = Sounds.objects.filter(path__startswith = self.__original_path)
for sound in sounds:
sound.path.replace(self.__original_path, self.path)
sound.save()
@classmethod
def get_from_path(cl, path):
"""
Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter).
"""
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/': path = path[1:]
while path[-1] == '/': path = path[:-2]
if '/' in path:
path = path[:path.index('/')]
path = path.split('_')
path = path[-1]
qs = cl.objects.filter(id = int(path))
return qs[0] if qs else None
class DiffusionManager(models.Manager):
def get_at(self, date = None, next = False):
"""
Return a queryset of diffusions that have the given date
in their range.
If date is a datetime.date object, check only against the
date.
"""
date = date or tz.now()
if not issubclass(type(date), datetime.datetime):
return self.filter(
models.Q(start__contains = date) | \
models.Q(end__contains = date)
)
if not next:
return self.filter(start__lte = date, end__gte = date) \
.order_by('start')
return self.filter(
models.Q(start__lte = date, end__gte = date) |
models.Q(start__gte = date),
).order_by('start')
def get_after(self, date = None):
"""
Return a queryset of diffusions that happen after the given
date.
"""
date = date_or_default(date)
return self.filter(
start__gte = date,
).order_by('start')
def get_before(self, date):
"""
Return a queryset of diffusions that finish before the given
date.
"""
date = date_or_default(date)
return self.filter(
end__lte = date,
).order_by('start')
class Stream(models.Model):
"""
When there are no program scheduled, it is possible to play sounds
in order to avoid blanks. A Stream is a Program that plays this role,
and whose linked to a Stream.
All sounds that are marked as good and that are under the related
program's archive dir are elligible for the sound's selection.
"""
program = models.ForeignKey(
'Program',
verbose_name = _('related program'),
)
delay = models.TimeField(
_('delay'),
blank = True, null = True,
help_text = _('delay between two sound plays')
)
begin = models.TimeField(
_('begin'),
blank = True, null = True,
help_text = _('used to define a time range this stream is'
'played')
)
end = models.TimeField(
_('end'),
blank = True, null = True,
help_text = _('used to define a time range this stream is'
'played')
)
class Diffusion(models.Model):
"""
A Diffusion is an occurrence of a Program that is scheduled on the
@ -669,7 +676,7 @@ class Diffusion(models.Model):
# common
program = models.ForeignKey (
'Program',
Program,
verbose_name = _('program'),
)
# specific
@ -759,6 +766,168 @@ class Diffusion(models.Model):
)
class Sound(Nameable):
"""
A Sound is the representation of a sound file that can be either an excerpt
or a complete archive of the related diffusion.
"""
class Type(IntEnum):
other = 0x00,
archive = 0x01,
excerpt = 0x02,
removed = 0x03,
program = models.ForeignKey(
Program,
verbose_name = _('program'),
blank = True, null = True,
help_text = _('program related to it'),
)
diffusion = models.ForeignKey(
'Diffusion',
verbose_name = _('diffusion'),
blank = True, null = True,
help_text = _('initial diffusion related it')
)
type = models.SmallIntegerField(
verbose_name = _('type'),
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
blank = True, null = True
)
path = models.FilePathField(
_('file'),
path = settings.AIRCOX_PROGRAMS_DIR,
match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \
.replace('.', r'\.') + ')$',
recursive = True,
blank = True, null = True,
max_length = 256
)
embed = models.TextField(
_('embed HTML code'),
blank = True, null = True,
help_text = _('HTML code used to embed a sound from external plateform'),
)
duration = models.TimeField(
_('duration'),
blank = True, null = True,
help_text = _('duration of the sound'),
)
mtime = models.DateTimeField(
_('modification time'),
blank = True, null = True,
help_text = _('last modification date and time'),
)
good_quality = models.BooleanField(
_('good quality'),
default = False,
help_text = _('sound\'s quality is okay')
)
public = models.BooleanField(
_('public'),
default = False,
help_text = _('the sound is accessible to the public')
)
def get_mtime(self):
"""
Get the last modification date from file
"""
mtime = os.stat(self.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
# db does not store microseconds
mtime = mtime.replace(microsecond = 0)
return tz.make_aware(mtime, tz.get_current_timezone())
def url(self):
"""
Return an url to the stream
"""
# path = self._meta.get_field('path').path
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
#path = self.path.replace(path, '', 1)
return main_settings.MEDIA_URL + '/' + path
def file_exists(self):
"""
Return true if the file still exists
"""
return os.path.exists(self.path)
def check_on_file(self):
"""
Check sound file info again'st self, and update informations if
needed (do not save). Return True if there was changes.
"""
if not self.file_exists():
if self.type == self.Type.removed:
return
logger.info('sound %s: has been removed', self.path)
self.type = self.Type.removed
return True
# not anymore removed
changed = False
if self.type == self.Type.removed and self.program:
changed = True
self.type = self.Type.archive \
if self.path.startswith(self.program.archives_path) else \
self.Type.excerpt
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()
if self.mtime != mtime:
self.mtime = mtime
self.good_quality = False
logger.info('sound %s: m_time has changed. Reset quality info',
self.path)
return True
return changed
def check_perms(self):
"""
Check file permissions and update it if the sound is public
"""
if not settings.AIRCOX_SOUND_AUTO_CHMOD or \
self.removed or not os.path.exists(self.path):
return
flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public]
try:
os.chmod(self.path, flags)
except PermissionError as err:
logger.error(
'cannot set permissions {} to file {}: {}'.format(
self.flags[self.public],
self.path, err
)
)
def __check_name(self):
if not self.name and self.path:
# FIXME: later, remove date?
self.name = os.path.basename(self.path)
self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__check_name()
def save(self, check = True, *args, **kwargs):
if check:
self.check_on_file()
self.__check_name()
super().save(*args, **kwargs)
def __str__(self):
return '/'.join(self.path.split('/')[-3:])
class Meta:
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
class Track(Related):
"""
Track of a playlist of an object. The position can either be expressed
@ -804,3 +973,133 @@ class Track(Related):
verbose_name_plural = _('Tracks')
#
# Controls and audio output
#
# FIXME HERE
# + station -> played, on_air and others
class Output (models.Model):
"""
Represent an audio output for the audio stream generation.
You might want to take a look to LiquidSoap's documentation
for the Jack, Alsa, and Icecast ouptuts.
"""
class Type(IntEnum):
jack = 0x00
alsa = 0x01
icecast = 0x02
station = models.ForeignKey(
Station,
verbose_name = _('station'),
)
type = models.SmallIntegerField(
_('type'),
# we don't translate the names since it is project names.
choices = [ (int(y), x) for x,y in Type.__members__.items() ],
)
active = models.BooleanField(
_('active'),
default = True,
help_text = _('this output is active')
)
settings = models.TextField(
_('output settings'),
help_text = _('list of comma separated params available; '
'this is put in the output config as raw code; '
'plugin related'),
blank = True, null = True
)
class Log(Related):
"""
Log sounds and diffusions that are played on the station.
This only remember what has been played on the outputs, not on each
track; Source designate here which source is responsible of that.
"""
class Type(IntEnum):
stop = 0x00
"""
Source has been stopped (only when there is no more sound)
"""
play = 0x01
"""
Source has been started/changed and is running related_object
If no related_object is available, comment is used to designate
the sound.
"""
load = 0x02
"""
Source starts to be preload related_object
"""
other = 0x03
"""
Other log
"""
type = models.SmallIntegerField(
verbose_name = _('type'),
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
blank = True, null = True,
)
station = models.ForeignKey(
Station,
verbose_name = _('station'),
help_text = _('station on which the event occured'),
)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
# source is removed
_('source'),
max_length=64,
help_text = _('source id that make it happen on the station'),
blank = True, null = True,
)
date = models.DateTimeField(
_('date'),
default=tz.now,
)
comment = models.CharField(
_('comment'),
max_length = 512,
blank = True, null = True,
)
@property
def end(self):
"""
Calculated end using self.related informations
"""
if self.related_type == Diffusion:
return self.related.end
if self.related_type == Sound:
return self.date + to_timedelta(self.duration)
return self.date
def is_expired(self, date = None):
"""
Return True if the log is expired. Note that it only check
against the date, so it is still possible that the expiration
occured because of a Stop or other source.
"""
date = date_or_default(date)
return self.end < date
def print(self):
logger.info('log #%s: %s%s',
str(self),
self.comment or '',
' -- {} #{}'.format(self.related_type, self.related_id)
if self.related else ''
)
def __str__(self):
return '#{} ({}, {})'.format(
self.pk, self.date.strftime('%Y/%m/%d %H:%M'), self.source
)

View File

@ -57,5 +57,7 @@ ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';')
ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"')
# Controllers working directory
ensure('AIRCOX_CONTROLLERS_WORKING_DIR', '/tmp/aircox')

View File

@ -0,0 +1,150 @@
{% comment %}
TODO: update doc
Base configuration file to configure a station on liquidsoap.
# Interactive elements:
An interactive element is accessible to the people, in order to:
- get metadata
- skip the current sound
- enable/disable it
# Element of the context
We use theses elements from the template's context:
- controller: controller describing the station itself
- settings: global settings
# Overwrite the template
It is possible to overwrite the template, there are blocks at different
position in order to do it. Keep in mind that you might want to avoid to
put station specific configuration in the template itself.
{% endcomment %}
{% block functions %}
{% comment %}
An interactive source is a source that:
- is skippable through the given id on external interfaces
- can be disabled
- store metadata
{% endcomment %}
def interactive_source (id, s, ~active=true, ~disable_switch=false) =
s = store_metadata(id=id, size=1, s)
add_skip_command(s)
if disable_switch then
s
else
at(interactive.bool('#{id}_active', active), s)
end
end
{% comment %}
A stream is a source that:
- is a playlist on random mode (playlist object accessible at {id}_playlist
- is interactive
{% endcomment %}
def stream (id, file) =
s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch',
file)
interactive_source(id, s)
end
{% endblock %}
{% block functions_extras %}
{% endblock %}
{% block config %}
set("server.socket", true)
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 %}
{% endblock %}
{% block config_extras %}
{% endblock %}
{% block sources %}
live = fallback([
{% with source=station.dealer %}
interactive_source('{{ source.id }}',
playlist.once(reload_mode='watch', "{{ source.path }}"),
active=false
),
{% endwith %}
])
stream = fallback([
rotate([
{% for source in station.sources %}
{% if source != station.dealer %}
{% with stream=source.stream %}
{% if stream.delay %}
delay({{ stream.delay }}.,
stream("{{ source.id }}", "{{ source.path }}")),
{% elif stream.begin and stream.end %}
at({ {{stream.begin}}-{{stream.end}} },
stream("{{ source.id }}", "{{ source.path }}")),
{% elif not stream %}
stream("{{ source.id }}", "{{ source.path }}"),
{% endif %}
{% endwith %}
{% endif %}
{% endfor %}
]),
blank(id="blank", duration=0.1),
])
{% endblock %}
{% block sources_extras %}
{% endblock %}
def to_live(stream,live)
stream = fade.final(duration=2., type='log', stream)
live = fade.initial(duration=2., type='log', live)
add(normalize=false, [stream,live])
end
def to_stream(live,stream)
source.skip(stream)
add(normalize=false, [live,stream])
end
{% block station %}
{{ station.streamer.id }} = interactive_source (
"{{ station.streamer.id }}",
fallback(
track_sensitive=false,
transitions=[to_live,to_stream],
[ live, stream ]
),
disable_switch=true
)
{% endblock %}
{% block station_extras %}
{% endblock %}
{% block outputs %}
{% for output in station.output_set.all %}
output.{{ output.get_type_display }}(
{{ station.streamer.id }},
{% if controller.settings %},
{{ output.settings }}
{% endif %}
)
{% endfor %}
{% endblock %}
{% block output_extras %}
{% endblock %}

View File

@ -0,0 +1,125 @@
{% load i18n %}
<style>
section.station {
padding: 0.4em;
font-size: 0.9em;
}
section.station header {
margin: 0.4em 0em;
}
section.station header > * {
margin: 0em 0.2em;
}
section.station h1 {
display: inline;
margin: 0px;
font-size: 1.4em;
}
section.station button {
float: right;
}
section.station .sources {
border: 1px grey solid;
}
section.station .source {
margin: 0.2em 0em;
}
section.station .name {
display: inline-block;
width: 10em;
}
section.station .file {
color: #007EDF;
}
section.station .source.current:before {
content: '▶';
color: red;
margin: 0em 1em;
}
</style>
<script>
var Monitor = {
get_token: function () {
return document.cookie.replace(/.*csrftoken=([^;]+)(;.*|$)/, '$1');
},
post: function(station, source, action) {
var params = 'station=' + station + '&&action=' + action;
if(source)
params += '&&source=' + source;
var req = new XMLHttpRequest()
req.open('POST', '{% url 'controllers.monitor' %}', false);
req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
req.setRequestHeader("Content-length", params.length);
req.setRequestHeader("Connection", "close");
req.setRequestHeader("X-CSRFToken", this.get_token());
req.send(params);
this.update();
},
skip: function(station, source) {
this.post(station, source, 'skip');
},
update: function(timeout) {
var req = new XMLHttpRequest()
req.open('GET', '{% url 'controllers.monitor' %}', true);
req.onreadystatechange = function() {
if(req.readyState != 4 || (req.status != 200 && req.status != 0))
return;
var doc = document.implementation.createHTMLDocument('xhr')
.documentElement;
doc.innerHTML = req.responseText;
document.getElementById('stations').innerHTML =
doc.querySelector('#stations').innerHTML;
if(timeout)
window.setTimeout(
function() { Monitor.update(timeout);}, timeout
);
};
req.send();
},
}
Monitor.update(1000);
</script>
<div id='stations'>
{% for station in stations %}
<section class="station">
<header>
<h1>{{ station.name }}</h1>
<button onclick="Monitor.skip('{{ station.name }}');">{% trans "skip" %}</button>
<button onclick="Monitor.update();">{% trans "update" %}</button>
</header>
<div class="sources">
{% for source in station.all_sources %}
{% if source.controller.current_sound %}
<div class="source{% if source == station.controller.current_source %} current{% endif %}">
<span class="name">{{ source.name }}</span>
<span class="file">{{ source.controller.current_sound }}</span>
<button onclick="Monitor.skip('{{ station.name }}','{{ source.name }}');">
{% trans "skip" %}</button>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
</div>