Merge pull request 'Fix sound monitor issues' (#82) from dev-1.0-sound-monitor-fixes into develop-1.0
Reviewed-on: #82
This commit is contained in:
commit
9097fd5310
|
@ -25,233 +25,23 @@ Sox (and soxi).
|
|||
"""
|
||||
from argparse import RawTextHelpFormatter
|
||||
import concurrent.futures as futures
|
||||
import datetime
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import mutagen
|
||||
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, CommandError
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext as _
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from aircox import settings, utils
|
||||
from aircox.models import Diffusion, Program, Sound, Track
|
||||
from .import_playlist import PlaylistImport
|
||||
from aircox import settings
|
||||
from aircox.models import Program, Sound
|
||||
from aircox.management.sound_file import SoundFile
|
||||
from aircox.management.sound_monitor import MonitorHandler
|
||||
|
||||
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):
|
||||
help = __doc__
|
||||
|
||||
|
|
|
@ -1,118 +1,16 @@
|
|||
"""
|
||||
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
|
||||
|
||||
from aircox.management.sound_stats import SoxStats, SoundStats
|
||||
|
||||
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):
|
||||
help = __doc__
|
||||
sounds = None
|
||||
|
@ -132,7 +30,7 @@ class Command (BaseCommand):
|
|||
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])
|
||||
', '.join(['"{}"'.format(attr) for attr in SoxStats.attributes])
|
||||
)
|
||||
parser.add_argument(
|
||||
'-r', '--range', type=float, nargs=2,
|
||||
|
@ -160,7 +58,7 @@ class Command (BaseCommand):
|
|||
self.bad = []
|
||||
self.good = []
|
||||
for sound in self.sounds:
|
||||
logger.info('analyse ' + sound.file.name)
|
||||
logger.info('analyse ' + sound.path)
|
||||
sound.analyse()
|
||||
sound.check(attr, minmax[0], minmax[1])
|
||||
if sound.bad:
|
||||
|
@ -171,6 +69,6 @@ class Command (BaseCommand):
|
|||
# resume
|
||||
if options.get('resume'):
|
||||
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:
|
||||
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 aircox import settings
|
||||
from .program import Program
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
subdir = AIRCOX_SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else \
|
||||
AIRCOX_SOUND_EXCERPTS_SUBDIR
|
||||
subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR \
|
||||
if self.type == self.TYPE_ARCHIVE else \
|
||||
settings.AIRCOX_SOUND_EXCERPTS_SUBDIR
|
||||
return os.path.join(self.program.path, subdir, filename)
|
||||
|
||||
file = models.FileField(
|
||||
_('file'), upload_to=_upload_to, max_length=256,
|
||||
db_index=True,
|
||||
db_index=True, unique=True,
|
||||
)
|
||||
duration = models.TimeField(
|
||||
_('duration'),
|
||||
|
@ -175,6 +179,7 @@ class Sound(models.Model):
|
|||
|
||||
return os.path.exists(self.file.path)
|
||||
|
||||
# TODO: rename to sync_fs()
|
||||
def check_on_file(self):
|
||||
"""
|
||||
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 self.type == self.TYPE_REMOVED:
|
||||
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
|
||||
return True
|
||||
|
||||
|
@ -202,8 +207,8 @@ class Sound(models.Model):
|
|||
if self.mtime != mtime:
|
||||
self.mtime = mtime
|
||||
self.is_good_quality = None
|
||||
logger.info('sound %s: m_time has changed. Reset quality info',
|
||||
self.file.name)
|
||||
logger.debug('sound %s: m_time has changed. Reset quality info',
|
||||
self.file.name)
|
||||
return True
|
||||
|
||||
return changed
|
||||
|
|
|
@ -46,7 +46,7 @@ class Station(models.Model):
|
|||
)
|
||||
default = models.BooleanField(
|
||||
_('default station'),
|
||||
default=True,
|
||||
default=False,
|
||||
help_text=_('use this station as the main one.')
|
||||
)
|
||||
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
|
||||
tzlocal~=4.2
|
||||
|
||||
dateutils~=0.6
|
||||
mutagen~=1.45
|
||||
Pillow~=9.0
|
||||
psutil~=5.9
|
||||
|
|
Loading…
Reference in New Issue
Block a user