aircox/aircox_streamer/controllers/monitor.py
Thomas Kairos b453c821c7 #106: tests: aircox_streamer (#110)
- Writes tests for aircox streamer application;
- Add test utilities in aircox

Co-authored-by: bkfox <thomas bkfox net>
Reviewed-on: #110
2023-06-18 17:00:08 +02:00

273 lines
8.8 KiB
Python

# 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 django.utils import timezone as tz
from aircox.models import Diffusion, Log, Sound, Track
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 = tz.timedelta(minutes=20)
"""Timeout in minutes before cancelling a diffusion."""
sync_timeout = tz.timedelta(minutes=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, **kwargs):
self.streamer = streamer
# adding time ensures all calculations have a margin
self.delay = delay + tz.timedelta(seconds=5)
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):
"""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 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:
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 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 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 + self.sync_timeout
for source in self.streamer.playlists:
source.sync()