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'] 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): class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline):
model = Diffusion model = Diffusion

View File

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

View File

@ -1,153 +1,139 @@
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 import tzlocal
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone as tz from django.utils import timezone as tz
import aircox.models as models from . import settings
import aircox.settings as settings from .models import Port, Station, Sound
from .connector import Connector
from aircox.connector import Connector
local_tz = tzlocal.get_localzone() local_tz = tzlocal.get_localzone()
logger = logging.getLogger('aircox.tools') logger = logging.getLogger('aircox')
class Streamer: 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 = None
""" process = None
Connector to Liquidsoap server
"""
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.station = station
self.id = self.station.slug.replace('-', '_')
self.path = os.path.join(station.path, 'station.liq') self.path = os.path.join(station.path, 'station.liq')
self.socket_path = os.path.join(station.path, 'station.sock') self.connector = Connector(os.path.join(station.path, 'station.sock'))
self.connector = Connector(self.socket_path) self.init_sources()
self.__dict__.update(kwargs)
@property @property
def id(self): def socket_path(self):
""" """ Path to Unix socket file """
Streamer identifier common in both external app and here return self.connector.address
"""
return self.station.slug
# @property
# RPC def inputs(self):
# """ Return input ports of the station """
def _send(self, *args, **kwargs): return self.station.port_set.filter(
return self.connector.send(*args, **kwargs) direction=Port.Direction.input,
active=True
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
) )
def push(self, config = True): @property
""" def outputs(self):
Update configuration and children's info. """ 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 @property
sources. The plugin must implement the other extra part def is_ready(self):
""" """
sources = self.station.sources If external program is ready to use, returns True
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)
#
# 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 self.send('list') != ''
# 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,
'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)
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] return ['liquidsoap', '-v', self.path]
def __check_for_zombie(self): def check_zombie_process(self):
"""
Check if there is a process that has not been killed
"""
if not os.path.exists(self.socket_path): if not os.path.exists(self.socket_path):
return return
import psutil conns = [conn for conn in psutil.net_connections(kind='unix')
conns = [ if conn.laddr == self.socket_path]
conn for conn in psutil.net_connections(kind='unix')
if conn.laddr == self.socket_path
]
for conn in conns: for conn in conns:
if conn.pid is not None: if conn.pid is not None:
os.kill(conn.pid, signal.SIGKILL) os.kill(conn.pid, signal.SIGKILL)
def process_run(self): def run_process(self):
""" """
Execute the external application with corresponding informations. Execute the external application with corresponding informations.
@ -156,26 +142,24 @@ class Streamer:
if self.process: if self.process:
return return
self.push() args = self.get_process_args()
args = self.__get_process_args()
if not args: if not args:
return return
self.__check_for_zombie() self.check_zombie_process()
self.process = subprocess.Popen(args, stderr=subprocess.STDOUT) 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: if self.process:
logger.info("kill process {pid}: {info}".format( logger.info("kill process {pid}: {info}".format(
pid = self.process.pid, pid=self.process.pid,
info = ' '.join(self.__get_process_args()) info=' '.join(self.get_process_args())
)) ))
self.process.kill() self.process.kill()
self.process = None self.process = None
def process_wait(self): def wait_process(self):
""" """
Wait for the process to terminate if there is a process Wait for the process to terminate if there is a process
""" """
@ -183,193 +167,96 @@ class Streamer:
self.process.wait() self.process.wait()
self.process = None self.process = None
def ready(self):
"""
If external program is ready to use, returns True
"""
return self._send('var.list') != ''
class Source: class Source:
""" controller = None
Controller of a Source. Value are usually updated directly on the id = None
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
uri = ''
# retrieved from fetch
sound = ''
""" (fetched) current sound being played """
rid = None rid = None
""" (fetched) current request id of the source in LiquidSoap """
air_time = None air_time = None
""" (fetched) datetime of last on_air """ status = None
@property @property
def id(self): def station(self):
return self.program.slug if self.program else 'dealer' return self.controller.station
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()
@property @property
def playlist(self): def is_playing(self):
""" return self.status == 'playing'
Current playlist on the Source, list of paths to play
"""
self.fetch()
return self.__playlist
@playlist.setter def __init__(self, controller, id=None):
def playlist(self, value): self.controller = controller
value = sorted(value) self.id = id
if value != self.__playlist:
self.__playlist = value
self.push()
def from_db(self, diffusion = None, program = None): def sync(self):
""" """ Synchronize what should be synchronized """
Load a playlist to the controller from the database. If diffusion or pass
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 fetch(self): def fetch(self):
""" data = self.controller.send(self.id, '.get', parse=True)
Get the source information self.on_metadata(data if data and isinstance(data, dict) else {})
"""
data = self._send(self.id, '.get', parse = True)
if not data or type(data) != dict:
return
def on_metadata(self, data):
""" Update source info from provided request metadata """
self.rid = data.get('rid') 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') air_time = data.get('on_air')
# try: if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S') air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
self.air_time = local_tz.localize(air_time) self.air_time = local_tz.localize(air_time)
# except: else:
# pass self.air_time = None
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): def skip(self):
""" """ Skip the current source sound """
Skip the current sound in the source self.controller.send(self.id, '.skip')
"""
self._send(self.id, '.skip')
def restart(self): def restart(self):
""" """ Restart current sound """
Restart the current sound in the source. Since liquidsoap # seek 10 hours back since there is not possibility to get current pos
does not give us current position in stream, it seeks back self.seek(-216000*10)
max 10 hours in the current sound.
"""
self.seek(-216000*10);
def seek(self, n): def seek(self, n):
""" """ Seeks into the sound. """
Seeks into the sound. Note that liquidsoap seems really slow for that. self.controller.send(self.id, '.seek ', str(n))
"""
self._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): def stream(self):
""" """ Return program's stream info if any (or None) as dict. """
Return dict of info for the current Stream program running on # used in templates
the source. If not, return None.
[ used in the templates ]
"""
# TODO: multiple streams # TODO: multiple streams
stream = self.program.stream_set.all().first() stream = self.program.stream_set.all().first()
if not stream or (not stream.begin and not stream.delay): 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 '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; - cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap - run Liquidsoap
""" """
import tzlocal
import time
import re
from argparse import RawTextHelpFormatter from argparse import RawTextHelpFormatter
import time
from django.conf import settings as main_settings import pytz
from django.core.management.base import BaseCommand, CommandError import tzlocal
from django.core.management.base import BaseCommand
from django.utils import timezone as tz 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 # force using UTC
import pytz
tz.activate(pytz.UTC) tz.activate(pytz.UTC)
@ -45,125 +40,91 @@ class Monitor:
- scheduled diffusions - scheduled diffusions
- tracks for sounds of streamed programs - tracks for sounds of streamed programs
""" """
station = None
streamer = None streamer = None
cancel_timeout = 60*10 """ Streamer controller """
""" logs = None
Time in seconds before a diffusion that have archives is cancelled """ Queryset to station's logs (ordered by -pk) """
because it has not been played. cancel_timeout = 20
""" """ Timeout in minutes before cancelling a diffusion. """
sync_timeout = 60*10 sync_timeout = 5
""" """ Timeout in minutes between two streamer's sync. """
Time in minuts before all stream playlists are checked and updated
"""
sync_next = None sync_next = None
""" """ Datetime of the next sync """
Datetime of the next sync
"""
def get_last_log(self, *args, **kwargs):
return self.log_qs.filter(*args, **kwargs).last()
@property @property
def log_qs(self): def station(self):
return Log.objects.station(self.station) \ return self.streamer.station
.select_related('diffusion', 'sound') \
.order_by('pk')
@property @property
def last_log(self): def last_log(self):
""" """ Last log of monitored station. """
Last log of monitored station return self.logs.first()
"""
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)
@property @property
def last_diff_start(self): def last_diff_start(self):
""" """ Log of last triggered item (sound or diffusion). """
Log of last triggered item (sound or diffusion) return self.logs.start().with_diff().first()
"""
return self.get_last_log(type=Log.Type.start, diffusion__isnull=False)
def __init__(self, station, **kwargs): def __init__(self, streamer, **kwargs):
self.station = station self.streamer = streamer
self.__dict__.update(kwargs) 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): def monitor(self):
""" """ Run all monitoring functions once. """
Run all monitoring functions. if not self.streamer.is_ready:
"""
if not self.streamer:
self.streamer = self.station.streamer
if not self.streamer.ready():
return return
self.streamer.fetch() self.streamer.fetch()
source = self.streamer.source source = self.streamer.source
if source and source.sound: if source and source.uri:
log = self.trace_sound(source) log = self.trace_sound(source)
if log: if log:
self.trace_tracks(log) self.trace_tracks(log)
else: else:
print('no source or sound for stream; source = ', source) print('no source or sound for stream; source = ', source)
self.sync_playlists() self.handle_diffusions()
self.handle() self.sync()
def log(self, date=None, **kwargs): def log(self, date=None, **kwargs):
""" Create a log using **kwargs, and print info """ """ Create a log using **kwargs, and print info """
log = Log(station=self.station, date=date or tz.now(), **kwargs) kwargs.setdefault('station', self.station)
if log.type == Log.Type.on_air and log.diffusion is None: log = Log(date=date or tz.now(), **kwargs)
log.collision = Diffusion.objects.station(log.station) \
.on_air().at(log.date).first()
log.save() log.save()
log.print() log.print()
return log return log
def trace_sound(self, source): def trace_sound(self, source):
""" """ Return on air sound log (create if not present). """
Return log for current on_air (create and save it if required). sound_path, air_time = source.uri, source.air_time
"""
sound_path = source.sound
air_time = source.air_time
# check if there is yet a log for this sound on the source # check if there is yet a log for this sound on the source
delta = tz.timedelta(seconds=5) delta = tz.timedelta(seconds=5)
air_times = (air_time - delta, air_time + delta) air_times = (air_time - delta, air_time + delta)
log = self.log_qs.on_air().filter( log = self.logs.on_air().filter(source=source.id,
source=source.id, sound__path=sound_path, sound__path=sound_path,
date__range=air_times, date__range=air_times).first()
).last()
if log: if log:
return log return log
# get sound # get sound
sound = Sound.objects.filter(path=sound_path) \
.select_related('diffusion').first()
diff = None diff = None
if sound and sound.diffusion: sound = Sound.objects.filter(path=sound_path).first()
diff = sound.diffusion.original if sound and sound.episode_id is not None:
# check for reruns diff = Diffusion.objects.episode(id=sound.episode_id).on_air() \
if not diff.is_date_in_range(air_time) and not diff.initial: .now(air_time).first()
diff = Diffusion.objects.at(air_time) \
.on_air().filter(initial=diff).first()
# log sound on air # log sound on air
return self.log( return self.log(type=Log.Type.on_air, date=source.air_time,
type=Log.Type.on_air, source=source.id, date=source.on_air, source=source.id, sound=sound, diffusion=diff,
sound=sound, diffusion=diff, comment=sound_path)
# if sound is removed, we keep sound path info
comment=sound_path,
)
def trace_tracks(self, log): def trace_tracks(self, log):
""" """
@ -172,10 +133,13 @@ class Monitor:
if log.diffusion: if log.diffusion:
return 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(): if not tracks.exists():
return return
# exclude already logged tracks
tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk) tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk)
now = tz.now() now = tz.now()
for track in tracks: for track in tracks:
@ -183,178 +147,40 @@ class Monitor:
if pos > now: if pos > now:
break break
# log track on air # log track on air
self.log( self.log(type=Log.Type.on_air, date=pos, source=log.source,
type=Log.Type.on_air, source=log.source, track=track, comment=track)
date=pos, track=track,
comment=track,
)
def sync_playlists(self): def handle_diffusions(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):
""" """
Handle scheduled diffusion, trigger if needed, preload playlists Handle scheduled diffusion, trigger if needed, preload playlists
and so on. and so on.
""" """
station = self.station # TODO: restart
dealer = station.dealer # TODO: handle conflict + cancel
if not dealer: 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 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() now = tz.now()
if self.sync_next is not None and now < self.sync_next:
return
# current and next diffs self.sync_next = now + tz.timedelta(minutes=self.sync_timeout)
current_diff, remaining_pl = self.__current_diff()
next_diff, next_pl = self.__next_diff(current_diff)
# playlist for source in self.streamer.sources:
dealer.active = bool(remaining_pl) if isinstance(source, PlaylistSource):
playlist = remaining_pl + next_pl source.sync()
self.handle_pl_sync(dealer, playlist, next_diff, now)
self.handle_diff_start(dealer, next_diff, now)
class Command (BaseCommand): class Command (BaseCommand):
@ -390,32 +216,31 @@ class Command (BaseCommand):
) )
group.add_argument( group.add_argument(
'-t', '--timeout', type=int, '-t', '--timeout', type=int,
default=600, default=Monitor.cancel_timeout,
help='time to wait in SECONDS before canceling a diffusion that ' help='time to wait in MINUTES before canceling a diffusion that '
'has not been ran but should have been. If 0, does not ' 'should have ran but did not. '
'check'
) )
# TODO: sync-timeout, cancel-timeout
def handle(self, *args, def handle(self, *args,
config=None, run=None, monitor=None, config=None, run=None, monitor=None,
station=[], delay=1000, timeout=600, station=[], delay=1000, timeout=600,
**options): **options):
stations = Station.objects.filter(name__in=station)[:] \ stations = Station.objects.filter(name__in=station) if station else \
if station else Station.objects.all()[:] Station.objects.all()
streamers = [Streamer(station) for station in stations]
for station in stations: for streamer in streamers:
# station.prepare() if config:
if config and not run: # no need to write it twice streamer.make_config()
station.streamer.push()
if run: if run:
station.streamer.process_run() streamer.run_process()
if monitor: if monitor:
monitors = [ monitors = [Monitor(streamer, cancel_timeout=timeout)
Monitor(station, cancel_timeout=timeout) for streamer in streamers]
for station in stations
]
delay = delay / 1000 delay = delay / 1000
while True: while True:
for monitor in monitors: for monitor in monitors:
@ -423,5 +248,5 @@ class Command (BaseCommand):
time.sleep(delay) time.sleep(delay)
if run: if run:
for station in stations: for streamer in streamers:
station.controller.process_wait() 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 aircox import settings, utils
from .program import Program, BaseRerun, BaseRerunQuerySet from .program import Program, InProgramQuerySet, \
BaseRerun, BaseRerunQuerySet
from .page import Page, PageQuerySet from .page import Page, PageQuerySet
__all__ = ['Episode', 'EpisodeQuerySet', 'Diffusion', 'DiffusionQuerySet'] __all__ = ['Episode', '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)
class Episode(Page): class Episode(Page):
@ -32,7 +24,7 @@ class Episode(Page):
verbose_name=_('program'), verbose_name=_('program'),
) )
objects = EpisodeQuerySet.as_manager() objects = InProgramQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _('Episode') verbose_name = _('Episode')
@ -52,40 +44,31 @@ class Episode(Page):
return cls(program=program, title=title) return cls(program=program, title=title)
class DiffusionQuerySet(BaseRerunQuerySet): class DiffusionQuerySet(BaseRerunQuerySet):
def station(self, station): def episode(self, episode=None, id=None):
return self.filter(episode__program__station=station) """ Diffusions for this episode """
return self.filter(episode=episode) if id is None else \
def program(self, program): self.filter(episode__id=id)
return self.filter(program=program)
def on_air(self): def on_air(self):
""" On air diffusions """
return self.filter(type=Diffusion.Type.on_air) return self.filter(type=Diffusion.Type.on_air)
def at(self, date=None): def now(self, now=None, order=True):
""" """ Diffusions occuring now """
Return diffusions occuring at the given date, ordered by +start 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 def today(self, today=None, order=True):
the given moment. If date is not a datetime object, it uses """ Diffusions occuring today. """
it as a date, and get diffusions that occurs this day. 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(). def at(self, date, order=True):
""" """ Return diffusions at specified date or datetime """
# note: we work with localtime return self.now(date, order) if isinstance(date, tz.datetime) else \
date = utils.date_or_default(date) self.today(date, order)
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 after(self, date=None): def after(self, date=None):
""" """
@ -183,10 +166,12 @@ class Diffusion(BaseRerun):
# self.check_conflicts() # self.check_conflicts()
def save_rerun(self): def save_rerun(self):
print('rerun save', self)
self.episode = self.initial.episode self.episode = self.initial.episode
self.program = self.episode.program self.program = self.episode.program
def save_original(self): def save_initial(self):
print('initial save', self)
self.program = self.episode.program self.program = self.episode.program
if self.episode != self._initial['episode']: if self.episode != self._initial['episode']:
self.rerun_set.update(episode=self.episode, program=self.program) 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()) 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? # TODO: property?
def is_live(self): 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 \ 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): def get_playlist(self, **types):
""" """

View File

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

View File

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

View File

@ -22,15 +22,32 @@ __all__ = ['Sound', 'SoundQuerySet', 'Track']
class SoundQuerySet(models.QuerySet): 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): def podcasts(self):
""" Return sound available as podcasts """ """ Return sounds available as podcasts """
return self.filter(Q(embed__isnull=False) | Q(is_public=True)) return self.filter(Q(embed__isnull=False) | Q(is_public=True))
def episode(self, episode): def archive(self):
return self.filter(episode=episode) """ Return sounds that are archives """
return self.filter(type=Sound.Type.archive)
def diffusion(self, diffusion): def paths(self, archive=True, order_by=True):
return self.filter(episode__diffusion=diffusion) """
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): class Sound(models.Model):
@ -46,6 +63,7 @@ class Sound(models.Model):
name = models.CharField(_('name'), max_length=64) name = models.CharField(_('name'), max_length=64)
program = models.ForeignKey( program = models.ForeignKey(
# FIXME: not nullable?
Program, models.SET_NULL, blank=True, null=True, Program, models.SET_NULL, blank=True, null=True,
verbose_name=_('program'), verbose_name=_('program'),
help_text=_('program related to it'), help_text=_('program related to it'),
@ -95,6 +113,21 @@ class Sound(models.Model):
objects = SoundQuerySet.as_manager() 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): def get_mtime(self):
""" """
Get the last modification date from file Get the last modification date from file
@ -220,21 +253,6 @@ class Sound(models.Model):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.__check_name() 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): class Track(models.Model):
""" """

View File

@ -33,6 +33,7 @@ class Station(models.Model):
""" """
name = models.CharField(_('name'), max_length=64) name = models.CharField(_('name'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, unique=True) slug = models.SlugField(_('slug'), max_length=64, unique=True)
# FIXME: remove - should be decided only by Streamer controller + settings
path = models.CharField( path = models.CharField(
_('path'), _('path'),
help_text=_('path to the working directory'), help_text=_('path to the working directory'),
@ -47,70 +48,13 @@ class Station(models.Model):
objects = StationQuerySet.as_manager() 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): def __str__(self):
return self.name return self.name
def save(self, make_sources=True, *args, **kwargs): def save(self, make_sources=True, *args, **kwargs):
if not self.path: if not self.path:
self.path = os.path.join( self.path = os.path.join(settings.AIRCOX_CONTROLLERS_WORKING_DIR,
settings.AIRCOX_CONTROLLERS_WORKING_DIR, self.slug.replace('-', '_'))
self.slug
)
if self.default: if self.default:
qs = Station.objects.filter(default=True) 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. type if any.
""" """
date = date if date is not None else datetime.date.today() \ date = date if date is not None else datetime.date.today() \
if into is not None and issubclass(into, datetime.date) else \ if into is not None and issubclass(into, datetime.date) else tz.now()
tz.datetime.now()
if into is not None: if into is not None:
date = cast_date(date, into) date = cast_date(date, into)