!111: tests: aircox.management
#114
8
aircox/controllers/README.md
Normal file
8
aircox/controllers/README.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# aircox.controllers
|
||||||
|
This module provides the following controllers classes:
|
||||||
|
- `log_archiver.LogArchiver`: dumps and load gzip archives from Log models.
|
||||||
|
- `sound_file.SoundFile`: handle synchronisation between filesystem and database for a sound file.
|
||||||
|
- `sound_monitor.SoundMonitor`: monitor filesystem for changes on audio files and synchronise database.
|
||||||
|
- `sound_stats.SoundStats` (+ `SoxStats`): get audio statistics of an audio file using Sox.
|
||||||
|
- `diffuions.Diffusions`: generate, update and clean diffusions.
|
||||||
|
- `playlist_import.PlaylistImport`: import playlists from CSV.
|
0
aircox/controllers/__init__.py
Normal file
0
aircox/controllers/__init__.py
Normal file
112
aircox/controllers/log_archiver.py
Normal file
112
aircox/controllers/log_archiver.py
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
from aircox.conf import settings
|
||||||
|
from aircox.models import Diffusion, Sound, Track, Log
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("LogArchiver",)
|
||||||
|
|
||||||
|
|
||||||
|
class LogArchiver:
|
||||||
|
"""Commodity class used to manage archives of logs."""
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def fields(self):
|
||||||
|
return Log._meta.get_fields()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_path(station, date):
|
||||||
|
return os.path.join(
|
||||||
|
settings.LOGS_ARCHIVES_DIR_ABS,
|
||||||
|
"{}_{}.log.gz".format(date.strftime("%Y%m%d"), station.pk),
|
||||||
|
)
|
||||||
|
|
||||||
|
def archive(self, qs, keep=False):
|
||||||
|
"""Archive logs of the given queryset.
|
||||||
|
|
||||||
|
Delete archived logs if not `keep`. Return the count of archived
|
||||||
|
logs
|
||||||
|
"""
|
||||||
|
if not qs.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(settings.LOGS_ARCHIVES_DIR_ABS, exist_ok=True)
|
||||||
|
count = qs.count()
|
||||||
|
logs = self.sort_logs(qs)
|
||||||
|
|
||||||
|
# Note: since we use Yaml, we can just append new logs when file
|
||||||
|
# exists yet <3
|
||||||
|
for (station, date), logs in logs.items():
|
||||||
|
path = self.get_path(station, date)
|
||||||
|
# FIXME: remove binary mode
|
||||||
|
with gzip.open(path, "ab") as archive:
|
||||||
|
data = yaml.dump(
|
||||||
|
[self.serialize(line) for line in logs]
|
||||||
|
).encode("utf8")
|
||||||
|
archive.write(data)
|
||||||
|
|
||||||
|
if not keep:
|
||||||
|
qs.delete()
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sort_logs(qs):
|
||||||
|
"""Sort logs by station and date and return a dict of `{
|
||||||
|
(station,date): [logs] }`."""
|
||||||
|
qs = qs.order_by("date")
|
||||||
|
logs = {}
|
||||||
|
for log in qs:
|
||||||
|
key = (log.station, log.date.date())
|
||||||
|
logs.setdefault(key, []).append(log)
|
||||||
|
return logs
|
||||||
|
|
||||||
|
def serialize(self, log):
|
||||||
|
"""Serialize log."""
|
||||||
|
return {i.attname: getattr(log, i.attname) for i in self.fields}
|
||||||
|
|
||||||
|
def load(self, station, date):
|
||||||
|
"""Load an archive returning logs in a list."""
|
||||||
|
path = self.get_path(station, date)
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
return self.load_file(path)
|
||||||
|
|
||||||
|
def load_file(self, path):
|
||||||
|
with gzip.open(path, "rb") as archive:
|
||||||
|
data = archive.read()
|
||||||
|
logs = yaml.load(data)
|
||||||
|
|
||||||
|
# we need to preload diffusions, sounds and tracks
|
||||||
|
rels = {
|
||||||
|
"diffusion": self.get_relations(logs, Diffusion, "diffusion"),
|
||||||
|
"sound": self.get_relations(logs, Sound, "sound"),
|
||||||
|
"track": self.get_relations(logs, Track, "track"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def rel_obj(log, attr):
|
||||||
|
rel_id = log.get(attr + "_id")
|
||||||
|
return rels[attr][rel_id] if rel_id else None
|
||||||
|
|
||||||
|
return [
|
||||||
|
Log(
|
||||||
|
diffusion=rel_obj(log, "diffusion"),
|
||||||
|
sound=rel_obj(log, "sound"),
|
||||||
|
track=rel_obj(log, "track"),
|
||||||
|
**log
|
||||||
|
)
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_relations(logs, model, attr):
|
||||||
|
"""From a list of dict representing logs, retrieve related objects of
|
||||||
|
the given type."""
|
||||||
|
attr_id = attr + "_id"
|
||||||
|
pks = {log[attr_id] for log in logs if attr_id in log}
|
||||||
|
return {rel.pk: rel for rel in model.objects.filter(pk__in=pks)}
|
117
aircox/controllers/playlist_import.py
Normal file
117
aircox/controllers/playlist_import.py
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
from aircox.conf import settings
|
||||||
|
from aircox.models import Track
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("PlaylistImport",)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistImport:
|
||||||
|
"""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
|
||||||
|
'{settings.IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
|
||||||
|
{settings.IMPORT_PLAYLIST_CSV_TEXT_QUOTE}.
|
||||||
|
|
||||||
|
If 'minutes' or 'seconds' are given, position will be expressed as timed
|
||||||
|
position, instead of position in playlist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = None
|
||||||
|
data = None
|
||||||
|
tracks = None
|
||||||
|
track_kwargs = {}
|
||||||
|
|
||||||
|
def __init__(self, path=None, **track_kwargs):
|
||||||
|
self.path = path
|
||||||
|
self.track_kwargs = track_kwargs
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.data = None
|
||||||
|
self.tracks = None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.read()
|
||||||
|
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.IMPORT_PLAYLIST_CSV_COLS,
|
||||||
|
delimiter=settings.IMPORT_PLAYLIST_CSV_DELIMITER,
|
||||||
|
quotechar=settings.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
|
||||||
|
"""
|
||||||
|
if self.track_kwargs.get("sound") is None:
|
||||||
|
logger.error(
|
||||||
|
"related track's sound is missing. Skip import of "
|
||||||
|
+ self.path
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
maps = settings.IMPORT_PLAYLIST_CSV_COLS
|
||||||
|
tracks = []
|
||||||
|
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
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"),
|
||||||
|
position=index,
|
||||||
|
**self.track_kwargs
|
||||||
|
)
|
||||||
|
track.timestamp = timestamp
|
||||||
|
track.info = line.get("info")
|
||||||
|
tags = line.get("tags")
|
||||||
|
if tags:
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
track.save()
|
||||||
|
tracks.append(track)
|
||||||
|
self.tracks = tracks
|
||||||
|
return tracks
|
|
@ -33,7 +33,7 @@ from django.utils.translation import gettext as _
|
||||||
from aircox import utils
|
from aircox import utils
|
||||||
from aircox.models import Program, Sound, Track
|
from aircox.models import Program, Sound, Track
|
||||||
|
|
||||||
from .commands.import_playlist import PlaylistImport
|
from .playlist_import import PlaylistImport
|
||||||
|
|
||||||
logger = logging.getLogger("aircox.commands")
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
321
aircox/controllers/sound_monitor.py
Normal file
321
aircox/controllers/sound_monitor.py
Normal file
|
@ -0,0 +1,321 @@
|
||||||
|
#! /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 SOUND_QUALITY. This script requires
|
||||||
|
Sox (and soxi).
|
||||||
|
"""
|
||||||
|
import atexit
|
||||||
|
from concurrent import futures
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
# from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.utils.timezone import datetime, timedelta
|
||||||
|
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import PatternMatchingEventHandler
|
||||||
|
|
||||||
|
from aircox.conf import settings
|
||||||
|
from aircox.models import Sound, Program
|
||||||
|
|
||||||
|
from .sound_file import SoundFile
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: logger should be different in used classes (e.g. "aircox.commands")
|
||||||
|
# defaulting to logging.
|
||||||
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"Task",
|
||||||
|
"CreateTask",
|
||||||
|
"DeleteTask",
|
||||||
|
"MoveTask",
|
||||||
|
"ModifiedTask",
|
||||||
|
"MonitorHandler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Task:
|
||||||
|
"""Base class used to execute a specific task on file change event.
|
||||||
|
|
||||||
|
Handlers are sent to a multithread pool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
future = None
|
||||||
|
"""Future that promised the handler's call."""
|
||||||
|
log_msg = None
|
||||||
|
"""Log message to display on event happens."""
|
||||||
|
timestamp = None
|
||||||
|
"""Last ping timestamp (the event happened)."""
|
||||||
|
|
||||||
|
def __init__(self, logger=logging):
|
||||||
|
self.ping()
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
""""""
|
||||||
|
self.timestamp = datetime.now()
|
||||||
|
|
||||||
|
def __call__(self, event, path=None, logger=logging, **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 CreateTask(Task):
|
||||||
|
log_msg = "Sound file created: {sound_file.path}"
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteTask(Task):
|
||||||
|
log_msg = "Sound file deleted: {sound_file.path}"
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
kwargs["deleted"] = True
|
||||||
|
return super().__call__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MoveTask(Task):
|
||||||
|
log_msg = "Sound file moved: {event.src_path} -> {event.dest_path}"
|
||||||
|
|
||||||
|
def __call__(self, event, **kw):
|
||||||
|
sound = Sound.objects.filter(file=event.src_path).first()
|
||||||
|
if sound:
|
||||||
|
kw["sound"] = sound
|
||||||
|
kw["path"] = event.src_path
|
||||||
|
else:
|
||||||
|
kw["path"] = event.dest_path
|
||||||
|
return super().__call__(event, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class ModifiedTask(Task):
|
||||||
|
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):
|
||||||
|
"""MonitorHandler is used as a Watchdog event handler.
|
||||||
|
|
||||||
|
It uses a multithread pool in order to execute tasks on events. If a
|
||||||
|
job already exists for this file and event, it pings existing job
|
||||||
|
without creating a new one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pool = None
|
||||||
|
jobs = None
|
||||||
|
|
||||||
|
def __init__(self, subdir, pool, jobs=None, **sync_kw):
|
||||||
|
"""
|
||||||
|
:param str subdir: sub-directory in program dirs to monitor \
|
||||||
|
(SOUND_ARCHIVES_SUBDIR or SOUND_EXCERPTS_SUBDIR);
|
||||||
|
: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.jobs = jobs or {}
|
||||||
|
self.sync_kw = sync_kw
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
"*/{}/*{}".format(self.subdir, ext)
|
||||||
|
for ext in settings.SOUND_FILE_EXT
|
||||||
|
]
|
||||||
|
super().__init__(patterns=patterns, ignore_directories=True)
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
self._submit(CreateTask(), event, "new", **self.sync_kw)
|
||||||
|
|
||||||
|
def on_deleted(self, event):
|
||||||
|
self._submit(DeleteTask(), event, "del")
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
self._submit(MoveTask(), event, "mv", **self.sync_kw)
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
self._submit(ModifiedTask(), 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):
|
||||||
|
if self.jobs.get(key) is handler:
|
||||||
|
del self.jobs[key]
|
||||||
|
|
||||||
|
handler.future.add_done_callback(done)
|
||||||
|
return handler, True
|
||||||
|
|
||||||
|
|
||||||
|
class SoundMonitor:
|
||||||
|
"""Monitor for filesystem changes in order to synchronise database and
|
||||||
|
analyse files of a provided program."""
|
||||||
|
|
||||||
|
def report(self, program=None, component=None, *content, logger=logging):
|
||||||
|
content = " ".join([str(c) for c in content])
|
||||||
|
logger.info(
|
||||||
|
f"{program}: {content}"
|
||||||
|
if not component
|
||||||
|
else f"{program}, {component}: {content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def scan(self, logger=logging):
|
||||||
|
"""For all programs, scan dirs.
|
||||||
|
|
||||||
|
Return scanned directories.
|
||||||
|
"""
|
||||||
|
logger.info("scan all programs...")
|
||||||
|
programs = Program.objects.filter()
|
||||||
|
|
||||||
|
dirs = []
|
||||||
|
for program in programs:
|
||||||
|
logger.info(f"#{program.id} {program.title}")
|
||||||
|
self.scan_for_program(
|
||||||
|
program,
|
||||||
|
settings.SOUND_ARCHIVES_SUBDIR,
|
||||||
|
logger=logger,
|
||||||
|
type=Sound.TYPE_ARCHIVE,
|
||||||
|
)
|
||||||
|
self.scan_for_program(
|
||||||
|
program,
|
||||||
|
settings.SOUND_EXCERPTS_SUBDIR,
|
||||||
|
logger=logger,
|
||||||
|
type=Sound.TYPE_EXCERPT,
|
||||||
|
)
|
||||||
|
dirs.append(program.abspath)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
def scan_for_program(
|
||||||
|
self, program, subdir, logger=logging, **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.abspath, subdir)
|
||||||
|
sounds = []
|
||||||
|
|
||||||
|
# sounds in directory
|
||||||
|
for path in os.listdir(subdir):
|
||||||
|
path = os.path.join(subdir, path)
|
||||||
|
if not path.endswith(settings.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(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."""
|
||||||
|
# check files
|
||||||
|
for sound in qs:
|
||||||
|
if sound.check_on_file():
|
||||||
|
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
|
||||||
|
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
def monitor(self, logger=logging):
|
||||||
|
if self._running:
|
||||||
|
raise RuntimeError("already running")
|
||||||
|
|
||||||
|
"""Run in monitor mode."""
|
||||||
|
with futures.ThreadPoolExecutor() as pool:
|
||||||
|
archives_handler = MonitorHandler(
|
||||||
|
settings.SOUND_ARCHIVES_SUBDIR,
|
||||||
|
pool,
|
||||||
|
type=Sound.TYPE_ARCHIVE,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
excerpts_handler = MonitorHandler(
|
||||||
|
settings.SOUND_EXCERPTS_SUBDIR,
|
||||||
|
pool,
|
||||||
|
type=Sound.TYPE_EXCERPT,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(
|
||||||
|
archives_handler,
|
||||||
|
settings.PROGRAMS_DIR_ABS,
|
||||||
|
recursive=True,
|
||||||
|
)
|
||||||
|
observer.schedule(
|
||||||
|
excerpts_handler,
|
||||||
|
settings.PROGRAMS_DIR_ABS,
|
||||||
|
recursive=True,
|
||||||
|
)
|
||||||
|
observer.start()
|
||||||
|
|
||||||
|
def leave():
|
||||||
|
observer.stop()
|
||||||
|
observer.join()
|
||||||
|
|
||||||
|
atexit.register(leave)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
while self._running:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
leave()
|
||||||
|
atexit.unregister(leave)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop monitor() loop."""
|
||||||
|
self._running = False
|
|
@ -24,16 +24,30 @@ class SoxStats:
|
||||||
"Length s",
|
"Length s",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, path, **kwargs):
|
values = None
|
||||||
|
|
||||||
|
def __init__(self, path=None, **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:
|
if path:
|
||||||
self.analyse(path, **kwargs)
|
self.analyse(path, **kwargs)
|
||||||
|
|
||||||
def get(self, attr):
|
def analyse(self, path, at=None, length=None):
|
||||||
return self.values.get(attr)
|
"""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.values = self.parse(str(out, encoding="utf-8"))
|
||||||
|
|
||||||
def parse(self, output):
|
def parse(self, output):
|
||||||
|
"""Parse sox output, settubg values from it."""
|
||||||
|
values = {}
|
||||||
for attr in self.attributes:
|
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()
|
value = value and value.groupdict()
|
||||||
|
@ -42,24 +56,12 @@ class SoxStats:
|
||||||
value = float(value.get("value"))
|
value = float(value.get("value"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
value = None
|
value = None
|
||||||
self.values[attr] = value
|
values[attr] = value
|
||||||
self.values["length"] = self.values["Length s"]
|
values["length"] = values.pop("Length s", None)
|
||||||
|
return values
|
||||||
|
|
||||||
def analyse(self, path, at=None, length=None):
|
def get(self, attr):
|
||||||
"""If at and length are given use them as excerpt to analyse."""
|
return self.values.get(attr)
|
||||||
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:
|
class SoundStats:
|
||||||
|
@ -71,19 +73,18 @@ class SoundStats:
|
||||||
|
|
||||||
def __init__(self, path, sample_length=None):
|
def __init__(self, path, sample_length=None):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.sample_length = (
|
if sample_length is not None:
|
||||||
sample_length if sample_length is not None else self.sample_length
|
self.sample_length = sample_length
|
||||||
)
|
|
||||||
|
|
||||||
def get_file_stats(self):
|
def get_file_stats(self):
|
||||||
return self.stats and self.stats[0]
|
return self.stats and self.stats[0] or None
|
||||||
|
|
||||||
def analyse(self):
|
def analyse(self):
|
||||||
logger.debug("complete file analysis")
|
logger.debug("complete file analysis")
|
||||||
self.stats = [SoxStats(self.path)]
|
self.stats = [SoxStats(self.path)]
|
||||||
position = 0
|
position = 0
|
||||||
length = self.stats[0].get("length")
|
length = self.stats[0].get("length")
|
||||||
|
print(self.stats, "-----")
|
||||||
if not self.sample_length:
|
if not self.sample_length:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -107,23 +108,23 @@ class SoundStats:
|
||||||
self.resume()
|
self.resume()
|
||||||
|
|
||||||
def resume(self):
|
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:
|
if self.good:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
self.path + " -> good: \033[92m%s\033[0m",
|
self.path + " -> good: \033[92m%s\033[0m",
|
||||||
", ".join(view(self.good)),
|
", ".join(self._view(self.good)),
|
||||||
)
|
)
|
||||||
if self.bad:
|
if self.bad:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
self.path + " -> bad: \033[91m%s\033[0m",
|
self.path + " -> bad: \033[91m%s\033[0m",
|
||||||
", ".join(view(self.bad)),
|
", ".join(self._view(self.bad)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _view(self, array):
|
||||||
|
return [
|
||||||
|
"file"
|
||||||
|
if index == 0
|
||||||
|
else "sample {} (at {} seconds)".format(
|
||||||
|
index, (index - 1) * self.sample_length
|
||||||
|
)
|
||||||
|
for index in array
|
||||||
|
]
|
|
@ -9,59 +9,13 @@ import logging
|
||||||
from argparse import RawTextHelpFormatter
|
from argparse import RawTextHelpFormatter
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone as tz
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
from aircox.models import Diffusion, Schedule
|
from aircox.controllers.diffusions import Diffusions
|
||||||
|
|
||||||
logger = logging.getLogger("aircox.commands")
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
||||||
|
|
||||||
class Actions:
|
|
||||||
date = None
|
|
||||||
|
|
||||||
def __init__(self, date):
|
|
||||||
self.date = date or datetime.date.today()
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
episodes, diffusions = [], []
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
logger.info(
|
|
||||||
"[update] save %d episodes and %d diffusions",
|
|
||||||
len(episodes),
|
|
||||||
len(diffusions),
|
|
||||||
)
|
|
||||||
for episode in episodes:
|
|
||||||
episode.save()
|
|
||||||
for diffusion in diffusions:
|
|
||||||
# force episode id's update
|
|
||||||
diffusion.episode = diffusion.episode
|
|
||||||
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.delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = __doc__
|
help = __doc__
|
||||||
|
|
||||||
|
@ -116,7 +70,7 @@ class Command(BaseCommand):
|
||||||
date += tz.timedelta(days=28)
|
date += tz.timedelta(days=28)
|
||||||
date = date.replace(day=1)
|
date = date.replace(day=1)
|
||||||
|
|
||||||
actions = Actions(date)
|
actions = Diffusions(date)
|
||||||
if options.get("update"):
|
if options.get("update"):
|
||||||
actions.update()
|
actions.update()
|
||||||
if options.get("clean"):
|
if options.get("clean"):
|
||||||
|
|
|
@ -9,7 +9,6 @@ The order of the elements is: {settings.IMPORT_PLAYLIST_CSV_COLS}
|
||||||
If 'minutes' or 'seconds' are given, position will be expressed as timed
|
If 'minutes' or 'seconds' are given, position will be expressed as timed
|
||||||
position, instead of position in playlist.
|
position, instead of position in playlist.
|
||||||
"""
|
"""
|
||||||
import csv
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from argparse import RawTextHelpFormatter
|
from argparse import RawTextHelpFormatter
|
||||||
|
@ -17,109 +16,19 @@ from argparse import RawTextHelpFormatter
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from aircox.conf import settings
|
from aircox.conf import settings
|
||||||
from aircox.models import Sound, Track
|
from aircox.models import Sound
|
||||||
|
from aircox.controllers.playlist_import import PlaylistImport
|
||||||
|
|
||||||
|
|
||||||
__doc__ = __doc__.format(settings=settings)
|
__doc__ = __doc__.format(settings=settings)
|
||||||
|
|
||||||
__all__ = ("PlaylistImport", "Command")
|
|
||||||
|
__all__ = ("Command",)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("aircox.commands")
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
||||||
|
|
||||||
class PlaylistImport:
|
|
||||||
path = None
|
|
||||||
data = None
|
|
||||||
tracks = None
|
|
||||||
track_kwargs = {}
|
|
||||||
|
|
||||||
def __init__(self, path=None, **track_kwargs):
|
|
||||||
self.path = path
|
|
||||||
self.track_kwargs = track_kwargs
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.data = None
|
|
||||||
self.tracks = None
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
self.read()
|
|
||||||
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.IMPORT_PLAYLIST_CSV_COLS,
|
|
||||||
delimiter=settings.IMPORT_PLAYLIST_CSV_DELIMITER,
|
|
||||||
quotechar=settings.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
|
|
||||||
"""
|
|
||||||
if self.track_kwargs.get("sound") is None:
|
|
||||||
logger.error(
|
|
||||||
"related track's sound is missing. Skip import of "
|
|
||||||
+ self.path
|
|
||||||
+ "."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
maps = settings.IMPORT_PLAYLIST_CSV_COLS
|
|
||||||
tracks = []
|
|
||||||
|
|
||||||
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:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
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"),
|
|
||||||
position=index,
|
|
||||||
**self.track_kwargs
|
|
||||||
)
|
|
||||||
track.timestamp = timestamp
|
|
||||||
track.info = line.get("info")
|
|
||||||
tags = line.get("tags")
|
|
||||||
if tags:
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
track.save()
|
|
||||||
tracks.append(track)
|
|
||||||
self.tracks = tracks
|
|
||||||
return tracks
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = __doc__
|
help = __doc__
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
#! /usr/bin/env python3
|
#! /usr/bin/env python3
|
||||||
|
# TODO: SoundMonitor class
|
||||||
|
|
||||||
"""Monitor sound files; For each program, check for:
|
"""Monitor sound files; For each program, check for:
|
||||||
|
|
||||||
|
@ -23,20 +24,12 @@ To check quality of files, call the command sound_quality_check using the
|
||||||
parameters given by the setting SOUND_QUALITY. This script requires
|
parameters given by the setting SOUND_QUALITY. This script requires
|
||||||
Sox (and soxi).
|
Sox (and soxi).
|
||||||
"""
|
"""
|
||||||
import atexit
|
|
||||||
import concurrent.futures as futures
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from argparse import RawTextHelpFormatter
|
from argparse import RawTextHelpFormatter
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from watchdog.observers import Observer
|
|
||||||
|
|
||||||
from aircox.conf import settings
|
from aircox.controllers.sound_monitor import SoundMonitor
|
||||||
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")
|
||||||
|
|
||||||
|
@ -44,109 +37,6 @@ logger = logging.getLogger("aircox.commands")
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = __doc__
|
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.SOUND_ARCHIVES_SUBDIR,
|
|
||||||
type=Sound.TYPE_ARCHIVE,
|
|
||||||
)
|
|
||||||
self.scan_for_program(
|
|
||||||
program,
|
|
||||||
settings.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)
|
|
||||||
if not program.ensure_dir(subdir):
|
|
||||||
return
|
|
||||||
|
|
||||||
subdir = os.path.join(program.abspath, subdir)
|
|
||||||
sounds = []
|
|
||||||
|
|
||||||
# sounds in directory
|
|
||||||
for path in os.listdir(subdir):
|
|
||||||
path = os.path.join(subdir, path)
|
|
||||||
if not path.endswith(settings.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(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."""
|
|
||||||
# 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."""
|
|
||||||
with futures.ThreadPoolExecutor() as pool:
|
|
||||||
archives_handler = MonitorHandler(
|
|
||||||
settings.SOUND_ARCHIVES_SUBDIR,
|
|
||||||
pool,
|
|
||||||
type=Sound.TYPE_ARCHIVE,
|
|
||||||
)
|
|
||||||
excerpts_handler = MonitorHandler(
|
|
||||||
settings.SOUND_EXCERPTS_SUBDIR,
|
|
||||||
pool,
|
|
||||||
type=Sound.TYPE_EXCERPT,
|
|
||||||
)
|
|
||||||
|
|
||||||
observer = Observer()
|
|
||||||
observer.schedule(
|
|
||||||
archives_handler,
|
|
||||||
settings.PROGRAMS_DIR_ABS,
|
|
||||||
recursive=True,
|
|
||||||
)
|
|
||||||
observer.schedule(
|
|
||||||
excerpts_handler,
|
|
||||||
settings.PROGRAMS_DIR_ABS,
|
|
||||||
recursive=True,
|
|
||||||
)
|
|
||||||
observer.start()
|
|
||||||
|
|
||||||
def leave():
|
|
||||||
observer.stop()
|
|
||||||
observer.join()
|
|
||||||
|
|
||||||
atexit.register(leave)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.formatter_class = RawTextHelpFormatter
|
parser.formatter_class = RawTextHelpFormatter
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -172,6 +62,7 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
SoundMonitor()
|
||||||
if options.get("scan"):
|
if options.get("scan"):
|
||||||
self.scan()
|
self.scan()
|
||||||
# if options.get('quality_check'):
|
# if options.get('quality_check'):
|
||||||
|
|
|
@ -4,7 +4,7 @@ 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 SoundStats, SoxStats
|
from aircox.controllers.sound_stats import SoundStats, SoxStats
|
||||||
|
|
||||||
logger = logging.getLogger("aircox.commands")
|
logger = logging.getLogger("aircox.commands")
|
||||||
|
|
||||||
|
|
|
@ -1,171 +0,0 @@
|
||||||
#! /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 SOUND_QUALITY. This script requires
|
|
||||||
Sox (and soxi).
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from watchdog.events import PatternMatchingEventHandler
|
|
||||||
|
|
||||||
from aircox.conf 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, **sync_kw):
|
|
||||||
"""
|
|
||||||
:param str subdir: sub-directory in program dirs to monitor \
|
|
||||||
(SOUND_ARCHIVES_SUBDIR or SOUND_EXCERPTS_SUBDIR);
|
|
||||||
: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.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):
|
|
||||||
if self.jobs.get(key) is handler:
|
|
||||||
del self.jobs[key]
|
|
||||||
|
|
||||||
handler.future.add_done_callback(done)
|
|
||||||
return handler, True
|
|
|
@ -2,7 +2,7 @@ from . import signals
|
||||||
from .article import Article
|
from .article import Article
|
||||||
from .diffusion import Diffusion, DiffusionQuerySet
|
from .diffusion import Diffusion, DiffusionQuerySet
|
||||||
from .episode import Episode
|
from .episode import Episode
|
||||||
from .log import Log, LogArchiver, LogQuerySet
|
from .log import Log, LogQuerySet
|
||||||
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
|
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
|
||||||
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
|
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
|
||||||
from .schedule import Schedule
|
from .schedule import Schedule
|
||||||
|
@ -18,7 +18,6 @@ __all__ = (
|
||||||
"DiffusionQuerySet",
|
"DiffusionQuerySet",
|
||||||
"Log",
|
"Log",
|
||||||
"LogQuerySet",
|
"LogQuerySet",
|
||||||
"LogArchiver",
|
|
||||||
"Category",
|
"Category",
|
||||||
"PageQuerySet",
|
"PageQuerySet",
|
||||||
"Page",
|
"Page",
|
||||||
|
|
|
@ -1,18 +1,11 @@
|
||||||
import datetime
|
import datetime
|
||||||
import gzip
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
import yaml
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone as tz
|
from django.utils import timezone as tz
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from aircox.conf import settings
|
|
||||||
|
|
||||||
__all__ = ("Settings", "settings")
|
|
||||||
from .diffusion import Diffusion
|
from .diffusion import Diffusion
|
||||||
from .sound import Sound, Track
|
from .sound import Sound, Track
|
||||||
from .station import Station
|
from .station import Station
|
||||||
|
@ -20,7 +13,7 @@ from .station import Station
|
||||||
logger = logging.getLogger("aircox")
|
logger = logging.getLogger("aircox")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Log", "LogQuerySet", "LogArchiver")
|
__all__ = ("Log", "LogQuerySet")
|
||||||
|
|
||||||
|
|
||||||
class LogQuerySet(models.QuerySet):
|
class LogQuerySet(models.QuerySet):
|
||||||
|
@ -240,104 +233,3 @@ class Log(models.Model):
|
||||||
self.comment or "",
|
self.comment or "",
|
||||||
" (" + ", ".join(r) + ")" if r else "",
|
" (" + ", ".join(r) + ")" if r else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LogArchiver:
|
|
||||||
"""Commodity class used to manage archives of logs."""
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def fields(self):
|
|
||||||
return Log._meta.get_fields()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_path(station, date):
|
|
||||||
return os.path.join(
|
|
||||||
settings.LOGS_ARCHIVES_DIR_ABS,
|
|
||||||
"{}_{}.log.gz".format(date.strftime("%Y%m%d"), station.pk),
|
|
||||||
)
|
|
||||||
|
|
||||||
def archive(self, qs, keep=False):
|
|
||||||
"""Archive logs of the given queryset.
|
|
||||||
|
|
||||||
Delete archived logs if not `keep`. Return the count of archived
|
|
||||||
logs
|
|
||||||
"""
|
|
||||||
if not qs.exists():
|
|
||||||
return 0
|
|
||||||
|
|
||||||
os.makedirs(settings.LOGS_ARCHIVES_DIR_ABS, exist_ok=True)
|
|
||||||
count = qs.count()
|
|
||||||
logs = self.sort_logs(qs)
|
|
||||||
|
|
||||||
# Note: since we use Yaml, we can just append new logs when file
|
|
||||||
# exists yet <3
|
|
||||||
for (station, date), logs in logs.items():
|
|
||||||
path = self.get_path(station, date)
|
|
||||||
with gzip.open(path, "ab") as archive:
|
|
||||||
data = yaml.dump(
|
|
||||||
[self.serialize(line) for line in logs]
|
|
||||||
).encode("utf8")
|
|
||||||
archive.write(data)
|
|
||||||
|
|
||||||
if not keep:
|
|
||||||
qs.delete()
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def sort_logs(qs):
|
|
||||||
"""Sort logs by station and date and return a dict of `{
|
|
||||||
(station,date): [logs] }`."""
|
|
||||||
qs = qs.order_by("date")
|
|
||||||
logs = {}
|
|
||||||
for log in qs:
|
|
||||||
key = (log.station, log.date)
|
|
||||||
if key not in logs:
|
|
||||||
logs[key] = [log]
|
|
||||||
else:
|
|
||||||
logs[key].append(log)
|
|
||||||
return logs
|
|
||||||
|
|
||||||
def serialize(self, log):
|
|
||||||
"""Serialize log."""
|
|
||||||
return {i.attname: getattr(log, i.attname) for i in self.fields}
|
|
||||||
|
|
||||||
def load(self, station, date):
|
|
||||||
"""Load an archive returning logs in a list."""
|
|
||||||
path = self.get_path(station, date)
|
|
||||||
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return []
|
|
||||||
|
|
||||||
with gzip.open(path, "rb") as archive:
|
|
||||||
data = archive.read()
|
|
||||||
logs = yaml.load(data)
|
|
||||||
|
|
||||||
# we need to preload diffusions, sounds and tracks
|
|
||||||
rels = {
|
|
||||||
"diffusion": self.get_relations(logs, Diffusion, "diffusion"),
|
|
||||||
"sound": self.get_relations(logs, Sound, "sound"),
|
|
||||||
"track": self.get_relations(logs, Track, "track"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def rel_obj(log, attr):
|
|
||||||
rel_id = log.get(attr + "_id")
|
|
||||||
return rels[attr][rel_id] if rel_id else None
|
|
||||||
|
|
||||||
return [
|
|
||||||
Log(
|
|
||||||
diffusion=rel_obj(log, "diffusion"),
|
|
||||||
sound=rel_obj(log, "sound"),
|
|
||||||
track=rel_obj(log, "track"),
|
|
||||||
**log
|
|
||||||
)
|
|
||||||
for log in logs
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_relations(logs, model, attr):
|
|
||||||
"""From a list of dict representing logs, retrieve related objects of
|
|
||||||
the given type."""
|
|
||||||
attr_id = attr + "_id"
|
|
||||||
pks = (log[attr_id] for log in logs if attr_id in log)
|
|
||||||
return {rel.pk: rel for rel in model.objects.filter(pk__in=pks)}
|
|
||||||
|
|
257
aircox/test.py
257
aircox/test.py
|
@ -1,7 +1,9 @@
|
||||||
"""This module provide test utilities."""
|
"""This module provide test utilities."""
|
||||||
|
from collections import namedtuple
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("interface",)
|
__all__ = ("interface", "Interface", "File")
|
||||||
|
|
||||||
|
|
||||||
def interface(obj, funcs):
|
def interface(obj, funcs):
|
||||||
|
@ -31,3 +33,256 @@ def interface_wrap(obj, attr, value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
setattr(obj, attr, wrapper)
|
setattr(obj, attr, wrapper)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
InterfaceTarget = namedtuple(
|
||||||
|
"InterfaceTarget",
|
||||||
|
["target", "namespace", "key"],
|
||||||
|
defaults=[("namespace", None), ("key", None)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WrapperMixin:
|
||||||
|
type_interface = None
|
||||||
|
"""For instance of class wrapped by an Interface, this is the wrapping
|
||||||
|
interface of the class."""
|
||||||
|
instances = None
|
||||||
|
ns = None
|
||||||
|
ns_attr = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, target=None, ns=None, ns_attr=None, type_interface=None, **kwargs
|
||||||
|
):
|
||||||
|
self.target = target
|
||||||
|
if ns:
|
||||||
|
self.inject(ns, ns_attr)
|
||||||
|
if self.type_interface:
|
||||||
|
self._set_type_interface(type_interface)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def _set_type_interface(self, type_interface):
|
||||||
|
if self.type_interface:
|
||||||
|
raise RuntimeError("a type interface is already assigned")
|
||||||
|
|
||||||
|
self.type_interface = type_interface
|
||||||
|
if not type_interface.instances:
|
||||||
|
type_interface.instances = [self]
|
||||||
|
else:
|
||||||
|
type_interface.instances.append(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ns_target(self):
|
||||||
|
"""Actual namespace's target (using ns.ns_attr)"""
|
||||||
|
if self.ns and self.ns_attr:
|
||||||
|
return getattr(self.ns, self.ns_attr, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def inject(self, ns=None, ns_attr=None):
|
||||||
|
"""Inject interface into namespace at given key."""
|
||||||
|
if not (ns and ns_attr):
|
||||||
|
raise ValueError("ns and ns_attr must be provided together")
|
||||||
|
|
||||||
|
ns_target = getattr(ns, ns_attr, None)
|
||||||
|
if self.target is ns_target:
|
||||||
|
return
|
||||||
|
elif self.target is not None and self.ns:
|
||||||
|
raise RuntimeError(
|
||||||
|
"self target already injected. It must be "
|
||||||
|
"`release` before `inject`."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.target = ns_target
|
||||||
|
setattr(ns, ns_attr, self.interface)
|
||||||
|
|
||||||
|
self.ns = ns
|
||||||
|
self.ns_attr = ns_attr
|
||||||
|
return self
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
"""Remove injection from previously injected parent, reset target."""
|
||||||
|
if self.ns_target is self.interface:
|
||||||
|
setattr(self.ns, self.ns_attr, self.target)
|
||||||
|
self.target = None
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class SpoofMixin:
|
||||||
|
traces = None
|
||||||
|
|
||||||
|
def __init__(self, funcs=None, **kwargs):
|
||||||
|
self.reset(
|
||||||
|
funcs or {},
|
||||||
|
)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def reset(self, funcs=None):
|
||||||
|
self.traces = {}
|
||||||
|
if funcs is not None:
|
||||||
|
self.funcs = funcs
|
||||||
|
|
||||||
|
def get_trace(self, name, args=False, kw=False):
|
||||||
|
"""Get a function call parameters.
|
||||||
|
|
||||||
|
:param str name: function name
|
||||||
|
:param bool args: return positional arguments
|
||||||
|
:param bool|str kwargs: return named arguments. If a string, get the \
|
||||||
|
named argument at this key.
|
||||||
|
:returns either a tuple of args, a dict of kwargs, or a tuple \
|
||||||
|
of `(args, kwargs)`.
|
||||||
|
:raises ValueError: the function has been called multiple time.
|
||||||
|
"""
|
||||||
|
trace = self.traces[name]
|
||||||
|
if isinstance(trace, list):
|
||||||
|
raise ValueError(f"{name} called multiple times.")
|
||||||
|
return self._get_trace(trace, args=args, kw=kw)
|
||||||
|
|
||||||
|
def get_traces(self, name, args=False, kw=False):
|
||||||
|
"""Get a tuple of all call parameters.
|
||||||
|
|
||||||
|
Parameters are the same as `get()`.
|
||||||
|
"""
|
||||||
|
traces = self.traces[name]
|
||||||
|
if not isinstance(traces, list):
|
||||||
|
traces = (traces,)
|
||||||
|
return tuple(
|
||||||
|
self._get_trace(trace, args=args, kw=kw) for trace in traces
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_trace(self, trace, args=False, kw=False):
|
||||||
|
if (args and kw) or (not args and not kw):
|
||||||
|
return trace
|
||||||
|
elif args:
|
||||||
|
return trace[0]
|
||||||
|
elif isinstance(kw, str):
|
||||||
|
return trace[1][kw]
|
||||||
|
return trace[1]
|
||||||
|
|
||||||
|
def call(self, name, args, kw):
|
||||||
|
"""Add call for function of provided name, and return predefined
|
||||||
|
result."""
|
||||||
|
self.add(name, args, kw)
|
||||||
|
return self.get_result(name, args, kw)
|
||||||
|
|
||||||
|
def add(self, name, args, kw):
|
||||||
|
"""Add call parameters to `self.traces` for the function with the
|
||||||
|
provided `name`."""
|
||||||
|
trace = self.traces.get(name)
|
||||||
|
if trace is None:
|
||||||
|
self.traces[name] = (args, kw)
|
||||||
|
elif isinstance(trace, tuple):
|
||||||
|
self.traces[name] = [trace, (args, kw)]
|
||||||
|
else:
|
||||||
|
trace.append((args, kw))
|
||||||
|
|
||||||
|
def get_result(self, name, a, kw):
|
||||||
|
"""Get result for the function of the provided `name`.
|
||||||
|
|
||||||
|
:raises KeyError: no registered function with this `name`.
|
||||||
|
"""
|
||||||
|
func = self.funcs[name]
|
||||||
|
if callable(func):
|
||||||
|
return func(self, *a, **kw)
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
class Interface:
|
||||||
|
class IMeta(SpoofMixin, WrapperMixin):
|
||||||
|
def __init__(self, interface, **kwargs):
|
||||||
|
self.interface = interface
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def clone(self, **kwargs):
|
||||||
|
"""Return an Interface copying some values from self."""
|
||||||
|
kwargs.update(
|
||||||
|
{
|
||||||
|
"target": self.target,
|
||||||
|
"funcs": self.funcs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return type(self.interface)(_imeta_kw=kwargs)._imeta
|
||||||
|
|
||||||
|
def __getitem__(self, name):
|
||||||
|
return self.traces[name]
|
||||||
|
|
||||||
|
_imeta = None
|
||||||
|
"""This contains a InterfaceMeta instance related to Interface one.
|
||||||
|
|
||||||
|
`_imeta` is used to check tests etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, _target=None, _funcs=None, _imeta_kw=None, **kwargs):
|
||||||
|
if _imeta_kw is None:
|
||||||
|
_imeta_kw = {}
|
||||||
|
if _funcs is not None:
|
||||||
|
_imeta_kw.setdefault("funcs", _funcs)
|
||||||
|
if _target is not None:
|
||||||
|
_imeta_kw.setdefault("target", _target)
|
||||||
|
self._imeta = self.IMeta(self, **_imeta_kw)
|
||||||
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _itarget(self):
|
||||||
|
return self._imeta.target
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def inject(cls, ns, ns_attr, funcs=None, **kwargs):
|
||||||
|
kwargs["_imeta_kw"] = {"ns": ns, "ns_attr": ns_attr, "funcs": funcs}
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
def _irelease(self):
|
||||||
|
"""Shortcut to `self._imeta.release`."""
|
||||||
|
self._imeta.release()
|
||||||
|
self._imeta.reset()
|
||||||
|
|
||||||
|
def _trace(self, *args, **kw):
|
||||||
|
"""Shortcut to `self._imeta.get_trace`."""
|
||||||
|
return self._imeta.get_trace(*args, **kw)
|
||||||
|
|
||||||
|
def _traces(self, *args, **kw):
|
||||||
|
"""Shortcut to `self._imeta.get_traces`."""
|
||||||
|
return self._imeta.get_traces(*args, **kw)
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
target = self._imeta.target
|
||||||
|
if inspect.isclass(target):
|
||||||
|
target = target(*args, **kwargs)
|
||||||
|
return type(self)(
|
||||||
|
target,
|
||||||
|
_imeta_kw={"type_interface": self, "funcs": self._imeta.funcs},
|
||||||
|
)
|
||||||
|
|
||||||
|
if "__call__" in self._imeta.funcs:
|
||||||
|
return self._imeta.call("__call__", args, kwargs)
|
||||||
|
|
||||||
|
self._imeta.add("__call__", args, kwargs)
|
||||||
|
return self._imeta.target(*args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
if attr in self._imeta.funcs:
|
||||||
|
return lambda *args, **kwargs: self._imeta.call(attr, args, kwargs)
|
||||||
|
return getattr(self._imeta.target, attr)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
iface = super().__str__()
|
||||||
|
return f"{iface}::{self._imeta.target}"
|
||||||
|
|
||||||
|
|
||||||
|
class File:
|
||||||
|
def __init__(self, data=""):
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.data += data
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.data = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_, **__):
|
||||||
|
pass
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
from datetime import time, timedelta
|
from datetime import time, timedelta
|
||||||
import itertools
|
import itertools
|
||||||
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from aircox import models
|
from aircox import models
|
||||||
|
from aircox.test import Interface
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logger():
|
||||||
|
logger = Interface(
|
||||||
|
logging, {"info": None, "debug": None, "error": None, "warning": None}
|
||||||
|
)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -100,3 +110,24 @@ def podcasts(episodes):
|
||||||
sound.file = f"test_sound_{episode.pk}_{i}.mp3"
|
sound.file = f"test_sound_{episode.pk}_{i}.mp3"
|
||||||
items += sounds
|
items += sounds
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sound(program):
|
||||||
|
return baker.make(models.Sound, file="tmp/test.wav", program=program)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tracks(episode, sound):
|
||||||
|
items = [
|
||||||
|
baker.prepare(
|
||||||
|
models.Track, episode=episode, position=i, timestamp=i * 60
|
||||||
|
)
|
||||||
|
for i in range(0, 3)
|
||||||
|
]
|
||||||
|
items += [
|
||||||
|
baker.prepare(models.Track, sound=sound, position=i, timestamp=i * 60)
|
||||||
|
for i in range(0, 3)
|
||||||
|
]
|
||||||
|
models.Track.objects.bulk_create(items)
|
||||||
|
return items
|
||||||
|
|
3
aircox/tests/controllers/playlist.csv
Normal file
3
aircox/tests/controllers/playlist.csv
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Artist 1;Title 1;1;0;tag1,tag12;info1
|
||||||
|
Artist 2;Title 2;2;1;tag2,tag12;info2
|
||||||
|
Artist 3;Title 3;3;2;;
|
|
110
aircox/tests/controllers/test_log_archiver.py
Normal file
110
aircox/tests/controllers/test_log_archiver.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from aircox import models
|
||||||
|
from aircox.test import Interface, File
|
||||||
|
from aircox.controllers import log_archiver
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def diffusions(episodes):
|
||||||
|
items = [
|
||||||
|
baker.prepare(
|
||||||
|
models.Diffusion,
|
||||||
|
program=episode.program,
|
||||||
|
episode=episode,
|
||||||
|
type=models.Diffusion.TYPE_ON_AIR,
|
||||||
|
)
|
||||||
|
for episode in episodes
|
||||||
|
]
|
||||||
|
models.Diffusion.objects.bulk_create(items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logs(diffusions, sound, tracks):
|
||||||
|
now = tz.now()
|
||||||
|
station = diffusions[0].program.station
|
||||||
|
items = [
|
||||||
|
models.Log(
|
||||||
|
station=diffusion.program.station,
|
||||||
|
type=models.Log.TYPE_START,
|
||||||
|
date=now + tz.timedelta(hours=-10, minutes=i),
|
||||||
|
source="13",
|
||||||
|
diffusion=diffusion,
|
||||||
|
)
|
||||||
|
for i, diffusion in enumerate(diffusions)
|
||||||
|
]
|
||||||
|
items += [
|
||||||
|
models.Log(
|
||||||
|
station=station,
|
||||||
|
type=models.Log.TYPE_ON_AIR,
|
||||||
|
date=now + tz.timedelta(hours=-9, minutes=i),
|
||||||
|
source="14",
|
||||||
|
track=track,
|
||||||
|
sound=track.sound,
|
||||||
|
)
|
||||||
|
for i, track in enumerate(tracks)
|
||||||
|
]
|
||||||
|
models.Log.objects.bulk_create(items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logs_qs(logs):
|
||||||
|
return models.Log.objects.filter(pk__in=(r.pk for r in logs))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file():
|
||||||
|
return File(data=b"")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gzip(file):
|
||||||
|
gzip = Interface.inject(log_archiver, "gzip", {"open": file})
|
||||||
|
yield gzip
|
||||||
|
gzip._irelease()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def archiver():
|
||||||
|
return log_archiver.LogArchiver()
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogArchiver:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_archive_then_load_file(self, archiver, file, gzip, logs, logs_qs):
|
||||||
|
# before logs are deleted from db, get data
|
||||||
|
sorted = archiver.sort_logs(logs_qs)
|
||||||
|
paths = {
|
||||||
|
archiver.get_path(station, date) for station, date in sorted.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
count = archiver.archive(logs_qs, keep=False)
|
||||||
|
assert count == len(logs)
|
||||||
|
assert not logs_qs.count()
|
||||||
|
assert all(
|
||||||
|
path in paths for path, *_ in gzip._traces("open", args=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = archiver.load_file("dummy path")
|
||||||
|
assert results
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_archive_no_qs(self, archiver):
|
||||||
|
count = archiver.archive(models.Log.objects.none())
|
||||||
|
assert not count
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_sort_log(self, archiver, logs_qs):
|
||||||
|
sorted = archiver.sort_logs(logs_qs)
|
||||||
|
|
||||||
|
assert sorted
|
||||||
|
for (station, date), logs in sorted.items():
|
||||||
|
assert all(
|
||||||
|
log.station == station and log.date.date() == date
|
||||||
|
for log in logs
|
||||||
|
)
|
64
aircox/tests/controllers/test_playlist_import.py
Normal file
64
aircox/tests/controllers/test_playlist_import.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aircox.test import Interface
|
||||||
|
from aircox.controllers import playlist_import
|
||||||
|
|
||||||
|
|
||||||
|
csv_data = [
|
||||||
|
{
|
||||||
|
"artist": "Artist 1",
|
||||||
|
"title": "Title 1",
|
||||||
|
"minutes": "1",
|
||||||
|
"seconds": "0",
|
||||||
|
"tags": "tag1,tag12",
|
||||||
|
"info": "info1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artist": "Artist 2",
|
||||||
|
"title": "Title 2",
|
||||||
|
"minutes": "2",
|
||||||
|
"seconds": "1",
|
||||||
|
"tags": "tag2,tag12",
|
||||||
|
"info": "info2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artist": "Artist 3",
|
||||||
|
"title": "Title 3",
|
||||||
|
"minutes": "3",
|
||||||
|
"seconds": "2",
|
||||||
|
"tags": "",
|
||||||
|
"info": "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def importer(sound):
|
||||||
|
path = os.path.join(os.path.dirname(__file__), "playlist.csv")
|
||||||
|
return playlist_import.PlaylistImport(path, sound=sound)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlaylistImport:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_run(self, importer):
|
||||||
|
iface = Interface(None, {"read": None, "make_playlist": None})
|
||||||
|
importer.read = iface.read
|
||||||
|
importer.make_playlist = iface.make_playlist
|
||||||
|
importer.run()
|
||||||
|
assert iface._trace("read")
|
||||||
|
assert iface._trace("make_playlist")
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_read(self, importer):
|
||||||
|
importer.read()
|
||||||
|
assert importer.data == csv_data
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_make_playlist(self, importer, sound):
|
||||||
|
importer.data = csv_data
|
||||||
|
importer.make_playlist()
|
||||||
|
track_artists = sound.track_set.all().values_list("artist", flat=True)
|
||||||
|
csv_artists = {r["artist"] for r in csv_data}
|
||||||
|
assert set(track_artists) == csv_artists
|
||||||
|
# TODO: check other values
|
|
@ -6,7 +6,7 @@ from django.conf import settings as conf
|
||||||
from django.utils import timezone as tz
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
from aircox import models
|
from aircox import models
|
||||||
from aircox.management.sound_file import SoundFile
|
from aircox.controllers.sound_file import SoundFile
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
265
aircox/tests/controllers/test_sound_monitor.py
Normal file
265
aircox/tests/controllers/test_sound_monitor.py
Normal file
|
@ -0,0 +1,265 @@
|
||||||
|
from concurrent import futures
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
|
||||||
|
from aircox.conf import settings
|
||||||
|
from aircox.models import Sound
|
||||||
|
from aircox.controllers import sound_monitor
|
||||||
|
from aircox.test import Interface, interface
|
||||||
|
|
||||||
|
|
||||||
|
now = tz.datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event():
|
||||||
|
return Interface(src_path="/tmp/src_path", dest_path="/tmp/dest_path")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def interfaces():
|
||||||
|
items = {
|
||||||
|
"SoundFile": Interface.inject(
|
||||||
|
sound_monitor,
|
||||||
|
"SoundFile",
|
||||||
|
{
|
||||||
|
"sync": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"time": Interface.inject(
|
||||||
|
sound_monitor,
|
||||||
|
"time",
|
||||||
|
{
|
||||||
|
"sleep": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"datetime": Interface.inject(sound_monitor, "datetime", {"now": now}),
|
||||||
|
}
|
||||||
|
yield items
|
||||||
|
for item in items.values():
|
||||||
|
item._irelease()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def task(interfaces):
|
||||||
|
return sound_monitor.Task()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def delete_task(interfaces):
|
||||||
|
return sound_monitor.DeleteTask()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def move_task(interfaces):
|
||||||
|
return sound_monitor.MoveTask()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def modified_task(interfaces):
|
||||||
|
return sound_monitor.ModifiedTask()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def monitor_handler(interfaces):
|
||||||
|
pool = Interface(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"submit": lambda imeta, *a, **kw: Interface(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"add_done_callback": None,
|
||||||
|
"done": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return sound_monitor.MonitorHandler("/tmp", pool=pool, sync_kw=13)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTask:
|
||||||
|
def test___init__(self, task):
|
||||||
|
assert task.timestamp is not None
|
||||||
|
|
||||||
|
def test_ping(self, task):
|
||||||
|
task.timestamp = None
|
||||||
|
task.ping()
|
||||||
|
assert task.timestamp >= now
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test___call__(self, logger, task, event):
|
||||||
|
task.log_msg = "--{event.src_path}--"
|
||||||
|
sound_file = task(event, logger=logger, kw=13)
|
||||||
|
assert sound_file._trace("sync", kw=True) == {"kw": 13}
|
||||||
|
assert logger._trace("info", args=True) == (
|
||||||
|
task.log_msg.format(event=event),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteTask:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test___call__(self, delete_task, logger, task, event):
|
||||||
|
sound_file = delete_task(event, logger=logger)
|
||||||
|
assert sound_file._trace("sync", kw=True) == {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoveTask:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test__call___with_sound(self, move_task, sound, event, logger):
|
||||||
|
event.src_path = sound.file.name
|
||||||
|
sound_file = move_task(event, logger=logger)
|
||||||
|
assert isinstance(sound_file._trace("sync", kw="sound"), Sound)
|
||||||
|
assert sound_file.path == sound.file.name
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test__call___no_sound(self, move_task, event, logger):
|
||||||
|
sound_file = move_task(event, logger=logger)
|
||||||
|
assert sound_file._trace("sync", kw=True) == {}
|
||||||
|
assert sound_file.path == event.dest_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestModifiedTask:
|
||||||
|
def test_wait(self, modified_task):
|
||||||
|
dt_now = now + modified_task.timeout_delta - tz.timedelta(hours=10)
|
||||||
|
datetime = Interface.inject(sound_monitor, "datetime", {"now": dt_now})
|
||||||
|
|
||||||
|
def sleep(imeta, n):
|
||||||
|
datetime._imeta.funcs[
|
||||||
|
"now"
|
||||||
|
] = modified_task.timestamp + tz.timedelta(hours=10)
|
||||||
|
|
||||||
|
time = Interface.inject(sound_monitor, "time", {"sleep": sleep})
|
||||||
|
modified_task.wait()
|
||||||
|
assert time._trace("sleep", args=True)
|
||||||
|
|
||||||
|
datetime._imeta.release()
|
||||||
|
|
||||||
|
def test__call__(self, modified_task, event):
|
||||||
|
interface(modified_task, {"wait": None})
|
||||||
|
modified_task(event)
|
||||||
|
assert modified_task.calls["wait"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMonitorHandler:
|
||||||
|
def test_on_created(self, monitor_handler, event):
|
||||||
|
monitor_handler._submit = monitor_handler.pool.submit
|
||||||
|
monitor_handler.on_created(event)
|
||||||
|
trace_args, trace_kwargs = monitor_handler.pool._trace("submit")
|
||||||
|
assert isinstance(trace_args[0], sound_monitor.CreateTask)
|
||||||
|
assert trace_args[1:] == (event, "new")
|
||||||
|
assert trace_kwargs == monitor_handler.sync_kw
|
||||||
|
|
||||||
|
def test_on_deleted(self, monitor_handler, event):
|
||||||
|
monitor_handler._submit = monitor_handler.pool.submit
|
||||||
|
monitor_handler.on_deleted(event)
|
||||||
|
trace_args, _ = monitor_handler.pool._trace("submit")
|
||||||
|
assert isinstance(trace_args[0], sound_monitor.DeleteTask)
|
||||||
|
assert trace_args[1:] == (event, "del")
|
||||||
|
|
||||||
|
def test_on_moved(self, monitor_handler, event):
|
||||||
|
monitor_handler._submit = monitor_handler.pool.submit
|
||||||
|
monitor_handler.on_moved(event)
|
||||||
|
trace_args, trace_kwargs = monitor_handler.pool._trace("submit")
|
||||||
|
assert isinstance(trace_args[0], sound_monitor.MoveTask)
|
||||||
|
assert trace_args[1:] == (event, "mv")
|
||||||
|
assert trace_kwargs == monitor_handler.sync_kw
|
||||||
|
|
||||||
|
def test_on_modified(self, monitor_handler, event):
|
||||||
|
monitor_handler._submit = monitor_handler.pool.submit
|
||||||
|
monitor_handler.on_modified(event)
|
||||||
|
trace_args, trace_kwargs = monitor_handler.pool._trace("submit")
|
||||||
|
assert isinstance(trace_args[0], sound_monitor.ModifiedTask)
|
||||||
|
assert trace_args[1:] == (event, "up")
|
||||||
|
assert trace_kwargs == monitor_handler.sync_kw
|
||||||
|
|
||||||
|
def test__submit(self, monitor_handler, event):
|
||||||
|
handler = Interface()
|
||||||
|
handler, created = monitor_handler._submit(
|
||||||
|
handler, event, "prefix", kw=13
|
||||||
|
)
|
||||||
|
assert created
|
||||||
|
assert handler.future._trace("add_done_callback")
|
||||||
|
assert monitor_handler.pool._trace("submit") == (
|
||||||
|
(handler, event),
|
||||||
|
{"kw": 13},
|
||||||
|
)
|
||||||
|
|
||||||
|
key = f"prefix:{event.src_path}"
|
||||||
|
assert monitor_handler.jobs.get(key) == handler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def monitor_interfaces():
|
||||||
|
items = {
|
||||||
|
"atexit": Interface.inject(
|
||||||
|
sound_monitor, "atexit", {"register": None, "leave": None}
|
||||||
|
),
|
||||||
|
"observer": Interface.inject(
|
||||||
|
sound_monitor,
|
||||||
|
"Observer",
|
||||||
|
{
|
||||||
|
"schedule": None,
|
||||||
|
"start": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
yield items
|
||||||
|
for item in items.values():
|
||||||
|
item.release()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def monitor():
|
||||||
|
yield sound_monitor.SoundMonitor()
|
||||||
|
|
||||||
|
|
||||||
|
class SoundMonitor:
|
||||||
|
def test_report(self, monitor, program, logger):
|
||||||
|
monitor.report(program, "component", "content", logger=logger)
|
||||||
|
msg = f"{program}, component: content"
|
||||||
|
assert logger._trace("info", args=True) == (msg,)
|
||||||
|
|
||||||
|
def test_scan(self, monitor, program, logger):
|
||||||
|
interface = Interface(None, {"scan_for_program": None})
|
||||||
|
monitor.scan_for_program = interface.scan_for_program
|
||||||
|
dirs = monitor.scan(logger)
|
||||||
|
|
||||||
|
assert logger._traces("info") == (
|
||||||
|
"scan all programs...",
|
||||||
|
f"#{program.id} {program.title}",
|
||||||
|
)
|
||||||
|
assert dirs == [program.abspath]
|
||||||
|
assert interface._traces("scan_for_program") == (
|
||||||
|
((program, settings.SOUND_ARCHIVES_SUBDIR), {"logger": logger})(
|
||||||
|
(program, settings.SOUND_EXCERPTS_SUBDIR), {"logger": logger}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monitor(self, monitor, monitor_interfaces, logger):
|
||||||
|
def sleep(*args, **kwargs):
|
||||||
|
monitor.stop()
|
||||||
|
|
||||||
|
time = Interface.inject(sound_monitor, "time", {"sleep": sleep})
|
||||||
|
monitor.monitor(logger=logger)
|
||||||
|
time._irelease()
|
||||||
|
|
||||||
|
observers = monitor_interfaces["observer"].instances
|
||||||
|
observer = observers and observers[0]
|
||||||
|
assert observer
|
||||||
|
schedules = observer._traces("schedule")
|
||||||
|
for (handler, *_), kwargs in schedules:
|
||||||
|
assert isinstance(handler, sound_monitor.MonitorHandler)
|
||||||
|
assert isinstance(handler.pool, futures.ThreadPoolExecutor)
|
||||||
|
assert (handler.subdir, handler.type) in (
|
||||||
|
(settings.SOUND_ARCHIVES_SUBDIR, Sound.TYPE_ARCHIVE),
|
||||||
|
(settings.SOUND_EXCERPTS_SUBDIR, Sound.TYPE_EXCERPT),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observer._trace("start")
|
||||||
|
|
||||||
|
atexit = monitor_interfaces["atexit"]
|
||||||
|
assert atexit._trace("register")
|
||||||
|
assert atexit._trace("unregister")
|
||||||
|
|
||||||
|
assert observers
|
123
aircox/tests/controllers/test_sound_stats.py
Normal file
123
aircox/tests/controllers/test_sound_stats.py
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aircox.test import Interface
|
||||||
|
from aircox.controllers import sound_stats
|
||||||
|
|
||||||
|
|
||||||
|
sox_output = """
|
||||||
|
DC offset 0.000000\n
|
||||||
|
Min level 0.000000\n
|
||||||
|
Max level 0.000000\n
|
||||||
|
Pk lev dB -inf\n
|
||||||
|
RMS lev dB -inf\n
|
||||||
|
RMS Pk dB -inf\n
|
||||||
|
RMS Tr dB -inf\n
|
||||||
|
Crest factor 1.00\n
|
||||||
|
Flat factor 179.37\n
|
||||||
|
Pk count 1.86G\n
|
||||||
|
Bit-depth 0/0\n
|
||||||
|
Num samples 930M\n
|
||||||
|
Length s 19383.312\n
|
||||||
|
Scale max 1.000000\n
|
||||||
|
Window s 0.050\n
|
||||||
|
"""
|
||||||
|
sox_values = {
|
||||||
|
"DC offset": 0.0,
|
||||||
|
"Min level": 0.0,
|
||||||
|
"Max level": 0.0,
|
||||||
|
"Pk lev dB": float("-inf"),
|
||||||
|
"RMS lev dB": float("-inf"),
|
||||||
|
"RMS Pk dB": float("-inf"),
|
||||||
|
"RMS Tr dB": float("-inf"),
|
||||||
|
"Flat factor": 179.37,
|
||||||
|
"length": 19383.312,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sox_interfaces():
|
||||||
|
process = Interface(
|
||||||
|
None, {"communicate": ("", sox_output.encode("utf-8"))}
|
||||||
|
)
|
||||||
|
subprocess = Interface.inject(
|
||||||
|
sound_stats, "subprocess", {"Popen": lambda *_, **__: process}
|
||||||
|
)
|
||||||
|
yield {"process": process, "subprocess": subprocess}
|
||||||
|
subprocess._irelease()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sox_stats(sox_interfaces):
|
||||||
|
return sound_stats.SoxStats()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stats():
|
||||||
|
return sound_stats.SoundStats("/tmp/audio.wav", sample_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stats_interfaces(stats):
|
||||||
|
def iw(path, **kw):
|
||||||
|
kw["path"] = path
|
||||||
|
kw.setdefault("length", stats.sample_length * 2)
|
||||||
|
return kw
|
||||||
|
|
||||||
|
SxS = sound_stats.SoxStats
|
||||||
|
sound_stats.SoxStats = iw
|
||||||
|
yield iw
|
||||||
|
sound_stats.SoxStats = SxS
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoxStats:
|
||||||
|
def test_parse(self, sox_stats):
|
||||||
|
values = sox_stats.parse(sox_output)
|
||||||
|
assert values == sox_values
|
||||||
|
|
||||||
|
def test_analyse(self, sox_stats, sox_interfaces):
|
||||||
|
sox_stats.analyse("fake_path", 1, 2)
|
||||||
|
assert sox_interfaces["subprocess"]._trace("Popen") == (
|
||||||
|
(["sox", "fake_path", "-n", "trim", "1", "2", "stats"],),
|
||||||
|
{"stdout": subprocess.PIPE, "stderr": subprocess.PIPE},
|
||||||
|
)
|
||||||
|
assert sox_stats.values == sox_values
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoundStats:
|
||||||
|
def test_get_file_stats(self, stats):
|
||||||
|
file_stats = {"a": 134}
|
||||||
|
stats.stats = [file_stats]
|
||||||
|
assert stats.get_file_stats() is file_stats
|
||||||
|
|
||||||
|
def test_get_file_stats_none(self, stats):
|
||||||
|
stats.stats = []
|
||||||
|
assert stats.get_file_stats() is None
|
||||||
|
|
||||||
|
def test_analyse(self, stats, stats_interfaces):
|
||||||
|
stats.analyse()
|
||||||
|
assert stats.stats == [
|
||||||
|
{"path": stats.path, "length": stats.sample_length * 2},
|
||||||
|
{"path": stats.path, "at": 0, "length": stats.sample_length},
|
||||||
|
{"path": stats.path, "at": 10, "length": stats.sample_length},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_analyse_no_sample_length(self, stats, stats_interfaces):
|
||||||
|
stats.sample_length = 0
|
||||||
|
stats.analyse()
|
||||||
|
assert stats.stats == [{"length": 0, "path": stats.path}]
|
||||||
|
|
||||||
|
def test_check(self, stats):
|
||||||
|
good = [{"val": i} for i in range(0, 11)]
|
||||||
|
bad = [{"val": i} for i in range(-10, 0)] + [
|
||||||
|
{"val": i} for i in range(11, 20)
|
||||||
|
]
|
||||||
|
stats.stats = good + bad
|
||||||
|
calls = {}
|
||||||
|
stats.resume = lambda *_: calls.setdefault("resume", True)
|
||||||
|
stats.check("val", 0, 10)
|
||||||
|
|
||||||
|
assert calls == {"resume": True}
|
||||||
|
assert all(i < len(good) for i in stats.good)
|
||||||
|
assert all(i >= len(good) for i in stats.bad)
|
64
aircox/tests/models/test_episode.py
Normal file
64
aircox/tests/models/test_episode.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import datetime
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aircox.conf import settings
|
||||||
|
from aircox import models
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisode:
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_program(self, episode):
|
||||||
|
assert episode.program == episode.parent.program
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_podcasts(self, episode, podcasts):
|
||||||
|
podcasts = {
|
||||||
|
podcast.pk: podcast
|
||||||
|
for podcast in podcasts
|
||||||
|
if podcast.episode == episode
|
||||||
|
}
|
||||||
|
for data in episode.podcasts:
|
||||||
|
podcast = podcasts[data["pk"]]
|
||||||
|
assert data["name"] == podcast.name
|
||||||
|
assert data["page_url"] == episode.get_absolute_url()
|
||||||
|
assert data["page_title"] == episode.title
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_absolute_url_is_published(self, episode):
|
||||||
|
episode.status = models.Episode.STATUS_PUBLISHED
|
||||||
|
assert episode.get_absolute_url() != episode.parent.get_absolute_url()
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_absolute_url_not_published(self, episode):
|
||||||
|
episode.status = models.Episode.STATUS_DRAFT
|
||||||
|
assert episode.get_absolute_url() == episode.parent.get_absolute_url()
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=False)
|
||||||
|
def test_save_raises_parent_is_none(self, episode):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
episode.parent = None
|
||||||
|
episode.save()
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_default_title(self, programs):
|
||||||
|
program = programs[0]
|
||||||
|
date = datetime.date(2023, 5, 17)
|
||||||
|
result = models.Episode.get_default_title(program, date)
|
||||||
|
assert program.title in result
|
||||||
|
assert date.strftime(settings.EPISODE_TITLE_DATE_FORMAT) in result
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_init_kwargs_from(self, program):
|
||||||
|
date = datetime.date(2023, 3, 14)
|
||||||
|
date_str = date.strftime(settings.EPISODE_TITLE_DATE_FORMAT)
|
||||||
|
|
||||||
|
kwargs = models.Episode.get_init_kwargs_from(program, date)
|
||||||
|
title = kwargs["title"]
|
||||||
|
assert program.title in title
|
||||||
|
assert date_str in title
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_init_kwargs_from_with_title(self, program):
|
||||||
|
title = "test title"
|
||||||
|
kwargs = models.Episode.get_init_kwargs_from(program, title=title)
|
||||||
|
assert title == kwargs["title"]
|
|
@ -3,7 +3,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
|
|
||||||
from ..models.log import LogArchiver
|
from aircox.controllers.log_archiver import LogArchiver
|
||||||
from .log import LogListView
|
from .log import LogListView
|
||||||
|
|
||||||
__all__ = ["AdminMixin", "StatisticsView"]
|
__all__ = ["AdminMixin", "StatisticsView"]
|
||||||
|
|
|
@ -18,36 +18,6 @@ local_tz = tzlocal.get_localzone()
|
||||||
working_dir = os.path.join(os.path.dirname(__file__), "working_dir")
|
working_dir = os.path.join(os.path.dirname(__file__), "working_dir")
|
||||||
|
|
||||||
|
|
||||||
def interface_wrap(obj, attr, value):
|
|
||||||
if not isinstance(getattr(obj, "calls", None), dict):
|
|
||||||
obj.calls = {}
|
|
||||||
obj.calls[attr] = None
|
|
||||||
|
|
||||||
def wrapper(*a, **kw):
|
|
||||||
call = obj.calls.get(attr)
|
|
||||||
if call is None:
|
|
||||||
obj.calls[attr] = (a, kw)
|
|
||||||
elif isinstance(call, tuple):
|
|
||||||
obj.calls[attr] = [call, (a, kw)]
|
|
||||||
else:
|
|
||||||
call.append((a, kw))
|
|
||||||
return value
|
|
||||||
|
|
||||||
setattr(obj, attr, wrapper)
|
|
||||||
|
|
||||||
|
|
||||||
def interface(obj, funcs):
|
|
||||||
"""Override provided object's functions using dict of funcs, as ``{
|
|
||||||
func_name: return_value}``.
|
|
||||||
|
|
||||||
Attribute ``obj.calls`` is a dict
|
|
||||||
with all call done using those methods, as
|
|
||||||
``{func_name: (args, kwargs)}``.
|
|
||||||
"""
|
|
||||||
for attr, value in funcs.items():
|
|
||||||
interface_wrap(obj, attr, value)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSocket:
|
class FakeSocket:
|
||||||
FAILING_ADDRESS = -1
|
FAILING_ADDRESS = -1
|
||||||
"""Connect with this address fails."""
|
"""Connect with this address fails."""
|
||||||
|
|
19
notes.md
19
notes.md
|
@ -73,18 +73,9 @@ cms:
|
||||||
- player support diffusions with multiple archive files
|
- player support diffusions with multiple archive files
|
||||||
- comments -> remove/edit by the author
|
- comments -> remove/edit by the author
|
||||||
|
|
||||||
# Timezone shit:
|
|
||||||
|
|
||||||
# Instance's TODO
|
# For the next version:
|
||||||
- menu_top .sections:
|
## Refactorisation
|
||||||
- display inline block
|
Move:
|
||||||
- search on the right
|
- into `aircox_streamer`: `Log`, `Port`
|
||||||
- lists > items style
|
- into `aircox_cms`: `Page`, `NavItem`, `Category`, `StaticPage`, etc.
|
||||||
- logo: url
|
|
||||||
- comments / more info (perhaps using the thing like the player)
|
|
||||||
- footer url to aircox's repo + admin
|
|
||||||
- styling cal (a.today colored)
|
|
||||||
|
|
||||||
- init of post related models
|
|
||||||
-> date is not formatted
|
|
||||||
-> missing image?
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user