fix bug in streamer; clean-up; wider sidebar

This commit is contained in:
bkfox 2018-07-18 02:22:29 +02:00
parent 09859c9410
commit c059a33077
9 changed files with 795 additions and 288 deletions

View File

@ -51,6 +51,9 @@ class TrackInline(GenericTabularInline):
extra = 0 extra = 0
fields = ('artist', 'title', 'info', 'position', 'in_seconds', 'tags') fields = ('artist', 'title', 'info', 'position', 'in_seconds', 'tags')
list_display = ['artist','title','tags','related']
list_filter = ['artist','title','tags']
@admin.register(Sound) @admin.register(Sound)
class SoundAdmin(NameableAdmin): class SoundAdmin(NameableAdmin):

View File

@ -1,17 +1,17 @@
import os import atexit, logging, os, re, signal, subprocess
import signal
import re import tzlocal
import subprocess
import atexit
import logging
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone as tz
import aircox.models as models import aircox.models as models
import aircox.settings as settings import aircox.settings as settings
from aircox.connector import Connector from aircox.connector import Connector
local_tz = tzlocal.get_localzone()
logger = logging.getLogger('aircox.tools') logger = logging.getLogger('aircox.tools')
@ -32,19 +32,14 @@ class Streamer:
""" """
Path of the configuration file. Path of the configuration file.
""" """
current_sound = '' source = None
""" """
Current sound being played (retrieved by fetch) Current source object that is responsible of self.sound
"""
current_source = None
"""
Current source object that is responsible of self.current_sound
""" """
process = None process = None
""" """
Application's process if ran from Streamer Application's process if ran from Streamer
""" """
socket_path = '' socket_path = ''
""" """
Path to the connector's socket Path to the connector's socket
@ -95,13 +90,11 @@ class Streamer:
if not data: if not data:
return return
self.current_sound = data.get('initial_uri') self.source = next(
self.current_source = next(
iter(source for source in self.station.sources iter(source for source in self.station.sources
if source.rid == rid), if source.rid == rid),
self.current_source self.source
) )
self.current_source.metadata = data
def push(self, config = True): def push(self, config = True):
""" """
@ -202,43 +195,25 @@ class Source:
Controller of a Source. Value are usually updated directly on the Controller of a Source. Value are usually updated directly on the
external side. external side.
""" """
program = None station = None
"""
Related source
"""
name = ''
path = ''
"""
Path to the Source's playlist file. Optional.
"""
active = True
"""
Source is available. May be different from the containing Source,
e.g. dealer and liquidsoap.
"""
current_sound = ''
"""
Current sound being played (retrieved by fetch)
"""
current_source = None
"""
Current source being responsible of the current sound
"""
rid = None
"""
Current request id of the source in LiquidSoap
"""
connector = None connector = None
""" """ Connector to Liquidsoap server """
Connector to Liquidsoap server program = None
""" """ Related program """
metadata = None name = ''
""" """ Name of the source """
Dict of file's metadata given by Liquidsoap. Set by Stream when path = ''
fetch()ing """ Path to the playlist file. """
""" on_air = None
# retrieved from fetch
sound = ''
""" (fetched) current sound being played """
rid = None
""" (fetched) current request id of the source in LiquidSoap """
air_time = None
""" (fetched) datetime of last on_air """
@property @property
def id(self): def id(self):
@ -267,12 +242,6 @@ class Source:
if not self.__playlist: if not self.__playlist:
self.from_db() self.from_db()
def is_stream(self):
return self.program and not self.program.show
def is_dealer(self):
return not self.program
@property @property
def playlist(self): def playlist(self):
""" """
@ -325,11 +294,19 @@ class Source:
if self.__playlist else [] if self.__playlist else []
# #
# RPC # RPC & States
# #
def _send(self, *args, **kwargs): def _send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) return self.connector.send(*args, **kwargs)
@property
def is_stream(self):
return self.program and not self.program.show
@property
def is_dealer(self):
return not self.program
@property @property
def active(self): def active(self):
return self._send('var.get ', self.id, '_active') == 'true' return self._send('var.get ', self.id, '_active') == 'true'
@ -348,9 +325,15 @@ class Source:
return return
self.rid = data.get('rid') self.rid = data.get('rid')
self.current_sound = data.get('initial_uri') self.sound = data.get('initial_uri')
# TODO: get metadata # get air_time
air_time = data.get('on_air')
# try:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
self.air_time = local_tz.localize(air_time)
# except:
# pass
def push(self): def push(self):
""" """
@ -383,8 +366,9 @@ class Source:
def stream(self): def stream(self):
""" """
Return a dict with stream info for a Stream program, or None if there Return dict of info for the current Stream program running on
is not. Used in the template. the source. If not, return None.
[ used in the templates ]
""" """
# TODO: multiple streams # TODO: multiple streams
stream = self.program.stream_set.all().first() stream = self.program.stream_set.all().first()

View File

@ -16,8 +16,9 @@ from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.db import models from django.db import models
from django.db.models import Q
from aircox.models import Station, Diffusion, Track, Sound, Log #, DiffusionLog, SoundLog from aircox.models import Station, Diffusion, Track, Sound, Log
# force using UTC # force using UTC
import pytz import pytz
@ -61,17 +62,20 @@ class Monitor:
""" """
def get_last_log(self, *args, **kwargs): def get_last_log(self, *args, **kwargs):
return self.log_qs.filter(*args, **kwargs).last()
@property
def log_qs(self):
return Log.objects.station(self.station) \ return Log.objects.station(self.station) \
.filter(*args, **kwargs) \
.select_related('diffusion', 'sound') \ .select_related('diffusion', 'sound') \
.order_by('pk').last() .order_by('pk')
@property @property
def last_log(self): def last_log(self):
""" """
Last log of monitored station Last log of monitored station
""" """
return self.get_last_log() return self.log_qs.last()
@property @property
def last_sound(self): def last_sound(self):
@ -104,7 +108,15 @@ class Monitor:
if not self.streamer.ready(): if not self.streamer.ready():
return return
self.trace() self.streamer.fetch()
source = self.streamer.source
if source and source.sound:
log = self.trace_sound(source)
if log:
self.trace_tracks(log)
else:
print('no source or sound for stream; source = ', source)
self.sync_playlists() self.sync_playlists()
self.handle() self.handle()
@ -116,92 +128,57 @@ class Monitor:
**kwargs) **kwargs)
log.save() log.save()
log.print() log.print()
# update last log
if log.type != Log.Type.other and \
self.last_log and not self.last_log.end:
self.last_log.end = log.date
return log return log
def trace(self): def trace_sound(self, source):
""" """
Check the current_sound of the station and update logs if Return log for current on_air (create and save it if required).
needed.
""" """
self.streamer.fetch() sound_path = source.sound
current_sound = self.streamer.current_sound air_time = source.air_time
current_source = self.streamer.current_source
if not current_sound or not current_source:
print('no source / no sound', current_sound, current_source)
return
log = self.get_last_log( # check if there is yet a log for this sound on the source
models.Q(sound__isnull = False) | delta = tz.timedelta(seconds=5)
models.Q(diffusion__isnull = False), air_times = (air_time - delta, air_time + delta)
type = Log.Type.on_air
)
on_air = None log = self.log_qs.on_air().filter(
source = source.id, sound__path = sound_path,
date__range = air_times,
).last()
if log: if log:
# we always check difference in sound info return log
is_diff = log.source != current_source.id or \
(log.sound and log.sound.path != current_sound)
# check if sound 'on air' time has changed compared to logged one. # get sound
# in some cases, there can be a gap between liquidsoap on_air and sound = Sound.objects.filter(path = sound_path) \
# log's date; to avoid duplicate we allow a difference of 5 seconds .select_related('diffusion').first()
if not is_diff:
try:
# FIXME: liquidsoap does not have timezone
on_air = current_source.metadata and \
current_source.metadata.get('on_air')
on_air = tz.datetime.strptime(on_air, "%Y/%m/%d %H:%M:%S")
on_air = local_tz.localize(on_air)
on_air = on_air.astimezone(pytz.utc)
is_diff = is_diff or ((log.date - on_air).total_seconds() > 5)
except:
pass
else:
# no log: sound is different
is_diff = True
if is_diff:
sound = Sound.objects.filter(path = current_sound).first()
# find an eventual diffusion associated to current sound
# => check using last (started) diffusion's archives
last_diff = self.last_diff_start
diff = None diff = None
if last_diff and not last_diff.is_expired(): if sound and sound.diffusion:
archives = last_diff.diffusion.sounds(archive = True) diff = sound.diffusion.original
if archives.filter(pk = sound.pk).exists(): # check for reruns
diff = last_diff.diffusion if not diff.is_date_in_range(air_time) and not diff.initial:
diff = Diffusion.objects.at(air_time) \
.filter(initial = diff).first()
# log sound on air # log sound on air
log = self.log( return self.log(
type = Log.Type.on_air, type = Log.Type.on_air,
source = current_source.id, source = source.id,
date = on_air or tz.now(), date = source.on_air,
sound = sound, sound = sound,
diffusion = diff, diffusion = diff,
# if sound is removed, we keep sound path info # if sound is removed, we keep sound path info
comment = current_sound, comment = sound_path,
) )
# trace tracks
self.trace_sound_tracks(log)
def trace_tracks(self, log):
def trace_sound_tracks(self, log):
""" """
Log tracks for the given sound log (for streamed programs only). Log tracks for the given sound log (for streamed programs only).
Called by self.trace
""" """
if log.diffusion: if log.diffusion:
return return
tracks = Track.objects.get_for(object = log.sound) \ tracks = Track.objects.related(object = log.sound) \
.filter(in_seconds = True) .filter(in_seconds = True)
if not tracks.exists(): if not tracks.exists():
return return
@ -249,7 +226,7 @@ class Monitor:
type = Diffusion.Type.normal, type = Diffusion.Type.normal,
sound__type = Sound.Type.archive, sound__type = Sound.Type.archive,
) )
logs = station.raw_on_air(diffusion__isnull = False) logs = Log.objects.station(station).on_air().with_diff()
date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout) date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout)
for diff in qs: for diff in qs:
@ -274,7 +251,7 @@ class Monitor:
station = self.station station = self.station
now = tz.now() now = tz.now()
log = station.raw_on_air(diffusion__isnull = False) \ log = Log.objects.station(station).on_air().with_diff() \
.select_related('diffusion') \ .select_related('diffusion') \
.order_by('date').last() .order_by('date').last()
if not log or not log.diffusion.is_date_in_range(now): if not log or not log.diffusion.is_date_in_range(now):
@ -282,7 +259,7 @@ class Monitor:
return None, [] return None, []
# last sound source change: end of file reached or forced to stop # last sound source change: end of file reached or forced to stop
sounds = station.raw_on_air(sound__isnull = False) \ sounds = Log.objects.station(station).on_air().with_sound() \
.filter(date__gte = log.date) \ .filter(date__gte = log.date) \
.order_by('date') .order_by('date')
@ -294,7 +271,7 @@ class Monitor:
.filter(source = log.source, pk__gt = log.pk) \ .filter(source = log.source, pk__gt = log.pk) \
.exclude(sound__type = Sound.Type.removed) .exclude(sound__type = Sound.Type.removed)
remaining = log.diffusion.sounds(archive = True) \ remaining = log.diffusion.get_sounds(archive = True) \
.exclude(pk__in = sounds) \ .exclude(pk__in = sounds) \
.values_list('path', flat = True) .values_list('path', flat = True)
return log.diffusion, list(remaining) return log.diffusion, list(remaining)

View File

@ -28,17 +28,15 @@ class AircoxMiddleware(object):
This middleware must be set after the middleware This middleware must be set after the middleware
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
""" """
default_qs = models.Station.objects.filter(default = True)
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def init_station(self, request, aircox):
# update current station def update_station(self, request):
station = request.GET.get('aircox.station') station = request.GET.get('aircox.station')
pk = None pk = None
try: try:
if station: if station is not None:
pk = request.GET['aircox.station'] pk = request.GET['aircox.station']
if station: if station:
pk = int(pk) pk = int(pk)
@ -47,28 +45,23 @@ class AircoxMiddleware(object):
except: except:
pass pass
# select current station def init_station(self, request, aircox):
station = None self.update_station(request)
pk = None
try: try:
pk = request.session.get('aircox.station') pk = request.session.get('aircox.station')
if pk: pk = int(pk) if pk else None
pk = int(pk)
station = models.Station.objects.filter(pk = pk).first()
except: except:
pass
if not station:
pk = None pk = None
station = self.default_qs.first() or \
models.Station.objects.first()
aircox.station = station aircox.station = models.Station.objects.default(pk)
aircox.default_station = (pk is None) aircox.default_station = (pk is None)
def init_timezone(self, request, aircox): def init_timezone(self, request, aircox):
# note: later we can use http://freegeoip.net/ on user side if # note: later we can use http://freegeoip.net/ on user side if
# required # required
# TODO: add to request's session
timezone = None timezone = None
try: try:
timezone = request.session.get('aircox.timezone') timezone = request.session.get('aircox.timezone')
@ -81,6 +74,7 @@ class AircoxMiddleware(object):
timezone = tz.get_current_timezone() timezone = tz.get_current_timezone()
tz.activate(timezone) tz.activate(timezone)
def __call__(self, request): def __call__(self, request):
tz.activate(pytz.timezone('Europe/Brussels')) tz.activate(pytz.timezone('Europe/Brussels'))
aircox = AircoxInfo() aircox = AircoxInfo()

View File

@ -12,7 +12,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.utils.translation import ugettext as _, ugettext_lazy from django.utils.translation import ugettext_lazy as _
from django.utils import timezone as tz from django.utils import timezone as tz
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -29,8 +29,8 @@ logger = logging.getLogger('aircox.core')
# #
# Abstracts # Abstracts
# #
class RelatedManager(models.Manager): class RelatedQuerySet(models.QuerySet):
def get_for(self, object = None, model = None, qs = None): def related(self, object = None, model = None):
""" """
Return a queryset that filter on the given object or model(s) Return a queryset that filter on the given object or model(s)
@ -40,18 +40,18 @@ class RelatedManager(models.Manager):
if not model and object: if not model and object:
model = type(object) model = type(object)
qs = self if qs is None else qs qs = self
if hasattr(model, '__iter__'): if hasattr(model, '__iter__'):
model = [ ContentType.objects.get_for_model(m).id model = [ ContentType.objects.get_for_model(m).id
for m in model ] for m in model ]
qs = qs.filter(related_type__pk__in = model) self = self.filter(related_type__pk__in = model)
else: else:
model = ContentType.objects.get_for_model(model) model = ContentType.objects.get_for_model(model)
qs = qs.filter(related_type__pk = model.id) self = self.filter(related_type__pk = model.id)
if object: if object:
qs = qs.filter(related_id = object.pk) self = self.filter(related_id = object.pk)
return qs return self
class Related(models.Model): class Related(models.Model):
""" """
@ -72,7 +72,7 @@ class Related(models.Model):
class Meta: class Meta:
abstract = True abstract = True
objects = RelatedManager() objects = RelatedQuerySet.as_manager()
@classmethod @classmethod
def ReverseField(cl): def ReverseField(cl):
@ -154,6 +154,21 @@ class Track(Related):
# #
# Station related classes # Station related classes
# #
class StationQuerySet(models.QuerySet):
def default(self, station = None):
"""
Return station model instance, using defaults or
given one.
"""
if station is None:
return self.order_by('-default', 'pk').first()
return self.filter(pk = station).first()
def default_station():
""" Return default station (used by model fields) """
return Station.objects.default()
class Station(Nameable): class Station(Nameable):
""" """
Represents a radio station, to which multiple programs are attached Represents a radio station, to which multiple programs are attached
@ -175,6 +190,8 @@ class Station(Nameable):
help_text = _('if checked, this station is used as the main one') help_text = _('if checked, this station is used as the main one')
) )
objects = StationQuerySet.as_manager()
# #
# Controllers # Controllers
# #
@ -233,12 +250,6 @@ class Station(Nameable):
self.__prepare_controls() self.__prepare_controls()
return self.__streamer return self.__streamer
def raw_on_air(self, **kwargs):
"""
Forward call to Log.objects.on_air for this station
"""
return Log.objects.station(self).on_air().filter(**kwargs)
def on_air(self, date = None, count = 0, no_cache = False): def on_air(self, date = None, count = 0, no_cache = False):
""" """
Return a queryset of what happened on air, based on logs and Return a queryset of what happened on air, based on logs and
@ -249,11 +260,10 @@ class Station(Nameable):
If date is not specified, count MUST be set to a non-zero value. If date is not specified, count MUST be set to a non-zero value.
It is different from Station.raw_on_air method since it filters It is different from Logs.on_air method since it filters
out elements that should have not been on air, such as a stream out elements that should have not been on air, such as a stream
that has been played when there was a live diffusion. that has been played when there was a live diffusion.
""" """
# FIXME: as an iterator?
# TODO argument to get sound instead of tracks # TODO argument to get sound instead of tracks
if not date and not count: if not date and not count:
raise ValueError('at least one argument must be set') raise ValueError('at least one argument must be set')
@ -267,7 +277,7 @@ class Station(Nameable):
now = tz.now() now = tz.now()
if date: if date:
logs = Log.objects.station(self).at(date) logs = Log.objects.at(date)
diffs = Diffusion.objects.station(self).at(date) \ diffs = Diffusion.objects.station(self).at(date) \
.filter(start__lte = now, type = Diffusion.Type.normal) \ .filter(start__lte = now, type = Diffusion.Type.normal) \
.order_by('-start') .order_by('-start')
@ -280,7 +290,7 @@ class Station(Nameable):
q = models.Q(diffusion__isnull = False) | \ q = models.Q(diffusion__isnull = False) | \
models.Q(track__isnull = False) models.Q(track__isnull = False)
logs = logs.filter(q, type = Log.Type.on_air).order_by('-date') logs = logs.station(self).on_air().filter(q).order_by('-date')
# filter out tracks played when there was a diffusion # filter out tracks played when there was a diffusion
n = 0 n = 0
@ -288,7 +298,9 @@ class Station(Nameable):
for diff in diffs: for diff in diffs:
if count and n >= count: if count and n >= count:
break break
q = q | models.Q(date__gte = diff.start, end__lte = diff.end) # FIXME: does not catch tracks started before diff end but
# that continued afterwards
q = q | models.Q(date__gte = diff.start, date__lte = diff.end)
n += 1 n += 1
logs = logs.exclude(q, diffusion__isnull = True) logs = logs.exclude(q, diffusion__isnull = True)
@ -317,6 +329,7 @@ class ProgramManager(models.Manager):
qs = self if qs is None else qs qs = self if qs is None else qs
return qs.filter(station = station, **kwargs) return qs.filter(station = station, **kwargs)
class Program(Nameable): class Program(Nameable):
""" """
A Program can either be a Streamed or a Scheduled program. A Program can either be a Streamed or a Scheduled program.
@ -461,7 +474,6 @@ class Stream(models.Model):
) )
# BIG FIXME: self.date is still used as datetime # BIG FIXME: self.date is still used as datetime
class Schedule(models.Model): class Schedule(models.Model):
""" """
@ -502,7 +514,7 @@ class Schedule(models.Model):
) )
timezone = models.CharField( timezone = models.CharField(
_('timezone'), _('timezone'),
default = pytz.UTC, default = tz.get_current_timezone,
choices = [(x, x) for x in pytz.all_timezones], choices = [(x, x) for x in pytz.all_timezones],
max_length = 100, max_length = 100,
help_text = _('timezone used for the date') help_text = _('timezone used for the date')
@ -831,10 +843,10 @@ class Diffusion(models.Model):
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
) )
initial = models.ForeignKey ( initial = models.ForeignKey (
'self', 'self', on_delete=models.SET_NULL,
verbose_name = _('initial diffusion'),
blank = True, null = True, blank = True, null = True,
on_delete=models.SET_NULL, related_name = 'reruns',
verbose_name = _('initial diffusion'),
help_text = _('the diffusion is a rerun of this one') help_text = _('the diffusion is a rerun of this one')
) )
# port = models.ForeignKey( # port = models.ForeignKey(
@ -885,7 +897,15 @@ class Diffusion(models.Model):
""" """
return tz.localtime(self.end, tz.get_current_timezone()) return tz.localtime(self.end, tz.get_current_timezone())
@property
def original(self):
""" Return the original diffusion (self or initial) """
return self.initial if self.initial else self
def is_live(self): def is_live(self):
"""
True if Diffusion is live (False if there are sounds files)
"""
return self.type == self.Type.normal and \ return self.type == self.Type.normal and \
not self.get_sounds(archive = True).count() not self.get_sounds(archive = True).count()
@ -948,9 +968,8 @@ class Diffusion(models.Model):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
if self.initial: if self.initial:
# force link to the first diffusion # enforce link to the original diffusion
if self.initial.initial: self.initial = self.initial.original
self.initial = self.initial.initial
self.program = self.initial.program self.program = self.initial.program
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -1271,22 +1290,20 @@ class LogQuerySet(models.QuerySet):
# models.Q(date__lte = end)) # models.Q(date__lte = end))
return self.filter(date__gte = start, date__lte = end) return self.filter(date__gte = start, date__lte = end)
def on_air(self, date = None): def on_air(self):
""" return self.filter(type = Log.Type.on_air)
Return a queryset of the played elements' log for the given
station and model. This queryset is ordered by date ascending
* station: return logs occuring on this station def start(self):
* date: only return logs that occured at this date return self.filter(type = Log.Type.start)
* kwargs: extra filter kwargs
"""
if date:
qs = self.at(date)
else:
qs = self
qs = qs.filter(type = Log.Type.on_air) def with_diff(self, with_it = True):
return qs.order_by('date') return self.filter(diffusion__isnull = not with_it)
def with_sound(self, with_it = True):
return self.filter(sound__isnull = not with_it)
def with_track(self, with_it = True):
return self.filter(track__isnull = not with_it)
@staticmethod @staticmethod
def _get_archive_path(station, date): def _get_archive_path(station, date):
@ -1452,12 +1469,6 @@ class Log(models.Model):
default=tz.now, default=tz.now,
db_index = True, db_index = True,
) )
# date of the next diffusion: used in order to ease on_air algo's
end = models.DateTimeField(
_('end'),
default=tz.now,
db_index = True,
)
comment = models.CharField( comment = models.CharField(
_('comment'), _('comment'),
max_length = 512, max_length = 512,
@ -1488,16 +1499,6 @@ class Log(models.Model):
objects = LogQuerySet.as_manager() objects = LogQuerySet.as_manager()
def estimate_end(self):
"""
Calculated end using self.related informations
"""
if self.diffusion:
return self.diffusion.end
if self.sound:
return self.date + utils.to_timedelta(self.sound.duration)
return self.date
@property @property
def related(self): def related(self):
return self.diffusion or self.sound or self.track return self.diffusion or self.sound or self.track
@ -1511,21 +1512,6 @@ class Log(models.Model):
""" """
return tz.localtime(self.date, tz.get_current_timezone()) return tz.localtime(self.date, tz.get_current_timezone())
def is_expired(self, date = None):
"""
Return True if the log is expired. Note that it only check
against the date, so it is still possible that the expiration
occured because of a Stop or other source.
For sound logs, also check against sound duration when
end == date (e.g after a crash)
"""
date = utils.date_or_default(date)
end = self.end
if end == self.date and self.sound:
end = self.date + to_timedelta(self.sound.duration)
return end < date
def print(self): def print(self):
r = [] r = []
if self.diffusion: if self.diffusion:
@ -1549,8 +1535,3 @@ class Log(models.Model):
self.local_date.strftime('%Y/%m/%d %H:%M%z'), self.local_date.strftime('%Y/%m/%d %H:%M%z'),
) )
def save(self, *args, **kwargs):
if not self.end:
self.end = self.estimate_end()
return super().save(*args, **kwargs)

View File

@ -1,57 +1,626 @@
/**
* Define rules for the default layouts, and some useful classes
*/
/** general **/
body { body {
background-color: #373737;
background-color: #F2F2F2; background-color: #F2F2F2;
font-family: 'Myriad Pro', Calibri, Helvetica, Arial, sans-serif; font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif;
font-size: 18px;
line-height: 1.5;
} }
main { h1, h2, h3, h4, h5 {
font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif;
margin: 0.4em 0em;
}
h1:first-letter, h2:first-letter, h3:first-letter, h4:first-letter {
text-transform: capitalize;
}
h1 { font-size: 1.4em; }
h2 { font-size: 1.2em; }
h3 { font-size: 0.9em; }
h4 { font-size: 0.8em; }
h1 > *, h2 > *, h3 > *, h4 > * { vertical-align: middle; }
a {
cursor: pointer;
text-decoration: none;
color: #616161;
}
a:hover { color: #007EDF; }
a:hover > .small_icon { box-shadow: 0em 0em 0.1em #007EDF; }
ul { margin: 0em; }
/**** position & box ****/
.float_right { float: right; }
.float_left { float: left; }
.flex_row {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
flex-direction: row;
}
.flex_column {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
}
.flex_row > .flex_item,
.flex_column > .flex_item {
-webkit-flex: auto;
flex: auto;
}
.small {
font-size: 0.8em;
}
/**** indicators & info ****/
time, .tags {
font-size: 0.9em;
color: #616161;
}
.info {
font-size: 0.9em;
padding: 0.1em;
color: #007EDF;
}
.error { color: red; }
.warning { color: orange; }
.success { color: green; }
.icon {
max-width: 2em;
max-height: 2em;
vertical-align: middle;
}
.small_icon {
max-height: 1.5em;
vertical-align: middle;
}
/** main layout **/
body > * {
max-width: 92em;
margin: 0em auto;
padding: 0em;
}
.menu {
padding: 0.4em;
}
.menu:empty {
display: none;
}
.menu.row section {
display: inline-block;
}
.menu.col > section {
margin-bottom: 1em;
}
/**** top + header layout ****/
body > .top {
position: fixed;
z-index: 10000000;
top: 0;
left: 0;
width: 100%;
max-width: 100%;
margin: 0em auto;
background-color: white;
border-bottom: 0.1em #dfdfdf solid;
box-shadow: 0em 0.1em 0.1em rgba(255,255,255,0.7);
box-shadow: 0em 0.1em 0.5em rgba(0,0,0,0.1);
transition: opacity 1.5s;
}
body > .top > .menu {
max-width: 92em;
height: 2.5em;
margin: 0em auto;
}
body[scrollY] > .top {
opacity: 0.1;
transition: opacity 1.5s 1s;
}
body > .top:hover {
opacity: 1.0;
transition: opacity 1.5s;
}
body > .header {
overflow: hidden;
margin-top: 3.3em;
margin-bottom: 1em;
}
/** FIXME: remove this once image slides impled **/
body > .header > div {
width: 15000%;
}
body > .header > div > section {
margin: 0;
margin-right: -0.4em;
}
/**** page layout ****/
.page {
display: flex;
}
.page > main {
flex: auto;
overflow: hidden;
margin: 0em 0em;
border-radius: 0.4em;
border: 0.1em #dfdfdf solid;
background-color: rgba(255,255,255,0.9);
box-shadow: inset 0.1em 0.1em 0.2em rgba(255, 255, 255, 0.8);
}
.page > nav {
flex: 1;
width: 50em;
overflow: hidden;
max-width: 16em;
}
.page > .menu.col:first-child { margin-right: 2em; }
.page > main + .menu.col { margin-left: 2em; }
/**** page main ****/
main:not(.detail) h1 {
margin: 0em 0em 0.4em 0em;
}
main .post_content {
display: block;
}
main .post_content section {
display: inline-block;
width: calc(50% - 1em);
vertical-align: top;
}
main.detail {
padding: 0em;
margin: 0em;
}
main > .content {
padding: 1em; padding: 1em;
} }
main > header {
margin: 0em;
padding: 1em;
position: relative;
}
input { main > header .foreground {
padding: 0.4em; position: absolute;
left: 0em;
top: 0em;
width: calc(100% - 2em);
padding: 1em;
}
main > header h1 {
width: calc(100% - 2em);
margin: 0em;
margin-bottom: 0.8em;
}
main header .headline {
display: inline-block;
width: calc(60% - 0.8em);
min-height: 1.2em;
font-size: 1.2em;
font-weight: bold;
}
main > header .background {
margin: -1em;
height: 17em;
overflow: hidden;
position: relative;
}
main > header .background img {
position: absolute;
/*! top: -40%; */
/*! left: -40%; */
width: 100%;
min-height: 100%;
filter: blur(20px);
opacity: 0.3;
}
main > header .cover {
right: 0em;
top: 1em;
width: auto;
max-height: calc(100% - 2em);
max-width: calc(40% - 2em);
margin: 1em;
position: absolute;
box-shadow: 0em 0em 4em rgba(0, 0, 0, 0.4);
} }
table {
background-color: #f2f2f2; /** sections **/
border: 1px black solid; body section ul {
width: 80%; padding: 0em;
padding-left: 1em;
}
/**** link list ****/
.menu.row .section_link_list > a {
display: inline-block;
margin: 0.2em 1em;
}
.menu.col .section_link_list > a {
display: block;
}
/** content: menus **/
/** content: list & items **/
.list {
width: 100%;
}
ul.list, .list > ul {
padding: 0.4em;
}
.list_item {
margin: 0.4em 0;
}
.list_item > *:not(:last-child) {
margin-right: 0.4em;
}
.list_item img.cover.big {
display: block;
max-width: 100%;
min-height: 15em;
margin: auto; margin: auto;
} }
td { .list_item img.cover.small {
margin: 0; margin-right: 0.4em;
padding: 0 0.4em; border-radius: 0.4em;
float: left;
min-height: 64px;
} }
th { .list_item > * {
text-align: left; margin: 0em 0.2em;
font-weight: normal; vertical-align: middle;
margin: 0;
padding: 0.4em;
} }
tr:not(.header):hover {
background-color: rgba(0, 0, 0, 0.1);
}
tr.header { .list nav {
background-color: #212121; text-align: center;
color: #eee; font-size: 0.9em;
} }
tr.bottom > td {
vertical-align: top; /** content: list items in full page **/
padding: 0.4em; .content > .list:not(.date_list) .list_item {
} min-width: 20em;
display: inline-block;
tr.subdata { min-height: 2.5em;
font-style: italic; margin: 0.4em;
}
/** content: date list **/
.date_list nav {
text-align:center;
}
.date_list nav a {
display: inline-block;
width: 2em;
}
.date_list nav a.date {
width: 4em;
}
.date_list nav a[selected] {
color: #007EDF;
border-bottom: 0.2em #007EDF dotted;
}
.date_list ul:not([selected]) {
display: none;
}
.date_list ul:target {
display: block;
}
.date_list h2 {
display: none;
}
.date_list_item .cover.small {
width: 64px;
margin: 0.4em;
}
.date_list_item h3 {
margin-top: 0em;
}
.date_list_item time {
color: #007EDF;
}
.date_list_item.now {
padding: 0.4em;
}
.date_list_item img.now {
width: 1.3em;
vertical-align: bottom;
}
/** content: date list in full page **/
.content > .date_list .date_list_item time {
color: #007EDF;
font-size: 1.1em;
display: block;
}
.content > .date_list .date_list_item:nth-child(2n+1),
.date_list_item.now {
box-shadow: inset 0em 0em 3em rgba(0, 124, 226, 0.1);
background-color: rgba(0, 124, 226, 0.05);
}
.content > .date_list {
padding: 0 10%;
margin: auto;
width: 80%;
}
/** content: comments **/
.comments form input:not([type=checkbox]),
.comments form textarea {
display: inline-block;
width: 100%;
max-height: 6em;
margin: 0.2em 0em;
padding: 0.2em;
}
.comments form input[type=checkbox],
.comments form button[type=submit] {
vertical-align:bottom;
margin: 0.2em 0em;
text-align: center;
}
.comments form button[type=submit] {
float: right;
}
.comments form #show_more:not(:checked) ~ .extra {
display: none;
}
.comments label[for="show_more"] {
font-size: 0.8em;
}
.comments ul {
margin-top: 2.5em;
}
.comment {
list-style: none;
border: 1px #818181 dotted;
margin: 0.4em 0em;
}
.comment .metadata {
font-size: 0.9em;
}
.comment time {
float: right;
}
/** component: sound **/
.component.sound {
display: flex;
flex-direction: row;
margin: 0.2em;
width: 100%;
}
.component.sound[state="play"] button {
animation-name: sound-blink;
animation-duration: 4s;
animation-iteration-count: infinite;
animation-direction: alternate;
}
@keyframes sound-blink {
from { background-color: rgba(255, 255, 255, 0); }
to { background-color: rgba(255, 255, 255, 0.6); }
}
.component.sound .button {
width: 4em;
height: 4em;
cursor: pointer;
position: relative;
margin-right: 0.4em;
}
.component.sound .button > img {
width: 100%;
height: 100%;
}
.component.sound button {
transition: background-color 0.5s;
background-color: rgba(255,255,255,0.1);
position: absolute;
cursor: pointer;
left: 0;
top: 0;
width: 100%;
height: 100%;
border: 0;
}
.component.sound button:hover {
background-color: rgba(255,255,255,0.5);
}
.component.sound button > img {
background-color: rgba(255,255,255,0.9);
border-radius: 50%;
}
.component.sound .content {
position: relative;
}
.component.sound .info {
text-align: right;
}
.component.sound progress {
width: 100%;
position: absolute;
bottom: 0;
height: 0.4em;
}
.component.sound progress:hover {
height: 1em;
}
/** component: playlist **/
.component.playlist footer {
text-align: right;
display: block;
}
.component.playlist .read_all {
display: none;
}
.component.playlist .read_all + label {
display: inline-block;
padding: 0.1em;
margin-left: 0.2em;
cursor: pointer;
font-size: 1em;
box-shadow: inset 0em 0em 0.1em #818181;
}
.component.playlist .read_all:not(:checked) + label {
border-left: 0.1em #818181 solid;
margin-right: 0em;
}
.component.playlist .read_all:checked + label {
border-right: 0.1em #007EDF solid;
box-shadow: inset 0em 0em 0.1em #007EDF;
margin-right: 0em;
}
/** content: page **/
main .body ~ section:not(.comments) {
width: calc(50% - 1em);
vertical-align: top;
display: inline-block;
}
.meta .author .headline {
display: none;
}
.meta .link_list > a {
font-size: 0.9em;
margin: 0em 0.1em;
padding: 0.2em;
line-height: 1.4em;
}
.meta .link_list > a:hover {
border-radius: 0.2em;
background-color: rgba(0, 126, 223, 0.1);
}
/** content: others **/
.list_item.track .title {
display: inline;
font-style: italic;
font-weight: normal;
font-size: 0.9em; font-size: 0.9em;
} }

View File

@ -125,7 +125,7 @@ Monitor.update(50000);
<tr> <tr>
<th class="name" colspan=2>{{ station.name }}</th> <th class="name" colspan=2>{{ station.name }}</th>
<td> <td>
{% with station.streamer.current_source.name as current_source %} {% with station.streamer.source.name as current_source %}
{% blocktrans %} {% blocktrans %}
Current source: {{ current_source }} Current source: {{ current_source }}
{% endblocktrans %} {% endblocktrans %}
@ -154,7 +154,7 @@ Monitor.update(50000);
{% endif %} {% endif %}
</td> </td>
<td class="source_info"> <td class="source_info">
{% if source.name == station.streamer.current_source.name %} {% if source.name == station.streamer.source.name %}
<img src="{% static "aircox/images/play.png" %}" alt="{% trans "current" %}"> <img src="{% static "aircox/images/play.png" %}" alt="{% trans "current" %}">
{% endif %} {% endif %}
{% if source.is_dealer %} {% if source.is_dealer %}
@ -167,7 +167,7 @@ Monitor.update(50000);
{% if source.is_dealer %} {% if source.is_dealer %}
{{ source.playlist|join:"<br>" }} {{ source.playlist|join:"<br>" }}
{% else %} {% else %}
{{ source.current_sound }} {{ source.sound }}
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">

View File

@ -127,7 +127,7 @@ class Monitor(View,TemplateResponseMixin,LoginRequiredMixin):
return Http404 return Http404
station.streamer.fetch() station.streamer.fetch()
source = source or station.streamer.current_source source = source or station.streamer.source
if action == 'skip': if action == 'skip':
self.actionSkip(request, station, source) self.actionSkip(request, station, source)
if action == 'restart': if action == 'restart':
@ -202,9 +202,8 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
stats = self.Stats(station = station, date = date, stats = self.Stats(station = station, date = date,
items = [], tags = {}) items = [], tags = {})
qs = station.raw_on_air(date = date) \ qs = Log.objects.station(station).on_air() \
.prefetch_related('diffusion', 'sound', 'track', .prefetch_related('diffusion', 'sound', 'track', 'track__tags')
'track__tags')
if not qs.exists(): if not qs.exists():
qs = models.Log.objects.load_archive(station, date) qs = models.Log.objects.load_archive(station, date)
@ -219,7 +218,7 @@ class StatisticsView(View,TemplateResponseMixin,LoginRequiredMixin):
name = rel.program.name, name = rel.program.name,
type = _('Diffusion'), type = _('Diffusion'),
col = 0, col = 0,
tracks = models.Track.objects.get_for(object = rel) tracks = models.Track.objects.related(object = rel)
.prefetch_related('tags'), .prefetch_related('tags'),
) )
sound_log = None sound_log = None

View File

@ -19,13 +19,13 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% if diffusion.diffusion_set.count %} {% if diffusion.reruns.count %}
<section class="dates"> <section class="dates">
<h2>{% trans "Dates of diffusion" %}</h2> <h2>{% trans "Dates of diffusion" %}</h2>
<ul> <ul>
{% with diffusion=page.diffusion %} {% with diffusion=page.diffusion %}
<li>{{ diffusion.date|date:"l d F Y, H:i" }}</li> <li>{{ diffusion.date|date:"l d F Y, H:i" }}</li>
{% for diffusion in diffusion.diffusion_set.all %} {% for diffusion in diffusion.reruns.all %}
<li>{{ diffusion.date|date:"l d F Y, H:i" }}</li> <li>{{ diffusion.date|date:"l d F Y, H:i" }}</li>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}