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
fields = ('artist', 'title', 'info', 'position', 'in_seconds', 'tags')
list_display = ['artist','title','tags','related']
list_filter = ['artist','title','tags']
@admin.register(Sound)
class SoundAdmin(NameableAdmin):

View File

@ -1,17 +1,17 @@
import os
import signal
import re
import subprocess
import atexit
import logging
import atexit, logging, os, re, signal, subprocess
import tzlocal
from django.template.loader import render_to_string
from django.utils import timezone as tz
import aircox.models as models
import aircox.settings as settings
from aircox.connector import Connector
local_tz = tzlocal.get_localzone()
logger = logging.getLogger('aircox.tools')
@ -32,19 +32,14 @@ class Streamer:
"""
Path of the configuration file.
"""
current_sound = ''
source = None
"""
Current sound being played (retrieved by fetch)
"""
current_source = None
"""
Current source object that is responsible of self.current_sound
Current source object that is responsible of self.sound
"""
process = None
"""
Application's process if ran from Streamer
"""
socket_path = ''
"""
Path to the connector's socket
@ -95,13 +90,11 @@ class Streamer:
if not data:
return
self.current_sound = data.get('initial_uri')
self.current_source = next(
self.source = next(
iter(source for source in self.station.sources
if source.rid == rid),
self.current_source
self.source
)
self.current_source.metadata = data
def push(self, config = True):
"""
@ -202,43 +195,25 @@ class Source:
Controller of a Source. Value are usually updated directly on the
external side.
"""
program = 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
"""
station = None
connector = None
"""
Connector to Liquidsoap server
"""
metadata = None
"""
Dict of file's metadata given by Liquidsoap. Set by Stream when
fetch()ing
"""
""" Connector to Liquidsoap server """
program = None
""" Related program """
name = ''
""" Name of the source """
path = ''
""" 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
def id(self):
@ -267,12 +242,6 @@ class Source:
if not self.__playlist:
self.from_db()
def is_stream(self):
return self.program and not self.program.show
def is_dealer(self):
return not self.program
@property
def playlist(self):
"""
@ -325,11 +294,19 @@ class Source:
if self.__playlist else []
#
# RPC
# RPC & States
#
def _send(self, *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
def active(self):
return self._send('var.get ', self.id, '_active') == 'true'
@ -348,9 +325,15 @@ class Source:
return
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):
"""
@ -383,8 +366,9 @@ class Source:
def stream(self):
"""
Return a dict with stream info for a Stream program, or None if there
is not. Used in the template.
Return dict of info for the current Stream program running on
the source. If not, return None.
[ used in the templates ]
"""
# TODO: multiple streams
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.functional import cached_property
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
import pytz
@ -61,17 +62,20 @@ class Monitor:
"""
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) \
.filter(*args, **kwargs) \
.select_related('diffusion', 'sound') \
.order_by('pk').last()
.order_by('pk')
@property
def last_log(self):
"""
Last log of monitored station
"""
return self.get_last_log()
return self.log_qs.last()
@property
def last_sound(self):
@ -104,7 +108,15 @@ class Monitor:
if not self.streamer.ready():
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.handle()
@ -116,92 +128,57 @@ class Monitor:
**kwargs)
log.save()
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
def trace(self):
def trace_sound(self, source):
"""
Check the current_sound of the station and update logs if
needed.
Return log for current on_air (create and save it if required).
"""
self.streamer.fetch()
current_sound = self.streamer.current_sound
current_source = self.streamer.current_source
if not current_sound or not current_source:
print('no source / no sound', current_sound, current_source)
return
sound_path = source.sound
air_time = source.air_time
log = self.get_last_log(
models.Q(sound__isnull = False) |
models.Q(diffusion__isnull = False),
type = Log.Type.on_air
# 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.log_qs.on_air().filter(
source = source.id, sound__path = sound_path,
date__range = air_times,
).last()
if log:
return log
# get sound
sound = Sound.objects.filter(path = sound_path) \
.select_related('diffusion').first()
diff = None
if sound and sound.diffusion:
diff = sound.diffusion.original
# check for reruns
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
return self.log(
type = Log.Type.on_air,
source = source.id,
date = source.on_air,
sound = sound,
diffusion = diff,
# if sound is removed, we keep sound path info
comment = sound_path,
)
on_air = None
if log:
# we always check difference in sound info
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.
# in some cases, there can be a gap between liquidsoap on_air and
# log's date; to avoid duplicate we allow a difference of 5 seconds
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
if last_diff and not last_diff.is_expired():
archives = last_diff.diffusion.sounds(archive = True)
if archives.filter(pk = sound.pk).exists():
diff = last_diff.diffusion
# log sound on air
log = self.log(
type = Log.Type.on_air,
source = current_source.id,
date = on_air or tz.now(),
sound = sound,
diffusion = diff,
# if sound is removed, we keep sound path info
comment = current_sound,
)
# trace tracks
self.trace_sound_tracks(log)
def trace_sound_tracks(self, log):
def trace_tracks(self, log):
"""
Log tracks for the given sound log (for streamed programs only).
Called by self.trace
"""
if log.diffusion:
return
tracks = Track.objects.get_for(object = log.sound) \
tracks = Track.objects.related(object = log.sound) \
.filter(in_seconds = True)
if not tracks.exists():
return
@ -249,7 +226,7 @@ class Monitor:
type = Diffusion.Type.normal,
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)
for diff in qs:
@ -274,17 +251,17 @@ class Monitor:
station = self.station
now = tz.now()
log = station.raw_on_air(diffusion__isnull = False) \
.select_related('diffusion') \
.order_by('date').last()
log = Log.objects.station(station).on_air().with_diff() \
.select_related('diffusion') \
.order_by('date').last()
if not log or not log.diffusion.is_date_in_range(now):
# not running anymore
return None, []
# last sound source change: end of file reached or forced to stop
sounds = station.raw_on_air(sound__isnull = False) \
.filter(date__gte = log.date) \
.order_by('date')
sounds = Log.objects.station(station).on_air().with_sound() \
.filter(date__gte = log.date) \
.order_by('date')
if sounds.count() and sounds.last().source != log.source:
return None, []
@ -294,7 +271,7 @@ class Monitor:
.filter(source = log.source, pk__gt = log.pk) \
.exclude(sound__type = Sound.Type.removed)
remaining = log.diffusion.sounds(archive = True) \
remaining = log.diffusion.get_sounds(archive = True) \
.exclude(pk__in = sounds) \
.values_list('path', flat = True)
return log.diffusion, list(remaining)

View File

@ -28,17 +28,15 @@ class AircoxMiddleware(object):
This middleware must be set after the middleware
'django.contrib.auth.middleware.AuthenticationMiddleware',
"""
default_qs = models.Station.objects.filter(default = True)
def __init__(self, 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')
pk = None
try:
if station:
if station is not None:
pk = request.GET['aircox.station']
if station:
pk = int(pk)
@ -47,28 +45,23 @@ class AircoxMiddleware(object):
except:
pass
# select current station
station = None
pk = None
def init_station(self, request, aircox):
self.update_station(request)
try:
pk = request.session.get('aircox.station')
if pk:
pk = int(pk)
station = models.Station.objects.filter(pk = pk).first()
pk = int(pk) if pk else None
except:
pass
if not station:
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)
def init_timezone(self, request, aircox):
# note: later we can use http://freegeoip.net/ on user side if
# required
# TODO: add to request's session
timezone = None
try:
timezone = request.session.get('aircox.timezone')
@ -81,6 +74,7 @@ class AircoxMiddleware(object):
timezone = tz.get_current_timezone()
tz.activate(timezone)
def __call__(self, request):
tz.activate(pytz.timezone('Europe/Brussels'))
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.db import models
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.html import strip_tags
from django.utils.functional import cached_property
@ -29,8 +29,8 @@ logger = logging.getLogger('aircox.core')
#
# Abstracts
#
class RelatedManager(models.Manager):
def get_for(self, object = None, model = None, qs = None):
class RelatedQuerySet(models.QuerySet):
def related(self, object = None, model = None):
"""
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:
model = type(object)
qs = self if qs is None else qs
qs = self
if hasattr(model, '__iter__'):
model = [ ContentType.objects.get_for_model(m).id
for m in model ]
qs = qs.filter(related_type__pk__in = model)
self = self.filter(related_type__pk__in = model)
else:
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:
qs = qs.filter(related_id = object.pk)
return qs
self = self.filter(related_id = object.pk)
return self
class Related(models.Model):
"""
@ -72,7 +72,7 @@ class Related(models.Model):
class Meta:
abstract = True
objects = RelatedManager()
objects = RelatedQuerySet.as_manager()
@classmethod
def ReverseField(cl):
@ -154,6 +154,21 @@ class Track(Related):
#
# 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):
"""
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')
)
objects = StationQuerySet.as_manager()
#
# Controllers
#
@ -233,12 +250,6 @@ class Station(Nameable):
self.__prepare_controls()
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):
"""
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.
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
that has been played when there was a live diffusion.
"""
# FIXME: as an iterator?
# TODO argument to get sound instead of tracks
if not date and not count:
raise ValueError('at least one argument must be set')
@ -267,7 +277,7 @@ class Station(Nameable):
now = tz.now()
if date:
logs = Log.objects.station(self).at(date)
logs = Log.objects.at(date)
diffs = Diffusion.objects.station(self).at(date) \
.filter(start__lte = now, type = Diffusion.Type.normal) \
.order_by('-start')
@ -280,7 +290,7 @@ class Station(Nameable):
q = models.Q(diffusion__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
n = 0
@ -288,7 +298,9 @@ class Station(Nameable):
for diff in diffs:
if count and n >= count:
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
logs = logs.exclude(q, diffusion__isnull = True)
@ -317,6 +329,7 @@ class ProgramManager(models.Manager):
qs = self if qs is None else qs
return qs.filter(station = station, **kwargs)
class Program(Nameable):
"""
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
class Schedule(models.Model):
"""
@ -502,7 +514,7 @@ class Schedule(models.Model):
)
timezone = models.CharField(
_('timezone'),
default = pytz.UTC,
default = tz.get_current_timezone,
choices = [(x, x) for x in pytz.all_timezones],
max_length = 100,
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() ],
)
initial = models.ForeignKey (
'self',
verbose_name = _('initial diffusion'),
'self', on_delete=models.SET_NULL,
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')
)
# port = models.ForeignKey(
@ -885,7 +897,15 @@ class Diffusion(models.Model):
"""
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):
"""
True if Diffusion is live (False if there are sounds files)
"""
return self.type == self.Type.normal and \
not self.get_sounds(archive = True).count()
@ -948,9 +968,8 @@ class Diffusion(models.Model):
return super().save(*args, **kwargs)
if self.initial:
# force link to the first diffusion
if self.initial.initial:
self.initial = self.initial.initial
# enforce link to the original diffusion
self.initial = self.initial.original
self.program = self.initial.program
super().save(*args, **kwargs)
@ -1271,22 +1290,20 @@ class LogQuerySet(models.QuerySet):
# models.Q(date__lte = end))
return self.filter(date__gte = start, date__lte = end)
def on_air(self, date = None):
"""
Return a queryset of the played elements' log for the given
station and model. This queryset is ordered by date ascending
def on_air(self):
return self.filter(type = Log.Type.on_air)
* station: return logs occuring on this station
* date: only return logs that occured at this date
* kwargs: extra filter kwargs
"""
if date:
qs = self.at(date)
else:
qs = self
def start(self):
return self.filter(type = Log.Type.start)
qs = qs.filter(type = Log.Type.on_air)
return qs.order_by('date')
def with_diff(self, with_it = True):
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
def _get_archive_path(station, date):
@ -1452,12 +1469,6 @@ class Log(models.Model):
default=tz.now,
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'),
max_length = 512,
@ -1488,16 +1499,6 @@ class Log(models.Model):
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
def related(self):
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())
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):
r = []
if self.diffusion:
@ -1549,8 +1535,3 @@ class Log(models.Model):
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 {
background-color: #373737;
background-color: #F2F2F2;
font-family: 'Myriad Pro', Calibri, Helvetica, Arial, sans-serif;
font-size: 18px;
line-height: 1.5;
font-family: "Myriad Pro",Calibri,Helvetica,Arial,sans-serif;
}
main {
padding: 1em;
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;
}
input {
.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;
}
table {
background-color: #f2f2f2;
border: 1px black solid;
width: 80%;
.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;
}
main > header {
margin: 0em;
padding: 1em;
position: relative;
}
main > header .foreground {
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);
}
/** sections **/
body section ul {
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;
}
td {
margin: 0;
padding: 0 0.4em;
.list_item img.cover.small {
margin-right: 0.4em;
border-radius: 0.4em;
float: left;
min-height: 64px;
}
th {
text-align: left;
font-weight: normal;
margin: 0;
padding: 0.4em;
.list_item > * {
margin: 0em 0.2em;
vertical-align: middle;
}
tr:not(.header):hover {
background-color: rgba(0, 0, 0, 0.1);
}
tr.header {
background-color: #212121;
color: #eee;
}
tr.bottom > td {
vertical-align: top;
padding: 0.4em;
}
tr.subdata {
font-style: italic;
.list nav {
text-align: center;
font-size: 0.9em;
}
/** content: list items in full page **/
.content > .list:not(.date_list) .list_item {
min-width: 20em;
display: inline-block;
min-height: 2.5em;
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;
}

View File

@ -125,7 +125,7 @@ Monitor.update(50000);
<tr>
<th class="name" colspan=2>{{ station.name }}</th>
<td>
{% with station.streamer.current_source.name as current_source %}
{% with station.streamer.source.name as current_source %}
{% blocktrans %}
Current source: {{ current_source }}
{% endblocktrans %}
@ -154,7 +154,7 @@ Monitor.update(50000);
{% endif %}
</td>
<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" %}">
{% endif %}
{% if source.is_dealer %}
@ -167,7 +167,7 @@ Monitor.update(50000);
{% if source.is_dealer %}
{{ source.playlist|join:"<br>" }}
{% else %}
{{ source.current_sound }}
{{ source.sound }}
{% endif %}
</td>
<td class="actions">

View File

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

View File

@ -19,13 +19,13 @@
{% endif %}
{% endwith %}
{% if diffusion.diffusion_set.count %}
{% if diffusion.reruns.count %}
<section class="dates">
<h2>{% trans "Dates of diffusion" %}</h2>
<ul>
{% with diffusion=page.diffusion %}
<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>
{% endfor %}
{% endwith %}