forked from rc/aircox
253 lines
8.2 KiB
Python
Executable File
253 lines
8.2 KiB
Python
Executable File
"""
|
|
Handle the audio streamer and controls it as we want it to be. It is
|
|
used to:
|
|
- generate config files and playlists;
|
|
- monitor Liquidsoap, logs and scheduled programs;
|
|
- cancels Diffusions that have an archive but could not have been played;
|
|
- run Liquidsoap
|
|
"""
|
|
from argparse import RawTextHelpFormatter
|
|
import time
|
|
|
|
import pytz
|
|
import tzlocal
|
|
from django.core.management.base import BaseCommand
|
|
from django.utils import timezone as tz
|
|
|
|
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
|
|
from aircox.controllers import Streamer, PlaylistSource
|
|
|
|
# force using UTC
|
|
tz.activate(pytz.UTC)
|
|
|
|
|
|
# FIXME liquidsoap does not manage timezones -- we have to convert
|
|
# 'on_air' metadata we get from it into utc one in order to work
|
|
# correctly.
|
|
local_tz = tzlocal.get_localzone()
|
|
|
|
|
|
class Monitor:
|
|
"""
|
|
Log and launch diffusions for the given station.
|
|
|
|
Monitor should be able to be used after a crash a go back
|
|
where it was playing, so we heavily use logs to be able to
|
|
do that.
|
|
|
|
We keep trace of played items on the generated stream:
|
|
- sounds played on this stream;
|
|
- scheduled diffusions
|
|
- tracks for sounds of streamed programs
|
|
"""
|
|
streamer = None
|
|
""" 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 """
|
|
|
|
@property
|
|
def station(self):
|
|
return self.streamer.station
|
|
|
|
@property
|
|
def last_log(self):
|
|
""" 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.logs.start().with_diff().first()
|
|
|
|
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 once. """
|
|
if not self.streamer.is_ready:
|
|
return
|
|
|
|
self.streamer.fetch()
|
|
source = self.streamer.source
|
|
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.handle_diffusions()
|
|
self.sync()
|
|
|
|
def log(self, date=None, **kwargs):
|
|
""" Create a log using **kwargs, and print info """
|
|
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 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.logs.on_air().filter(source=source.id,
|
|
sound__path=sound_path,
|
|
date__range=air_times).first()
|
|
if log:
|
|
return log
|
|
|
|
# get sound
|
|
diff = None
|
|
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, date=source.air_time,
|
|
source=source.id, sound=sound, diffusion=diff,
|
|
comment=sound_path)
|
|
|
|
def trace_tracks(self, log):
|
|
"""
|
|
Log tracks for the given sound log (for streamed programs only).
|
|
"""
|
|
if log.diffusion:
|
|
return
|
|
|
|
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:
|
|
pos = log.date + tz.timedelta(seconds=track.timestamp)
|
|
if pos > now:
|
|
break
|
|
# log track on air
|
|
self.log(type=Log.Type.on_air, date=pos, source=log.source,
|
|
track=track, comment=track)
|
|
|
|
def handle_diffusions(self):
|
|
"""
|
|
Handle scheduled diffusion, trigger if needed, preload playlists
|
|
and so on.
|
|
"""
|
|
# 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
|
|
|
|
self.sync_next = now + tz.timedelta(minutes=self.sync_timeout)
|
|
|
|
for source in self.streamer.sources:
|
|
if isinstance(source, PlaylistSource):
|
|
source.sync()
|
|
|
|
|
|
class Command (BaseCommand):
|
|
help = __doc__
|
|
|
|
def add_arguments(self, parser):
|
|
parser.formatter_class = RawTextHelpFormatter
|
|
group = parser.add_argument_group('actions')
|
|
group.add_argument(
|
|
'-c', '--config', action='store_true',
|
|
help='generate configuration files for the stations'
|
|
)
|
|
group.add_argument(
|
|
'-m', '--monitor', action='store_true',
|
|
help='monitor the scheduled diffusions and log what happens'
|
|
)
|
|
group.add_argument(
|
|
'-r', '--run', action='store_true',
|
|
help='run the required applications for the stations'
|
|
)
|
|
|
|
group = parser.add_argument_group('options')
|
|
group.add_argument(
|
|
'-d', '--delay', type=int,
|
|
default=1000,
|
|
help='time to sleep in MILLISECONDS between two updates when we '
|
|
'monitor'
|
|
)
|
|
group.add_argument(
|
|
'-s', '--station', type=str, action='append',
|
|
help='name of the station to monitor instead of monitoring '
|
|
'all stations'
|
|
)
|
|
group.add_argument(
|
|
'-t', '--timeout', type=int,
|
|
default=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()
|
|
streamers = [Streamer(station) for station in stations]
|
|
|
|
for streamer in streamers:
|
|
if config:
|
|
streamer.make_config()
|
|
if run:
|
|
streamer.run_process()
|
|
|
|
if monitor:
|
|
monitors = [Monitor(streamer, cancel_timeout=timeout)
|
|
for streamer in streamers]
|
|
|
|
delay = delay / 1000
|
|
while True:
|
|
for monitor in monitors:
|
|
monitor.monitor()
|
|
time.sleep(delay)
|
|
|
|
if run:
|
|
for streamer in streamers:
|
|
streamer.wait_process()
|