diff --git a/programs/README.md b/programs/README.md deleted file mode 100644 index ebfc9dd..0000000 --- a/programs/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Aircox Programs - -This application defines all base models and basic control of them. We have: -* **Nameable**: generic class used in any class needing to be named. Includes some utility functions; -* **Station**: a station -* **Program**: the program itself; -* **Diffusion**: occurrence of a program planified in the timetable. For rerun, informations are bound to the initial diffusion; -* **Schedule**: describes diffusions frequencies for each program; -* **Track**: track informations in a playlist of a diffusion; -* **Sound**: information about a sound that can be used for podcast or rerun; -* **Log**: logs - - -## Architecture -A Station is basically an object that represent a radio station. On each station, we use the Program object, that is declined in two different types: -* **Scheduled**: the diffusion is based on a timetable and planified through one Schedule or more; Diffusion object represent the occurrence of these programs; -* **Streamed**: the diffusion is based on random playlist, used to fill gaps between the programs; - -Each program has a directory in **AIRCOX_PROGRAMS_DIR**; For each, subdir: -* **archives**: complete episode record, can be used for diffusions or as a podcast -* **excerpts**: excerpt of an episode, or other elements, can be used as a podcast - - -## manage.py's commands -* **diffusions_monitor**: update/create, check and clean diffusions; When a diffusion is created its type can be set on "unconfirmed" (this depends on the approval mode). -* **sound_monitor**: check for existing and missing sounds files in programs directories and synchronize the database. Can also check for the quality of file and synchronize the database according to them. -* **sound_quality_check**: check for the quality of the file (don't update database) - - -## Requirements -* Sox (and soxi): sound file monitor and quality check -* requirements.txt for python's dependecies - diff --git a/programs/__init__.py b/programs/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/programs/admin.py b/programs/admin.py deleted file mode 100755 index 3ede9e0..0000000 --- a/programs/admin.py +++ /dev/null @@ -1,193 +0,0 @@ -import copy - -from django import forms -from django.contrib import admin -from django.contrib.contenttypes.admin import GenericTabularInline -from django.db import models -from django.utils.translation import ugettext as _, ugettext_lazy - -from aircox.programs.models import * - - -# -# Inlines -# -class SoundInline(admin.TabularInline): - model = Sound - - -class ScheduleInline(admin.TabularInline): - model = Schedule - extra = 1 - -class StreamInline(admin.TabularInline): - fields = ['delay', 'begin', 'end'] - model = Stream - extra = 1 - -class SoundInline(admin.TabularInline): - fields = ['type', 'path', 'duration','public'] - # readonly_fields = fields - model = Sound - extra = 0 - - -class DiffusionInline(admin.StackedInline): - model = Diffusion - extra = 0 - fields = ['type', 'start', 'end'] - -class NameableAdmin(admin.ModelAdmin): - fields = [ 'name' ] - - list_display = ['id', 'name'] - list_filter = [] - search_fields = ['name',] - - -class TrackInline(GenericTabularInline): - ct_field = 'related_type' - ct_fk_field = 'related_id' - model = Track - extra = 0 - fields = ('artist', 'title', 'info', 'position') - readonly_fields = ('position',) - - -@admin.register(Sound) -class SoundAdmin(NameableAdmin): - fields = None - list_display = ['id', 'name', 'duration', 'type', 'mtime', - 'public', 'good_quality', 'path'] - fieldsets = [ - (None, { 'fields': NameableAdmin.fields + - ['path', 'type', 'program', 'diffusion'] } ), - (None, { 'fields': ['embed', 'duration', 'public', 'mtime'] }), - (None, { 'fields': ['good_quality' ] } ) - ] - readonly_fields = ('path', 'duration',) - inlines = [TrackInline] - - -@admin.register(Stream) -class StreamAdmin(admin.ModelAdmin): - list_display = ('id', 'program', 'delay', 'begin', 'end') - - -@admin.register(Program) -class ProgramAdmin(NameableAdmin): - def schedule(self, obj): - return Schedule.objects.filter(program = obj).count() > 0 - schedule.boolean = True - schedule.short_description = _("Schedule") - - list_display = ('id', 'name', 'active', 'schedule') - fields = NameableAdmin.fields + [ 'active' ] - # TODO list_display - inlines = [ ScheduleInline, StreamInline ] - - # SO#8074161 - #def get_form(self, request, obj=None, **kwargs): - #if obj: - # if Schedule.objects.filter(program = obj).count(): - # self.inlines.remove(StreamInline) - # elif Stream.objects.filter(program = obj).count(): - # self.inlines.remove(ScheduleInline) - #return super().get_form(request, obj, **kwargs) - - -@admin.register(Diffusion) -class DiffusionAdmin(admin.ModelAdmin): - def archives(self, obj): - sounds = [ str(s) for s in obj.get_archives()] - return ', '.join(sounds) if sounds else '' - - def conflicts(self, obj): - if obj.type == Diffusion.Type.unconfirmed: - return ', '.join([ str(d) for d in obj.get_conflicts()]) - return '' - - def end_time(self, obj): - return obj.end.strftime('%H:%M') - end_time.short_description = _('end') - - def first(self, obj): - return obj.initial.start if obj.initial else '' - - list_display = ('id', 'program', 'start', 'end_time', 'type', 'first', 'archives', 'conflicts') - list_filter = ('type', 'start', 'program') - list_editable = ('type',) - ordering = ('-start', 'id') - - fields = ['type', 'start', 'end', 'initial', 'program'] - inlines = [ DiffusionInline, SoundInline ] - - - def get_form(self, request, obj=None, **kwargs): - if request.user.has_perm('aircox_program.programming'): - self.readonly_fields = [] - else: - self.readonly_fields = ['program', 'start', 'end'] - return super().get_form(request, obj, **kwargs) - - def get_object(self, *args, **kwargs): - """ - We want rerun to redirect to the given object. - """ - obj = super().get_object(*args, **kwargs) - if obj and obj.initial: - obj = obj.initial - return obj - - def get_queryset(self, request): - qs = super().get_queryset(request) - if request.GET and len(request.GET): - return qs - return qs.exclude(type = Diffusion.Type.unconfirmed) - - -@admin.register(Schedule) -class ScheduleAdmin(admin.ModelAdmin): - def program_name(self, obj): - return obj.program.name - program_name.short_description = _('Program') - - def day(self, obj): - return obj.date.strftime('%A') - day.short_description = _('Day') - - def rerun(self, obj): - return obj.initial != None - rerun.short_description = _('Rerun') - rerun.boolean = True - - list_filter = ['frequency', 'program'] - list_display = ['id', 'program_name', 'frequency', 'date', 'day', 'rerun'] - list_editable = ['frequency', 'date'] - - -@admin.register(Track) -class TrackAdmin(admin.ModelAdmin): - list_display = ['id', 'title', 'artist', 'position', 'in_seconds', 'related'] - - - -# TODO: sort & redo -class OutputInline(admin.StackedInline): - model = Output - extra = 0 - -@admin.register(Station) -class StationAdmin(admin.ModelAdmin): - inlines = [ OutputInline ] - -@admin.register(Log) -class LogAdmin(admin.ModelAdmin): - list_display = ['id', 'date', 'station', 'source', 'type', 'comment', 'related'] - list_filter = ['date', 'source', 'related_type'] - -admin.site.register(Output) - - - - diff --git a/programs/connector.py b/programs/connector.py deleted file mode 100644 index 047fc65..0000000 --- a/programs/connector.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import socket -import re -import json - - -class Connector: - """ - Simple connector class that retrieve/send data through a unix - domain socket file or a TCP/IP connection - - It is able to parse list of `key=value`, and JSON data. - """ - __socket = None - __available = False - address = None - """ - a string to the unix domain socket file, or a tuple (host, port) for - TCP/IP connection - """ - - @property - def available(self): - return self.__available - - def __init__(self, address = None): - if address: - self.address = address - - def open(self): - if self.__available: - return - - try: - family = socket.AF_INET if type(self.address) in (tuple, list) else \ - socket.AF_UNIX - self.__socket = socket.socket(family, socket.SOCK_STREAM) - self.__socket.connect(self.address) - self.__available = True - except: - self.__available = False - return -1 - - def send(self, *data, try_count = 1, parse = False, parse_json = False): - if self.open(): - return '' - data = bytes(''.join([str(d) for d in data]) + '\n', encoding='utf-8') - - try: - reg = re.compile(r'(.*)\s+END\s*$') - self.__socket.sendall(data) - data = '' - while not reg.search(data): - data += self.__socket.recv(1024).decode('utf-8') - - if data: - data = reg.sub(r'\1', data) - data = data.strip() - if parse: - data = self.parse(data) - elif parse_json: - data = self.parse_json(data) - return data - except: - self.__available = False - if try_count > 0: - return self.send(data, try_count - 1) - - def parse(self, string): - string = string.split('\n') - data = {} - for line in string: - line = re.search(r'(?P[^=]+)="?(?P([^"]|\\")+)"?', line) - if not line: - continue - line = line.groupdict() - data[line['key']] = line['value'] - return data - - def parse_json(self, string): - try: - if string[0] == '"' and string[-1] == '"': - string = string[1:-1] - return json.loads(string) if string else None - except: - return None - - - - diff --git a/programs/controllers.py b/programs/controllers.py deleted file mode 100644 index 7ac8815..0000000 --- a/programs/controllers.py +++ /dev/null @@ -1,359 +0,0 @@ -import os -import re -import subprocess -import atexit - -from django.template.loader import render_to_string - -import aircox.programs.models as models -import aircox.programs.settings as settings - -from aircox.programs.connector import Connector - - -class Streamer: - """ - Audio controller of a Station. - """ - station = None - """ - Related station - """ - template_name = 'aircox/controllers/liquidsoap.liq' - """ - If set, use this template in order to generated the configuration - file in self.path file - """ - path = None - """ - Path of the configuration file. - """ - current_sound = '' - """ - Current sound being played (retrieved by fetch) - """ - current_source = None - """ - Current source object that is responsible of self.current_sound - """ - process = None - """ - Application's process if ran from Streamer - """ - - socket_path = '' - """ - Path to the connector's socket - """ - connector = None - """ - Connector to Liquidsoap server - """ - - def __init__(self, station, **kwargs): - self.station = station - self.path = os.path.join(station.path, 'station.liq') - self.socket_path = os.path.join(station.path, 'station.sock') - self.connector = Connector(self.socket_path) - self.__dict__.update(kwargs) - - @property - def id(self): - """ - Streamer identifier common in both external app and here - """ - return self.station.slug - - # - # RPC - # - def _send(self, *args, **kwargs): - return self.connector.send(*args, **kwargs) - - def fetch(self): - """ - Fetch data of the children and so on - - The base function just execute the function of all children - sources. The plugin must implement the other extra part - """ - sources = self.station.sources - for source in sources: - source.fetch() - - rid = self._send('request.on_air').split(' ')[0] - if ' ' in rid: - rid = rid[:rid.index(' ')] - if not rid: - return - - data = self._send('request.metadata ', rid, parse = True) - if not data: - return - - self.current_sound = data.get('initial_uri') - try: - self.current_source = next( - source for source in self.station.sources - if source.rid == rid - ) - except: - self.current_source = None - - def push(self, config = True): - """ - Update configuration and children's info. - - The base function just execute the function of all children - sources. The plugin must implement the other extra part - """ - sources = self.station.sources - for source in sources: - source.push() - - if config and self.path and self.template_name: - data = render_to_string(self.template_name, { - 'station': self.station, - 'streamer': self, - 'settings': settings, - }) - data = re.sub('[\t ]+\n', '\n', data) - data = re.sub('\n{3,}', '\n\n', data) - - os.makedirs(os.path.dirname(self.path), exist_ok = True) - with open(self.path, 'w+') as file: - file.write(data) - - def skip(self): - """ - Skip a given source. If no source, use master. - """ - if self.current_source: - self.current_source.skip() - else: - self._send(self.id, '.skip') - - # - # Process management - # - def __get_process_args(self): - """ - Get arguments for the executed application. Called by exec, to be - used as subprocess.Popen(__get_process_args()). - If no value is returned, abort the execution. - """ - return ['liquidsoap', '-v', self.path] - - def process_run(self): - """ - Execute the external application with corresponding informations. - - This function must make sure that all needed files have been generated. - """ - if self.process: - return - - self.push() - - args = self.__get_process_args() - if not args: - return - self.process = subprocess.Popen(args, stderr=subprocess.STDOUT) - atexit.register(self.process.terminate) - - def process_terminate(self): - if self.process: - self.process.terminate() - self.process = None - - def process_wait(self): - """ - Wait for the process to terminate if there is a process - """ - if self.process: - self.process.wait() - self.process = None - - def ready(self): - """ - If external program is ready to use, returns True - """ - return self._send('var.list') != '' - - -class Source: - """ - Controller of a Source. Value are usually updated directly on the - external side. - """ - program = None - """ - Related source - """ - name = 'dealer' - - path = '' - """ - Path to the Source's playlist file. Optional. - """ - active = True - """ - Source is available. May be different from the containing Source, - e.g. dealer and liquidsoap. - """ - current_sound = '' - """ - Current sound being played (retrieved by fetch) - """ - current_source = None - """ - Current source being responsible of the current sound - """ - - rid = None - """ - Current request id of the source in LiquidSoap - """ - connector = None - """ - Connector to Liquidsoap server - """ - - @property - def id(self): - return self.program.slug if self.program else 'dealer' - - def __init__(self, station, **kwargs): - self.station = station - self.connector = self.station.streamer.connector - self.__dict__.update(kwargs) - self.__init_playlist() - if self.program: - self.name = self.program.name - - # - # Playlist - # - __playlist = None - - def __init_playlist(self): - self.__playlist = [] - if not self.path: - self.path = os.path.join(self.station.path, - self.id + '.m3u') - self.from_file() - - if not self.__playlist: - self.from_db() - - @property - def playlist(self): - """ - Current playlist on the Source, list of paths to play - """ - self.fetch() - return self.__playlist - - @playlist.setter - def playlist(self, value): - value = sorted(value) - if value != self.__playlist: - self.__playlist = value - self.push() - - def from_db(self, diffusion = None, program = None): - """ - Load a playlist to the controller from the database. If diffusion or - program is given use it, otherwise, try with self.program if exists, or - (if URI, self.url). - - A playlist from a program uses all its available archives. - """ - if diffusion: - self.playlist = diffusion.playlist - return - - program = program or self.program - if program: - self.playlist = [ sound.path for sound in - models.Sound.objects.filter( - type = models.Sound.Type.archive, - program = program, - ) - ] - return - - def from_file(self, path = None): - """ - Load a playlist from the given file (if not, use the - controller's one - """ - path = path or self.path - if not os.path.exists(path): - return - - with open(path, 'r') as file: - self.__playlist = file.read() - self.__playlist = self.__playlist.split('\n') \ - if self.__playlist else [] - - # - # RPC - # - def _send(self, *args, **kwargs): - return self.connector.send(*args, **kwargs) - - @property - def active(self): - return self._send('var.get ', self.id, '_active') == 'true' - - @active.setter - def active(self, value): - self._send('var.set ', self.id, '_active', '=', - 'true' if value else 'false') - - def fetch(self): - """ - Get the source information - """ - data = self._send(self.id, '.get', parse = True) - if not data or type(data) != dict: - return - - self.rid = data.get('rid') - self.current_sound = data.get('initial_uri') - - def push(self): - """ - Update data relative to the source on the external program. - By default write the playlist. - """ - os.makedirs(os.path.dirname(self.path), exist_ok = True) - with open(self.path, 'w') as file: - file.write('\n'.join(self.__playlist or [])) - - def skip(self): - """ - Skip the current sound in the source - """ - self._send(self.id, '.skip') - - def stream(self): - """ - Return a dict with stream info for a Stream program, or None if there - is not. Used in the template. - """ - # TODO: multiple streams - stream = self.program.stream_set.all().first() - if not stream or (not stream.begin and not stream.delay): - return - - def to_seconds(time): - return 3600 * time.hour + 60 * time.minute + time.second - - return { - 'begin': stream.begin.strftime('%Hh%M') if stream.begin else None, - 'end': stream.end.strftime('%Hh%M') if stream.end else None, - 'delay': to_seconds(stream.delay) if stream.delay else 0 - } - diff --git a/programs/locale/fr/LC_MESSAGES/django.po b/programs/locale/fr/LC_MESSAGES/django.po deleted file mode 100644 index ab59dcf..0000000 --- a/programs/locale/fr/LC_MESSAGES/django.po +++ /dev/null @@ -1,317 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-09-10 17:54+0200\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" - -#: programs/admin.py:82 programs/models.py:648 -msgid "Schedule" -msgstr "" - -#: programs/admin.py:112 programs/models.py:451 -msgid "end" -msgstr "" - -#: programs/admin.py:153 -msgid "Program" -msgstr "" - -#: programs/admin.py:157 -msgid "Day" -msgstr "" - -#: programs/admin.py:161 -msgid "Rerun" -msgstr "" - -#: programs/models.py:98 -msgid "name" -msgstr "" - -#: programs/models.py:131 -msgid "path" -msgstr "" - -#: programs/models.py:132 -msgid "path to the working directory" -msgstr "" - -#: programs/models.py:295 programs/models.py:995 programs/models.py:1050 -msgid "station" -msgstr "" - -#: programs/models.py:298 programs/models.py:1003 -msgid "active" -msgstr "" - -#: programs/models.py:300 -msgid "if not set this program is no longer active" -msgstr "" - -#: programs/models.py:437 programs/models.py:481 -msgid "related program" -msgstr "" - -#: programs/models.py:440 -msgid "delay" -msgstr "" - -#: programs/models.py:442 -msgid "delay between two sound plays" -msgstr "" - -#: programs/models.py:445 -msgid "begin" -msgstr "" - -#: programs/models.py:447 programs/models.py:453 -msgid "used to define a time range this stream isplayed" -msgstr "" - -#: programs/models.py:483 programs/models.py:1062 -msgid "date" -msgstr "" - -#: programs/models.py:485 programs/models.py:812 -msgid "duration" -msgstr "" - -#: programs/models.py:486 -msgid "regular duration" -msgstr "" - -#: programs/models.py:489 -msgid "frequency" -msgstr "" - -#: programs/models.py:492 -msgid "first week of the month" -msgstr "" - -#: programs/models.py:493 -msgid "second week of the month" -msgstr "" - -#: programs/models.py:494 -msgid "third week of the month" -msgstr "" - -#: programs/models.py:495 -msgid "fourth week of the month" -msgstr "" - -#: programs/models.py:496 -msgid "last week of the month" -msgstr "" - -#: programs/models.py:497 -msgid "first and third weeks of the month" -msgstr "" - -#: programs/models.py:498 -msgid "second and fourth weeks of the month" -msgstr "" - -#: programs/models.py:499 -msgid "every week" -msgstr "" - -#: programs/models.py:500 -msgid "one week on two" -msgstr "" - -#: programs/models.py:506 programs/models.py:689 -msgid "initial" -msgstr "" - -#: programs/models.py:649 -msgid "Schedules" -msgstr "" - -#: programs/models.py:680 programs/models.py:782 -msgid "program" -msgstr "" - -#: programs/models.py:684 programs/models.py:793 programs/models.py:998 -#: programs/models.py:1044 -msgid "type" -msgstr "" - -#: programs/models.py:691 -msgid "the diffusion is a rerun of this one" -msgstr "" - -#: programs/models.py:693 -msgid "start of the diffusion" -msgstr "" - -#: programs/models.py:694 -msgid "end of the diffusion" -msgstr "" - -#: programs/models.py:761 -msgid "Diffusion" -msgstr "" - -#: programs/models.py:762 -msgid "Diffusions" -msgstr "" - -#: programs/models.py:765 -msgid "edit the diffusion's planification" -msgstr "" - -#: programs/models.py:784 -msgid "program related to it" -msgstr "" - -#: programs/models.py:788 -msgid "diffusion" -msgstr "" - -#: programs/models.py:790 -msgid "initial diffusion related it" -msgstr "" - -#: programs/models.py:798 -msgid "file" -msgstr "" - -#: programs/models.py:807 -msgid "embed HTML code" -msgstr "" - -#: programs/models.py:809 -msgid "HTML code used to embed a sound from external plateform" -msgstr "" - -#: programs/models.py:814 -msgid "duration of the sound" -msgstr "" - -#: programs/models.py:817 -msgid "modification time" -msgstr "" - -#: programs/models.py:819 -msgid "last modification date and time" -msgstr "" - -#: programs/models.py:822 -msgid "good quality" -msgstr "" - -#: programs/models.py:824 -msgid "sound's quality is okay" -msgstr "" - -#: programs/models.py:827 -msgid "public" -msgstr "" - -#: programs/models.py:829 -msgid "the sound is accessible to the public" -msgstr "" - -#: programs/models.py:927 -msgid "Sound" -msgstr "" - -#: programs/models.py:928 -msgid "Sounds" -msgstr "" - -#: programs/models.py:940 -msgid "title" -msgstr "" - -#: programs/models.py:944 -msgid "artist" -msgstr "" - -#: programs/models.py:948 -msgid "tags" -msgstr "" - -#: programs/models.py:952 -msgid "information" -msgstr "" - -#: programs/models.py:955 -msgid "" -"additional informations about this track, such as the version, if is it a " -"remix, features, etc." -msgstr "" - -#: programs/models.py:960 -msgid "position in the playlist" -msgstr "" - -#: programs/models.py:963 -msgid "in seconds" -msgstr "" - -#: programs/models.py:965 -msgid "position in the playlist is expressed in seconds" -msgstr "" - -#: programs/models.py:972 -msgid "Track" -msgstr "" - -#: programs/models.py:973 -msgid "Tracks" -msgstr "" - -#: programs/models.py:1005 -msgid "this output is active" -msgstr "" - -#: programs/models.py:1008 -msgid "output settings" -msgstr "" - -#: programs/models.py:1009 -msgid "" -"list of comma separated params available; this is put in the output config " -"as raw code; plugin related" -msgstr "" - -#: programs/models.py:1051 -msgid "station on which the event occured" -msgstr "" - -#: programs/models.py:1056 -msgid "source" -msgstr "" - -#: programs/models.py:1058 -msgid "source id that make it happen on the station" -msgstr "" - -#: programs/models.py:1066 -msgid "comment" -msgstr "" - -#: programs/templates/aircox/controllers/monitor.html:107 -#: programs/templates/aircox/controllers/monitor.html:117 -msgid "skip" -msgstr "" - -#: programs/templates/aircox/controllers/monitor.html:108 -msgid "update" -msgstr "" diff --git a/programs/management/__init__.py b/programs/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/programs/management/__pycache__/__init__.cpython-34.pyc b/programs/management/__pycache__/__init__.cpython-34.pyc deleted file mode 100644 index cdbd9eb..0000000 Binary files a/programs/management/__pycache__/__init__.cpython-34.pyc and /dev/null differ diff --git a/programs/management/__pycache__/__init__.cpython-35.pyc b/programs/management/__pycache__/__init__.cpython-35.pyc deleted file mode 100644 index 424eb8f..0000000 Binary files a/programs/management/__pycache__/__init__.cpython-35.pyc and /dev/null differ diff --git a/programs/management/commands/__init__.py b/programs/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/programs/management/commands/_private.py b/programs/management/commands/_private.py deleted file mode 100644 index e69de29..0000000 diff --git a/programs/management/commands/diffusions_monitor.py b/programs/management/commands/diffusions_monitor.py deleted file mode 100644 index 794563d..0000000 --- a/programs/management/commands/diffusions_monitor.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -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 ---approval. - -Different actions are available: -- "update" is the process that is used to generated them using programs -schedules for the (given) month. - -- "clean" will remove all diffusions that are still unconfirmed and have been -planified before the (given) month. - -- "check" will remove all diffusions that are unconfirmed and have been planified -from the (given) month and later. -""" -import logging -from argparse import RawTextHelpFormatter - -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone as tz - -from aircox.programs.models import * - -logger = logging.getLogger('aircox.tools') - -class Actions: - @staticmethod - def __check_conflicts (item, saved_items): - """ - Check for conflicts, and update conflictual - items if they have been generated during this - update. - - It set an attribute 'do_not_save' if the item should not - be saved. FIXME: find proper way - - Return the number of conflicts - """ - conflicts = list(item.get_conflicts()) - for i, conflict in enumerate(conflicts): - if conflict.program == item.program: - item.do_not_save = True - del conflicts[i] - continue - - if conflict.pk in saved_items and \ - conflict.type != Diffusion.Type.unconfirmed: - conflict.type = Diffusion.Type.unconfirmed - conflict.save() - - if not conflicts: - item.type = Diffusion.Type.normal - return 0 - - item.type = Diffusion.Type.unconfirmed - return len(conflicts) - - @classmethod - def update (cl, date, mode): - manual = (mode == 'manual') - if not manual: - saved_items = set() - - count = [0, 0] - for schedule in Schedule.objects.filter(program__active = True) \ - .order_by('initial'): - # in order to allow rerun links between diffusions, we save items - # by schedule; - items = schedule.diffusions_of_month(date, exclude_saved = True) - count[0] += len(items) - - if manual: - Diffusion.objects.bulk_create(items) - else: - for item in items: - count[1] += cl.__check_conflicts(item, saved_items) - if hasattr(item, 'do_not_save'): - count[0] -= 1 - continue - - item.save() - saved_items.add(item) - - logger.info('[update] schedule %s: %d new diffusions', - str(schedule), len(items), - ) - - logger.info('[update] %d diffusions have been created, %s', count[0], - 'do not forget manual approval' if manual else - '{} conflicts found'.format(count[1])) - - @staticmethod - def clean (date): - qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed, - start__lt = date) - logger.info('[clean] %d diffusions will be removed', qs.count()) - qs.delete() - - @staticmethod - def check (date): - qs = Diffusion.objects.filter(type = Diffusion.Type.unconfirmed, - start__gt = date) - items = [] - for diffusion in qs: - schedules = Schedule.objects.filter(program = diffusion.program) - for schedule in schedules: - if schedule.match(diffusion.start): - break - else: - items.append(diffusion.id) - - logger.info('[check] %d diffusions will be removed', len(items)) - if len(items): - Diffusion.objects.filter(id__in = items).delete() - - -class Command (BaseCommand): - help= __doc__ - - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter - now = tz.datetime.today() - - group = parser.add_argument_group('action') - group.add_argument( - '--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( - '--clean', action='store_true', - help='remove unconfirmed diffusions older than the given month') - - group.add_argument( - '--check', action='store_true', - help='check future unconfirmed diffusions from the given date ' - 'agains\'t schedules and remove it if that do not match any ' - 'schedule') - - group = parser.add_argument_group('date') - group.add_argument( - '--year', type=int, default=now.year, - help='used by update, default is today\'s year') - group.add_argument( - '--month', type=int, default=now.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' - ) - - group = parser.add_argument_group('options') - group.add_argument( - '--mode', type=str, choices=['manual', 'auto'], - default='auto', - help='manual means that all generated diffusions are unconfirmed, ' - 'thus must be approved manually; auto confirmes all ' - 'diffusions except those that conflicts with others' - ) - - - def handle (self, *args, **options): - date = tz.datetime(year = options.get('year'), - month = options.get('month'), - day = 1) - date = tz.make_aware(date) - 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) - - if options.get('update'): - Actions.update(date, mode = options.get('mode')) - if options.get('clean'): - Actions.clean(date) - if options.get('check'): - Actions.check(date) - diff --git a/programs/management/commands/import_playlist.py b/programs/management/commands/import_playlist.py deleted file mode 100644 index f7c29db..0000000 --- a/programs/management/commands/import_playlist.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Import one or more playlist for the given sound. Attach it to the sound -or to the related Diffusion if wanted. - -Playlists are in CSV format, where columns are separated with a -'{settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is -{settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE}. -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 -from argparse import RawTextHelpFormatter - -from django.core.management.base import BaseCommand, CommandError -from django.contrib.contenttypes.models import ContentType - -from aircox.programs.models import * -import aircox.programs.settings as settings -__doc__ = __doc__.format(settings = settings) - -logger = logging.getLogger('aircox.tools') - - -class Importer: - data = None - tracks = None - - def __init__(self, related = None, path = None, save = False): - if path: - self.read(path) - if related: - self.make_playlist(related, save) - - def reset(self): - self.data = None - self.tracks = None - - def read(self, path): - if not os.path.exists(path): - return True - with open(path, 'r') as file: - self.data = list(csv.reader( - file, - delimiter = settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER, - quotechar = settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE, - )) - - def __get(self, line, field, default = None): - maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS - if field not in maps: - return default - index = maps.index(field) - return line[index] if index < len(line) else default - - def make_playlist(self, related, save = False): - """ - Make a playlist from the read data, and return it. If save is - true, save it into the database - """ - maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS - tracks = [] - - in_seconds = ('minutes' or 'seconds') in maps - for index, line in enumerate(self.data): - position = \ - int(self.__get(line, 'minutes', 0)) * 60 + \ - int(self.__get(line, 'seconds', 0)) \ - if in_seconds else index - - track, created = Track.objects.get_or_create( - related_type = ContentType.objects.get_for_model(related), - related_id = related.pk, - title = self.__get(line, 'title'), - artist = self.__get(line, 'artist'), - position = position, - ) - - track.in_seconds = in_seconds - track.info = self.__get(line, 'info') - tags = self.__get(line, 'tags') - if tags: - track.tags.add(*tags.split(',')) - - if save: - track.save() - tracks.append(track) - self.tracks = tracks - return tracks - - -class Command (BaseCommand): - help= __doc__ - - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter - now = tz.datetime.today() - - parser.add_argument( - '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.' - ) - parser.add_argument( - '--diffusion', '-d', action='store_true', - help='try to get the diffusion relative to the sound if it exists' - ) - - def handle (self, path, *args, **options): - # FIXME: absolute/relative path of sounds vs given path - if options.get('sound'): - related = Sound.objects.filter( - path__icontains = options.get('sound') - ).first() - else: - path, ext = os.path.splitext(options.get('path')) - related = Sound.objects.filter(path__icontains = path).first() - - if not related: - logger.error('no sound found in the database for the path ' \ - '{path}'.format(path=path)) - return -1 - - if options.get('diffusion') and related.diffusion: - related = related.diffusion - - importer = Importer(related = related, path = path, save = True) - for track in importer.tracks: - logger.info('imported track at {pos}: {title}, by ' - '{artist}'.format( - pos = track.position, - title = track.title, artist = track.artist - ) - ) - diff --git a/programs/management/commands/sounds_monitor.py b/programs/management/commands/sounds_monitor.py deleted file mode 100644 index e180e4d..0000000 --- a/programs/management/commands/sounds_monitor.py +++ /dev/null @@ -1,383 +0,0 @@ -""" -Monitor sound files; For each program, check for: -- new files; -- deleted files; -- differences between files and sound; -- quality of the files; - -It tries to parse the file name to get the date of the diffusion of an -episode and associate the file with it; We use the following format: - yyyymmdd[_n][_][name] - -Where: - 'yyyy' the year of the episode's diffusion; - 'mm' the month of the episode's diffusion; - 'dd' the day of the episode's diffusion; - 'n' the number of the episode (if multiple episodes); - 'name' the title of the sound; - - -To check quality of files, call the command sound_quality_check using the -parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires -Sox (and soxi). -""" -import os -import time -import re -import logging -import subprocess -from argparse import RawTextHelpFormatter -import atexit - -from watchdog.observers import Observer -from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent - -from django.core.management.base import BaseCommand, CommandError - -from aircox.programs.models import * -import aircox.programs.settings as settings -import aircox.programs.utils as utils - -logger = logging.getLogger('aircox.tools') - -class SoundInfo: - name = '' - sound = None - - year = None - month = None - day = None - n = None - duration = None - - @property - def path(self): - return self._path - - @path.setter - def path(self, value): - """ - Parse file name to get info on the assumption it has the correct - format (given in Command.help) - """ - file_name = os.path.basename(value) - file_name = os.path.splitext(file_name)[0] - r = re.search('^(?P[0-9]{4})' - '(?P[0-9]{2})' - '(?P[0-9]{2})' - '(_(?P[0-9]+))?' - '_?(?P.*)$', - file_name) - - if not (r and r.groupdict()): - r = { 'name': file_name } - logger.info('file name can not be parsed -> %s', value) - else: - r = r.groupdict() - - self._path = value - self.name = r['name'].replace('_', ' ').capitalize() - self.year = int(r.get('year')) if 'year' in r else None - self.month = int(r.get('month')) if 'month' in r else None - self.day = int(r.get('day')) if 'day' in r else None - self.n = r.get('n') - return r - - def __init__(self, path = ''): - self.path = path - - def get_duration(self): - p = subprocess.Popen(['soxi', '-D', self.path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - out, err = p.communicate() - if not err: - duration = utils.seconds_to_time(int(float(out))) - self.duration = duration - return duration - - def get_sound(self, kwargs = None, save = True): - """ - Get or create a sound using self info. - - If the sound is created/modified, get its duration and update it - (if save is True, sync to DB), and check for a playlist file. - """ - sound, created = Sound.objects.get_or_create( - path = self.path, - defaults = kwargs - ) - if created or sound.check_on_file(): - logger.info('sound is new or have been modified -> %s', self.path) - sound.duration = self.get_duration() - sound.name = self.name - if save: - sound.save() - self.sound = sound - return sound - - def find_playlist(self, sound): - """ - Find a playlist file corresponding to the sound path - """ - import aircox.programs.management.commands.import_playlist \ - as import_playlist - - path = os.path.splitext(self.sound.path)[0] + '.csv' - if not os.path.exists(path): - return - - old = Track.objects.get_for(object = sound) - if old: - return - - import_playlist.Importer(sound, path, save=True) - - def find_diffusion(self, program, save = True): - """ - 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. - """ - if self.year == None or not self.sound or self.sound.diffusion: - return; - - diffusion = Diffusion.objects.filter( - program = program, - initial__isnull = True, - start__year = self.year, - start__month = self.month, - start__day = self.day, - ) - if not diffusion: - return - diffusion = diffusion[0] - - logger.info('diffusion %s mathes to sound -> %s', str(diffusion), - self.sound.path) - self.sound.diffusion = diffusion - if save: - self.sound.save() - return diffusion - - -class MonitorHandler(PatternMatchingEventHandler): - """ - Event handler for watchdog, in order to be used in monitoring. - """ - def __init__(self, subdir): - """ - subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR - """ - self.subdir = subdir - if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR: - self.sound_kwargs = { 'type': Sound.Type.archive } - else: - self.sound_kwargs = { 'type': Sound.Type.excerpt } - - patterns = ['*/{}/*{}'.format(self.subdir, ext) - for ext in settings.AIRCOX_SOUND_FILE_EXT ] - super().__init__(patterns=patterns, ignore_directories=True) - - def on_created(self, event): - self.on_modified(event) - - def on_modified(self, event): - logger.info('sound modified: %s', event.src_path) - program = Program.get_from_path(event.src_path) - if not program: - return - - si = SoundInfo(event.src_path) - si.get_sound(self.sound_kwargs, True) - if si.year != None: - si.find_diffusion(program) - - def on_deleted(self, event): - logger.info('sound deleted: %s', event.src_path) - sound = Sound.objects.filter(path = event.src_path) - if sound: - sound = sound[0] - sound.type = sound.Type.removed - sound.save() - - def on_moved(self, event): - logger.info('sound moved: %s -> %s', event.src_path, event.dest_path) - sound = Sound.objects.filter(path = event.src_path) - if not sound: - self.on_modified( - FileModifiedEvent(event.dest_path) - ) - return - - sound = sound[0] - sound.path = event.dest_path - sound.save() - - -class Command(BaseCommand): - help= __doc__ - - def report(self, program = None, component = None, *content): - if not component: - logger.info('%s: %s', str(program), ' '.join([str(c) for c in content])) - else: - logger.info('%s, %s: %s', str(program), str(component), - ' '.join([str(c) for c in content])) - - def add_arguments(self, parser): - parser.formatter_class=RawTextHelpFormatter - parser.add_argument( - '-q', '--quality_check', action='store_true', - help='Enable quality check using sound_quality_check on all ' \ - 'sounds marqued as not good' - ) - parser.add_argument( - '-s', '--scan', action='store_true', - help='Scan programs directories for changes, plus check for a ' - ' matching diffusion on sounds that have not been yet assigned' - ) - parser.add_argument( - '-m', '--monitor', action='store_true', - help='Run in monitor mode, watch for modification in the filesystem ' - 'and react in consequence' - ) - - def handle(self, *args, **options): - if options.get('scan'): - self.scan() - if options.get('quality_check'): - self.check_quality(check = (not options.get('scan')) ) - if options.get('monitor'): - self.monitor() - - @staticmethod - def check_sounds(qs): - """ - Only check for the sound existence or update - """ - # check files - for sound in qs: - if sound.check_on_file(): - sound.save(check = False) - - def scan(self): - """ - For all programs, scan dirs - """ - logger.info('scan all programs...') - programs = Program.objects.filter() - - for program in programs: - logger.info('#%d %s', program.id, program.name) - self.scan_for_program( - program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, - type = Sound.Type.archive, - ) - self.scan_for_program( - program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, - type = Sound.Type.excerpt, - ) - - 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 - - sound_kwargs['program'] = program - - subdir = os.path.join(program.path, subdir) - sounds = [] - - # sounds in directory - for path in os.listdir(subdir): - path = os.path.join(subdir, path) - if not path.endswith(settings.AIRCOX_SOUND_FILE_EXT): - continue - - si = SoundInfo(path) - si.get_sound(sound_kwargs, True) - si.find_diffusion(program) - si.find_playlist(si.sound) - sounds.append(si.sound.pk) - - # sounds in db & unchecked - sounds = Sound.objects.filter(path__startswith = subdir). \ - exclude(pk__in = sounds) - self.check_sounds(sounds) - - def check_quality(self, check = False): - """ - Check all files where quality has been set to bad - """ - import aircox.programs.management.commands.sounds_quality_check \ - as quality_check - - # get available sound files - sounds = Sound.objects.filter(good_quality = False) \ - .exclude(type = Sound.Type.removed) - if check: - self.check_sounds(sounds) - - files = [ sound.path for sound in sounds - if os.path.exists(sound.path) ] - - # check quality - logger.info('quality check...',) - cmd = quality_check.Command() - cmd.handle( files = files, - **settings.AIRCOX_SOUND_QUALITY ) - - # update stats - logger.info('update stats in database') - def update_stats(sound_info, sound): - stats = sound_info.get_file_stats() - if stats: - duration = int(stats.get('length')) - sound.duration = utils.seconds_to_time(duration) - - for sound_info in cmd.good: - sound = Sound.objects.get(path = sound_info.path) - sound.good_quality = True - update_stats(sound_info, sound) - sound.save(check = False) - - for sound_info in cmd.bad: - sound = Sound.objects.get(path = sound_info.path) - update_stats(sound_info, sound) - sound.save(check = False) - - def monitor(self): - """ - Run in monitor mode - """ - archives_handler = MonitorHandler( - subdir = settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - excerpts_handler = MonitorHandler( - subdir = settings.AIRCOX_SOUND_EXCERPTS_SUBDIR - ) - - observer = Observer() - observer.schedule(archives_handler, settings.AIRCOX_PROGRAMS_DIR, - recursive=True) - observer.schedule(excerpts_handler, settings.AIRCOX_PROGRAMS_DIR, - recursive=True) - observer.start() - - def leave(): - observer.stop() - observer.join() - atexit.register(leave) - - while True: - time.sleep(1) - - diff --git a/programs/management/commands/sounds_quality_check.py b/programs/management/commands/sounds_quality_check.py deleted file mode 100644 index d512a36..0000000 --- a/programs/management/commands/sounds_quality_check.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Analyse and check files using Sox, prints good and bad files. -""" -import sys -import logging -import re -import subprocess -from argparse import RawTextHelpFormatter - -from django.core.management.base import BaseCommand, CommandError - -logger = logging.getLogger('aircox.tools') - -class Stats: - attributes = [ - 'DC offset', 'Min level', 'Max level', - 'Pk lev dB', 'RMS lev dB', 'RMS Pk dB', - 'RMS Tr dB', 'Flat factor', 'Length s', - ] - - def __init__ (self, path, **kwargs): - """ - If path is given, call analyse with path and kwargs - """ - self.values = {} - if path: - self.analyse(path, **kwargs) - - def get (self, attr): - return self.values.get(attr) - - def parse (self, output): - for attr in Stats.attributes: - value = re.search(attr + r'\s+(?P\S+)', output) - value = value and value.groupdict() - if value: - try: - value = float(value.get('value')) - except ValueError: - value = None - self.values[attr] = value - self.values['length'] = self.values['Length s'] - - def analyse (self, path, at = None, length = None): - """ - If at and length are given use them as excerpt to analyse. - """ - args = ['sox', path, '-n'] - - if at is not None and length is not None: - args += ['trim', str(at), str(length) ] - - args.append('stats') - - p = subprocess.Popen(args, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - # sox outputs to stderr (my god WHYYYY) - out_, out = p.communicate() - self.parse(str(out, encoding='utf-8')) - - -class Sound: - path = None # file path - sample_length = 120 # default sample length in seconds - stats = None # list of samples statistics - bad = None # list of bad samples - good = None # list of good samples - - def __init__ (self, path, sample_length = None): - self.path = path - self.sample_length = sample_length if sample_length is not None \ - else self.sample_length - - def get_file_stats (self): - return self.stats and self.stats[0] - - def analyse (self): - logger.info('complete file analysis') - self.stats = [ Stats(self.path) ] - position = 0 - length = self.stats[0].get('length') - - if not self.sample_length: - return - - logger.info('start samples analysis...') - while position < length: - stats = Stats(self.path, at = position, length = self.sample_length) - self.stats.append(stats) - position += self.sample_length - - def check (self, name, min_val, max_val): - self.good = [ index for index, stats in enumerate(self.stats) - if min_val <= stats.get(name) <= max_val ] - self.bad = [ index for index, stats in enumerate(self.stats) - if index not in self.good ] - self.resume() - - def resume (self): - view = lambda array: [ - 'file' if index is 0 else - 'sample {} (at {} seconds)'.format(index, (index-1) * self.sample_length) - for index in array - ] - - if self.good: - logger.info(self.path + ' -> good: \033[92m%s\033[0m', - ', '.join(view(self.good))) - if self.bad: - logger.info(self.path + ' -> bad: \033[91m%s\033[0m', - ', '.join(view(self.bad))) - -class Command (BaseCommand): - help = __doc__ - sounds = None - - def add_arguments (self, parser): - parser.formatter_class=RawTextHelpFormatter - - parser.add_argument( - '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', - ) - parser.add_argument( - '-a', '--attribute', type=str, - help='attribute name to use to check, that can be:\n' + \ - ', '.join([ '"{}"'.format(attr) for attr in Stats.attributes ]) - ) - parser.add_argument( - '-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' - ) - - def handle (self, *args, **options): - # parameters - minmax = options.get('range') - if not minmax: - raise CommandError('no range specified') - - attr = options.get('attribute') - if not attr: - raise CommandError('no attribute specified') - - # sound analyse and checks - self.sounds = [ Sound(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) - sound.analyse() - sound.check(attr, minmax[0], minmax[1]) - if sound.bad: - self.bad.append(sound) - else: - self.good.append(sound) - - # resume - if options.get('resume'): - for sound in self.good: - logger.info('\033[92m+ %s\033[0m', sound.path) - for sound in self.bad: - logger.info('\033[91m+ %s\033[0m', sound.path) - diff --git a/programs/models.py b/programs/models.py deleted file mode 100755 index 0362401..0000000 --- a/programs/models.py +++ /dev/null @@ -1,1100 +0,0 @@ -import datetime -import calendar -import os -import shutil -import logging -from enum import IntEnum - -from django.db import models -from django.template.defaultfilters import slugify -from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils import timezone as tz -from django.utils.html import strip_tags -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.conf import settings as main_settings - -from taggit.managers import TaggableManager - -import aircox.programs.utils as utils -import aircox.programs.settings as settings - - -logger = logging.getLogger('aircox.core') - - -def as_date(date, as_datetime = True): - """ - If as_datetime, return the date with time info set to 0; else, return - a date with date informations of the given date/time. - """ - import datetime - if as_datetime: - return date.replace(hour = 0, minute = 0, second = 0, microsecond = 0) - return datetime.date(date.year, date.month, date.day) - -def date_or_default(date, no_time = False): - """ - Return date or default value (now) if not defined, and remove time info - if date_only is True - """ - date = date or tz.now() - if not tz.is_aware(date): - date = tz.make_aware(date) - if no_time: - return as_date(date) - return date - - -# -# Abstracts -# -class RelatedManager(models.Manager): - def get_for(self, object = None, model = None): - """ - Return a queryset that filter on the given object or model(s) - - * object: if given, use its type and pk; match on models only. - * model: one model or an iterable of models - """ - if not model and object: - model = type(object) - - if hasattr(model, '__iter__'): - model = [ ContentType.objects.get_for_model(m).id - for m in model ] - qs = self.filter(related_type__pk__in = model) - else: - model = ContentType.objects.get_for_model(model) - qs = self.filter(related_type__pk = model.id) - if object: - qs = qs.filter(related_id = object.pk) - return qs - - -class Related(models.Model): - """ - Add a field "related" of type GenericForeignKey, plus utilities. - """ - related_type = models.ForeignKey( - ContentType, - blank = True, null = True, - ) - related_id = models.PositiveIntegerField( - blank = True, null = True, - ) - related = GenericForeignKey( - 'related_type', 'related_id', - ) - - objects = RelatedManager() - - class Meta: - abstract = True - - -class Nameable(models.Model): - name = models.CharField ( - _('name'), - max_length = 128, - ) - - @property - def slug(self): - """ - Slug based on the name. We replace '-' by '_' - """ - return slugify(self.name).replace('-', '_') - - def __str__(self): - #if self.pk: - # return '#{} {}'.format(self.pk, self.name) - return '{}'.format(self.name) - - class Meta: - abstract = True - - -# -# Station related classes -# -class Station(Nameable): - """ - Represents a radio station, to which multiple programs are attached - and that is used as the top object for everything. - - A Station holds controllers for the audio stream generation too. - Theses are set up when needed (at the first access to these elements) - then cached. - """ - path = models.CharField( - _('path'), - help_text = _('path to the working directory'), - max_length = 256, - blank = True, - ) - - # - # Controllers - # - __sources = None - __dealer = None - __streamer = None - - def __prepare(self): - import aircox.programs.controllers as controllers - if not self.__streamer: - self.__streamer = controllers.Streamer(station = self) - self.__dealer = controllers.Source(station = self) - self.__sources = [ - controllers.Source(station = self, program = program) - for program in Program.objects.filter(stream__isnull = False) - ] + [ self.__dealer ] - - @property - def sources(self): - """ - Audio sources, dealer included - """ - self.__prepare() - return self.__sources - - @property - def dealer(self): - self.__prepare() - return self.__dealer - - @property - def streamer(self): - """ - Audio controller for the station - """ - self.__prepare() - return self.__streamer - - def get_played(self, models, archives = True): - """ - Return a queryset with log of played elements on this station, - of the given models, ordered by date ascending. - - * models: a model or a list of models - * archives: if false, exclude log of diffusion's archives from - the queryset; - """ - qs = Log.objects.get_for(model = models) \ - .filter(station = self, type = Log.Type.play) - if not archives and self.dealer: - qs = qs.exclude( - source = self.dealer.id, - related_type = ContentType.objects.get_for_model(Sound) - ) - return qs.order_by('date') - - @staticmethod - def __mix_logs_and_diff(diffs, logs, count = 0): - """ - Mix together logs and diffusion items of the same day, - ordered by their date. - - Diffs and Logs are assumed to be ordered by -date, and so is - the resulting list - """ - # we fill a list with diff and retrieve logs that happened between - # each to put them too there - items = [] - diff_ = None - for diff in diffs.order_by('-start'): - logs_ = \ - logs.filter(date__gt = diff.end, date__lt = diff_.start) \ - if diff_ else \ - logs.filter(date__gt = diff.end) - diff_ = diff - items.extend(logs_) - items.append(diff) - if count and len(items) >= count: - break - - if diff_: - if count and len(items) >= count: - return items[:count] - logs_ = logs.filter(date__lt = diff_.end) - else: - logs_ = logs.all() - - items.extend(logs_) - return items[:count] if count else items - - def on_air(self, date = None, count = 0): - """ - Return a list of what happened on air, based on logs and - diffusions informations. The list is sorted by -date. - - * date: only for what happened on this date; - * count: number of items to retrieve if not zero; - - If date is not specified, count MUST be set to a non-zero value. - Be careful with what you which for: the result is a plain list. - - The list contains: - * track logs: for the streamed programs; - * diffusion: for the scheduled diffusions; - """ - # FIXME: as an iterator? - # TODO argument to get sound instead of tracks - if not date and not count: - raise ValueError('at least one argument must be set') - - if date and date > datetime.date.today(): - return [] - - logs = Log.objects.get_for(model = Track) \ - .filter(station = self) \ - .order_by('-date') - - if date: - logs = logs.filter(date__contains = date) - diffs = Diffusion.objects.get_at(date) - else: - diffs = Diffusion.objects - - diffs = diffs.filter(program__station = self) \ - .filter(type = Diffusion.Type.normal) \ - .filter(start__lte = tz.now()) - return self.__mix_logs_and_diff(diffs, logs, count) - - def save(self, make_sources = True, *args, **kwargs): - if not self.path: - self.path = os.path.join( - settings.AIRCOX_CONTROLLERS_WORKING_DIR, - self.slug - ) - - super().save(*args, **kwargs) - - -class Program(Nameable): - """ - A Program can either be a Streamed or a Scheduled program. - - A Streamed program is used to generate non-stop random playlists when there - is not scheduled diffusion. In such a case, a Stream is used to describe - diffusion informations. - - A Scheduled program has a schedule and is the one with a normal use case. - - Renaming a Program rename the corresponding directory to matches the new - name if it does not exists. - """ - station = models.ForeignKey( - Station, - verbose_name = _('station'), - ) - active = models.BooleanField( - _('active'), - default = True, - help_text = _('if not set this program is no longer active') - ) - - @property - def path(self): - """ - Return the path to the programs directory - """ - return os.path.join(settings.AIRCOX_PROGRAMS_DIR, - self.slug + '_' + str(self.id) ) - - def ensure_dir(self, subdir = None): - """ - Make sur the program's dir exists (and optionally subdir). Return True - if the dir (or subdir) exists. - """ - path = os.path.join(self.path, subdir) if subdir else \ - self.path - os.makedirs(path, exist_ok = True) - return os.path.exists(path) - - @property - def archives_path(self): - return os.path.join( - self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - - @property - def excerpts_path(self): - return os.path.join( - self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - - def find_schedule(self, date): - """ - Return the first schedule that matches a given date. - """ - schedules = Schedule.objects.filter(program = self) - for schedule in schedules: - if schedule.match(date, check_time = False): - return schedule - - def __init__(self, *kargs, **kwargs): - super().__init__(*kargs, **kwargs) - if self.name: - self.__original_path = self.path - - def save(self, *kargs, **kwargs): - super().save(*kargs, **kwargs) - if hasattr(self, '__original_path') and \ - self.__original_path != self.path and \ - os.path.exists(self.__original_path) and \ - not os.path.exists(self.path): - logger.info('program #%s\'s name changed to %s. Change dir name', - self.id, self.name) - shutil.move(self.__original_path, self.path) - - sounds = Sounds.objects.filter(path__startswith = self.__original_path) - for sound in sounds: - sound.path.replace(self.__original_path, self.path) - sound.save() - - @classmethod - def get_from_path(cl, path): - """ - Return a Program from the given path. We assume the path has been - given in a previous time by this model (Program.path getter). - """ - path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') - while path[0] == '/': path = path[1:] - while path[-1] == '/': path = path[:-2] - if '/' in path: - path = path[:path.index('/')] - - path = path.split('_') - path = path[-1] - qs = cl.objects.filter(id = int(path)) - return qs[0] if qs else None - - -class DiffusionManager(models.Manager): - def get_at(self, date = None, next = False): - """ - Return a queryset of diffusions that have the given date - in their range. - - If date is a datetime.date object, check only against the - date. - """ - date = date or tz.now() - if not issubclass(type(date), datetime.datetime): - return self.filter( - models.Q(start__contains = date) | \ - models.Q(end__contains = date) - ) - - if not next: - return self.filter(start__lte = date, end__gte = date) \ - .order_by('start') - - return self.filter( - models.Q(start__lte = date, end__gte = date) | - models.Q(start__gte = date), - ).order_by('start') - - def get_after(self, date = None): - """ - Return a queryset of diffusions that happen after the given - date. - """ - date = date_or_default(date) - return self.filter( - start__gte = date, - ).order_by('start') - - def get_before(self, date): - """ - Return a queryset of diffusions that finish before the given - date. - """ - date = date_or_default(date) - return self.filter( - end__lte = date, - ).order_by('start') - - -class Stream(models.Model): - """ - When there are no program scheduled, it is possible to play sounds - in order to avoid blanks. A Stream is a Program that plays this role, - and whose linked to a Stream. - - All sounds that are marked as good and that are under the related - program's archive dir are elligible for the sound's selection. - """ - program = models.ForeignKey( - Program, - verbose_name = _('related program'), - ) - delay = models.TimeField( - _('delay'), - blank = True, null = True, - help_text = _('delay between two sound plays') - ) - begin = models.TimeField( - _('begin'), - blank = True, null = True, - help_text = _('used to define a time range this stream is' - 'played') - ) - end = models.TimeField( - _('end'), - blank = True, null = True, - help_text = _('used to define a time range this stream is' - 'played') - ) - - -class Schedule(models.Model): - """ - A Schedule defines time slots of programs' diffusions. It can be an initial - run or a rerun (in such case it is linked to the related schedule). - """ - # Frequency for schedules. Basically, it is a mask of bits where each bit is - # a week. Bits > rank 5 are used for special schedules. - # Important: the first week is always the first week where the weekday of - # the schedule is present. - # For ponctual programs, there is no need for a schedule, only a diffusion - class Frequency(IntEnum): - first = 0b000001 - second = 0b000010 - third = 0b000100 - fourth = 0b001000 - last = 0b010000 - first_and_third = 0b000101 - second_and_fourth = 0b001010 - every = 0b011111 - one_on_two = 0b100000 - - program = models.ForeignKey( - Program, - verbose_name = _('related program'), - ) - date = models.DateTimeField(_('date')) - duration = models.TimeField( - _('duration'), - help_text = _('regular duration'), - ) - frequency = models.SmallIntegerField( - _('frequency'), - choices = [ - (int(y), { - 'first': _('first week of the month'), - 'second': _('second week of the month'), - 'third': _('third week of the month'), - 'fourth': _('fourth week of the month'), - 'last': _('last week of the month'), - 'first_and_third': _('first and third weeks of the month'), - 'second_and_fourth': _('second and fourth weeks of the month'), - 'every': _('every week'), - 'one_on_two': _('one week on two'), - }[x]) for x,y in Frequency.__members__.items() - ], - ) - initial = models.ForeignKey( - 'self', - verbose_name = _('initial'), - blank = True, null = True, - help_text = 'this schedule is a rerun of this one', - ) - - @property - def end(self): - return self.date + utils.to_timedelta(self.duration) - - def match(self, date = None, check_time = True): - """ - Return True if the given datetime matches the schedule - """ - date = date_or_default(date) - if self.date.weekday() == date.weekday() and self.match_week(date): - return self.date.time() == date.time() if check_time else True - return False - - def match_week(self, date = None): - """ - Return True if the given week number matches the schedule, False - otherwise. - If the schedule is ponctual, return None. - """ - # since we care only about the week, go to the same day of the week - date = date_or_default(date) - date += tz.timedelta(days = self.date.weekday() - date.weekday() ) - - if self.frequency == Schedule.Frequency.one_on_two: - # cf notes in date_of_month - diff = as_date(date, False) - as_date(self.date, False) - return not (diff.days % 14) - - first_of_month = date.replace(day = 1) - week = date.isocalendar()[1] - first_of_month.isocalendar()[1] - - # weeks of month - if week == 4: - # fifth week: return if for every week - return self.frequency == 0b1111 - return (self.frequency & (0b0001 << week) > 0) - - def normalize(self, date): - """ - Set the time of a datetime to the schedule's one - """ - return date.replace(hour = self.date.hour, minute = self.date.minute) - - def dates_of_month(self, date = None): - """ - Return a list with all matching dates of date.month (=today) - """ - date = date_or_default(date, True).replace(day=1) - freq = self.frequency - - # last of the month - if freq == Schedule.Frequency.last: - date = date.replace(day=calendar.monthrange(date.year, date.month)[1]) - - # end of month before the wanted weekday: move one week back - if date.weekday() < self.date.weekday(): - date -= datetime.timedelta(days = 7) - - delta = self.date.weekday() - date.weekday() - date += datetime.timedelta(days = delta) - return [self.normalize(date)] - - # move to the first day of the month that matches the schedule's weekday - # check on SO#3284452 for the formula - first_weekday = date.weekday() - sched_weekday = self.date.weekday() - date += tz.timedelta(days = (7 if first_weekday > sched_weekday else 0) \ - - first_weekday + sched_weekday) - month = date.month - - dates = [] - if freq == Schedule.Frequency.one_on_two: - # check date base on a diff of dates base on a 14 days delta - diff = as_date(date, False) - as_date(self.date, False) - if diff.days % 14: - date += tz.timedelta(days = 7) - - while date.month == month: - dates.append(date) - date += tz.timedelta(days = 14) - else: - week = 0 - while week < 5 and date.month == month: - if freq & (0b1 << week): - dates.append(date) - date += tz.timedelta(days = 7) - week += 1; - return [self.normalize(date) for date in dates] - - def diffusions_of_month(self, date, exclude_saved = False): - """ - Return a list of Diffusion instances, from month of the given date, that - can be not in the database. - - If exclude_saved, exclude all diffusions that are yet in the database. - """ - dates = self.dates_of_month(date) - diffusions = [] - - # existing diffusions - for item in Diffusion.objects.filter( - program = self.program, start__in = dates): - if item.start in dates: - dates.remove(item.start) - if not exclude_saved: - diffusions.append(item) - - # new diffusions - duration = utils.to_timedelta(self.duration) - if self.initial: - delta = self.date - self.initial.date - diffusions += [ - Diffusion( - program = self.program, - type = Diffusion.Type.unconfirmed, - initial = \ - Diffusion.objects.filter(start = date - delta).first() \ - if self.initial else None, - start = date, - end = date + duration, - ) for date in dates - ] - return diffusions - - def __str__(self): - return ' | '.join([ '#' + str(self.id), self.program.name, - self.get_frequency_display(), - self.date.strftime('%a %H:%M') ]) - - def save(self, *args, **kwargs): - if self.initial: - self.program = self.initial.program - self.duration = self.initial.duration - self.frequency = self.initial.frequency - super().save(*args, **kwargs) - - class Meta: - verbose_name = _('Schedule') - verbose_name_plural = _('Schedules') - - -class Diffusion(models.Model): - """ - A Diffusion is an occurrence of a Program that is scheduled on the - station's timetable. It can be a rerun of a previous diffusion. In such - a case, use rerun's info instead of its own. - - A Diffusion without any rerun is named Episode (previously, a - Diffusion was different from an Episode, but in the end, an - episode only has a name, a linked program, and a list of sounds, so we - finally merge theme). - - A Diffusion can have different types: - - default: simple diffusion that is planified / did occurred - - unconfirmed: a generated diffusion that has not been confirmed and thus - is not yet planified - - cancel: the diffusion has been canceled - - stop: the diffusion has been manually stopped - """ - objects = DiffusionManager() - - class Type(IntEnum): - normal = 0x00 - unconfirmed = 0x01 - canceled = 0x02 - - # common - program = models.ForeignKey ( - Program, - verbose_name = _('program'), - ) - # specific - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - ) - initial = models.ForeignKey ( - 'self', - verbose_name = _('initial'), - blank = True, null = True, - help_text = _('the diffusion is a rerun of this one') - ) - start = models.DateTimeField( _('start of the diffusion') ) - end = models.DateTimeField( _('end of the diffusion') ) - - @property - def duration(self): - return self.end - self.start - - @property - def date(self): - return self.start - - @property - def playlist(self): - """ - List of archives' path; uses get_archives - """ - return [ sound.path for sound in self.get_archives() ] - - def get_archives(self): - """ - Return a list of available archives sounds for the given episode, - ordered by path. - """ - sounds = self.initial.sound_set if self.initial else self.sound_set - return sounds.filter(type = Sound.Type.archive).order_by('path') - - def get_excerpts(self): - """ - Return a list of available archives sounds for the given episode, - ordered by path. - """ - sounds = self.initial.sound_set if self.initial else self.sound_set - return sounds.filter(type = Sound.Type.excerpt).order_by('path') - - def is_date_in_range(self, date = None): - """ - Return true if the given date is in the diffusion's start-end - range. - """ - date = date or tz.now() - return self.start < date < self.end - - def get_conflicts(self): - """ - Return a list of conflictual diffusions, based on the scheduled duration. - """ - r = Diffusion.objects.filter( - models.Q(start__lt = self.start, - end__gt = self.start) | - models.Q(start__gt = self.start, - start__lt = self.end) - ) - return r - - def save(self, *args, **kwargs): - if self.initial: - # force link to the top initial diffusion - if self.initial.initial: - self.initial = self.initial.initial - self.program = self.initial.program - super().save(*args, **kwargs) - - def __str__(self): - return '{self.program.name} {date} #{self.pk}'.format( - self=self, date=self.date.strftime('%Y-%m-%d %H:%M') - ) - - class Meta: - verbose_name = _('Diffusion') - verbose_name_plural = _('Diffusions') - - permissions = ( - ('programming', _('edit the diffusion\'s planification')), - ) - - -class Sound(Nameable): - """ - A Sound is the representation of a sound file that can be either an excerpt - or a complete archive of the related diffusion. - """ - class Type(IntEnum): - other = 0x00, - archive = 0x01, - excerpt = 0x02, - removed = 0x03, - - program = models.ForeignKey( - Program, - verbose_name = _('program'), - blank = True, null = True, - help_text = _('program related to it'), - ) - diffusion = models.ForeignKey( - 'Diffusion', - verbose_name = _('diffusion'), - blank = True, null = True, - help_text = _('initial diffusion related it') - ) - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - blank = True, null = True - ) - path = models.FilePathField( - _('file'), - path = settings.AIRCOX_PROGRAMS_DIR, - match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \ - .replace('.', r'\.') + ')$', - recursive = True, - blank = True, null = True, - max_length = 256 - ) - embed = models.TextField( - _('embed HTML code'), - blank = True, null = True, - help_text = _('HTML code used to embed a sound from external plateform'), - ) - duration = models.TimeField( - _('duration'), - blank = True, null = True, - help_text = _('duration of the sound'), - ) - mtime = models.DateTimeField( - _('modification time'), - blank = True, null = True, - help_text = _('last modification date and time'), - ) - good_quality = models.BooleanField( - _('good quality'), - default = False, - help_text = _('sound\'s quality is okay') - ) - public = models.BooleanField( - _('public'), - default = False, - help_text = _('the sound is accessible to the public') - ) - - def get_mtime(self): - """ - Get the last modification date from file - """ - mtime = os.stat(self.path).st_mtime - mtime = tz.datetime.fromtimestamp(mtime) - # db does not store microseconds - mtime = mtime.replace(microsecond = 0) - return tz.make_aware(mtime, tz.get_current_timezone()) - - def url(self): - """ - Return an url to the stream - """ - # path = self._meta.get_field('path').path - path = self.path.replace(main_settings.MEDIA_ROOT, '', 1) - #path = self.path.replace(path, '', 1) - return main_settings.MEDIA_URL + '/' + path - - def file_exists(self): - """ - Return true if the file still exists - """ - return os.path.exists(self.path) - - def check_on_file(self): - """ - Check sound file info again'st self, and update informations if - needed (do not save). Return True if there was changes. - """ - if not self.file_exists(): - if self.type == self.Type.removed: - return - logger.info('sound %s: has been removed', self.path) - self.type = self.Type.removed - return True - - # not anymore removed - changed = False - if self.type == self.Type.removed and self.program: - changed = True - self.type = self.Type.archive \ - if self.path.startswith(self.program.archives_path) else \ - self.Type.excerpt - - # check mtime -> reset quality if changed (assume file changed) - mtime = self.get_mtime() - if self.mtime != mtime: - self.mtime = mtime - self.good_quality = False - logger.info('sound %s: m_time has changed. Reset quality info', - self.path) - return True - return changed - - def check_perms(self): - """ - Check file permissions and update it if the sound is public - """ - if not settings.AIRCOX_SOUND_AUTO_CHMOD or \ - self.removed or not os.path.exists(self.path): - return - - flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public] - try: - os.chmod(self.path, flags) - except PermissionError as err: - logger.error( - 'cannot set permissions {} to file {}: {}'.format( - self.flags[self.public], - self.path, err - ) - ) - - def __check_name(self): - if not self.name and self.path: - # FIXME: later, remove date? - self.name = os.path.basename(self.path) - self.name = os.path.splitext(self.name)[0] - self.name = self.name.replace('_', ' ') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__check_name() - - def save(self, check = True, *args, **kwargs): - if check: - self.check_on_file() - self.__check_name() - super().save(*args, **kwargs) - - def __str__(self): - return '/'.join(self.path.split('/')[-3:]) - - class Meta: - verbose_name = _('Sound') - verbose_name_plural = _('Sounds') - - -class Track(Related): - """ - Track of a playlist of an object. The position can either be expressed - as the position in the playlist or as the moment in seconds it started. - """ - # There are no nice solution for M2M relations ship (even without - # through) in django-admin. So we unfortunately need to make one- - # to-one relations and add a position argument - title = models.CharField ( - _('title'), - max_length = 128, - ) - artist = models.CharField( - _('artist'), - max_length = 128, - ) - tags = TaggableManager( - verbose_name=_('tags'), - blank=True, - ) - info = models.CharField( - _('information'), - max_length = 128, - blank = True, null = True, - help_text=_('additional informations about this track, such as ' - 'the version, if is it a remix, features, etc.'), - ) - position = models.SmallIntegerField( - default = 0, - help_text=_('position in the playlist'), - ) - in_seconds = models.BooleanField( - _('in seconds'), - default = False, - help_text=_('position in the playlist is expressed in seconds') - ) - - def __str__(self): - return '{self.artist} -- {self.title}'.format(self=self) - - class Meta: - verbose_name = _('Track') - verbose_name_plural = _('Tracks') - - -# -# Controls and audio output -# - -# FIXME HERE -# + station -> played, on_air and others -class Output (models.Model): - """ - Represent an audio output for the audio stream generation. - You might want to take a look to LiquidSoap's documentation - for the Jack, Alsa, and Icecast ouptuts. - """ - class Type(IntEnum): - jack = 0x00 - alsa = 0x01 - icecast = 0x02 - - station = models.ForeignKey( - Station, - verbose_name = _('station'), - ) - type = models.SmallIntegerField( - _('type'), - # we don't translate the names since it is project names. - choices = [ (int(y), x) for x,y in Type.__members__.items() ], - ) - active = models.BooleanField( - _('active'), - default = True, - help_text = _('this output is active') - ) - settings = models.TextField( - _('output settings'), - help_text = _('list of comma separated params available; ' - 'this is put in the output config as raw code; ' - 'plugin related'), - blank = True, null = True - ) - - -class Log(Related): - """ - Log sounds and diffusions that are played on the station. - - This only remember what has been played on the outputs, not on each - track; Source designate here which source is responsible of that. - """ - class Type(IntEnum): - stop = 0x00 - """ - Source has been stopped (only when there is no more sound) - """ - play = 0x01 - """ - Source has been started/changed and is running related_object - If no related_object is available, comment is used to designate - the sound. - """ - load = 0x02 - """ - Source starts to be preload related_object - """ - other = 0x03 - """ - Other log - """ - - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], - blank = True, null = True, - ) - station = models.ForeignKey( - Station, - verbose_name = _('station'), - help_text = _('station on which the event occured'), - ) - source = models.CharField( - # we use a CharField to avoid loosing logs information if the - # source is removed - _('source'), - max_length=64, - help_text = _('source id that make it happen on the station'), - blank = True, null = True, - ) - date = models.DateTimeField( - _('date'), - default=tz.now, - ) - comment = models.CharField( - _('comment'), - max_length = 512, - blank = True, null = True, - ) - - @property - def end(self): - """ - Calculated end using self.related informations - """ - if self.related_type == Diffusion: - return self.related.end - if self.related_type == Sound: - return self.date + to_timedelta(self.duration) - return self.date - - def is_expired(self, date = None): - """ - Return True if the log is expired. Note that it only check - against the date, so it is still possible that the expiration - occured because of a Stop or other source. - """ - date = date_or_default(date) - return self.end < date - - def print(self): - logger.info('log #%s: %s%s', - str(self), - self.comment or '', - ' -- {} #{}'.format(self.related_type, self.related_id) - if self.related else '' - ) - - def __str__(self): - return '#{} ({}, {})'.format( - self.pk, self.date.strftime('%Y/%m/%d %H:%M'), self.source - ) - - - diff --git a/programs/settings.py b/programs/settings.py deleted file mode 100755 index 0cf7e63..0000000 --- a/programs/settings.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import stat - -from django.conf import settings - -def ensure (key, default): - globals()[key] = getattr(settings, key, default) - - -# Directory for the programs data -ensure('AIRCOX_PROGRAMS_DIR', - os.path.join(settings.MEDIA_ROOT, 'programs')) - -# Default directory for the sounds that not linked to a program -ensure('AIRCOX_SOUND_DEFAULT_DIR', - os.path.join(AIRCOX_PROGRAMS_DIR, 'defaults')), -# Sub directory used for the complete episode sounds -ensure('AIRCOX_SOUND_ARCHIVES_SUBDIR', 'archives') -# Sub directory used for the excerpts of the episode -ensure('AIRCOX_SOUND_EXCERPTS_SUBDIR', 'excerpts') - -# Change sound perms based on 'public' attribute if True -ensure('AIRCOX_SOUND_AUTO_CHMOD', True) -# Chmod bits flags as a tuple for (not public, public). Use os.chmod -# and stat.* -ensure( - 'AIRCOX_SOUND_CHMOD_FLAGS', - (stat.S_IRWXU, stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH ) -) - -# Quality attributes passed to sound_quality_check from sounds_monitor -ensure('AIRCOX_SOUND_QUALITY', { - 'attribute': 'RMS lev dB', - 'range': (-18.0, -8.0), - 'sample_length': 120, - } -) - -# Extension of sound files -ensure( - 'AIRCOX_SOUND_FILE_EXT', - ('.ogg','.flac','.wav','.mp3','.opus') -) - -# Stream for the scheduled diffusions -ensure('AIRCOX_SCHEDULED_STREAM', 0) - - -# Import playlist: columns for CSV file -ensure( - 'AIRCOX_IMPORT_PLAYLIST_CSV_COLS', - ('artist', 'title', 'minutes', 'seconds', 'tags', 'info') -) -# Import playlist: column delimiter of csv text files -ensure('AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER', ';') -# Import playlist: text delimiter of csv text files -ensure('AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE', '"') - - -# Controllers working directory -ensure('AIRCOX_CONTROLLERS_WORKING_DIR', '/tmp/aircox') - - diff --git a/programs/templates/aircox/controllers/liquidsoap.liq b/programs/templates/aircox/controllers/liquidsoap.liq deleted file mode 100644 index 014bd6a..0000000 --- a/programs/templates/aircox/controllers/liquidsoap.liq +++ /dev/null @@ -1,150 +0,0 @@ -{% comment %} -TODO: update doc -Base configuration file to configure a station on liquidsoap. - -# Interactive elements: -An interactive element is accessible to the people, in order to: -- get metadata -- skip the current sound -- enable/disable it - -# Element of the context -We use theses elements from the template's context: -- controller: controller describing the station itself -- settings: global settings - -# Overwrite the template -It is possible to overwrite the template, there are blocks at different -position in order to do it. Keep in mind that you might want to avoid to -put station specific configuration in the template itself. -{% endcomment %} - - -{% block functions %} -{% comment %} -An interactive source is a source that: -- is skippable through the given id on external interfaces -- can be disabled -- store metadata -{% endcomment %} -def interactive_source (id, s, ~active=true, ~disable_switch=false) = - s = store_metadata(id=id, size=1, s) - add_skip_command(s) - if disable_switch then - s - else - at(interactive.bool('#{id}_active', active), s) - end -end - -{% comment %} -A stream is a source that: -- is a playlist on random mode (playlist object accessible at {id}_playlist -- is interactive -{% endcomment %} -def stream (id, file) = - s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch', - file) - interactive_source(id, s) -end -{% endblock %} - -{% block functions_extras %} -{% endblock %} - - -{% block config %} -set("server.socket", true) -set("server.socket.path", "{{ station.streamer.socket_path }}") -set("log.file.path", "{{ station.path }}/liquidsoap.log") -{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %} -set("{{ key|safe }}", {{ value|safe }}) -{% endfor %} -{% endblock %} - -{% block config_extras %} -{% endblock %} - - -{% block sources %} -live = fallback([ - {% with source=station.dealer %} - interactive_source('{{ source.id }}', - playlist.once(reload_mode='watch', "{{ source.path }}"), - active=false - ), - {% endwith %} -]) - - -stream = fallback([ - rotate([ - {% for source in station.sources %} - {% if source != station.dealer %} - {% with stream=source.stream %} - {% if stream.delay %} - delay({{ stream.delay }}., - stream("{{ source.id }}", "{{ source.path }}")), - {% elif stream.begin and stream.end %} - at({ {{stream.begin}}-{{stream.end}} }, - stream("{{ source.id }}", "{{ source.path }}")), - {% elif not stream %} - stream("{{ source.id }}", "{{ source.path }}"), - {% endif %} - {% endwith %} - {% endif %} - {% endfor %} - ]), - - blank(id="blank", duration=0.1), -]) - -{% endblock %} - -{% block sources_extras %} -{% endblock %} - - -def to_live(stream,live) - stream = fade.final(duration=2., type='log', stream) - live = fade.initial(duration=2., type='log', live) - add(normalize=false, [stream,live]) -end - -def to_stream(live,stream) - source.skip(stream) - add(normalize=false, [live,stream]) -end - - -{% block station %} -{{ station.streamer.id }} = interactive_source ( - "{{ station.streamer.id }}", - fallback( - track_sensitive=false, - transitions=[to_live,to_stream], - [ live, stream ] - ), - disable_switch=true -) -{% endblock %} - - -{% block station_extras %} -{% endblock %} - - -{% block outputs %} -{% for output in station.output_set.all %} -output.{{ output.get_type_display }}( - {{ station.streamer.id }}, - {% if controller.settings %}, - {{ output.settings }} - {% endif %} -) -{% endfor %} -{% endblock %} - -{% block output_extras %} -{% endblock %} - diff --git a/programs/templates/aircox/controllers/monitor.html b/programs/templates/aircox/controllers/monitor.html deleted file mode 100644 index c7e2d0a..0000000 --- a/programs/templates/aircox/controllers/monitor.html +++ /dev/null @@ -1,125 +0,0 @@ -{% load i18n %} - - - - -
- {% for station in stations %} -
-
-

{{ station.name }}

- - -
-
- {% for source in station.all_sources %} - {% if source.controller.current_sound %} -
- {{ source.name }} - {{ source.controller.current_sound }} - -
- {% endif %} - {% endfor %} -
-
- {% endfor %} -
- diff --git a/programs/tests.py b/programs/tests.py deleted file mode 100755 index d2f1578..0000000 --- a/programs/tests.py +++ /dev/null @@ -1,66 +0,0 @@ -import datetime -import calendar -import logging -from dateutil.relativedelta import relativedelta - -from django.test import TestCase -from django.utils import timezone as tz - -from aircox.programs.models import * - -logger = logging.getLogger('aircox.test') -logger.setLevel('INFO') - -class ScheduleCheck (TestCase): - def setUp(self): - self.schedules = [ - Schedule( - date = tz.now(), - duration = datetime.time(1,30), - frequency = frequency, - ) - for frequency in Schedule.Frequency.__members__.values() - ] - - def test_frequencies(self): - for schedule in self.schedules: - logger.info('- test frequency %s' % schedule.get_frequency_display()) - date = schedule.date - count = 24 - while count: - logger.info('- month %(month)s/%(year)s' % { - 'month': date.month, - 'year': date.year - }) - count -= 1 - dates = schedule.dates_of_month(date) - if schedule.frequency == schedule.Frequency.one_on_two: - self.check_one_on_two(schedule, date, dates) - elif schedule.frequency == schedule.Frequency.last: - self.check_last(schedule, date, dates) - else: - pass - date += relativedelta(months = 1) - - def check_one_on_two(self, schedule, date, dates): - for date in dates: - delta = date.date() - schedule.date.date() - self.assertEqual(delta.days % 14, 0) - - def check_last(self, schedule, date, dates): - month_info = calendar.monthrange(date.year, date.month) - date = datetime.date(date.year, date.month, month_info[1]) - - # end of month before the wanted weekday: move one week back - if date.weekday() < schedule.date.weekday(): - date -= datetime.timedelta(days = 7) - - date -= datetime.timedelta(days = date.weekday()) - date += datetime.timedelta(days = schedule.date.weekday()) - self.assertEqual(date, dates[0].date()) - - def check_n_of_week(self, schedule, date, dates): - pass - - - diff --git a/programs/urls.py b/programs/urls.py deleted file mode 100644 index 67d3e0d..0000000 --- a/programs/urls.py +++ /dev/null @@ -1,9 +0,0 @@ - -from django.conf.urls import include, url -import aircox.programs.views as views - -urls = [ - url(r'^on_air', views.on_air, name='aircox.on_air'), - url(r'^monitor', views.Monitor.as_view(), name='aircox.monitor') -] - diff --git a/programs/utils.py b/programs/utils.py deleted file mode 100644 index 5d4482c..0000000 --- a/programs/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import datetime - - -def to_timedelta (time): - """ - Transform a datetime or a time instance to a timedelta, - only using time info - """ - return datetime.timedelta( - hours = time.hour, - minutes = time.minute, - seconds = time.second - ) - -def seconds_to_time (seconds): - """ - Seconds to datetime.time - """ - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - return datetime.time(hour = hours, minute = minutes, second = seconds) - diff --git a/programs/views.py b/programs/views.py deleted file mode 100755 index d9bb805..0000000 --- a/programs/views.py +++ /dev/null @@ -1,126 +0,0 @@ -import json - -from django.views.generic.base import View, TemplateResponseMixin -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, Http404 -from django.shortcuts import render -from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils import timezone as tz - -import aircox.programs.models as models - - -class Stations: - stations = models.Station.objects.all() - update_timeout = None - fetch_timeout = None - - def fetch(self): - if self.fetch_timeout and self.fetch_timeout > tz.now(): - return - - self.fetch_timeout = tz.now() + tz.timedelta(seconds = 5) - for station in self.stations: - station.streamer.fetch() - -stations = Stations() - - -def on_air(request): - try: - import aircox.cms.models as cms - except: - cms = None - - station = request.GET.get('station'); - if station: - station = stations.stations.filter(name = station) - else: - station = stations.stations.first() - - last = station.on_air(count = 1) - if not last: - return HttpResponse('') - - last = last[0] - if type(last) == models.Log: - last = { - 'type': 'track', - 'artist': last.related.artist, - 'title': last.related.title, - 'date': last.date, - } - else: - try: - publication = None - if cms: - publication = \ - cms.DiffusionPage.objects.filter( - diffusion = last.initial or last).first() or \ - cms.ProgramPage.objects.filter( - program = last.program).first() - except: - pass - - last = { - 'type': 'diffusion', - 'title': last.program.name, - 'date': last.start, - 'url': publication.specific.url if publication else None, - } - - last['date'] = str(last['date']) - return HttpResponse(json.dumps(last)) - - -# TODO: -# - login url -class Monitor(View,TemplateResponseMixin,LoginRequiredMixin): - template_name = 'aircox/controllers/monitor.html' - - def get_context_data(self, **kwargs): - stations.fetch() - return { 'stations': stations.stations } - - def get (self, request = None, **kwargs): - if not request.user.is_active: - return Http404() - - self.request = request - context = self.get_context_data(**kwargs) - return render(request, self.template_name, context) - - def post (self, request = None, **kwargs): - if not request.user.is_active: - return Http404() - - if not ('action' or 'station') in request.POST: - return HttpResponse('') - - POST = request.POST - controller = POST.get('controller') - action = POST.get('action') - - station = stations.stations.filter(name = POST.get('station')) \ - .first() - if not station: - return HttpResponse('') - station.prepare(fetch=True) - - source = None - if 'source' in POST: - source = next([ s for s in station.sources - if s.name == POST['source']], None) - - if station and action == 'skip': - if source: - source.skip() - else: - station.streamer.skip() - - return HttpResponse('') - - - - -