forked from rc/aircox
391 lines
12 KiB
Python
Executable File
391 lines
12 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
|
|
"""
|
|
import time
|
|
|
|
# TODO:
|
|
# x controllers: remaining
|
|
# x diffusion conflicts
|
|
# x cancel
|
|
# x when liquidsoap fails to start/exists: exit
|
|
# - handle restart after failure
|
|
# - is stream restart after live ok?
|
|
from argparse import RawTextHelpFormatter
|
|
|
|
import pytz
|
|
from django.core.management.base import BaseCommand
|
|
from django.utils import timezone as tz
|
|
|
|
from aircox.models import Diffusion, Log, Sound, Station, Track
|
|
from aircox_streamer.controllers import Streamer
|
|
|
|
# force using UTC
|
|
tz.activate(pytz.UTC)
|
|
|
|
|
|
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."""
|
|
delay = None
|
|
""" Timedelta: minimal delay between two call of monitor. """
|
|
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."""
|
|
last_sound_logs = None
|
|
"""Last logged sounds, as ``{source_id: log}``."""
|
|
|
|
@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, delay, cancel_timeout, **kwargs):
|
|
self.streamer = streamer
|
|
# adding time ensure all calculation have a margin
|
|
self.delay = delay + tz.timedelta(seconds=5)
|
|
self.cancel_timeout = cancel_timeout
|
|
self.__dict__.update(kwargs)
|
|
self.logs = self.get_logs_queryset()
|
|
self.init_last_sound_logs()
|
|
|
|
def get_logs_queryset(self):
|
|
"""Return queryset to assign as `self.logs`"""
|
|
return self.station.log_set.select_related(
|
|
"diffusion", "sound", "track"
|
|
).order_by("-pk")
|
|
|
|
def init_last_sound_logs(self, key=None):
|
|
"""Retrieve last logs and initialize `last_sound_logs`"""
|
|
logs = {}
|
|
for source in self.streamer.sources:
|
|
qs = self.logs.filter(source=source.id, sound__isnull=False)
|
|
logs[source.id] = qs.first()
|
|
self.last_sound_logs = logs
|
|
|
|
def monitor(self):
|
|
"""Run all monitoring functions once."""
|
|
if not self.streamer.is_ready:
|
|
return
|
|
|
|
self.streamer.fetch()
|
|
|
|
# Skip tracing - analyzis:
|
|
# Reason: multiple database request every x seconds, reducing it.
|
|
# We could skip this part when remaining time is higher than a minimal
|
|
# value (which should be derived from Command's delay). Problems:
|
|
# - How to trace tracks? (+ Source can change: caching log might sucks)
|
|
# - if liquidsoap's source/request changes: remaining time goes higher,
|
|
# thus avoiding fetch
|
|
#
|
|
# Approach: something like having a mean time, such as:
|
|
#
|
|
# ```
|
|
# source = stream.source
|
|
# mean_time = source.air_time
|
|
# + min(next_track.timestamp, source.remaining)
|
|
# - (command.delay + 1)
|
|
# trace_required = \/ source' != source
|
|
# \/ source.uri' != source.uri
|
|
# \/ now < mean_time
|
|
# ```
|
|
#
|
|
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, source, **kwargs):
|
|
"""Create a log using **kwargs, and print info."""
|
|
kwargs.setdefault("station", self.station)
|
|
kwargs.setdefault("date", tz.now())
|
|
log = Log(source=source, **kwargs)
|
|
log.save()
|
|
log.print()
|
|
|
|
if log.sound:
|
|
self.last_sound_logs[source] = log
|
|
return log
|
|
|
|
def trace_sound(self, source):
|
|
"""Return on air sound log (create if not present)."""
|
|
air_uri, air_time = source.uri, source.air_time
|
|
last_log = self.last_sound_logs.get(source.id)
|
|
if last_log and last_log.sound.file.path == source.uri:
|
|
return last_log
|
|
|
|
# FIXME: can be a sound played when no Sound instance? If not, remove
|
|
# comment.
|
|
# check if there is yet a log for this sound on the source
|
|
# log = self.logs.on_air().filter(
|
|
# Q(sound__file=air_uri) |
|
|
# # sound can be null when arbitrary sound file is played
|
|
# Q(sound__isnull=True, track__isnull=True, comment=air_uri),
|
|
# source=source.id,
|
|
# date__range=date_range(air_time, self.delay),
|
|
# ).first()
|
|
# if log:
|
|
# return log
|
|
|
|
# get sound
|
|
diff = None
|
|
sound = Sound.objects.path(air_uri).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=air_uri,
|
|
)
|
|
|
|
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
|
|
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: program restart
|
|
|
|
# Diffusion conflicts are handled by the way a diffusion is defined
|
|
# as candidate for the next dealer's start.
|
|
#
|
|
# ```
|
|
# logged_diff: /\ \A diff in diffs: \E log: /\ log.type = START
|
|
# /\ log.diff = diff
|
|
# /\ log.date = diff.start
|
|
# queue_empty: /\ dealer.queue is empty
|
|
# /\ \/ ~dealer.on_air
|
|
# \/ dealer.remaining < delay
|
|
#
|
|
# start_allowed: /\ diff not in logged_diff
|
|
# /\ queue_empty
|
|
#
|
|
# start_canceled: /\ diff not in logged diff
|
|
# /\ ~queue_empty
|
|
# /\ diff.start < now + cancel_timeout
|
|
# ```
|
|
#
|
|
now = tz.now()
|
|
diff = (
|
|
Diffusion.objects.station(self.station)
|
|
.on_air()
|
|
.now(now)
|
|
.filter(episode__sound__type=Sound.TYPE_ARCHIVE)
|
|
.first()
|
|
)
|
|
# Can't use delay: diffusion may start later than its assigned start.
|
|
log = None if not diff else self.logs.start().filter(diffusion=diff)
|
|
if not diff or log:
|
|
return
|
|
|
|
dealer = self.streamer.dealer
|
|
# start
|
|
if (
|
|
not dealer.queue
|
|
and dealer.rid is None
|
|
or dealer.remaining < self.delay.total_seconds()
|
|
):
|
|
self.start_diff(dealer, diff)
|
|
|
|
# cancel
|
|
if diff.start < now - self.cancel_timeout:
|
|
self.cancel_diff(dealer, diff)
|
|
|
|
def start_diff(self, source, diff):
|
|
playlist = Sound.objects.episode(id=diff.episode_id).playlist()
|
|
source.push(*playlist)
|
|
self.log(
|
|
type=Log.TYPE_START,
|
|
source=source.id,
|
|
diffusion=diff,
|
|
comment=str(diff),
|
|
)
|
|
|
|
def cancel_diff(self, source, diff):
|
|
diff.type = Diffusion.TYPE_CANCEL
|
|
diff.save()
|
|
self.log(
|
|
type=Log.TYPE_CANCEL,
|
|
source=source.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.playlists:
|
|
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. This influence the delay before a diffusion is "
|
|
"launched.",
|
|
)
|
|
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=float,
|
|
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 not streamer.outputs:
|
|
raise RuntimeError(
|
|
"Streamer {} has no outputs".format(streamer.id)
|
|
)
|
|
if config:
|
|
streamer.make_config()
|
|
if run:
|
|
streamer.run_process()
|
|
|
|
if monitor:
|
|
delay = tz.timedelta(milliseconds=delay)
|
|
timeout = tz.timedelta(minutes=timeout)
|
|
monitors = [
|
|
Monitor(streamer, delay, timeout) for streamer in streamers
|
|
]
|
|
|
|
while not run or streamer.is_running:
|
|
for monitor in monitors:
|
|
monitor.monitor()
|
|
time.sleep(delay.total_seconds())
|
|
|
|
if run:
|
|
for streamer in streamers:
|
|
streamer.wait_process()
|