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