code quality

This commit is contained in:
bkfox
2023-03-13 17:47:00 +01:00
parent 934817da8a
commit 112770eddf
162 changed files with 4798 additions and 4069 deletions

View File

@ -1,41 +1,48 @@
"""Handle archiving of logs in order to keep database light and fast.
The logs are archived in gzip files, per day.
"""
Handle archiving of logs in order to keep database light and fast. The
logs are archived in gzip files, per day.
"""
from argparse import RawTextHelpFormatter
import datetime
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from django.utils import timezone as tz
import aircox.settings as settings
from aircox.models import Log, Station
from aircox.models import Log
from aircox.models.log import LogArchiver
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
class Command (BaseCommand):
__all__ = ("Command",)
class Command(BaseCommand):
help = __doc__
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
group = parser.add_argument_group('actions')
group = parser.add_argument_group("actions")
group.add_argument(
'-a', '--age', type=int,
"-a",
"--age",
type=int,
default=settings.AIRCOX_LOGS_ARCHIVES_AGE,
help='minimal age in days of logs to archive. Default is '
'settings.AIRCOX_LOGS_ARCHIVES_AGE'
help="minimal age in days of logs to archive. Default is "
"settings.AIRCOX_LOGS_ARCHIVES_AGE",
)
group.add_argument(
'-k', '--keep', action='store_true',
help='keep logs in database instead of deleting them'
"-k",
"--keep",
action="store_true",
help="keep logs in database instead of deleting them",
)
def handle(self, *args, age, keep, **options):
date = datetime.date.today() - tz.timedelta(days=age)
# FIXME: mysql support?
logger.info('archive logs for %s and earlier', date)
logger.info("archive logs for %s and earlier", date)
count = LogArchiver().archive(Log.objects.filter(date__date__lte=date))
logger.info('total log archived %d', count)
logger.info("total log archived %d", count)

View File

@ -1,5 +1,4 @@
"""
Manage diffusions using schedules, to update, clean up or check diffusions.
"""Manage diffusions using schedules, to update, clean up or check diffusions.
A generated diffusion can be unconfirmed, that means that the user must confirm
it by changing its type to "normal". The behaviour is controlled using
@ -13,9 +12,9 @@ from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone as tz
from aircox.models import Schedule, Diffusion
from aircox.models import Diffusion, Schedule
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
class Actions:
@ -26,20 +25,28 @@ class Actions:
def update(self):
episodes, diffusions = [], []
for schedule in Schedule.objects.filter(program__active=True,
initial__isnull=True):
for schedule in Schedule.objects.filter(
program__active=True, initial__isnull=True
):
eps, diffs = schedule.diffusions_of_month(self.date)
if eps:
episodes += eps
if diffs:
diffusions += diffs
logger.info('[update] %s: %d episodes, %d diffusions and reruns',
str(schedule), len(eps), len(diffs))
logger.info(
"[update] %s: %d episodes, %d diffusions and reruns",
str(schedule),
len(eps),
len(diffs),
)
with transaction.atomic():
logger.info('[update] save %d episodes and %d diffusions',
len(episodes), len(diffusions))
logger.info(
"[update] save %d episodes and %d diffusions",
len(episodes),
len(diffusions),
)
for episode in episodes:
episode.save()
for diffusion in diffusions:
@ -48,9 +55,10 @@ class Actions:
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(type=Diffusion.TYPE_UNCONFIRMED,
start__lt=self.date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs = Diffusion.objects.filter(
type=Diffusion.TYPE_UNCONFIRMED, start__lt=self.date
)
logger.info("[clean] %d diffusions will be removed", qs.count())
qs.delete()
@ -61,45 +69,57 @@ class Command(BaseCommand):
parser.formatter_class = RawTextHelpFormatter
today = datetime.date.today()
group = parser.add_argument_group('action')
group = parser.add_argument_group("action")
group.add_argument(
'-u', '--update', action='store_true',
help='generate (unconfirmed) diffusions for the given month. '
'These diffusions must be confirmed manually by changing '
'their type to "normal"'
"-u",
"--update",
action="store_true",
help="generate (unconfirmed) diffusions for the given month. "
"These diffusions must be confirmed manually by changing "
'their type to "normal"',
)
group.add_argument(
'-l', '--clean', action='store_true',
help='remove unconfirmed diffusions older than the given month'
"-l",
"--clean",
action="store_true",
help="remove unconfirmed diffusions older than the given month",
)
group = parser.add_argument_group('date')
group = parser.add_argument_group("date")
group.add_argument(
'--year', type=int, default=today.year,
help='used by update, default is today\'s year')
"--year",
type=int,
default=today.year,
help="used by update, default is today's year",
)
group.add_argument(
'--month', type=int, default=today.month,
help='used by update, default is today\'s month')
"--month",
type=int,
default=today.month,
help="used by update, default is today's month",
)
group.add_argument(
'--next-month', action='store_true',
help='set the date to the next month of given date'
' (if next month from today'
"--next-month",
action="store_true",
help="set the date to the next month of given date"
" (if next month from today",
)
def handle(self, *args, **options):
date = datetime.date(year=options['year'], month=options['month'],
day=1)
if options.get('next_month'):
month = options.get('month')
date = datetime.date(
year=options["year"], month=options["month"], day=1
)
if options.get("next_month"):
month = options.get("month")
date += tz.timedelta(days=28)
if date.month == month:
date += tz.timedelta(days=28)
date = date.replace(day=1)
actions = Actions(date)
if options.get('update'):
if options.get("update"):
actions.update()
if options.get('clean'):
if options.get("clean"):
actions.clean()
if options.get('check'):
if options.get("check"):
actions.check()

View File

@ -1,5 +1,4 @@
"""
Import one or more playlist for the given sound. Attach it to the provided
"""Import one or more playlist for the given sound. Attach it to the provided
sound.
Playlists are in CSV format, where columns are separated with a
@ -10,23 +9,22 @@ The order of the elements is: {settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS}
If 'minutes' or 'seconds' are given, position will be expressed as timed
position, instead of position in playlist.
"""
import os
import csv
import logging
import os
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from aircox import settings
from aircox.models import *
from aircox.models import Sound, Track
__doc__ = __doc__.format(settings=settings)
__all__ = ('PlaylistImport', 'Command')
__all__ = ("PlaylistImport", "Command")
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
class PlaylistImport:
@ -45,62 +43,74 @@ class PlaylistImport:
def run(self):
self.read()
if self.track_kwargs.get('sound') is not None:
if self.track_kwargs.get("sound") is not None:
self.make_playlist()
def read(self):
if not os.path.exists(self.path):
return True
with open(self.path, 'r') as file:
logger.info('start reading csv ' + self.path)
self.data = list(csv.DictReader(
(row for row in file
if not (row.startswith('#') or row.startswith('\ufeff#'))
and row.strip()),
fieldnames=settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS,
delimiter=settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar=settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE,
))
with open(self.path, "r") as file:
logger.info("start reading csv " + self.path)
self.data = list(
csv.DictReader(
(
row
for row in file
if not (
row.startswith("#") or row.startswith("\ufeff#")
)
and row.strip()
),
fieldnames=settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS,
delimiter=settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar=settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE,
)
)
def make_playlist(self):
"""Make a playlist from the read data, and return it.
If save is true, save it into the database
"""
Make a playlist from the read data, and return it. If save is
true, save it into the database
"""
if self.track_kwargs.get('sound') is None:
logger.error('related track\'s sound is missing. Skip import of ' +
self.path + '.')
if self.track_kwargs.get("sound") is None:
logger.error(
"related track's sound is missing. Skip import of "
+ self.path
+ "."
)
return
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
tracks = []
logger.info('parse csv file ' + self.path)
has_timestamp = ('minutes' or 'seconds') in maps
logger.info("parse csv file " + self.path)
has_timestamp = ("minutes" or "seconds") in maps
for index, line in enumerate(self.data):
if ('title' or 'artist') not in line:
if ("title" or "artist") not in line:
return
try:
timestamp = int(line.get('minutes') or 0) * 60 + \
int(line.get('seconds') or 0) \
if has_timestamp else None
timestamp = (
int(line.get("minutes") or 0) * 60
+ int(line.get("seconds") or 0)
if has_timestamp
else None
)
track, created = Track.objects.get_or_create(
title=line.get('title'),
artist=line.get('artist'),
title=line.get("title"),
artist=line.get("artist"),
position=index,
**self.track_kwargs
)
track.timestamp = timestamp
track.info = line.get('info')
tags = line.get('tags')
track.info = line.get("info")
tags = line.get("tags")
if tags:
track.tags.add(*tags.lower().split(','))
track.tags.add(*tags.lower().split(","))
except Exception as err:
logger.warning(
'an error occured for track {index}, it may not '
'have been saved: {err}'
.format(index=index, err=err)
"an error occured for track {index}, it may not "
"have been saved: {err}".format(index=index, err=err)
)
continue
@ -116,33 +126,41 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
parser.add_argument(
'path', metavar='PATH', type=str,
help='path of the input playlist to read'
"path",
metavar="PATH",
type=str,
help="path of the input playlist to read",
)
parser.add_argument(
'--sound', '-s', type=str,
help='generate a playlist for the sound of the given path. '
'If not given, try to match a sound with the same path.'
"--sound",
"-s",
type=str,
help="generate a playlist for the sound of the given path. "
"If not given, try to match a sound with the same path.",
)
def handle(self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get('sound'):
sound = Sound.objects.filter(file__icontains=options.get('sound'))\
.first()
if options.get("sound"):
sound = Sound.objects.filter(
file__icontains=options.get("sound")
).first()
else:
path_, ext = os.path.splitext(path)
sound = Sound.objects.filter(path__icontains=path_).first()
if not sound:
logger.error('no sound found in the database for the path '
'{path}'.format(path=path))
logger.error(
"no sound found in the database for the path "
"{path}".format(path=path)
)
return
# FIXME: auto get sound.episode if any
importer = PlaylistImport(path, sound=sound).run()
for track in importer.tracks:
logger.info('track #{pos} imported: {title}, by {artist}'.format(
pos=track.position, title=track.title, artist=track.artist
))
logger.info(
"track #{pos} imported: {title}, by {artist}".format(
pos=track.position, title=track.title, artist=track.artist
)
)

View File

@ -1,7 +1,7 @@
#! /usr/bin/env python3
"""
Monitor sound files; For each program, check for:
"""Monitor sound files; For each program, check for:
- new files;
- deleted files;
- differences between files and sound;
@ -23,23 +23,22 @@ 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 atexit
import concurrent.futures as futures
import logging
import os
import time
from watchdog.observers import Observer
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from watchdog.observers import Observer
from aircox import settings
from aircox.models import Program, Sound
from aircox.management.sound_file import SoundFile
from aircox.management.sound_monitor import MonitorHandler
from aircox.models import Program, Sound
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
class Command(BaseCommand):
@ -47,39 +46,42 @@ class Command(BaseCommand):
def report(self, program=None, component=None, *content):
if not component:
logger.info('%s: %s', str(program),
' '.join([str(c) for c in content]))
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]))
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...')
"""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)
logger.info("#%d %s", program.id, program.title)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
program,
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type=Sound.TYPE_ARCHIVE,
)
self.scan_for_program(
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
program,
settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type=Sound.TYPE_EXCERPT,
)
dirs.append(os.path.join(program.abspath))
return dirs
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)
"""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
@ -97,37 +99,49 @@ class Command(BaseCommand):
sounds.append(sound_file.sound.pk)
# sounds in db & unchecked
sounds = Sound.objects.filter(file__startswith=subdir). \
exclude(pk__in=sounds)
sounds = Sound.objects.filter(file__startswith=subdir).exclude(
pk__in=sounds
)
self.check_sounds(sounds, program=program)
def check_sounds(self, qs, **sync_kwargs):
""" Only check for the sound existence or update """
"""Only check for the sound existence or update."""
# check files
for sound in qs:
if sound.check_on_file():
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
def monitor(self):
""" Run in monitor mode """
"""Run in monitor mode."""
with futures.ThreadPoolExecutor() as pool:
archives_handler = MonitorHandler(
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, pool,
type=Sound.TYPE_ARCHIVE)
settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
pool,
type=Sound.TYPE_ARCHIVE,
)
excerpts_handler = MonitorHandler(
settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, pool,
type=Sound.TYPE_EXCERPT)
settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
pool,
type=Sound.TYPE_EXCERPT,
)
observer = Observer()
observer.schedule(archives_handler, settings.AIRCOX_PROGRAMS_DIR_ABS,
recursive=True)
observer.schedule(excerpts_handler, settings.AIRCOX_PROGRAMS_DIR_ABS,
recursive=True)
observer.schedule(
archives_handler,
settings.AIRCOX_PROGRAMS_DIR_ABS,
recursive=True,
)
observer.schedule(
excerpts_handler,
settings.AIRCOX_PROGRAMS_DIR_ABS,
recursive=True,
)
observer.start()
def leave():
observer.stop()
observer.join()
atexit.register(leave)
while True:
@ -136,25 +150,31 @@ class Command(BaseCommand):
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'
"-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'
"-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'
"-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'):
if options.get("scan"):
self.scan()
#if options.get('quality_check'):
# if options.get('quality_check'):
# self.check_quality(check=(not options.get('scan')))
if options.get('monitor'):
if options.get("monitor"):
self.monitor()

View File

@ -1,17 +1,15 @@
"""
Analyse and check files using Sox, prints good and bad files.
"""
"""Analyse and check files using Sox, prints good and bad files."""
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from aircox.management.sound_stats import SoxStats, SoundStats
from aircox.management.sound_stats import SoundStats, SoxStats
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
class Command (BaseCommand):
class Command(BaseCommand):
help = __doc__
sounds = None
@ -19,46 +17,61 @@ class Command (BaseCommand):
parser.formatter_class = RawTextHelpFormatter
parser.add_argument(
'files', metavar='FILE', type=str, nargs='+',
help='file(s) to analyse'
"files",
metavar="FILE",
type=str,
nargs="+",
help="file(s) to analyse",
)
parser.add_argument(
'-s', '--sample_length', type=int, default=120,
help='size of sample to analyse in seconds. If not set (or 0), does'
' not analyse by sample',
"-s",
"--sample_length",
type=int,
default=120,
help="size of sample to analyse in seconds. If not set (or 0), "
"does not analyse by sample",
)
parser.add_argument(
'-a', '--attribute', type=str,
help='attribute name to use to check, that can be:\n' +
', '.join(['"{}"'.format(attr) for attr in SoxStats.attributes])
"-a",
"--attribute",
type=str,
help="attribute name to use to check, that can be:\n"
+ ", ".join(['"{}"'.format(attr) for attr in SoxStats.attributes]),
)
parser.add_argument(
'-r', '--range', type=float, nargs=2,
help='range of minimal and maximal accepted value such as: '
'--range min max'
"-r",
"--range",
type=float,
nargs=2,
help="range of minimal and maximal accepted value such as: "
"--range min max",
)
parser.add_argument(
'-i', '--resume', action='store_true',
help='print a resume of good and bad files'
"-i",
"--resume",
action="store_true",
help="print a resume of good and bad files",
)
def handle(self, *args, **options):
# parameters
minmax = options.get('range')
minmax = options.get("range")
if not minmax:
raise CommandError('no range specified')
raise CommandError("no range specified")
attr = options.get('attribute')
attr = options.get("attribute")
if not attr:
raise CommandError('no attribute specified')
raise CommandError("no attribute specified")
# sound analyse and checks
self.sounds = [SoundStats(path, options.get('sample_length'))
for path in options.get('files')]
self.sounds = [
SoundStats(path, options.get("sample_length"))
for path in options.get("files")
]
self.bad = []
self.good = []
for sound in self.sounds:
logger.info('analyse ' + sound.path)
logger.info("analyse " + sound.path)
sound.analyse()
sound.check(attr, minmax[0], minmax[1])
if sound.bad:
@ -67,8 +80,8 @@ class Command (BaseCommand):
self.good.append(sound)
# resume
if options.get('resume'):
if options.get("resume"):
for sound in self.good:
logger.info('\033[92m+ %s\033[0m', sound.path)
logger.info("\033[92m+ %s\033[0m", sound.path)
for sound in self.bad:
logger.info('\033[91m+ %s\033[0m', sound.path)
logger.info("\033[91m+ %s\033[0m", sound.path)

View File

@ -1,7 +1,5 @@
#! /usr/bin/env python3
"""
Provide SoundFile which is used to link between database and file system.
"""Provide SoundFile which is used to link between database and file system.
File name
=========
@ -22,28 +20,27 @@ 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
from datetime import date
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')
logger = logging.getLogger("aircox.commands")
class SoundFile:
"""
Handle synchronisation between sounds on files and database.
"""
"""Handle synchronisation between sounds on files and database."""
path = None
info = None
path_info = None
@ -54,18 +51,22 @@ class SoundFile:
@property
def sound_path(self):
""" Relative path name """
return self.path.replace(conf.MEDIA_ROOT + '/', '')
"""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, keep_deleted=False,
**kwargs):
"""
Update related sound model and save it.
"""
def sync(
self,
sound=None,
program=None,
deleted=False,
keep_deleted=False,
**kwargs
):
"""Update related sound model and save it."""
if deleted:
return self._on_delete(self.path, keep_deleted)
@ -73,26 +74,27 @@ class SoundFile:
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
kwargs["program_id"] = program.pk
if sound:
created = False
else:
sound, created = Sound.objects.get_or_create(
file=self.sound_path, defaults=kwargs)
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')
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:
if sound.episode is None and "year" in self.path_info:
sound.episode = self.find_episode(sound, self.path_info)
sound.save()
@ -114,8 +116,9 @@ class SoundFile:
Sound.objects.path(self.path).delete()
def read_path(self, path):
"""
Parse path name returning dictionary of extracted info. It can contain:
"""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)
@ -126,29 +129,29 @@ class SoundFile:
reg_match = self._path_re.search(basename)
if reg_match:
info = reg_match.groupdict()
for k in ('year', 'month', 'day', 'hour', 'minute', 'n'):
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
name = info.get("name")
info["name"] = name and self._into_name(name) or basename
else:
info = {'name': basename}
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>.*)$'
"^(?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(' '))
name = name.replace("_", " ")
return " ".join(r.capitalize() for r in name.split(" "))
def read_file_info(self):
""" Read file information and metadata. """
"""Read file information and metadata."""
try:
if os.path.exists(self.path):
return mutagen.File(self.path)
@ -157,22 +160,21 @@ class SoundFile:
return 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.
"""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.
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:
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))
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)
@ -181,13 +183,12 @@ class SoundFile:
if not diffusion:
return None
logger.debug('%s <--> %s', sound.file.name, str(diffusion.episode))
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
"""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.
@ -199,7 +200,7 @@ class SoundFile:
# import playlist
path_noext, ext = os.path.splitext(self.sound.file.path)
path = path_noext + '.csv'
path = path_noext + ".csv"
if os.path.exists(path):
PlaylistImport(path, sound=sound).run()
# use metadata
@ -209,18 +210,27 @@ class SoundFile:
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'))
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,
)
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()

View File

@ -1,7 +1,7 @@
#! /usr/bin/env python3
"""
Monitor sound files; For each program, check for:
"""Monitor sound files; For each program, check for:
- new files;
- deleted files;
- differences between files and sound;
@ -23,9 +23,9 @@ 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 datetime import datetime, timedelta
from watchdog.events import PatternMatchingEventHandler
@ -34,12 +34,17 @@ from aircox.models import Sound
from .sound_file import SoundFile
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
__all__ = ('NotifyHandler', 'CreateHandler', 'DeleteHandler',
'MoveHandler', 'ModifiedHandler', 'MonitorHandler',)
__all__ = (
"NotifyHandler",
"CreateHandler",
"DeleteHandler",
"MoveHandler",
"ModifiedHandler",
"MonitorHandler",
)
class NotifyHandler:
@ -63,34 +68,34 @@ class NotifyHandler:
class CreateHandler(NotifyHandler):
log_msg = 'Sound file created: {sound_file.path}'
log_msg = "Sound file created: {sound_file.path}"
class DeleteHandler(NotifyHandler):
log_msg = 'Sound file deleted: {sound_file.path}'
log_msg = "Sound file deleted: {sound_file.path}"
def __call__(self, *args, **kwargs):
kwargs['deleted'] = True
kwargs["deleted"] = True
return super().__call__(*args, **kwargs)
class MoveHandler(NotifyHandler):
log_msg = 'Sound file moved: {event.src_path} -> {event.dest_path}'
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
kw["sound"] = sound
kw["path"] = event.src_path
else:
kw['path'] = event.dest_path
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}'
log_msg = "Sound file updated: {sound_file.path}"
def wait(self):
# multiple call of this handler can be done consecutively, we block
@ -108,9 +113,8 @@ class ModifiedHandler(NotifyHandler):
class MonitorHandler(PatternMatchingEventHandler):
"""
Event handler for watchdog, in order to be used in monitoring.
"""
"""Event handler for watchdog, in order to be used in monitoring."""
pool = None
jobs = {}
@ -118,35 +122,39 @@ class MonitorHandler(PatternMatchingEventHandler):
"""
:param str subdir: sub-directory in program dirs to monitor \
(AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR);
:param concurrent.futures.Executor pool: pool executing jobs on file change;
:param concurrent.futures.Executor pool: pool executing jobs on file
change;
:param **sync_kw: kwargs passed to `SoundFile.sync`;
"""
self.subdir = subdir
self.pool = pool
self.sync_kw = sync_kw
patterns = ['*/{}/*{}'.format(self.subdir, ext)
for ext in settings.AIRCOX_SOUND_FILE_EXT]
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)
self._submit(CreateHandler(), event, "new", **self.sync_kw)
def on_deleted(self, event):
self._submit(DeleteHandler(), event, 'del')
self._submit(DeleteHandler(), event, "del")
def on_moved(self, event):
self._submit(MoveHandler(), event, 'mv', **self.sync_kw)
self._submit(MoveHandler(), event, "mv", **self.sync_kw)
def on_modified(self, event):
self._submit(ModifiedHandler(), event, 'up', **self.sync_kw)
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.
"""
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
key = job_key_prefix + ":" + event.src_path
job = self.jobs.get(key)
if job and not job.future.done():
job.ping()
@ -158,5 +166,6 @@ class MonitorHandler(PatternMatchingEventHandler):
def done(r):
if self.jobs.get(key) is handler:
del self.jobs[key]
handler.future.add_done_callback(done)
return handler, True

View File

@ -1,30 +1,31 @@
"""
Provide sound analysis class using Sox.
"""
"""Provide sound analysis class using Sox."""
import logging
import re
import subprocess
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
__all__ = ('SoxStats', 'SoundStats')
__all__ = ("SoxStats", "SoundStats")
class SoxStats:
"""
Run Sox process and parse output
"""
"""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',
"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
"""
"""If path is given, call analyse with path and kwargs."""
self.values = {}
if path:
self.analyse(path, **kwargs)
@ -34,82 +35,95 @@ class SoxStats:
def parse(self, output):
for attr in self.attributes:
value = re.search(attr + r'\s+(?P<value>\S+)', output)
value = re.search(attr + r"\s+(?P<value>\S+)", output)
value = value and value.groupdict()
if value:
try:
value = float(value.get('value'))
value = float(value.get("value"))
except ValueError:
value = None
self.values[attr] = value
self.values['length'] = self.values['Length s']
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 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 += ["trim", str(at), str(length)]
args.append('stats')
args.append("stats")
p = subprocess.Popen(args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
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'))
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
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
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')
logger.debug("complete file analysis")
self.stats = [SoxStats(self.path)]
position = 0
length = self.stats[0].get('length')
length = self.stats[0].get("length")
if not self.sample_length:
return
logger.debug('start samples analysis...')
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.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
]
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)))
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)))
logger.debug(
self.path + " -> bad: \033[91m%s\033[0m",
", ".join(view(self.bad)),
)