merge aircox and aircox_instance

This commit is contained in:
bkfox
2016-10-10 15:04:15 +02:00
parent 72fd7bd490
commit 191d337c3f
100 changed files with 4686 additions and 360 deletions

View File

View File

@ -0,0 +1,184 @@
"""
Manage diffusions using schedules, to update, clean up or check diffusions.
A generated diffusion can be unconfirmed, that means that the user must confirm
it by changing its type to "normal". The behaviour is controlled using
--approval.
Different actions are available:
- "update" is the process that is used to generated them using programs
schedules for the (given) month.
- "clean" will remove all diffusions that are still unconfirmed and have been
planified before the (given) month.
- "check" will remove all diffusions that are unconfirmed and have been planified
from the (given) month and later.
"""
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from aircox.models import *
logger = logging.getLogger('aircox.tools')
class Actions:
@staticmethod
def __check_conflicts (item, saved_items):
"""
Check for conflicts, and update conflictual
items if they have been generated during this
update.
It set an attribute 'do_not_save' if the item should not
be saved. FIXME: find proper way
Return the number of conflicts
"""
conflicts = list(item.get_conflicts())
for i, conflict in enumerate(conflicts):
if conflict.program == item.program:
item.do_not_save = True
del conflicts[i]
continue
if conflict.pk in saved_items and \
conflict.type != Diffusion.Type.unconfirmed:
conflict.type = Diffusion.Type.unconfirmed
conflict.save()
if not conflicts:
item.type = Diffusion.Type.normal
return 0
item.type = Diffusion.Type.unconfirmed
return len(conflicts)
@classmethod
def update (cl, date, mode):
manual = (mode == 'manual')
if not manual:
saved_items = set()
count = [0, 0]
for schedule in Schedule.objects.filter(program__active = True) \
.order_by('initial'):
# in order to allow rerun links between diffusions, we save items
# by schedule;
items = schedule.diffusions_of_month(date, exclude_saved = True)
count[0] += len(items)
if manual:
Diffusion.objects.bulk_create(items)
else:
for item in items:
count[1] += cl.__check_conflicts(item, saved_items)
if hasattr(item, 'do_not_save'):
count[0] -= 1
continue
item.save()
saved_items.add(item)
logger.info('[update] schedule %s: %d new diffusions',
str(schedule), len(items),
)
logger.info('[update] %d diffusions have been created, %s', count[0],
'do not forget manual approval' if manual else
'{} conflicts found'.format(count[1]))
@staticmethod
def clean (date):
qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed,
start__lt = date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete()
@staticmethod
def check (date):
qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed,
start__gt = date)
items = []
for diffusion in qs:
schedules = Schedule.objects.filter(program = diffusion.program)
for schedule in schedules:
if schedule.match(diffusion.start):
break
else:
items.append(diffusion.id)
logger.info('[check] %d diffusions will be removed', len(items))
if len(items):
Diffusion.objects.filter(id__in = items).delete()
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
now = tz.datetime.today()
group = parser.add_argument_group('action')
group.add_argument(
'--update', action='store_true',
help='generate (unconfirmed) diffusions for the given month. '
'These diffusions must be confirmed manually by changing '
'their type to "normal"')
group.add_argument(
'--clean', action='store_true',
help='remove unconfirmed diffusions older than the given month')
group.add_argument(
'--check', action='store_true',
help='check future unconfirmed diffusions from the given date '
'agains\'t schedules and remove it if that do not match any '
'schedule')
group = parser.add_argument_group('date')
group.add_argument(
'--year', type=int, default=now.year,
help='used by update, default is today\'s year')
group.add_argument(
'--month', type=int, default=now.month,
help='used by update, default is today\'s month')
group.add_argument(
'--next-month', action='store_true',
help='set the date to the next month of given date'
' (if next month from today'
)
group = parser.add_argument_group('options')
group.add_argument(
'--mode', type=str, choices=['manual', 'auto'],
default='auto',
help='manual means that all generated diffusions are unconfirmed, '
'thus must be approved manually; auto confirmes all '
'diffusions except those that conflicts with others'
)
def handle (self, *args, **options):
date = tz.datetime(year = options.get('year'),
month = options.get('month'),
day = 1)
date = tz.make_aware(date)
if options.get('next_month'):
month = options.get('month')
date += tz.timedelta(days = 28)
if date.month == month:
date += tz.timedelta(days = 28)
date = date.replace(day = 1)
if options.get('update'):
Actions.update(date, mode = options.get('mode'))
if options.get('clean'):
Actions.clean(date)
if options.get('check'):
Actions.check(date)

View File

@ -0,0 +1,142 @@
"""
Import one or more playlist for the given sound. Attach it to the sound
or to the related Diffusion if wanted.
Playlists are in CSV format, where columns are separated with a
'{settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
{settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE}.
The order of the elements is: {settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS}
If 'minutes' or 'seconds' are given, position will be expressed as timed
position, instead of position in playlist.
"""
import os
import csv
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from aircox.models import *
import aircox.settings as settings
__doc__ = __doc__.format(settings = settings)
logger = logging.getLogger('aircox.tools')
class Importer:
data = None
tracks = None
def __init__(self, related = None, path = None, save = False):
if path:
self.read(path)
if related:
self.make_playlist(related, save)
def reset(self):
self.data = None
self.tracks = None
def read(self, path):
if not os.path.exists(path):
return True
with open(path, 'r') as file:
self.data = list(csv.reader(
file,
delimiter = settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar = settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE,
))
def __get(self, line, field, default = None):
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
if field not in maps:
return default
index = maps.index(field)
return line[index] if index < len(line) else default
def make_playlist(self, related, save = False):
"""
Make a playlist from the read data, and return it. If save is
true, save it into the database
"""
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
tracks = []
in_seconds = ('minutes' or 'seconds') in maps
for index, line in enumerate(self.data):
position = \
int(self.__get(line, 'minutes', 0)) * 60 + \
int(self.__get(line, 'seconds', 0)) \
if in_seconds else index
track, created = Track.objects.get_or_create(
related_type = ContentType.objects.get_for_model(related),
related_id = related.pk,
title = self.__get(line, 'title'),
artist = self.__get(line, 'artist'),
position = position,
)
track.in_seconds = in_seconds
track.info = self.__get(line, 'info')
tags = self.__get(line, 'tags')
if tags:
track.tags.add(*tags.split(','))
if save:
track.save()
tracks.append(track)
self.tracks = tracks
return tracks
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
now = tz.datetime.today()
parser.add_argument(
'path', metavar='PATH', type=str,
help='path of the input playlist to read'
)
parser.add_argument(
'--sound', '-s', type=str,
help='generate a playlist for the sound of the given path. '
'If not given, try to match a sound with the same path.'
)
parser.add_argument(
'--diffusion', '-d', action='store_true',
help='try to get the diffusion relative to the sound if it exists'
)
def handle (self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get('sound'):
related = Sound.objects.filter(
path__icontains = options.get('sound')
).first()
else:
path, ext = os.path.splitext(options.get('path'))
related = Sound.objects.filter(path__icontains = path).first()
if not related:
logger.error('no sound found in the database for the path ' \
'{path}'.format(path=path))
return -1
if options.get('diffusion') and related.diffusion:
related = related.diffusion
importer = Importer(related = related, path = path, save = True)
for track in importer.tracks:
logger.info('imported track at {pos}: {title}, by '
'{artist}'.format(
pos = track.position,
title = track.title, artist = track.artist
)
)

View File

@ -0,0 +1,383 @@
"""
Monitor sound files; For each program, check for:
- new files;
- deleted files;
- differences between files and sound;
- quality of the files;
It tries to parse the file name to get the date of the diffusion of an
episode and associate the file with it; We use the following format:
yyyymmdd[_n][_][name]
Where:
'yyyy' the year of the episode's diffusion;
'mm' the month of the episode's diffusion;
'dd' the day of the episode's diffusion;
'n' the number of the episode (if multiple episodes);
'name' the title of the sound;
To check quality of files, call the command sound_quality_check using the
parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires
Sox (and soxi).
"""
import os
import time
import re
import logging
import subprocess
from argparse import RawTextHelpFormatter
import atexit
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
from django.core.management.base import BaseCommand, CommandError
from aircox.models import *
import aircox.settings as settings
import aircox.utils as utils
logger = logging.getLogger('aircox.tools')
class SoundInfo:
name = ''
sound = None
year = None
month = None
day = None
n = None
duration = None
@property
def path(self):
return self._path
@path.setter
def path(self, value):
"""
Parse file name to get info on the assumption it has the correct
format (given in Command.help)
"""
file_name = os.path.basename(value)
file_name = os.path.splitext(file_name)[0]
r = re.search('^(?P<year>[0-9]{4})'
'(?P<month>[0-9]{2})'
'(?P<day>[0-9]{2})'
'(_(?P<n>[0-9]+))?'
'_?(?P<name>.*)$',
file_name)
if not (r and r.groupdict()):
r = { 'name': file_name }
logger.info('file name can not be parsed -> %s', value)
else:
r = r.groupdict()
self._path = value
self.name = r['name'].replace('_', ' ').capitalize()
self.year = int(r.get('year')) if 'year' in r else None
self.month = int(r.get('month')) if 'month' in r else None
self.day = int(r.get('day')) if 'day' in r else None
self.n = r.get('n')
return r
def __init__(self, path = ''):
self.path = path
def get_duration(self):
p = subprocess.Popen(['soxi', '-D', self.path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
out, err = p.communicate()
if not err:
duration = utils.seconds_to_time(int(float(out)))
self.duration = duration
return duration
def get_sound(self, kwargs = None, save = True):
"""
Get or create a sound using self info.
If the sound is created/modified, get its duration and update it
(if save is True, sync to DB), and check for a playlist file.
"""
sound, created = Sound.objects.get_or_create(
path = self.path,
defaults = kwargs
)
if created or sound.check_on_file():
logger.info('sound is new or have been modified -> %s', self.path)
sound.duration = self.get_duration()
sound.name = self.name
if save:
sound.save()
self.sound = sound
return sound
def find_playlist(self, sound):
"""
Find a playlist file corresponding to the sound path
"""
import aircox.management.commands.import_playlist \
as import_playlist
path = os.path.splitext(self.sound.path)[0] + '.csv'
if not os.path.exists(path):
return
old = Track.objects.get_for(object = sound)
if old:
return
import_playlist.Importer(sound, path, save=True)
def find_diffusion(self, program, save = True):
"""
For a given program, check if there is an initial diffusion
to associate to, using the date info we have. Update self.sound
and save it consequently.
We only allow initial diffusion since there should be no
rerun.
"""
if self.year == None or not self.sound or self.sound.diffusion:
return;
diffusion = Diffusion.objects.filter(
program = program,
initial__isnull = True,
start__year = self.year,
start__month = self.month,
start__day = self.day,
)
if not diffusion:
return
diffusion = diffusion[0]
logger.info('diffusion %s mathes to sound -> %s', str(diffusion),
self.sound.path)
self.sound.diffusion = diffusion
if save:
self.sound.save()
return diffusion
class MonitorHandler(PatternMatchingEventHandler):
"""
Event handler for watchdog, in order to be used in monitoring.
"""
def __init__(self, subdir):
"""
subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR
"""
self.subdir = subdir
if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
self.sound_kwargs = { 'type': Sound.Type.archive }
else:
self.sound_kwargs = { 'type': Sound.Type.excerpt }
patterns = ['*/{}/*{}'.format(self.subdir, ext)
for ext in settings.AIRCOX_SOUND_FILE_EXT ]
super().__init__(patterns=patterns, ignore_directories=True)
def on_created(self, event):
self.on_modified(event)
def on_modified(self, event):
logger.info('sound modified: %s', event.src_path)
program = Program.get_from_path(event.src_path)
if not program:
return
si = SoundInfo(event.src_path)
si.get_sound(self.sound_kwargs, True)
if si.year != None:
si.find_diffusion(program)
def on_deleted(self, event):
logger.info('sound deleted: %s', event.src_path)
sound = Sound.objects.filter(path = event.src_path)
if sound:
sound = sound[0]
sound.type = sound.Type.removed
sound.save()
def on_moved(self, event):
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
sound = Sound.objects.filter(path = event.src_path)
if not sound:
self.on_modified(
FileModifiedEvent(event.dest_path)
)
return
sound = sound[0]
sound.path = event.dest_path
sound.save()
class Command(BaseCommand):
help= __doc__
def report(self, program = None, component = None, *content):
if not component:
logger.info('%s: %s', str(program), ' '.join([str(c) for c in content]))
else:
logger.info('%s, %s: %s', str(program), str(component),
' '.join([str(c) for c in content]))
def add_arguments(self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'-q', '--quality_check', action='store_true',
help='Enable quality check using sound_quality_check on all ' \
'sounds marqued as not good'
)
parser.add_argument(
'-s', '--scan', action='store_true',
help='Scan programs directories for changes, plus check for a '
' matching diffusion on sounds that have not been yet assigned'
)
parser.add_argument(
'-m', '--monitor', action='store_true',
help='Run in monitor mode, watch for modification in the filesystem '
'and react in consequence'
)
def handle(self, *args, **options):
if options.get('scan'):
self.scan()
if options.get('quality_check'):
self.check_quality(check = (not options.get('scan')) )
if options.get('monitor'):
self.monitor()
@staticmethod
def check_sounds(qs):
"""
Only check for the sound existence or update
"""
# check files
for sound in qs:
if sound.check_on_file():
sound.save(check = False)
def scan(self):
"""
For all programs, scan dirs
"""
logger.info('scan all programs...')
programs = Program.objects.filter()
for program in programs:
logger.info('#%d %s', program.id, program.name)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type = Sound.Type.archive,
)
self.scan_for_program(
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type = Sound.Type.excerpt,
)
def scan_for_program(self, program, subdir, **sound_kwargs):
"""
Scan a given directory that is associated to the given program, and
update sounds information.
"""
logger.info('- %s/', subdir)
if not program.ensure_dir(subdir):
return
sound_kwargs['program'] = program
subdir = os.path.join(program.path, subdir)
sounds = []
# sounds in directory
for path in os.listdir(subdir):
path = os.path.join(subdir, path)
if not path.endswith(settings.AIRCOX_SOUND_FILE_EXT):
continue
si = SoundInfo(path)
si.get_sound(sound_kwargs, True)
si.find_diffusion(program)
si.find_playlist(si.sound)
sounds.append(si.sound.pk)
# sounds in db & unchecked
sounds = Sound.objects.filter(path__startswith = subdir). \
exclude(pk__in = sounds)
self.check_sounds(sounds)
def check_quality(self, check = False):
"""
Check all files where quality has been set to bad
"""
import aircox.management.commands.sounds_quality_check \
as quality_check
# get available sound files
sounds = Sound.objects.filter(good_quality = False) \
.exclude(type = Sound.Type.removed)
if check:
self.check_sounds(sounds)
files = [ sound.path for sound in sounds
if os.path.exists(sound.path) ]
# check quality
logger.info('quality check...',)
cmd = quality_check.Command()
cmd.handle( files = files,
**settings.AIRCOX_SOUND_QUALITY )
# update stats
logger.info('update stats in database')
def update_stats(sound_info, sound):
stats = sound_info.get_file_stats()
if stats:
duration = int(stats.get('length'))
sound.duration = utils.seconds_to_time(duration)
for sound_info in cmd.good:
sound = Sound.objects.get(path = sound_info.path)
sound.good_quality = True
update_stats(sound_info, sound)
sound.save(check = False)
for sound_info in cmd.bad:
sound = Sound.objects.get(path = sound_info.path)
update_stats(sound_info, sound)
sound.save(check = False)
def monitor(self):
"""
Run in monitor mode
"""
archives_handler = MonitorHandler(
subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
excerpts_handler = MonitorHandler(
subdir = settings.AIRCOX_SOUND_EXCERPTS_SUBDIR
)
observer = Observer()
observer.schedule(archives_handler, settings.AIRCOX_PROGRAMS_DIR,
recursive=True)
observer.schedule(excerpts_handler, settings.AIRCOX_PROGRAMS_DIR,
recursive=True)
observer.start()
def leave():
observer.stop()
observer.join()
atexit.register(leave)
while True:
time.sleep(1)

View File

@ -0,0 +1,174 @@
"""
Analyse and check files using Sox, prints good and bad files.
"""
import sys
import logging
import re
import subprocess
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
logger = logging.getLogger('aircox.tools')
class Stats:
attributes = [
'DC offset', 'Min level', 'Max level',
'Pk lev dB', 'RMS lev dB', 'RMS Pk dB',
'RMS Tr dB', 'Flat factor', 'Length s',
]
def __init__ (self, path, **kwargs):
"""
If path is given, call analyse with path and kwargs
"""
self.values = {}
if path:
self.analyse(path, **kwargs)
def get (self, attr):
return self.values.get(attr)
def parse (self, output):
for attr in Stats.attributes:
value = re.search(attr + r'\s+(?P<value>\S+)', output)
value = value and value.groupdict()
if value:
try:
value = float(value.get('value'))
except ValueError:
value = None
self.values[attr] = value
self.values['length'] = self.values['Length s']
def analyse (self, path, at = None, length = None):
"""
If at and length are given use them as excerpt to analyse.
"""
args = ['sox', path, '-n']
if at is not None and length is not None:
args += ['trim', str(at), str(length) ]
args.append('stats')
p = subprocess.Popen(args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# sox outputs to stderr (my god WHYYYY)
out_, out = p.communicate()
self.parse(str(out, encoding='utf-8'))
class Sound:
path = None # file path
sample_length = 120 # default sample length in seconds
stats = None # list of samples statistics
bad = None # list of bad samples
good = None # list of good samples
def __init__ (self, path, sample_length = None):
self.path = path
self.sample_length = sample_length if sample_length is not None \
else self.sample_length
def get_file_stats (self):
return self.stats and self.stats[0]
def analyse (self):
logger.info('complete file analysis')
self.stats = [ Stats(self.path) ]
position = 0
length = self.stats[0].get('length')
if not self.sample_length:
return
logger.info('start samples analysis...')
while position < length:
stats = Stats(self.path, at = position, length = self.sample_length)
self.stats.append(stats)
position += self.sample_length
def check (self, name, min_val, max_val):
self.good = [ index for index, stats in enumerate(self.stats)
if min_val <= stats.get(name) <= max_val ]
self.bad = [ index for index, stats in enumerate(self.stats)
if index not in self.good ]
self.resume()
def resume (self):
view = lambda array: [
'file' if index is 0 else
'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length)
for index in array
]
if self.good:
logger.info(self.path + ' -> good: \033[92m%s\033[0m',
', '.join(view(self.good)))
if self.bad:
logger.info(self.path + ' -> bad: \033[91m%s\033[0m',
', '.join(view(self.bad)))
class Command (BaseCommand):
help = __doc__
sounds = None
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'files', metavar='FILE', type=str, nargs='+',
help='file(s) to analyse'
)
parser.add_argument(
'-s', '--sample_length', type=int, default=120,
help='size of sample to analyse in seconds. If not set (or 0), does'
' not analyse by sample',
)
parser.add_argument(
'-a', '--attribute', type=str,
help='attribute name to use to check, that can be:\n' + \
', '.join([ '"{}"'.format(attr) for attr in Stats.attributes ])
)
parser.add_argument(
'-r', '--range', type=float, nargs=2,
help='range of minimal and maximal accepted value such as: ' \
'--range min max'
)
parser.add_argument(
'-i', '--resume', action='store_true',
help='print a resume of good and bad files'
)
def handle (self, *args, **options):
# parameters
minmax = options.get('range')
if not minmax:
raise CommandError('no range specified')
attr = options.get('attribute')
if not attr:
raise CommandError('no attribute specified')
# sound analyse and checks
self.sounds = [ Sound(path, options.get('sample_length'))
for path in options.get('files') ]
self.bad = []
self.good = []
for sound in self.sounds:
logger.info('analyse ' + sound.path)
sound.analyse()
sound.check(attr, minmax[0], minmax[1])
if sound.bad:
self.bad.append(sound)
else:
self.good.append(sound)
# resume
if options.get('resume'):
for sound in self.good:
logger.info('\033[92m+ %s\033[0m', sound.path)
for sound in self.bad:
logger.info('\033[91m+ %s\033[0m', sound.path)

View File

@ -0,0 +1,346 @@
"""
Handle the audio streamer and controls it as we want it to be. It is
used to:
- generate config files and playlists;
- monitor Liquidsoap, logs and scheduled programs;
- cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap
"""
import os
import time
import re
from argparse import RawTextHelpFormatter
from django.conf import settings as main_settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from aircox.models import Station, Diffusion, Track, Sound, Log
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
"""
station = None
streamer = None
cancel_timeout = 60*10
"""
Time in seconds before a diffusion that have archives is cancelled
because it has not been played.
"""
sync_timeout = 60*10
"""
Time in minuts before all stream playlists are checked and updated
"""
sync_next = None
"""
Datetime of the next sync
"""
def __init__(self, station, **kwargs):
self.station = station
self.__dict__.update(kwargs)
def monitor(self):
"""
Run all monitoring functions.
"""
if not self.streamer:
self.streamer = self.station.streamer
if not self.streamer.ready():
return
self.trace()
self.sync_playlists()
self.handle()
def log(self, **kwargs):
"""
Create a log using **kwargs, and print info
"""
log = Log(station = self.station, **kwargs)
log.save()
log.print()
def trace(self):
"""
Check the current_sound of the station and update logs if
needed.
"""
self.streamer.fetch()
current_sound = self.streamer.current_sound
current_source = self.streamer.current_source
if not current_sound or not current_source:
return
log = Log.objects.get_for(model = Sound) \
.filter(station = self.station) \
.order_by('date').last()
# only streamed
if log and (log.related and not log.related.diffusion):
self.trace_sound_tracks(log)
# TODO: expiration
if log and (log.source == current_source.id and \
log.related and
log.related.path == current_sound):
return
sound = Sound.objects.filter(path = current_sound)
self.log(
type = Log.Type.play,
source = current_source.id,
date = tz.now(),
related = sound[0] if sound else None,
comment = None if sound else current_sound,
)
def trace_sound_tracks(self, log):
"""
Log tracks for the given sound (for streamed programs); Called by
self.trace
"""
logs = Log.objects.get_for(model = Track) \
.filter(pk__gt = log.pk)
logs = [ log.related_id for log in logs ]
tracks = Track.objects.get_for(object = log.related) \
.filter(in_seconds = True)
if tracks and len(tracks) == len(logs):
return
tracks = tracks.exclude(pk__in = logs).order_by('position')
now = tz.now()
for track in tracks:
pos = log.date + tz.timedelta(seconds = track.position)
if pos < now:
self.log(
type = Log.Type.play,
source = log.source,
date = pos,
related = track
)
def sync_playlists(self):
"""
Synchronize updated playlists
"""
now = tz.now()
if self.sync_next and self.sync_next < now:
return
self.sync_next = now + tz.timedelta(seconds = self.sync_timeout)
for source in self.station.sources:
if source == self.station.dealer:
continue
playlist = [ sound.path for sound in
source.program.sound_set.all() ]
source.playlist = playlist
def trace_canceled(self):
"""
Check diffusions that should have been played but did not start,
and cancel them
"""
if not self.cancel_timeout:
return
diffs = Diffusions.objects.get_at().filter(
type = Diffusion.Type.normal,
sound__type = Sound.Type.archive,
)
logs = station.get_played(models = Diffusion)
date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout)
for diff in diffs:
if logs.filter(related = diff):
continue
if diff.start < now:
diff.type = Diffusion.Type.canceled
diff.save()
self.log(
type = Log.Type.other,
related = diff,
comment = 'Diffusion canceled after {} seconds' \
.format(self.cancel_timeout)
)
def __current_diff(self):
"""
Return a tuple with the currently running diffusion and the items
that still have to be played. If there is not, return None
"""
station = self.station
now = tz.make_aware(tz.datetime.now())
diff_log = station.get_played(models = Diffusion) \
.order_by('date').last()
if not diff_log or \
not diff_log.related.is_date_in_range(now):
return None, []
# sound has switched? assume it has been (forced to) stopped
sounds = station.get_played(models = Sound) \
.filter(date__gte = diff_log.date) \
.order_by('date')
if sounds.last() and sounds.last().source != diff_log.source:
return diff_log, []
# last diff is still playing: get the remaining playlist
sounds = sounds.filter(
source = diff_log.source, pk__gt = diff_log.pk
)
sounds = [
sound.related.path for sound in sounds
if sound.related.type != Sound.Type.removed
]
return (
diff_log.related,
[ path for path in diff_log.related.playlist
if path not in sounds ]
)
def __next_diff(self, diff):
"""
Return the tuple with the next diff that should be played and
the playlist
Note: diff is a log
"""
station = self.station
now = tz.now()
args = {'start__gt': diff.date } if diff else {}
diff = Diffusion.objects.get_at(now).filter(
type = Diffusion.Type.normal,
sound__type = Sound.Type.archive,
**args
).distinct().order_by('start').first()
return (diff, diff and diff.playlist or [])
def handle(self):
"""
Handle scheduled diffusion, trigger if needed, preload playlists
and so on.
"""
station = self.station
dealer = station.dealer
if not dealer:
return
now = tz.now()
# current and next diffs
diff, playlist = self.__current_diff()
dealer.active = bool(playlist)
next_diff, next_playlist = self.__next_diff(diff)
playlist += next_playlist
# playlist update
if dealer.playlist != playlist:
dealer.playlist = playlist
if next_diff:
self.log(
type = Log.Type.load,
source = dealer.id,
date = now,
related = next_diff
)
# dealer.on when next_diff start <= now
if next_diff and not dealer.active and \
next_diff.start <= now:
dealer.active = True
self.log(
type = Log.Type.play,
source = dealer.id,
date = now,
related = next_diff,
)
class Command (BaseCommand):
help= __doc__
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
group = parser.add_argument_group('actions')
group.add_argument(
'-c', '--config', action='store_true',
help='generate configuration files for the stations'
)
group.add_argument(
'-m', '--monitor', action='store_true',
help='monitor the scheduled diffusions and log what happens'
)
group.add_argument(
'-r', '--run', action='store_true',
help='run the required applications for the stations'
)
group = parser.add_argument_group('options')
group.add_argument(
'-d', '--delay', type=int,
default=1000,
help='time to sleep in MILLISECONDS between two updates when we '
'monitor'
)
group.add_argument(
'-s', '--station', type=str, action='append',
help='name of the station to monitor instead of monitoring '
'all stations'
)
group.add_argument(
'-t', '--timeout', type=int,
default=600,
help='time to wait in SECONDS before canceling a diffusion that '
'has not been ran but should have been. If 0, does not '
'check'
)
def handle (self, *args,
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()[:]
for station in stations:
# station.prepare()
if config and not run: # no need to write it twice
station.streamer.push()
if run:
station.streamer.process_run()
if monitor:
monitors = [
Monitor(station, cancel_timeout = timeout)
for station in stations
]
delay = delay / 1000
while True:
for monitor in monitors:
monitor.monitor()
time.sleep(delay)
if run:
for station in stations:
station.controller.process_wait()