redesign streamer, make it sexy

This commit is contained in:
bkfox
2019-08-02 17:06:23 +02:00
parent 8e1d2b6769
commit 432467ec8d
18 changed files with 210 additions and 100 deletions

View File

@ -12,7 +12,7 @@ from django.utils import timezone as tz
import aircox.settings as settings
from aircox.models import Log, Station
logger = logging.getLogger('aircox.tools')
logger = logging.getLogger('aircox.commands')
class Command (BaseCommand):

View File

@ -25,7 +25,7 @@ from django.utils import timezone as tz
from aircox.models import Schedule, Diffusion
logger = logging.getLogger('aircox.tools')
logger = logging.getLogger('aircox.commands')
class Actions:

View File

@ -23,7 +23,7 @@ from aircox.models import *
__doc__ = __doc__.format(settings=settings)
logger = logging.getLogger('aircox.tools')
logger = logging.getLogger('aircox.commands')
class PlaylistImport:

View File

@ -42,7 +42,7 @@ from aircox import settings, utils
from aircox.models import Diffusion, Program, Sound
from .import_playlist import PlaylistImport
logger = logging.getLogger('aircox.tools')
logger = logging.getLogger('aircox.commands')
sound_path_re = re.compile(

View File

@ -9,7 +9,7 @@ from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger('aircox.tools')
logger = logging.getLogger('aircox.commands')
class Stats:

View File

@ -6,27 +6,31 @@ used to:
- cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap
"""
# TODO:
# x controllers: remaining
# x diffusion conflicts
# x cancel
# x when liquidsoap fails to start/exists: exit
# - handle restart after failure
# - file in queue without sound not logged?
# - is stream restart after live ok?
from argparse import RawTextHelpFormatter
import time
import pytz
import tzlocal
from django.db.models import Q
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
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.utils import date_range
# 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.
@ -42,6 +46,8 @@ class Monitor:
"""
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
@ -65,8 +71,11 @@ class Monitor:
""" Log of last triggered item (sound or diffusion). """
return self.logs.start().with_diff().first()
def __init__(self, streamer, **kwargs):
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()
@ -81,6 +90,27 @@ class Monitor:
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)
@ -102,21 +132,22 @@ class Monitor:
def trace_sound(self, source):
""" Return on air sound log (create if not present). """
sound_path, air_time = source.uri, source.air_time
air_uri, 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()
log = self.logs.on_air().filter(
Q(sound__path=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.filter(path=sound_path).first()
sound = Sound.objects.filter(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()
@ -124,7 +155,7 @@ class Monitor:
# 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)
comment=air_uri)
def trace_tracks(self, log):
"""
@ -155,19 +186,56 @@ class Monitor:
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() \
# 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()
log = self.logs.start().filter(diffusion=diff) if diff else None
# 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
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,
# 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).paths()
source.append(*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):
@ -207,7 +275,8 @@ class Command (BaseCommand):
'-d', '--delay', type=int,
default=1000,
help='time to sleep in MILLISECONDS between two updates when we '
'monitor'
'monitor. This influence the delay before a diffusion is '
'launched.'
)
group.add_argument(
'-s', '--station', type=str, action='append',
@ -215,7 +284,7 @@ class Command (BaseCommand):
'all stations'
)
group.add_argument(
'-t', '--timeout', type=int,
'-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. '
@ -226,7 +295,6 @@ class Command (BaseCommand):
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]
@ -238,14 +306,15 @@ class Command (BaseCommand):
streamer.run_process()
if monitor:
monitors = [Monitor(streamer, cancel_timeout=timeout)
delay = tz.timedelta(milliseconds=delay)
timeout = tz.timedelta(minutes=timeout)
monitors = [Monitor(streamer, delay, timeout)
for streamer in streamers]
delay = delay / 1000
while True:
while not run or streamer.is_running:
for monitor in monitors:
monitor.monitor()
time.sleep(delay)
time.sleep(delay.total_seconds())
if run:
for streamer in streamers: