Fix sound monitor issues #82
|
@ -25,233 +25,23 @@ Sox (and soxi).
|
||||||
"""
|
"""
|
||||||
from argparse import RawTextHelpFormatter
|
from argparse import RawTextHelpFormatter
|
||||||
import concurrent.futures as futures
|
import concurrent.futures as futures
|
||||||
import datetime
|
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import mutagen
|
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
|
|
||||||
|
|
||||||
from django.conf import settings as conf
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.utils import timezone as tz
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from aircox import settings, utils
|
from aircox import settings
|
||||||
from aircox.models import Diffusion, Program, Sound, Track
|
from aircox.models import Program, Sound
|
||||||
from .import_playlist import PlaylistImport
|
from aircox.management.sound_file import SoundFile
|
||||||
|
from aircox.management.sound_monitor import MonitorHandler
|
||||||
|
|
||||||
logger = logging.getLogger('aircox.commands')
|
logger = logging.getLogger('aircox.commands')
|
||||||
|
|
||||||
|
|
||||||
sound_path_re = re.compile(
|
|
||||||
'^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
|
|
||||||
'(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?'
|
|
||||||
'(_(?P<n>[0-9]+))?'
|
|
||||||
'_?[ -]*(?P<name>.*)$'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SoundFile:
|
|
||||||
path = None
|
|
||||||
info = None
|
|
||||||
path_info = None
|
|
||||||
sound = None
|
|
||||||
|
|
||||||
def __init__(self, path):
|
|
||||||
self.path = path
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sound_path(self):
|
|
||||||
""" Relative path name """
|
|
||||||
return self.path.replace(conf.MEDIA_ROOT + '/', '')
|
|
||||||
|
|
||||||
def sync(self, sound=None, program=None, deleted=False, **kwargs):
|
|
||||||
"""
|
|
||||||
Update related sound model and save it.
|
|
||||||
"""
|
|
||||||
if deleted:
|
|
||||||
sound = Sound.objects.path(self.path).first()
|
|
||||||
if sound:
|
|
||||||
sound.type = sound.TYPE_REMOVED
|
|
||||||
sound.check_on_file()
|
|
||||||
sound.save()
|
|
||||||
return sound
|
|
||||||
|
|
||||||
# FIXME: sound.program as not null
|
|
||||||
if not program:
|
|
||||||
program = Program.get_from_path(self.path)
|
|
||||||
logger.info('program from path "%s" -> %s', self.path, program)
|
|
||||||
kwargs['program_id'] = program.pk
|
|
||||||
|
|
||||||
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs) \
|
|
||||||
if not sound else (sound, False)
|
|
||||||
self.sound = sound
|
|
||||||
sound.program = program
|
|
||||||
if created or sound.check_on_file():
|
|
||||||
logger.info('sound is new or have been modified -> %s', self.sound_path)
|
|
||||||
self.read_path()
|
|
||||||
sound.name = self.path_info.get('name')
|
|
||||||
|
|
||||||
self.read_file_info()
|
|
||||||
if self.info is not None:
|
|
||||||
sound.duration = utils.seconds_to_time(self.info.info.length)
|
|
||||||
|
|
||||||
# check for episode
|
|
||||||
if sound.episode is None and self.read_path():
|
|
||||||
self.find_episode(program)
|
|
||||||
|
|
||||||
sound.save()
|
|
||||||
|
|
||||||
# check for playlist
|
|
||||||
self.find_playlist(sound)
|
|
||||||
return sound
|
|
||||||
|
|
||||||
def read_path(self):
|
|
||||||
"""
|
|
||||||
Parse file name to get info on the assumption it has the correct
|
|
||||||
format (given in Command.help). Return True if path contains informations.
|
|
||||||
"""
|
|
||||||
if self.path_info:
|
|
||||||
return 'year' in self.path_info
|
|
||||||
|
|
||||||
name = os.path.splitext(os.path.basename(self.path))[0]
|
|
||||||
match = sound_path_re.search(name)
|
|
||||||
if match:
|
|
||||||
path_info = match.groupdict()
|
|
||||||
for k in ('year', 'month', 'day', 'hour', 'minute'):
|
|
||||||
if path_info.get(k) is not None:
|
|
||||||
path_info[k] = int(path_info[k])
|
|
||||||
self.path_info = path_info
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.path_info = {'name': name}
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read_file_info(self):
|
|
||||||
""" Read file information and metadata. """
|
|
||||||
if os.path.exists(self.path):
|
|
||||||
self.info = mutagen.File(self.path)
|
|
||||||
else:
|
|
||||||
self.info = None
|
|
||||||
|
|
||||||
def find_episode(self, program):
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
pi = self.path_info
|
|
||||||
if 'year' not in pi or not self.sound or self.sound.episode:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if pi.get('hour') is not None:
|
|
||||||
date = tz.datetime(pi.get('year'), pi.get('month'), pi.get('day'),
|
|
||||||
pi.get('hour') or 0, pi.get('minute') or 0)
|
|
||||||
date = tz.get_current_timezone().localize(date)
|
|
||||||
else:
|
|
||||||
date = datetime.date(pi.get('year'), pi.get('month'), pi.get('day'))
|
|
||||||
|
|
||||||
diffusion = program.diffusion_set.at(date).first()
|
|
||||||
if not diffusion:
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info('%s <--> %s', self.sound.file.name, str(diffusion.episode))
|
|
||||||
self.sound.episode = diffusion.episode
|
|
||||||
return diffusion
|
|
||||||
|
|
||||||
def find_playlist(self, sound=None, use_meta=True):
|
|
||||||
"""
|
|
||||||
Find a playlist file corresponding to the sound path, such as:
|
|
||||||
my_sound.ogg => my_sound.csv
|
|
||||||
|
|
||||||
Use sound's file metadata if no corresponding playlist has been
|
|
||||||
found and `use_meta` is True.
|
|
||||||
"""
|
|
||||||
if sound is None:
|
|
||||||
sound = self.sound
|
|
||||||
if sound.track_set.count() > 1:
|
|
||||||
return
|
|
||||||
|
|
||||||
# import playlist
|
|
||||||
path_noext, ext = os.path.splitext(self.sound.file.path)
|
|
||||||
path = path_noext + '.csv'
|
|
||||||
if os.path.exists(path):
|
|
||||||
PlaylistImport(path, sound=sound).run()
|
|
||||||
# use metadata
|
|
||||||
elif use_meta:
|
|
||||||
if self.info is None:
|
|
||||||
self.read_file_info()
|
|
||||||
if self.info and self.info.tags:
|
|
||||||
tags = self.info.tags
|
|
||||||
title, artist, album, year = tuple(
|
|
||||||
t and ', '.join(t) for t in (
|
|
||||||
tags.get(k) for k in ('title', 'artist', 'album', 'year'))
|
|
||||||
)
|
|
||||||
title = title or (self.path_info and self.path_info.get('name')) or \
|
|
||||||
os.path.basename(path_noext)
|
|
||||||
info = '{} ({})'.format(album, year) if album and year else \
|
|
||||||
album or year or ''
|
|
||||||
track = Track(sound=sound,
|
|
||||||
position=int(tags.get('tracknumber', 0)),
|
|
||||||
title=title,
|
|
||||||
artist=artist or _('unknown'),
|
|
||||||
info=info)
|
|
||||||
track.save()
|
|
||||||
|
|
||||||
|
|
||||||
class MonitorHandler(PatternMatchingEventHandler):
|
|
||||||
"""
|
|
||||||
Event handler for watchdog, in order to be used in monitoring.
|
|
||||||
"""
|
|
||||||
pool = None
|
|
||||||
|
|
||||||
def __init__(self, subdir, pool):
|
|
||||||
"""
|
|
||||||
subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR
|
|
||||||
"""
|
|
||||||
self.subdir = subdir
|
|
||||||
self.pool = pool
|
|
||||||
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)
|
|
||||||
def updated(event, sound_kwargs):
|
|
||||||
SoundFile(event.src_path).sync(**sound_kwargs)
|
|
||||||
self.pool.submit(updated, event, self.sound_kwargs)
|
|
||||||
|
|
||||||
def on_moved(self, event):
|
|
||||||
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
|
|
||||||
def moved(event, sound_kwargs):
|
|
||||||
sound = Sound.objects.filter(file=event.src_path)
|
|
||||||
sound_file = SoundFile(event.dest_path) if not sound else sound
|
|
||||||
sound_file.sync(**sound_kwargs)
|
|
||||||
self.pool.submit(moved, event, self.sound_kwargs)
|
|
||||||
|
|
||||||
def on_deleted(self, event):
|
|
||||||
logger.info('sound deleted: %s', event.src_path)
|
|
||||||
def deleted(event):
|
|
||||||
SoundFile(event.src_path).sync(deleted=True)
|
|
||||||
self.pool.submit(deleted, event)
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = __doc__
|
help = __doc__
|
||||||
|
|
||||||
|
|
|
@ -1,118 +1,16 @@
|
||||||
"""
|
"""
|
||||||
Analyse and check files using Sox, prints good and bad files.
|
Analyse and check files using Sox, prints good and bad files.
|
||||||
"""
|
"""
|
||||||
import sys
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from argparse import RawTextHelpFormatter
|
from argparse import RawTextHelpFormatter
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from aircox.management.sound_stats import SoxStats, SoundStats
|
||||||
|
|
||||||
logger = logging.getLogger('aircox.commands')
|
logger = logging.getLogger('aircox.commands')
|
||||||
|
|
||||||
|
|
||||||
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 SoundStats:
|
|
||||||
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):
|
|
||||||
def view(array): return [
|
|
||||||
'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):
|
class Command (BaseCommand):
|
||||||
help = __doc__
|
help = __doc__
|
||||||
sounds = None
|
sounds = None
|
||||||
|
@ -132,7 +30,7 @@ class Command (BaseCommand):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-a', '--attribute', type=str,
|
'-a', '--attribute', type=str,
|
||||||
help='attribute name to use to check, that can be:\n' +
|
help='attribute name to use to check, that can be:\n' +
|
||||||
', '.join(['"{}"'.format(attr) for attr in Stats.attributes])
|
', '.join(['"{}"'.format(attr) for attr in SoxStats.attributes])
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-r', '--range', type=float, nargs=2,
|
'-r', '--range', type=float, nargs=2,
|
||||||
|
@ -160,7 +58,7 @@ class Command (BaseCommand):
|
||||||
self.bad = []
|
self.bad = []
|
||||||
self.good = []
|
self.good = []
|
||||||
for sound in self.sounds:
|
for sound in self.sounds:
|
||||||
logger.info('analyse ' + sound.file.name)
|
logger.info('analyse ' + sound.path)
|
||||||
sound.analyse()
|
sound.analyse()
|
||||||
sound.check(attr, minmax[0], minmax[1])
|
sound.check(attr, minmax[0], minmax[1])
|
||||||
if sound.bad:
|
if sound.bad:
|
||||||
|
@ -171,6 +69,6 @@ class Command (BaseCommand):
|
||||||
# resume
|
# resume
|
||||||
if options.get('resume'):
|
if options.get('resume'):
|
||||||
for sound in self.good:
|
for sound in self.good:
|
||||||
logger.info('\033[92m+ %s\033[0m', sound.file.name)
|
logger.info('\033[92m+ %s\033[0m', sound.path)
|
||||||
for sound in self.bad:
|
for sound in self.bad:
|
||||||
logger.info('\033[91m+ %s\033[0m', sound.file.name)
|
logger.info('\033[91m+ %s\033[0m', sound.path)
|
||||||
|
|
216
aircox/management/sound_file.py
Normal file
216
aircox/management/sound_file.py
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
#! /usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Provide SoundFile which is used to link between database and file system.
|
||||||
|
|
||||||
|
|
||||||
|
File name
|
||||||
|
=========
|
||||||
|
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;
|
||||||
|
|
||||||
|
Sound Quality
|
||||||
|
=============
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import mutagen
|
||||||
|
|
||||||
|
from django.conf import settings as conf
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from aircox import utils
|
||||||
|
from aircox.models import Program, Sound, Track
|
||||||
|
from .commands.import_playlist import PlaylistImport
|
||||||
|
|
||||||
|
logger = logging.getLogger('aircox.commands')
|
||||||
|
|
||||||
|
|
||||||
|
class SoundFile:
|
||||||
|
"""
|
||||||
|
Handle synchronisation between sounds on files and database.
|
||||||
|
"""
|
||||||
|
path = None
|
||||||
|
info = None
|
||||||
|
path_info = None
|
||||||
|
sound = None
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sound_path(self):
|
||||||
|
""" Relative path name """
|
||||||
|
return self.path.replace(conf.MEDIA_ROOT + '/', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def episode(self):
|
||||||
|
return self.sound and self.sound.episode
|
||||||
|
|
||||||
|
def sync(self, sound=None, program=None, deleted=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Update related sound model and save it.
|
||||||
|
"""
|
||||||
|
if deleted:
|
||||||
|
return self._on_delete(self.path)
|
||||||
|
|
||||||
|
# FIXME: sound.program as not null
|
||||||
|
if not program:
|
||||||
|
program = Program.get_from_path(self.path)
|
||||||
|
logger.debug('program from path "%s" -> %s', self.path, program)
|
||||||
|
kwargs['program_id'] = program.pk
|
||||||
|
|
||||||
|
if sound:
|
||||||
|
created = False
|
||||||
|
else:
|
||||||
|
sound, created = Sound.objects.get_or_create(
|
||||||
|
file=self.sound_path, defaults=kwargs)
|
||||||
|
|
||||||
|
self.sound = sound
|
||||||
|
self.path_info = self.read_path(self.path)
|
||||||
|
|
||||||
|
sound.program = program
|
||||||
|
if created or sound.check_on_file():
|
||||||
|
sound.name = self.path_info.get('name')
|
||||||
|
self.info = self.read_file_info()
|
||||||
|
if self.info is not None:
|
||||||
|
sound.duration = utils.seconds_to_time(self.info.info.length)
|
||||||
|
|
||||||
|
# check for episode
|
||||||
|
if sound.episode is None and 'year' in self.path_info:
|
||||||
|
sound.episode = self.find_episode(sound, self.path_info)
|
||||||
|
sound.save()
|
||||||
|
|
||||||
|
# check for playlist
|
||||||
|
self.find_playlist(sound)
|
||||||
|
return sound
|
||||||
|
|
||||||
|
def _on_delete(self, path):
|
||||||
|
# TODO: remove from db on delete
|
||||||
|
sound = Sound.objects.path(self.path).first()
|
||||||
|
if sound:
|
||||||
|
sound.type = sound.TYPE_REMOVED
|
||||||
|
sound.check_on_file()
|
||||||
|
sound.save()
|
||||||
|
return sound
|
||||||
|
|
||||||
|
def read_path(self, path):
|
||||||
|
"""
|
||||||
|
Parse path name returning dictionary of extracted info. It can contain:
|
||||||
|
- `year`, `month`, `day`: diffusion date
|
||||||
|
- `hour`, `minute`: diffusion time
|
||||||
|
- `n`: sound arbitrary number (used for sound ordering)
|
||||||
|
- `name`: cleaned name extracted or file name (without extension)
|
||||||
|
"""
|
||||||
|
basename = os.path.basename(path)
|
||||||
|
basename = os.path.splitext(basename)[0]
|
||||||
|
reg_match = self._path_re.search(basename)
|
||||||
|
if reg_match:
|
||||||
|
info = reg_match.groupdict()
|
||||||
|
for k in ('year', 'month', 'day', 'hour', 'minute', 'n'):
|
||||||
|
if info.get(k) is not None:
|
||||||
|
info[k] = int(info[k])
|
||||||
|
|
||||||
|
name = info.get('name')
|
||||||
|
info['name'] = name and self._into_name(name) or basename
|
||||||
|
else:
|
||||||
|
info = {'name': basename}
|
||||||
|
return info
|
||||||
|
|
||||||
|
_path_re = re.compile(
|
||||||
|
'^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
|
||||||
|
'(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?'
|
||||||
|
'(_(?P<n>[0-9]+))?'
|
||||||
|
'_?[ -]*(?P<name>.*)$'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _into_name(self, name):
|
||||||
|
name = name.replace('_', ' ')
|
||||||
|
return ' '.join(r.capitalize() for r in name.split(' '))
|
||||||
|
|
||||||
|
def read_file_info(self):
|
||||||
|
""" Read file information and metadata. """
|
||||||
|
return mutagen.File(self.path) if os.path.exists(self.path) else None
|
||||||
|
|
||||||
|
def find_episode(self, sound, path_info):
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
program, pi = sound.program, path_info
|
||||||
|
if 'year' not in pi or not sound or sound.episode:
|
||||||
|
return None
|
||||||
|
|
||||||
|
year, month, day = pi.get('year'), pi.get('month'), pi.get('day')
|
||||||
|
if pi.get('hour') is not None:
|
||||||
|
at = tz.datetime(year, month, day, pi.get('hour', 0),
|
||||||
|
pi.get('minute', 0))
|
||||||
|
at = tz.get_current_timezone().localize(at)
|
||||||
|
else:
|
||||||
|
at = date(year, month, day)
|
||||||
|
|
||||||
|
diffusion = program.diffusion_set.at(at).first()
|
||||||
|
if not diffusion:
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug('%s <--> %s', sound.file.name, str(diffusion.episode))
|
||||||
|
return diffusion.episode
|
||||||
|
|
||||||
|
def find_playlist(self, sound=None, use_meta=True):
|
||||||
|
"""
|
||||||
|
Find a playlist file corresponding to the sound path, such as:
|
||||||
|
my_sound.ogg => my_sound.csv
|
||||||
|
|
||||||
|
Use sound's file metadata if no corresponding playlist has been
|
||||||
|
found and `use_meta` is True.
|
||||||
|
"""
|
||||||
|
if sound is None:
|
||||||
|
sound = self.sound
|
||||||
|
if sound.track_set.count() > 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# import playlist
|
||||||
|
path_noext, ext = os.path.splitext(self.sound.file.path)
|
||||||
|
path = path_noext + '.csv'
|
||||||
|
if os.path.exists(path):
|
||||||
|
PlaylistImport(path, sound=sound).run()
|
||||||
|
# use metadata
|
||||||
|
elif use_meta:
|
||||||
|
if self.info is None:
|
||||||
|
self.read_file_info()
|
||||||
|
if self.info and self.info.tags:
|
||||||
|
tags = self.info.tags
|
||||||
|
title, artist, album, year = tuple(
|
||||||
|
t and ', '.join(t) for t in (
|
||||||
|
tags.get(k) for k in ('title', 'artist', 'album',
|
||||||
|
'year'))
|
||||||
|
)
|
||||||
|
title = title or (self.path_info and
|
||||||
|
self.path_info.get('name')) or \
|
||||||
|
os.path.basename(path_noext)
|
||||||
|
info = '{} ({})'.format(album, year) if album and year else \
|
||||||
|
album or year or ''
|
||||||
|
track = Track(sound=sound,
|
||||||
|
position=int(tags.get('tracknumber', 0)),
|
||||||
|
title=title,
|
||||||
|
artist=artist or _('unknown'),
|
||||||
|
info=info)
|
||||||
|
track.save()
|
163
aircox/management/sound_monitor.py
Normal file
163
aircox/management/sound_monitor.py
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
#! /usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
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; WNotifye the following format:
|
||||||
|
yyyymmdd[_n][_][name]
|
||||||
|
|
||||||
|
Where:
|
||||||
|
'yyyy' the year Notifyhe episode's diffusion;
|
||||||
|
'mm' the month of the episode's difNotifyon;
|
||||||
|
'dd' the day of the episode's diffusion;
|
||||||
|
'n' the number of the episode (if multiple episodes);
|
||||||
|
'name' the title of the sNotify;
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from watchdog.events import PatternMatchingEventHandler
|
||||||
|
|
||||||
|
from aircox import settings
|
||||||
|
from aircox.models import Sound
|
||||||
|
|
||||||
|
from .sound_file import SoundFile
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('aircox.commands')
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('NotifyHandler', 'CreateHandler', 'DeleteHandler',
|
||||||
|
'MoveHandler', 'ModifiedHandler', 'MonitorHandler',)
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyHandler:
|
||||||
|
future = None
|
||||||
|
log_msg = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.timestamp = datetime.now()
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
self.timestamp = datetime.now()
|
||||||
|
|
||||||
|
def __call__(self, event, path=None, **kw):
|
||||||
|
sound_file = SoundFile(path or event.src_path)
|
||||||
|
if self.log_msg:
|
||||||
|
msg = self.log_msg.format(event=event, sound_file=sound_file)
|
||||||
|
logger.info(msg)
|
||||||
|
|
||||||
|
sound_file.sync(**kw)
|
||||||
|
return sound_file
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHandler(NotifyHandler):
|
||||||
|
log_msg = 'Sound file created: {sound_file.path}'
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteHandler(NotifyHandler):
|
||||||
|
log_msg = 'Sound file deleted: {sound_file.path}'
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
kwargs['deleted'] = True
|
||||||
|
return super().__call__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MoveHandler(NotifyHandler):
|
||||||
|
log_msg = 'Sound file moved: {event.src_path} -> {event.dest_path}'
|
||||||
|
|
||||||
|
def __call__(self, event, **kw):
|
||||||
|
sound = Sound.objects.filter(file=event.src_path)
|
||||||
|
# FIXME: this is wrong
|
||||||
|
if sound:
|
||||||
|
kw['sound'] = sound
|
||||||
|
kw['path'] = event.src_path
|
||||||
|
else:
|
||||||
|
kw['path'] = event.dest_path
|
||||||
|
return super().__call__(event, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class ModifiedHandler(NotifyHandler):
|
||||||
|
timeout_delta = timedelta(seconds=30)
|
||||||
|
log_msg = 'Sound file updated: {sound_file.path}'
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
# multiple call of this handler can be done consecutively, we block
|
||||||
|
# its thread using timeout
|
||||||
|
# Note: this method may be subject to some race conflicts, but this
|
||||||
|
# should not be big a real issue.
|
||||||
|
timeout = self.timestamp + self.timeout_delta
|
||||||
|
while datetime.now() < timeout:
|
||||||
|
time.sleep(self.timeout_delta.total_seconds())
|
||||||
|
timeout = self.timestamp + self.timeout_delta
|
||||||
|
|
||||||
|
def __call__(self, event, **kw):
|
||||||
|
self.wait()
|
||||||
|
return super().__call__(event, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorHandler(PatternMatchingEventHandler):
|
||||||
|
"""
|
||||||
|
Event handler for watchdog, in order to be used in monitoring.
|
||||||
|
"""
|
||||||
|
pool = None
|
||||||
|
jobs = {}
|
||||||
|
|
||||||
|
def __init__(self, subdir, pool):
|
||||||
|
"""
|
||||||
|
subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR
|
||||||
|
"""
|
||||||
|
self.subdir = subdir
|
||||||
|
self.pool = pool
|
||||||
|
if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
|
||||||
|
self.sync_kw = {'type': Sound.TYPE_ARCHIVE}
|
||||||
|
else:
|
||||||
|
self.sync_kw = {'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._submit(CreateHandler(), event, 'new', **self.sync_kw)
|
||||||
|
|
||||||
|
def on_deleted(self, event):
|
||||||
|
self._submit(DeleteHandler(), event, 'del')
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
self._submit(MoveHandler(), event, 'mv', **self.sync_kw)
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
self._submit(ModifiedHandler(), event, 'up', **self.sync_kw)
|
||||||
|
|
||||||
|
def _submit(self, handler, event, job_key_prefix, **kwargs):
|
||||||
|
"""
|
||||||
|
Send handler job to pool if not already running.
|
||||||
|
Return tuple with running job and boolean indicating if its a new one.
|
||||||
|
"""
|
||||||
|
key = job_key_prefix + ':' + event.src_path
|
||||||
|
job = self.jobs.get(key)
|
||||||
|
if job and not job.future.done():
|
||||||
|
job.ping()
|
||||||
|
return job, False
|
||||||
|
|
||||||
|
handler.future = self.pool.submit(handler, event, **kwargs)
|
||||||
|
self.jobs[key] = handler
|
||||||
|
|
||||||
|
def done(r):
|
||||||
|
print(':::: job done', key)
|
||||||
|
if self.jobs.get(key) is handler:
|
||||||
|
del self.jobs[key]
|
||||||
|
handler.future.add_done_callback(done)
|
||||||
|
return handler, True
|
115
aircox/management/sound_stats.py
Normal file
115
aircox/management/sound_stats.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
"""
|
||||||
|
Provide sound analysis class using Sox.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger('aircox.commands')
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('SoxStats', 'SoundStats')
|
||||||
|
|
||||||
|
|
||||||
|
class SoxStats:
|
||||||
|
"""
|
||||||
|
Run Sox process and parse output
|
||||||
|
"""
|
||||||
|
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 self.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 SoundStats:
|
||||||
|
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.debug('complete file analysis')
|
||||||
|
self.stats = [SoxStats(self.path)]
|
||||||
|
position = 0
|
||||||
|
length = self.stats[0].get('length')
|
||||||
|
|
||||||
|
if not self.sample_length:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug('start samples analysis...')
|
||||||
|
while position < length:
|
||||||
|
stats = SoxStats(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):
|
||||||
|
def view(array): return [
|
||||||
|
'file' if index == 0 else
|
||||||
|
'sample {} (at {} seconds)'.format(
|
||||||
|
index, (index-1) * self.sample_length)
|
||||||
|
for index in array
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.good:
|
||||||
|
logger.debug(self.path + ' -> good: \033[92m%s\033[0m',
|
||||||
|
', '.join(view(self.good)))
|
||||||
|
if self.bad:
|
||||||
|
logger.debug(self.path + ' -> bad: \033[91m%s\033[0m',
|
||||||
|
', '.join(view(self.bad)))
|
|
@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
|
from aircox import settings
|
||||||
from .program import Program
|
from .program import Program
|
||||||
from .episode import Episode
|
from .episode import Episode
|
||||||
|
|
||||||
|
@ -73,6 +74,8 @@ class SoundQuerySet(models.QuerySet):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
# - provide a default name based on program and episode
|
||||||
class Sound(models.Model):
|
class Sound(models.Model):
|
||||||
"""
|
"""
|
||||||
A Sound is the representation of a sound file that can be either an excerpt
|
A Sound is the representation of a sound file that can be either an excerpt
|
||||||
|
@ -105,13 +108,14 @@ class Sound(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _upload_to(self, filename):
|
def _upload_to(self, filename):
|
||||||
subdir = AIRCOX_SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else \
|
subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR \
|
||||||
AIRCOX_SOUND_EXCERPTS_SUBDIR
|
if self.type == self.TYPE_ARCHIVE else \
|
||||||
|
settings.AIRCOX_SOUND_EXCERPTS_SUBDIR
|
||||||
return os.path.join(self.program.path, subdir, filename)
|
return os.path.join(self.program.path, subdir, filename)
|
||||||
|
|
||||||
file = models.FileField(
|
file = models.FileField(
|
||||||
_('file'), upload_to=_upload_to, max_length=256,
|
_('file'), upload_to=_upload_to, max_length=256,
|
||||||
db_index=True,
|
db_index=True, unique=True,
|
||||||
)
|
)
|
||||||
duration = models.TimeField(
|
duration = models.TimeField(
|
||||||
_('duration'),
|
_('duration'),
|
||||||
|
@ -175,6 +179,7 @@ class Sound(models.Model):
|
||||||
|
|
||||||
return os.path.exists(self.file.path)
|
return os.path.exists(self.file.path)
|
||||||
|
|
||||||
|
# TODO: rename to sync_fs()
|
||||||
def check_on_file(self):
|
def check_on_file(self):
|
||||||
"""
|
"""
|
||||||
Check sound file info again'st self, and update informations if
|
Check sound file info again'st self, and update informations if
|
||||||
|
@ -183,7 +188,7 @@ class Sound(models.Model):
|
||||||
if not self.file_exists():
|
if not self.file_exists():
|
||||||
if self.type == self.TYPE_REMOVED:
|
if self.type == self.TYPE_REMOVED:
|
||||||
return
|
return
|
||||||
logger.info('sound %s: has been removed', self.file.name)
|
logger.debug('sound %s: has been removed', self.file.name)
|
||||||
self.type = self.TYPE_REMOVED
|
self.type = self.TYPE_REMOVED
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -202,7 +207,7 @@ class Sound(models.Model):
|
||||||
if self.mtime != mtime:
|
if self.mtime != mtime:
|
||||||
self.mtime = mtime
|
self.mtime = mtime
|
||||||
self.is_good_quality = None
|
self.is_good_quality = None
|
||||||
logger.info('sound %s: m_time has changed. Reset quality info',
|
logger.debug('sound %s: m_time has changed. Reset quality info',
|
||||||
self.file.name)
|
self.file.name)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ class Station(models.Model):
|
||||||
)
|
)
|
||||||
default = models.BooleanField(
|
default = models.BooleanField(
|
||||||
_('default station'),
|
_('default station'),
|
||||||
default=True,
|
default=False,
|
||||||
help_text=_('use this station as the main one.')
|
help_text=_('use this station as the main one.')
|
||||||
)
|
)
|
||||||
active = models.BooleanField(
|
active = models.BooleanField(
|
||||||
|
|
2
aircox/tests/__init__.py
Normal file
2
aircox/tests/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from .management import *
|
||||||
|
|
2
aircox/tests/management/__init__.py
Normal file
2
aircox/tests/management/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from .sound_file import *
|
||||||
|
from .sound_monitor import *
|
73
aircox/tests/management/sound_file.py
Normal file
73
aircox/tests/management/sound_file.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings as conf
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
|
from aircox import models
|
||||||
|
from aircox.management.sound_file import SoundFile
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('SoundFileTestCase',)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundFileTestCase(TestCase):
|
||||||
|
path_infos = {
|
||||||
|
'test/20220101_10h13_1_sample_1.mp3': {
|
||||||
|
'year': 2022, 'month': 1, 'day': 1, 'hour': 10, 'minute': 13,
|
||||||
|
'n': 1, 'name': 'Sample 1'},
|
||||||
|
'test/20220102_10h13_sample_2.mp3': {
|
||||||
|
'year': 2022, 'month': 1, 'day': 2, 'hour': 10, 'minute': 13,
|
||||||
|
'name': 'Sample 2'},
|
||||||
|
'test/20220103_1_sample_3.mp3': {
|
||||||
|
'year': 2022, 'month': 1, 'day': 3, 'n': 1, 'name': 'Sample 3'},
|
||||||
|
'test/20220104_sample_4.mp3': {
|
||||||
|
'year': 2022, 'month': 1, 'day': 4, 'name': 'Sample 4'},
|
||||||
|
'test/20220105.mp3': {
|
||||||
|
'year': 2022, 'month': 1, 'day': 5, 'name': '20220105'},
|
||||||
|
}
|
||||||
|
subdir_prefix = 'test'
|
||||||
|
sound_files = {k: r for k, r in (
|
||||||
|
(path, SoundFile(conf.MEDIA_ROOT + '/' + path))
|
||||||
|
for path in path_infos.keys()
|
||||||
|
)}
|
||||||
|
|
||||||
|
def test_sound_path(self):
|
||||||
|
for path, sound_file in self.sound_files.items():
|
||||||
|
self.assertEqual(path, sound_file.sound_path)
|
||||||
|
|
||||||
|
def test_read_path(self):
|
||||||
|
for path, sound_file in self.sound_files.items():
|
||||||
|
expected = self.path_infos[path]
|
||||||
|
result = sound_file.read_path(path)
|
||||||
|
# remove None values
|
||||||
|
result = {k: v for k, v in result.items() if v is not None}
|
||||||
|
self.assertEqual(expected, result, "path: {}".format(path))
|
||||||
|
|
||||||
|
def _setup_diff(self, program, info):
|
||||||
|
episode = models.Episode(program=program, title='test-episode')
|
||||||
|
at = tz.datetime(**{
|
||||||
|
k: info[k] for k in ('year', 'month', 'day', 'hour', 'minute')
|
||||||
|
if info.get(k)
|
||||||
|
})
|
||||||
|
at = tz.make_aware(at)
|
||||||
|
diff = models.Diffusion(episode=episode, start=at,
|
||||||
|
end=at+timedelta(hours=1))
|
||||||
|
episode.save()
|
||||||
|
diff.save()
|
||||||
|
return diff
|
||||||
|
|
||||||
|
def test_find_episode(self):
|
||||||
|
station = models.Station(name='test-station')
|
||||||
|
program = models.Program(station=station, title='test')
|
||||||
|
station.save()
|
||||||
|
program.save()
|
||||||
|
|
||||||
|
for path, sound_file in self.sound_files.items():
|
||||||
|
infos = sound_file.read_path(path)
|
||||||
|
diff = self._setup_diff(program, infos)
|
||||||
|
sound = models.Sound(program=diff.program, file=path)
|
||||||
|
result = sound_file.find_episode(sound, infos)
|
||||||
|
self.assertEquals(diff.episode, result)
|
||||||
|
|
||||||
|
# TODO: find_playlist, sync
|
76
aircox/tests/management/sound_monitor.py
Normal file
76
aircox/tests/management/sound_monitor.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import concurrent.futures as futures
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from aircox.management.sound_monitor import \
|
||||||
|
NotifyHandler, MoveHandler, ModifiedHandler, MonitorHandler
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ('NotifyHandlerTestCase', 'MoveHandlerTestCase',
|
||||||
|
'ModifiedHandlerTestCase', 'MonitorHandlerTestCase',)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEvent:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.__dict__.update(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class WaitHandler(NotifyHandler):
|
||||||
|
def __call__(self, timeout=0.5, *args, **kwargs):
|
||||||
|
# using time.sleep make the future done directly, don't know why
|
||||||
|
start = datetime.now()
|
||||||
|
while datetime.now() - start < timedelta(seconds=4):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyHandlerTestCase(TestCase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MoveHandlerTestCase(TestCase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModifiedHandlerTestCase(TestCase):
|
||||||
|
def test_wait(self):
|
||||||
|
handler = ModifiedHandler()
|
||||||
|
handler.timeout_delta = timedelta(seconds=0.1)
|
||||||
|
start = datetime.now()
|
||||||
|
handler.wait()
|
||||||
|
delta = datetime.now() - start
|
||||||
|
self.assertTrue(delta < handler.timeout_delta + timedelta(seconds=0.1))
|
||||||
|
|
||||||
|
def test_wait_ping(self):
|
||||||
|
pool = futures.ThreadPoolExecutor(1)
|
||||||
|
handler = ModifiedHandler()
|
||||||
|
handler.timeout_delta = timedelta(seconds=0.5)
|
||||||
|
|
||||||
|
future = pool.submit(handler.wait)
|
||||||
|
time.sleep(0.3)
|
||||||
|
handler.ping()
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.assertTrue(future.running())
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorHandlerTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
pool = futures.ThreadPoolExecutor(2)
|
||||||
|
self.monitor = MonitorHandler('archives', pool)
|
||||||
|
|
||||||
|
def test_submit_new_job(self):
|
||||||
|
event = FakeEvent(src_path='dummy_src')
|
||||||
|
handler = NotifyHandler()
|
||||||
|
result = self.monitor._submit(handler, event, 'up')
|
||||||
|
self.assertIs(handler, result)
|
||||||
|
self.assertIsInstance(handler.future, futures.Future)
|
||||||
|
self.monitor.pool.shutdown()
|
||||||
|
|
||||||
|
def test_submit_job_exists(self):
|
||||||
|
event = FakeEvent(src_path='dummy_src')
|
||||||
|
|
||||||
|
job_1 = self.monitor._submit(WaitHandler(), event, 'up')
|
||||||
|
job_2 = self.monitor._submit(NotifyHandler(), event, 'up')
|
||||||
|
self.assertIs(job_1, job_2)
|
||||||
|
self.monitor.pool.shutdown()
|
|
@ -12,6 +12,7 @@ bleach~=5.0
|
||||||
easy-thumbnails~=2.8
|
easy-thumbnails~=2.8
|
||||||
tzlocal~=4.2
|
tzlocal~=4.2
|
||||||
|
|
||||||
|
dateutils~=0.6
|
||||||
mutagen~=1.45
|
mutagen~=1.45
|
||||||
Pillow~=9.0
|
Pillow~=9.0
|
||||||
psutil~=5.9
|
psutil~=5.9
|
||||||
|
|
Loading…
Reference in New Issue
Block a user