350 lines
12 KiB
Python
Executable File
350 lines
12 KiB
Python
Executable File
#! /usr/bin/env python3
|
|
|
|
# TODO:
|
|
# - quality check
|
|
# - Sound model => program field as not null
|
|
|
|
"""
|
|
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).
|
|
"""
|
|
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.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.models import Diffusion, Program, Sound, Track
|
|
from .import_playlist import PlaylistImport
|
|
|
|
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
|
|
|
|
def sync(self, sound=None, program=None, deleted=False, **kwargs):
|
|
"""
|
|
Update related sound model and save it.
|
|
"""
|
|
if deleted:
|
|
sound = Sound.objects.filter(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
|
|
program = kwargs['program'] = Program.get_from_path(self.path)
|
|
sound, created = Sound.objects.get_or_create(path=self.path, defaults=kwargs) \
|
|
if not sound else (sound, False)
|
|
|
|
sound.program = program
|
|
if created or sound.check_on_file():
|
|
logger.info('sound is new or have been modified -> %s', self.path)
|
|
self.read_path()
|
|
self.read_file_info()
|
|
sound.duration = utils.seconds_to_time(self.info.info.length)
|
|
sound.name = self.path_info.get('name')
|
|
|
|
# check for episode
|
|
if sound.episode is None and self.read_path():
|
|
self.find_episode(program)
|
|
|
|
self.sound = sound
|
|
sound.save()
|
|
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:
|
|
self.path_info = match.groupdict()
|
|
return True
|
|
else:
|
|
self.path_info = {'name': name}
|
|
return False
|
|
|
|
def read_file_info(self):
|
|
""" Read file information and metadata. """
|
|
self.info = mutagen.File(self.path)
|
|
|
|
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 'hour' not in pi:
|
|
date = datetime.date(pi.get('year'), pi.get('month'), pi.get('day'))
|
|
else:
|
|
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)
|
|
|
|
diffusion = program.diffusion_set.initial().at(date).first()
|
|
if not diffusion:
|
|
return None
|
|
|
|
logger.info('%s <--> %s', self.sound.path, 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():
|
|
return
|
|
|
|
# import playlist
|
|
path = os.path.splitext(self.sound.path)[0] + '.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.tags:
|
|
tags = self.info.tags
|
|
info = '{} ({})'.format(tags.get('album'), tags.get('year')) \
|
|
if ('album' and 'year' in tags) else tags.get('album') \
|
|
if 'album' in tags else tags.get('year', '')
|
|
|
|
track = Track(sound=sound,
|
|
position=int(tags.get('tracknumber', 0)),
|
|
title=tags.get('title', self.path_info['name']),
|
|
artist=tags.get('artist', _('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(path=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.src_path)
|
|
|
|
|
|
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 scan(self):
|
|
"""
|
|
For all programs, scan dirs
|
|
"""
|
|
logger.info('scan all programs...')
|
|
programs = Program.objects.filter()
|
|
|
|
dirs = []
|
|
for program in programs:
|
|
logger.info('#%d %s', program.id, program.title)
|
|
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,
|
|
)
|
|
dirs.append(os.path.join(program.path))
|
|
|
|
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
|
|
|
|
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
|
|
|
|
sound_file = SoundFile(path)
|
|
sound_file.sync(program=program, **sound_kwargs)
|
|
sounds.append(sound_file.sound.pk)
|
|
|
|
# sounds in db & unchecked
|
|
sounds = Sound.objects.filter(path__startswith=subdir). \
|
|
exclude(pk__in=sounds)
|
|
self.check_sounds(sounds)
|
|
|
|
@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.sync(sound=sound)
|
|
|
|
def monitor(self):
|
|
""" Run in monitor mode """
|
|
with futures.ThreadPoolExecutor() as pool:
|
|
archives_handler = MonitorHandler(settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, pool)
|
|
excerpts_handler = MonitorHandler(settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, pool)
|
|
|
|
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)
|
|
|
|
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()
|