aircox/aircox_streamer/management/commands/streamer.py
2023-03-13 17:47:00 +01:00

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