rewrite streamer and controller -- much cleaner and efficient; continue to work on new architecture

This commit is contained in:
bkfox 2019-07-31 02:17:30 +02:00
parent 8581743d13
commit 8e1d2b6769
20 changed files with 550 additions and 2540 deletions

View File

@ -1,81 +0,0 @@
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.models import Diffusion, Sound, Track
from .playlist import TracksInline
class SoundInline(admin.TabularInline):
model = Sound
fk_name = 'diffusion'
fields = ['type', 'path', 'duration', 'is_public']
readonly_fields = ['type']
extra = 0
class RediffusionInline(admin.StackedInline):
model = Diffusion
fk_name = 'initial'
extra = 0
fields = ['type', 'start', 'end']
@admin.register(Diffusion)
class DiffusionAdmin(admin.ModelAdmin):
def archives(self, obj):
sounds = [str(s) for s in obj.get_sounds(archive=True)]
return ', '.join(sounds) if sounds else ''
def conflicts_count(self, obj):
if obj.conflicts.count():
return obj.conflicts.count()
return ''
conflicts_count.short_description = _('Conflicts')
def start_date(self, obj):
return obj.local_start.strftime('%Y/%m/%d %H:%M')
start_date.short_description = _('start')
def end_date(self, obj):
return obj.local_end.strftime('%H:%M')
end_date.short_description = _('end')
def first(self, obj):
return obj.initial.start if obj.initial else ''
list_display = ('id', 'program', 'start_date', 'end_date', 'type', 'first', 'archives', 'conflicts_count')
list_filter = ('type', 'start', 'program')
list_editable = ('type',)
ordering = ('-start', 'id')
fields = ['type', 'start', 'end', 'initial', 'program', 'conflicts']
readonly_fields = ('conflicts',)
inlines = [TracksInline, RediffusionInline, SoundInline]
def get_playlist(self, request, obj=None):
return obj and getattr(obj, 'playlist', None)
def get_form(self, request, obj=None, **kwargs):
if request.user.has_perm('aircox_program.programming'):
self.readonly_fields = []
else:
self.readonly_fields = ['program', 'start', 'end']
return super().get_form(request, obj, **kwargs)
def get_object(self, *args, **kwargs):
"""
We want rerun to redirect to the given object.
"""
obj = super().get_object(*args, **kwargs)
if obj and obj.initial:
obj = obj.initial
return obj
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.GET and len(request.GET):
return qs
return qs.exclude(type=Diffusion.Type.unconfirmed)

View File

@ -36,21 +36,6 @@ class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin):
fields = ['type', 'start', 'end', 'initial', 'program']
def get_object(self, *args, **kwargs):
"""
We want rerun to redirect to the given object.
"""
obj = super().get_object(*args, **kwargs)
if obj and obj.initial:
obj = obj.initial
return obj
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.GET and len(request.GET):
return qs
return qs.exclude(type=Diffusion.Type.unconfirmed)
class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline):
model = Diffusion

View File

@ -1,90 +1,84 @@
import os
import socket
import re
import json
response_re = re.compile(r'(.*)\s+END\s*$')
key_val_re = re.compile(r'(?P<key>[^=]+)="?(?P<value>([^"]|\\")+)"?')
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.
Connection to AF_UNIX or AF_INET, get and send data. Received
data can be parsed from list of `key=value` or JSON.
"""
__socket = None
__available = False
socket = None
""" The socket """
address = None
"""
a string to the unix domain socket file, or a tuple (host, port) for
String to a Unix domain socket file, or a tuple (host, port) for
TCP/IP connection
"""
@property
def available(self):
return self.__available
def is_open(self):
return self.socket is not None
def __init__(self, address = None):
def __init__(self, address=None):
if address:
self.address = address
def open(self):
if self.__available:
if self.is_open:
return
family = socket.AF_UNIX if isinstance(self.address, str) else \
socket.AF_INET
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
self.socket = socket.socket(family, socket.SOCK_STREAM)
self.socket.connect(self.address)
except:
self.__available = False
self.close()
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')
def close(self):
self.socket.close()
self.socket = None
# FIXME: return None on failed
def send(self, *data, try_count=1, parse=False, parse_json=False):
if self.open():
return None
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)
self.socket.sendall(data)
data = ''
while not reg.search(data):
data += self.__socket.recv(1024).decode('utf-8')
while not response_re.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)
data = response_re.sub(r'\1', data).strip()
data = self.parse(data) if parse else \
self.parse_json(data) if parse_json else data
return data
except:
self.__available = False
self.close()
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(self, value):
return {
line.groupdict()['key']: line.groupdict()['value']
for line in (key_val_re.search(line) for line in value.split('\n'))
if line
}
def parse_json(self, string):
def parse_json(self, value):
try:
if string[0] == '"' and string[-1] == '"':
string = string[1:-1]
return json.loads(string) if string else None
if value[0] == '"' and value[-1] == '"':
value = value[1:-1]
return json.loads(value) if value else None
except:
return None

View File

@ -1,113 +1,87 @@
import atexit, logging, os, re, signal, subprocess
from collections import OrderedDict
import atexit
import logging
import os
import re
import signal
import subprocess
import psutil
import tzlocal
from django.template.loader import render_to_string
from django.utils import timezone as tz
import aircox.models as models
import aircox.settings as settings
from aircox.connector import Connector
from . import settings
from .models import Port, Station, Sound
from .connector import Connector
local_tz = tzlocal.get_localzone()
logger = logging.getLogger('aircox.tools')
logger = logging.getLogger('aircox')
class Streamer:
"""
Audio controller of a Station.
"""
station = None
"""
Related station
"""
template_name = 'aircox/config/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.
"""
source = None
"""
Current source object that is responsible of self.sound
"""
process = None
"""
Application's process if ran from Streamer
"""
socket_path = ''
"""
Path to the connector's socket
"""
connector = None
"""
Connector to Liquidsoap server
"""
process = None
def __init__(self, station, **kwargs):
station = None
template_name = 'aircox/scripts/station.liq'
path = None
""" Config path """
sources = None
""" List of all monitored sources """
source = None
""" Current on air source """
def __init__(self, station):
self.station = station
self.id = self.station.slug.replace('-', '_')
self.path = os.path.join(station.path, 'station.liq')
self.socket_path = os.path.join(station.path, 'station.sock')
self.connector = Connector(self.socket_path)
self.__dict__.update(kwargs)
self.connector = Connector(os.path.join(station.path, 'station.sock'))
self.init_sources()
@property
def id(self):
"""
Streamer identifier common in both external app and here
"""
return self.station.slug
def socket_path(self):
""" Path to Unix socket file """
return self.connector.address
#
# 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.source = next(
iter(source for source in self.station.sources
if source.rid == rid),
self.source
@property
def inputs(self):
""" Return input ports of the station """
return self.station.port_set.filter(
direction=Port.Direction.input,
active=True
)
def push(self, config = True):
"""
Update configuration and children's info.
@property
def outputs(self):
""" Return output ports of the station """
return self.station.port_set.filter(
direction=Port.Direction.output,
active=True,
)
The base function just execute the function of all children
sources. The plugin must implement the other extra part
@property
def is_ready(self):
"""
sources = self.station.sources
for source in sources:
source.push()
If external program is ready to use, returns True
"""
return self.send('list') != ''
if config and self.path and self.template_name:
# Sources and config ###############################################
def send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) or ''
def init_sources(self):
streams = self.station.program_set.filter(stream__isnull=False)
self.dealer = QueueSource(self, 'dealer')
self.sources = [self.dealer] + [
PlaylistSource(self, program=program) for program in streams
]
def make_config(self):
""" Make configuration files and directory (and sync sources) """
data = render_to_string(self.template_name, {
'station': self.station,
'streamer': self,
@ -116,38 +90,50 @@ class Streamer:
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)
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, 'w+') as file:
file.write(data)
#
# 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.
"""
self.sync()
def sync(self):
""" Sync all sources. """
for source in self.sources:
source.sync()
def fetch(self):
""" Fetch data from liquidsoap """
for source in self.sources:
source.fetch()
rid = self.send('request.on_air').split(' ')
if rid:
rid = rid[-1]
# data = self._send('request.metadata ', rid, parse=True)
# if not data:
# return
pred = lambda s: s.rid == rid
else:
pred = lambda s: s.is_playing
self.source = next((source for source in self.sources if pred(source)),
self.source)
# Process ##########################################################
def get_process_args(self):
return ['liquidsoap', '-v', self.path]
def __check_for_zombie(self):
"""
Check if there is a process that has not been killed
"""
def check_zombie_process(self):
if not os.path.exists(self.socket_path):
return
import psutil
conns = [
conn for conn in psutil.net_connections(kind='unix')
if conn.laddr == self.socket_path
]
conns = [conn for conn in psutil.net_connections(kind='unix')
if conn.laddr == self.socket_path]
for conn in conns:
if conn.pid is not None:
os.kill(conn.pid, signal.SIGKILL)
def process_run(self):
def run_process(self):
"""
Execute the external application with corresponding informations.
@ -156,26 +142,24 @@ class Streamer:
if self.process:
return
self.push()
args = self.__get_process_args()
args = self.get_process_args()
if not args:
return
self.__check_for_zombie()
self.check_zombie_process()
self.process = subprocess.Popen(args, stderr=subprocess.STDOUT)
atexit.register(lambda: self.process_terminate())
atexit.register(lambda: self.kill_process())
def process_terminate(self):
def kill_process(self):
if self.process:
logger.info("kill process {pid}: {info}".format(
pid = self.process.pid,
info = ' '.join(self.__get_process_args())
pid=self.process.pid,
info=' '.join(self.get_process_args())
))
self.process.kill()
self.process = None
def process_wait(self):
def wait_process(self):
"""
Wait for the process to terminate if there is a process
"""
@ -183,193 +167,96 @@ class Streamer:
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.
"""
station = None
connector = None
""" Connector to Liquidsoap server """
program = None
""" Related program """
name = ''
""" Name of the source """
path = ''
""" Path to the playlist file. """
on_air = None
controller = None
id = None
# retrieved from fetch
sound = ''
""" (fetched) current sound being played """
uri = ''
rid = None
""" (fetched) current request id of the source in LiquidSoap """
air_time = None
""" (fetched) datetime of last on_air """
status = None
@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()
if self.program:
self.name = self.program.name
#
# 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()
def station(self):
return self.controller.station
@property
def playlist(self):
"""
Current playlist on the Source, list of paths to play
"""
self.fetch()
return self.__playlist
def is_playing(self):
return self.status == 'playing'
@playlist.setter
def playlist(self, value):
value = sorted(value)
if value != self.__playlist:
self.__playlist = value
self.push()
def __init__(self, controller, id=None):
self.controller = controller
self.id = id
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.get_playlist(archive = True)
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 & States
#
def _send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs)
@property
def is_stream(self):
return self.program and not self.program.show
@property
def is_dealer(self):
return not self.program
@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 sync(self):
""" Synchronize what should be synchronized """
pass
def fetch(self):
"""
Get the source information
"""
data = self._send(self.id, '.get', parse = True)
if not data or type(data) != dict:
return
data = self.controller.send(self.id, '.get', parse=True)
self.on_metadata(data if data and isinstance(data, dict) else {})
def on_metadata(self, data):
""" Update source info from provided request metadata """
self.rid = data.get('rid')
self.sound = data.get('initial_uri')
self.uri = data.get('initial_uri')
self.status = data.get('status')
# get air_time
air_time = data.get('on_air')
# try:
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
self.air_time = local_tz.localize(air_time)
# except:
# 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 []))
else:
self.air_time = None
def skip(self):
"""
Skip the current sound in the source
"""
self._send(self.id, '.skip')
""" Skip the current source sound """
self.controller.send(self.id, '.skip')
def restart(self):
"""
Restart the current sound in the source. Since liquidsoap
does not give us current position in stream, it seeks back
max 10 hours in the current sound.
"""
self.seek(-216000*10);
""" Restart current sound """
# seek 10 hours back since there is not possibility to get current pos
self.seek(-216000*10)
def seek(self, n):
"""
Seeks into the sound. Note that liquidsoap seems really slow for that.
"""
self._send(self.id, '.seek ', str(n))
""" Seeks into the sound. """
self.controller.send(self.id, '.seek ', str(n))
class PlaylistSource(Source):
""" Source handling playlists (program streams) """
path = None
""" Path to playlist """
program = None
""" Related program """
playlist = None
""" The playlist """
def __init__(self, controller, id=None, program=None, **kwargs):
id = program.slug.replace('-', '_') if id is None else id
self.program = program
super().__init__(controller, id=id, **kwargs)
self.path = os.path.join(self.station.path, self.id + '.m3u')
def get_sound_queryset(self):
""" Get playlist's sounds queryset """
return self.program.sound_set.archive()
def load_playlist(self):
""" Load playlist """
self.playlist = self.get_sound_queryset().paths()
def write_playlist(self):
""" Write playlist file. """
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 stream(self):
"""
Return dict of info for the current Stream program running on
the source. If not, return None.
[ used in the templates ]
"""
""" Return program's stream info if any (or None) as dict. """
# used in templates
# TODO: multiple streams
stream = self.program.stream_set.all().first()
if not stream or (not stream.begin and not stream.delay):
@ -384,3 +271,14 @@ class Source:
'delay': to_seconds(stream.delay) if stream.delay else 0
}
def sync(self):
self.load_playlist()
self.write_playlist()
class QueueSource(Source):
def queue(self, *paths):
""" Add the provided paths to source's play queue """
for path in paths:
print(self.controller.send(self.id, '_queue.push ', path))

View File

@ -6,23 +6,18 @@ used to:
- cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap
"""
import tzlocal
import time
import re
from argparse import RawTextHelpFormatter
import time
from django.conf import settings as main_settings
from django.core.management.base import BaseCommand, CommandError
import pytz
import tzlocal
from django.core.management.base import BaseCommand
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.db import models
from django.db.models import Q
from aircox.models import Station, Diffusion, Track, Sound, Log
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.controllers import Streamer, PlaylistSource
# force using UTC
import pytz
tz.activate(pytz.UTC)
@ -45,125 +40,91 @@ class Monitor:
- scheduled diffusions
- tracks for sounds of streamed programs
"""
station = None
streamer = 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
"""
""" Streamer controller """
logs = None
""" Queryset to station's logs (ordered by -pk) """
cancel_timeout = 20
""" Timeout in minutes before cancelling a diffusion. """
sync_timeout = 5
""" Timeout in minutes between two streamer's sync. """
sync_next = None
"""
Datetime of the next sync
"""
def get_last_log(self, *args, **kwargs):
return self.log_qs.filter(*args, **kwargs).last()
""" Datetime of the next sync """
@property
def log_qs(self):
return Log.objects.station(self.station) \
.select_related('diffusion', 'sound') \
.order_by('pk')
def station(self):
return self.streamer.station
@property
def last_log(self):
"""
Last log of monitored station
"""
return self.log_qs.last()
@property
def last_sound(self):
"""
Last sound log of monitored station that occurred on_air
"""
return self.get_last_log(type=Log.Type.on_air, sound__isnull=False)
""" Last log of monitored station. """
return self.logs.first()
@property
def last_diff_start(self):
"""
Log of last triggered item (sound or diffusion)
"""
return self.get_last_log(type=Log.Type.start, diffusion__isnull=False)
""" Log of last triggered item (sound or diffusion). """
return self.logs.start().with_diff().first()
def __init__(self, station, **kwargs):
self.station = station
def __init__(self, streamer, **kwargs):
self.streamer = streamer
self.__dict__.update(kwargs)
self.logs = self.get_logs_queryset()
def get_logs_queryset(self):
""" Return queryset to assign as `self.logs` """
return self.station.log_set.select_related('diffusion', 'sound') \
.order_by('-pk')
def monitor(self):
"""
Run all monitoring functions.
"""
if not self.streamer:
self.streamer = self.station.streamer
if not self.streamer.ready():
""" Run all monitoring functions once. """
if not self.streamer.is_ready:
return
self.streamer.fetch()
source = self.streamer.source
if source and source.sound:
if source and source.uri:
log = self.trace_sound(source)
if log:
self.trace_tracks(log)
else:
print('no source or sound for stream; source = ', source)
self.sync_playlists()
self.handle()
self.handle_diffusions()
self.sync()
def log(self, date=None, **kwargs):
""" Create a log using **kwargs, and print info """
log = Log(station=self.station, date=date or tz.now(), **kwargs)
if log.type == Log.Type.on_air and log.diffusion is None:
log.collision = Diffusion.objects.station(log.station) \
.on_air().at(log.date).first()
kwargs.setdefault('station', self.station)
log = Log(date=date or tz.now(), **kwargs)
log.save()
log.print()
return log
def trace_sound(self, source):
"""
Return log for current on_air (create and save it if required).
"""
sound_path = source.sound
air_time = source.air_time
""" Return on air sound log (create if not present). """
sound_path, air_time = source.uri, source.air_time
# check if there is yet a log for this sound on the source
delta = tz.timedelta(seconds=5)
air_times = (air_time - delta, air_time + delta)
log = self.log_qs.on_air().filter(
source=source.id, sound__path=sound_path,
date__range=air_times,
).last()
log = self.logs.on_air().filter(source=source.id,
sound__path=sound_path,
date__range=air_times).first()
if log:
return log
# get sound
sound = Sound.objects.filter(path=sound_path) \
.select_related('diffusion').first()
diff = None
if sound and sound.diffusion:
diff = sound.diffusion.original
# check for reruns
if not diff.is_date_in_range(air_time) and not diff.initial:
diff = Diffusion.objects.at(air_time) \
.on_air().filter(initial=diff).first()
sound = Sound.objects.filter(path=sound_path).first()
if sound and sound.episode_id is not None:
diff = Diffusion.objects.episode(id=sound.episode_id).on_air() \
.now(air_time).first()
# log sound on air
return self.log(
type=Log.Type.on_air, source=source.id, date=source.on_air,
sound=sound, diffusion=diff,
# if sound is removed, we keep sound path info
comment=sound_path,
)
return self.log(type=Log.Type.on_air, date=source.air_time,
source=source.id, sound=sound, diffusion=diff,
comment=sound_path)
def trace_tracks(self, log):
"""
@ -172,10 +133,13 @@ class Monitor:
if log.diffusion:
return
tracks = Track.objects.filter(sound=log.sound, timestamp__isnull=False)
tracks = Track.objects \
.filter(sound__id=log.sound_id, timestamp__isnull=False)\
.order_by('timestamp')
if not tracks.exists():
return
# exclude already logged tracks
tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk)
now = tz.now()
for track in tracks:
@ -183,178 +147,40 @@ class Monitor:
if pos > now:
break
# log track on air
self.log(
type=Log.Type.on_air, source=log.source,
date=pos, track=track,
comment=track,
)
self.log(type=Log.Type.on_air, date=pos, source=log.source,
track=track, comment=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.sources:
if source == self.station.dealer:
continue
playlist = source.program.sound_set.all() \
.filter(type=Sound.Type.archive) \
.values_list('path', flat=True)
source.playlist = list(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
qs = Diffusions.objects.station(self.station).at().filter(
type=Diffusion.Type.on_air,
sound__type=Sound.Type.archive,
)
logs = Log.objects.station(station).on_air().with_diff()
date = tz.now() - datetime.timedelta(seconds=self.cancel_timeout)
for diff in qs:
if logs.filter(diffusion=diff):
continue
if diff.start < now:
diff.type = Diffusion.Type.canceled
diff.save()
# log canceled diffusion
self.log(
type=Log.Type.other,
diffusion=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.now()
log = Log.objects.station(station).on_air().with_diff() \
.select_related('diffusion') \
.order_by('date').last()
if not log or not log.diffusion.is_date_in_range(now):
# not running anymore
return None, []
# last sound source change: end of file reached or forced to stop
sounds = Log.objects.station(station).on_air().with_sound() \
.filter(date__gte=log.date) \
.order_by('date')
if sounds.count() and sounds.last().source != log.source:
return None, []
# last diff is still playing: get remaining playlist
sounds = sounds \
.filter(source=log.source, pk__gt=log.pk) \
.exclude(sound__type=Sound.Type.removed)
remaining = log.diffusion.get_sounds(archive=True) \
.exclude(pk__in=sounds) \
.values_list('path', flat=True)
return log.diffusion, list(remaining)
def __next_diff(self, diff):
"""
Return the next diffusion to be played as tuple of (diff, playlist).
If diff is given, it is the one to be played right after it.
"""
station = self.station
kwargs = {'start__gte': diff.end} if diff else {}
qs = Diffusion.objects.station(station) \
.on_air().at().filter(**kwargs) \
.distinct().order_by('start')
diff = qs.first()
return (diff, diff and diff.get_playlist(archive=True) or [])
def handle_pl_sync(self, source, playlist, diff=None, date=None):
"""
Update playlist of a source if required, and handle logging when
it is needed.
- source: source on which it happens
- playlist: list of sounds to use to update
- diff: related diffusion
"""
if source.playlist == playlist:
return
source.playlist = playlist
if diff and not diff.is_live():
# log diffusion archive load
self.log(type=Log.Type.load,
source=source.id,
diffusion=diff,
date=date,
comment='\n'.join(playlist))
def handle_diff_start(self, source, diff, date):
"""
Enable dealer in order to play a given diffusion if required,
handle start of diffusion
"""
if not diff or diff.start > date:
return
# TODO: user has not yet put the diffusion sound when diff started
# => live logged; what we want: if user put a sound after it
# has been logged as live, load and start this sound
# live: just log it
if diff.is_live():
diff_ = Log.objects.station(self.station) \
.filter(diffusion=diff, type=Log.Type.on_air)
if not diff_.count():
# log live diffusion
self.log(type=Log.Type.on_air, source=source.id,
diffusion=diff, date=date)
return
# enable dealer
if not source.active:
source.active = True
last_start = self.last_diff_start
if not last_start or last_start.diffusion_id != diff.pk:
# log triggered diffusion
self.log(type=Log.Type.start, source=source.id,
diffusion=diff, date=date)
def handle(self):
def handle_diffusions(self):
"""
Handle scheduled diffusion, trigger if needed, preload playlists
and so on.
"""
station = self.station
dealer = station.dealer
if not dealer:
# TODO: restart
# TODO: handle conflict + cancel
diff = Diffusion.objects.station(self.station).on_air().now() \
.filter(episode__sound__type=Sound.Type.archive) \
.first()
log = self.logs.start().filter(diffusion=diff) if diff else None
if not diff or log:
return
playlist = Sound.objects.episode(id=diff.episode_id).paths()
dealer = self.streamer.dealer
dealer.queue(*playlist)
self.log(type=Log.Type.start, source=dealer.id, diffusion=diff,
comment=str(diff))
def sync(self):
""" Update sources' playlists. """
now = tz.now()
if self.sync_next is not None and now < self.sync_next:
return
# current and next diffs
current_diff, remaining_pl = self.__current_diff()
next_diff, next_pl = self.__next_diff(current_diff)
self.sync_next = now + tz.timedelta(minutes=self.sync_timeout)
# playlist
dealer.active = bool(remaining_pl)
playlist = remaining_pl + next_pl
self.handle_pl_sync(dealer, playlist, next_diff, now)
self.handle_diff_start(dealer, next_diff, now)
for source in self.streamer.sources:
if isinstance(source, PlaylistSource):
source.sync()
class Command (BaseCommand):
@ -390,32 +216,31 @@ class Command (BaseCommand):
)
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'
default=Monitor.cancel_timeout,
help='time to wait in MINUTES before canceling a diffusion that '
'should have ran but did not. '
)
# TODO: sync-timeout, cancel-timeout
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()[:]
stations = Station.objects.filter(name__in=station) if station else \
Station.objects.all()
streamers = [Streamer(station) for station in stations]
for station in stations:
# station.prepare()
if config and not run: # no need to write it twice
station.streamer.push()
for streamer in streamers:
if config:
streamer.make_config()
if run:
station.streamer.process_run()
streamer.run_process()
if monitor:
monitors = [
Monitor(station, cancel_timeout=timeout)
for station in stations
]
monitors = [Monitor(streamer, cancel_timeout=timeout)
for streamer in streamers]
delay = delay / 1000
while True:
for monitor in monitors:
@ -423,5 +248,5 @@ class Command (BaseCommand):
time.sleep(delay)
if run:
for station in stations:
station.controller.process_wait()
for streamer in streamers:
streamer.wait_process()

File diff suppressed because it is too large Load Diff

View File

@ -10,20 +10,12 @@ from django.utils.functional import cached_property
from aircox import settings, utils
from .program import Program, BaseRerun, BaseRerunQuerySet
from .program import Program, InProgramQuerySet, \
BaseRerun, BaseRerunQuerySet
from .page import Page, PageQuerySet
__all__ = ['Episode', 'EpisodeQuerySet', 'Diffusion', 'DiffusionQuerySet']
class EpisodeQuerySet(PageQuerySet):
def station(self, station):
return self.filter(program__station=station)
# FIXME: useful??? might use program.episode_set
def program(self, program):
return self.filter(program=program)
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet']
class Episode(Page):
@ -32,7 +24,7 @@ class Episode(Page):
verbose_name=_('program'),
)
objects = EpisodeQuerySet.as_manager()
objects = InProgramQuerySet.as_manager()
class Meta:
verbose_name = _('Episode')
@ -52,40 +44,31 @@ class Episode(Page):
return cls(program=program, title=title)
class DiffusionQuerySet(BaseRerunQuerySet):
def station(self, station):
return self.filter(episode__program__station=station)
def program(self, program):
return self.filter(program=program)
def episode(self, episode=None, id=None):
""" Diffusions for this episode """
return self.filter(episode=episode) if id is None else \
self.filter(episode__id=id)
def on_air(self):
""" On air diffusions """
return self.filter(type=Diffusion.Type.on_air)
def at(self, date=None):
"""
Return diffusions occuring at the given date, ordered by +start
def now(self, now=None, order=True):
""" Diffusions occuring now """
now = now or tz.now()
qs = self.filter(start__lte=now, end__gte=now).distinct()
return qs.order_by('start') if order else qs
If date is a datetime instance, get diffusions that occurs at
the given moment. If date is not a datetime object, it uses
it as a date, and get diffusions that occurs this day.
def today(self, today=None, order=True):
""" Diffusions occuring today. """
today = today or datetime.date.today()
qs = self.filter(Q(start__date=today) | Q(end__date=today))
return qs.order_by('start') if order else qs
When date is None, uses tz.now().
"""
# note: we work with localtime
date = utils.date_or_default(date)
qs = self
filters = None
if isinstance(date, datetime.datetime):
# use datetime: we want diffusion that occurs around this
# range
filters = {'start__lte': date, 'end__gte': date}
qs = qs.filter(**filters)
else:
# use date: we want diffusions that occurs this day
qs = qs.filter(Q(start__date=date) | Q(end__date=date))
return qs.order_by('start').distinct()
def at(self, date, order=True):
""" Return diffusions at specified date or datetime """
return self.now(date, order) if isinstance(date, tz.datetime) else \
self.today(date, order)
def after(self, date=None):
"""
@ -183,10 +166,12 @@ class Diffusion(BaseRerun):
# self.check_conflicts()
def save_rerun(self):
print('rerun save', self)
self.episode = self.initial.episode
self.program = self.episode.program
def save_original(self):
def save_initial(self):
print('initial save', self)
self.program = self.episode.program
if self.episode != self._initial['episode']:
self.rerun_set.update(episode=self.episode, program=self.program)
@ -221,20 +206,11 @@ class Diffusion(BaseRerun):
return tz.localtime(self.end, tz.get_current_timezone())
@property
def original(self):
""" Return the original diffusion (self or initial) """
return self.initial.original if self.initial else self
# TODO: property?
def is_live(self):
"""
True if Diffusion is live (False if there are sounds files)
"""
""" True if Diffusion is live (False if there are sounds files). """
return self.type == self.Type.on_air and \
not self.get_sounds(archive=True).count()
not self.episode.sound_set.archive().count()
def get_playlist(self, **types):
"""

View File

@ -21,8 +21,9 @@ __all__ = ['Log', 'LogQuerySet']
class LogQuerySet(models.QuerySet):
def station(self, station):
return self.filter(station=station)
def station(self, station=None, id=None):
return self.filter(station=station) if id is None else \
self.filter(station_id=id)
def at(self, date=None):
date = utils.date_or_default(date)
@ -189,16 +190,20 @@ class Log(models.Model):
Other log
"""
station = models.ForeignKey(
Station, models.CASCADE,
verbose_name=_('station'),
help_text=_('related station'),
)
type = models.SmallIntegerField(
choices=[(int(y), _(x.replace('_', ' ')))
for x, y in Type.__members__.items()],
blank=True, null=True,
verbose_name=_('type'),
)
station = models.ForeignKey(
Station, models.CASCADE,
verbose_name=_('station'),
help_text=_('related station'),
date = models.DateTimeField(
default=tz.now, db_index=True,
verbose_name=_('date'),
)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
@ -207,10 +212,6 @@ class Log(models.Model):
verbose_name=_('source'),
help_text=_('identifier of the source related to this log'),
)
date = models.DateTimeField(
default=tz.now, db_index=True,
verbose_name=_('date'),
)
comment = models.CharField(
max_length=512, blank=True, null=True,
verbose_name=_('comment'),

View File

@ -66,7 +66,8 @@ class Program(Page):
@property
def path(self):
""" Return program's directory path """
return os.path.join(settings.AIRCOX_PROGRAMS_DIR, self.slug)
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
self.slug.replace('-', '_'))
@property
def archives_path(self):
@ -80,7 +81,6 @@ class Program(Page):
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
if self.slug:
self.__initial_path = self.path
@ -137,7 +137,21 @@ class Program(Page):
.update(path=Concat('path', Substr(F('path'), len(path_))))
class BaseRerunQuerySet(models.QuerySet):
class InProgramQuerySet(models.QuerySet):
"""
Queryset for model having a ForeignKey field "program" to `Program`.
"""
def station(self, station=None, id=None):
return self.filter(program__station=station) if id is None else \
self.filter(program__station__id=id)
def program(self, program=None, id=None):
return self.filter(program=program) if id is None else \
self.filter(program__id=id)
class BaseRerunQuerySet(InProgramQuerySet):
""" Queryset for BaseRerun (sub)classes. """
def rerun(self):
return self.filter(initial__isnull=False)
@ -147,8 +161,8 @@ class BaseRerunQuerySet(models.QuerySet):
class BaseRerun(models.Model):
"""
Abstract model offering rerun facilities.
`start` datetime field or property must be implemented by sub-classes
Abstract model offering rerun facilities. Assume `start` is a
datetime field or attribute implemented by subclass.
"""
program = models.ForeignKey(
Program, models.CASCADE,
@ -157,10 +171,13 @@ class BaseRerun(models.Model):
initial = models.ForeignKey(
'self', models.SET_NULL, related_name='rerun_set',
verbose_name=_('initial schedule'),
limit_choices_to={'initial__isnull': True},
blank=True, null=True,
help_text=_('mark as rerun of this %(model_name)'),
)
objects = BaseRerunQuerySet.as_manager()
class Meta:
abstract = True

View File

@ -22,15 +22,32 @@ __all__ = ['Sound', 'SoundQuerySet', 'Track']
class SoundQuerySet(models.QuerySet):
def episode(self, episode=None, id=None):
return self.filter(episode=episode) if id is None else \
self.filter(episode__id=id)
def diffusion(self, diffusion=None, id=None):
return self.filter(episode__diffusion=diffusion) if id is None else \
self.filter(episode__diffusion__id=id)
def podcasts(self):
""" Return sound available as podcasts """
""" Return sounds available as podcasts """
return self.filter(Q(embed__isnull=False) | Q(is_public=True))
def episode(self, episode):
return self.filter(episode=episode)
def archive(self):
""" Return sounds that are archives """
return self.filter(type=Sound.Type.archive)
def diffusion(self, diffusion):
return self.filter(episode__diffusion=diffusion)
def paths(self, archive=True, order_by=True):
"""
Return paths as a flat list (exclude sound without path).
If `order_by` is True, order by path.
"""
if archive:
self = self.archive()
if order_by:
self = self.order_by('path')
return self.filter(path__isnull=False).values_list('path', flat=True)
class Sound(models.Model):
@ -46,6 +63,7 @@ class Sound(models.Model):
name = models.CharField(_('name'), max_length=64)
program = models.ForeignKey(
# FIXME: not nullable?
Program, models.SET_NULL, blank=True, null=True,
verbose_name=_('program'),
help_text=_('program related to it'),
@ -95,6 +113,21 @@ class Sound(models.Model):
objects = SoundQuerySet.as_manager()
class Meta:
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
def __str__(self):
return '/'.join(self.path.split('/')[-3:])
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
self.program = self.episode.program
if check:
self.check_on_file()
self.__check_name()
super().save(*args, **kwargs)
def get_mtime(self):
"""
Get the last modification date from file
@ -220,21 +253,6 @@ class Sound(models.Model):
super().__init__(*args, **kwargs)
self.__check_name()
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
self.program = self.episode.program
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(models.Model):
"""

View File

@ -33,6 +33,7 @@ class Station(models.Model):
"""
name = models.CharField(_('name'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, unique=True)
# FIXME: remove - should be decided only by Streamer controller + settings
path = models.CharField(
_('path'),
help_text=_('path to the working directory'),
@ -47,70 +48,13 @@ class Station(models.Model):
objects = StationQuerySet.as_manager()
#
# Controllers
#
__sources = None
__dealer = None
__streamer = None
def __prepare_controls(self):
import aircox.controllers as controllers
from .program import Program
if not self.__streamer:
self.__streamer = controllers.Streamer(station=self)
self.__dealer = controllers.Source(station=self)
self.__sources = [self.__dealer] + [
controllers.Source(station=self, program=program)
for program in Program.objects.filter(stream__isnull=False)
]
@property
def inputs(self):
"""
Return all active input ports of the station
"""
return self.port_set.filter(
direction=Port.Direction.input,
active=True
)
@property
def outputs(self):
""" Return all active output ports of the station """
return self.port_set.filter(
direction=Port.Direction.output,
active=True,
)
@property
def sources(self):
""" Audio sources, dealer included """
self.__prepare_controls()
return self.__sources
@property
def dealer(self):
""" Get dealer control """
self.__prepare_controls()
return self.__dealer
@property
def streamer(self):
""" Audio controller for the station """
self.__prepare_controls()
return self.__streamer
def __str__(self):
return self.name
def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(
settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug
)
self.path = os.path.join(settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug.replace('-', '_'))
if self.default:
qs = Station.objects.filter(default=True)

View File

@ -1,171 +0,0 @@
{% 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
- seek
- 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 %}
Seek function
{% endcomment %}
def seek(source, t) =
t = float_of_string(default=0.,t)
ret = source.seek(source,t)
log("seek #{ret} seconds.")
"#{ret}"
end
{% comment %}
Transition to live sources
{% endcomment %}
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
{% comment %}
Transition to stream sources
{% endcomment %}
def to_stream(live,stream)
source.skip(stream)
add(normalize=false, [live,stream])
end
{% comment %}
An interactive source is a source that:
- is skippable through the given id on external interfaces
- is seekable through the given id and amount of seconds on e.i.
- can be disabled
- store metadata
{% endcomment %}
def interactive_source (id, s, ~active=true, ~disable_switch=false) =
server.register(namespace=id,
description="Seek to a relative position",
usage="seek <duration>",
"seek", fun (x) -> begin seek(s, x) end)
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 }}")),
{% else %}
stream("{{ source.id }}", "{{ source.path }}"),
{% endif %}
{% endwith %}
{% endif %}
{% endfor %}
]),
blank(id="blank", duration=0.1),
])
{% endblock %}
{% block sources_extras %}
{% endblock %}
{% 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.outputs %}
output.{{ output.get_type_display }}(
{% if output.settings %}
{{ output.settings|safe }},
{% endif %}
{{ station.streamer.id }}
)
{% endfor %}
{% endblock %}
{% block output_extras %}
{% endblock %}

View File

@ -0,0 +1,125 @@
{% comment %}
Base liquidsoap station configuration.
[stream] +--> streams ---+---> station
|
dealer ---'
{% endcomment %}
{% block functions %}
{# Seek function #}
def seek(source, t) =
t = float_of_string(default=0.,t)
ret = source.seek(source,t)
log("seek #{ret} seconds.")
"#{ret}"
end
{# Transition to live sources #}
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
{# Transition to stream sources #}
def to_stream(live, stream)
source.skip(stream)
add(normalize=false, [live,stream])
end
{% comment %}
An interactive source is a source that:
- is skippable through the given id on external interfaces
- is seekable through the given id and amount of seconds on e.i.
- store metadata
{% endcomment %}
def interactive (id, s) =
server.register(namespace=id,
description="Seek to a relative position",
usage="seek <duration>",
"seek", fun (x) -> begin seek(s, x) end)
s = store_metadata(id=id, size=1, s)
add_skip_command(s)
s
end
{% comment %}
A stream is an interactive playlist
{% endcomment %}
def stream (id, file) =
s = playlist(mode = "random", reload_mode='watch', file)
interactive(id, s)
end
{% endblock %}
{% block config %}
set("server.socket", true)
set("server.socket.path", "{{ 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 %}
{% with source=streamer.dealer %}
live = interactive('{{ source.id }}',
request.queue(id="{{ source.id }}_queue")
)
{% endwith %}
streams = rotate(id="streams", [
{% for source in streamer.sources %}
{% if source != streamer.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 }}")),
{% else %}
stream("{{ source.id }}", "{{ source.path }}"),
{% endif %}
{% endwith %}
{% endif %}
{% endfor %}
])
{% endblock %}
{% block station %}
{{ streamer.id }} = interactive (
"{{ streamer.id }}",
fallback([
live,
streams,
blank(id="blank", duration=0.1)
], track_sensitive=false, transitions=[to_live,to_stream])
)
{% endblock %}
{% block outputs %}
{% for output in streamer.outputs %}
output.{{ output.get_type_display }}(
{% if output.settings %}
{{ output.settings|safe }},
{% endif %}
{{ streamer.id }}
)
{% endfor %}
{% endblock %}

View File

@ -29,8 +29,7 @@ def date_or_default(date, into=None):
type if any.
"""
date = date if date is not None else datetime.date.today() \
if into is not None and issubclass(into, datetime.date) else \
tz.datetime.now()
if into is not None and issubclass(into, datetime.date) else tz.now()
if into is not None:
date = cast_date(date, into)