diff --git a/README.md b/README.md index eb93234..ac3ed95 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,39 @@ Platform to manage a radio, schedules, website, and so on. We use the power of great tools like Django or Liquidsoap. -## Current features -* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency; +This project is distributed under GPL version 3. More information in the LICENSE file, except for some files whose license is indicated. + + +## Features +* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency for each; * **diffusions**: generate diffusions time slot for programs that have schedule informations. Check for conflicts and rerun. * **liquidsoap**: create a configuration to use liquidsoap as a stream generator. Also provides interface and control to it; * **sounds**: each programs have a folder where sounds can be put, that will be detected by the system. Quality can be check and reported for later use. Later, we plan to have uploaders to external plateforms. Sounds can be defined as excerpts or as archives. * **cms**: application that can be used as basis for website (we use Wagtail; if you don't want it this application is not required to make everything run); * **log**: keep a trace of every played/loaded sounds on the stream generator. -## Applications -* **programs**: managing stations, programs, schedules and diffusions. This is the core application, that handle most of the work; -* **controllers**: interface with external stream generators. For the moment only support [Liquidsoap](http://liquidsoap.fm/). Generate configuration files, trigger scheduled diffusions and so on; -* **cms**: defines models and templates to generate a website connected to Aircox; + +## Architecture +Aircox is a complete Django project, that includes multiple Django's applications (if you don't know what it is, it is just like modules). There are somes scripts that can be used for deployment. + +**For the moment it is assumed that the application is installed in `/srv/apps/aircox`**, and that you have installed all the dependencies for aircox (external applications and python modules) + +### Applications +* **aircox**: managing stations, programs, schedules and diffusions + interfaces with the stream generator (for the moment only support [Liquidsoap](http://liquidsoap.fm/)). This is the core application, that handle most of the work: diffusions generation, conflicts checks, creates configuration files for the controllers, monitors scheduled diffusions, etc, etc. +* **aircox_cms**: defines models and templates to generate a website connected to Aircox; + +### Scripts +There are script/config file for various programs. You can copy and paste them, +or even link them in their correct directory. For the moment there are scripts +for: + +* cron: daily cron configuration for the generation of the diffusions +* supervisorctl: audio stream generation, website, sounds monitoring +* nginx: sampe config file (must be adapted) + +The scripts are written with a combination of `cron`, `supervisord`, `nginx` +and `gunicorn` in mind. + ## Installation ### Dependencies @@ -26,84 +47,57 @@ Python modules: * `bleach`: 'aircox.cms` (comments sanitization) * `dateutils`: `aircox.programs` (used for tests) * `Pillow`: `aircox.cms` (needed by `wagtail`) +* Django's required database modules -Applications: -* `liquidsoap`: `aircox.controllers` (generation of the audio streams) +External applications: +* `liquidsoap`: `aircox` (generation of the audio streams) +* `sox`: `aircox` (check sounds quality and metadatas) +* note there might be external dependencies for python's Pillow too +* sqlite, mysql or any database library that you need to run a database, that is supported by python -### settings.py -Base configuration: -```python -INSTALLED_APPS = ( - # dependencies - 'wagtail.wagtailforms', - 'wagtail.wagtailredirects', - 'wagtail.wagtailembeds', - 'wagtail.wagtailsites', - 'wagtail.wagtailusers', - 'wagtail.wagtailsnippets', - 'wagtail.wagtaildocs', - 'wagtail.wagtailimages', - 'wagtail.wagtailsearch', - 'wagtail.wagtailadmin', - 'wagtail.wagtailcore', - 'wagtail.contrib.settings', - 'taggit', - 'honeypot', +### Configuration +You must write a settings.py file in the `instance` directory (you can just +copy and paste `instance/sample_settings.py`. - # ... +You also want to redefine the following variable (required by Wagtail for the CMS): - # aircox - 'aircox.programs', - 'aircox.controllers', - 'aircox.cms', -) - -MIDDLEWARE_CLASSES = ( - # ... - 'wagtail.wagtailcore.middleware.SiteMiddleware', - 'wagtail.wagtailredirects.middleware.RedirectMiddleware', -) - -TEMPLATES = [ - { - # ... - 'OPTIONS': { - 'context_processors': ( - # ... - 'wagtail.contrib.settings.context_processors.settings', - ), - }, - }, -] - -# define your wagtail site name -WAGTAIL_SITE_NAME = 'My Radio' +``` +WAGTAIL_SITE_NAME = 'Aircox' ``` -To enable logging: +Each application have a `settings.py` that defines extra options that can be redefined in this file. Look in their respective directories for more informations. -```python -LOGGING = { - # ... - 'loggers': { - 'aircox.core': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), - }, - 'aircox.test': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), - }, - 'aircox.tools': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), - }, - }, -} + +### Installation and first run +Create the database if needed, and generate the tables: + +```bash +./manage.py migrate --fake-initial ``` -Each application have a `settings.py` that defines options that can be reused in application's `settings.py` file. +You must then configure the programs, schedules and audio streams. Start the +server from this directory: + +```bash +./manage.py runserver +``` + +You can access to the django admin interface at `http://127.0.0.1:8000/admin` +and to the cms interface at `http://127.0.0.1:8000/cms/`. + +Once the configuration is okay, you must start the *controllers monitor*, +that creates configuration file for the audio streams using the new information +and that run the appropriate application (note that you dont need to restart it +after adding a program that is based on schedules). + +If you use supervisord and our script with it, you can use the services defined +in it instead of running commands manually. +Note: later we want to provide an installation script in order to make your life easy. + +## More informations +There are extra informations in `aircox/README.md` and `aircox_cms/README.md` files. + diff --git a/aircox/README.md b/aircox/README.md new file mode 100644 index 0000000..ebfc9dd --- /dev/null +++ b/aircox/README.md @@ -0,0 +1,33 @@ +# 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/aircox/__init__.py b/aircox/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/aircox/admin.py b/aircox/admin.py new file mode 100755 index 0000000..09b71bd --- /dev/null +++ b/aircox/admin.py @@ -0,0 +1,193 @@ +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.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/aircox/connector.py b/aircox/connector.py new file mode 100644 index 0000000..047fc65 --- /dev/null +++ b/aircox/connector.py @@ -0,0 +1,90 @@ +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/aircox/controllers.py b/aircox/controllers.py new file mode 100644 index 0000000..335e572 --- /dev/null +++ b/aircox/controllers.py @@ -0,0 +1,359 @@ +import os +import re +import subprocess +import atexit + +from django.template.loader import render_to_string + +import aircox.models as models +import aircox.settings as settings + +from aircox.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/aircox/locale/fr/LC_MESSAGES/django.po b/aircox/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..ab59dcf --- /dev/null +++ b/aircox/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,317 @@ +# 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/aircox/management/__init__.py b/aircox/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aircox/management/commands/__init__.py b/aircox/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aircox/management/commands/diffusions_monitor.py b/aircox/management/commands/diffusions_monitor.py new file mode 100644 index 0000000..f3b70bd --- /dev/null +++ b/aircox/management/commands/diffusions_monitor.py @@ -0,0 +1,184 @@ +""" +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.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/aircox/management/commands/import_playlist.py b/aircox/management/commands/import_playlist.py new file mode 100644 index 0000000..07bba2e --- /dev/null +++ b/aircox/management/commands/import_playlist.py @@ -0,0 +1,142 @@ +""" +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.models import * +import aircox.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/aircox/management/commands/sounds_monitor.py b/aircox/management/commands/sounds_monitor.py new file mode 100644 index 0000000..591465f --- /dev/null +++ b/aircox/management/commands/sounds_monitor.py @@ -0,0 +1,383 @@ +""" +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.models import * +import aircox.settings as settings +import aircox.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.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.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/aircox/management/commands/sounds_quality_check.py b/aircox/management/commands/sounds_quality_check.py new file mode 100644 index 0000000..d512a36 --- /dev/null +++ b/aircox/management/commands/sounds_quality_check.py @@ -0,0 +1,174 @@ +""" +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/aircox/management/commands/streamer.py b/aircox/management/commands/streamer.py new file mode 100644 index 0000000..ac0f84c --- /dev/null +++ b/aircox/management/commands/streamer.py @@ -0,0 +1,346 @@ +""" +Handle the audio streamer and controls it as we want it to be. It is +used to: +- generate config files and playlists; +- monitor Liquidsoap, logs and scheduled programs; +- cancels Diffusions that have an archive but could not have been played; +- run Liquidsoap +""" +import os +import time +import re + +from argparse import RawTextHelpFormatter + +from django.conf import settings as main_settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone as tz + +from aircox.models import Station, Diffusion, Track, Sound, Log + + +class Monitor: + """ + Log and launch diffusions for the given station. + + Monitor should be able to be used after a crash a go back + where it was playing, so we heavily use logs to be able to + do that. + + We keep trace of played items on the generated stream: + - sounds played on this stream; + - scheduled diffusions + - tracks for sounds of streamed programs + """ + station = None + streamer = None + cancel_timeout = 60*10 + """ + Time in seconds before a diffusion that have archives is cancelled + because it has not been played. + """ + sync_timeout = 60*10 + """ + Time in minuts before all stream playlists are checked and updated + """ + sync_next = None + """ + Datetime of the next sync + """ + + def __init__(self, station, **kwargs): + self.station = station + self.__dict__.update(kwargs) + + def monitor(self): + """ + Run all monitoring functions. + """ + if not self.streamer: + self.streamer = self.station.streamer + + if not self.streamer.ready(): + return + + self.trace() + self.sync_playlists() + self.handle() + + def log(self, **kwargs): + """ + Create a log using **kwargs, and print info + """ + log = Log(station = self.station, **kwargs) + log.save() + log.print() + + def trace(self): + """ + Check the current_sound of the station and update logs if + needed. + """ + self.streamer.fetch() + current_sound = self.streamer.current_sound + current_source = self.streamer.current_source + if not current_sound or not current_source: + return + + log = Log.objects.get_for(model = Sound) \ + .filter(station = self.station) \ + .order_by('date').last() + + # only streamed + if log and (log.related and not log.related.diffusion): + self.trace_sound_tracks(log) + + # TODO: expiration + if log and (log.source == current_source.id and \ + log.related and + log.related.path == current_sound): + return + + sound = Sound.objects.filter(path = current_sound) + self.log( + type = Log.Type.play, + source = current_source.id, + date = tz.now(), + related = sound[0] if sound else None, + comment = None if sound else current_sound, + ) + + def trace_sound_tracks(self, log): + """ + Log tracks for the given sound (for streamed programs); Called by + self.trace + """ + logs = Log.objects.get_for(model = Track) \ + .filter(pk__gt = log.pk) + logs = [ log.related_id for log in logs ] + + tracks = Track.objects.get_for(object = log.related) \ + .filter(in_seconds = True) + if tracks and len(tracks) == len(logs): + return + + tracks = tracks.exclude(pk__in = logs).order_by('position') + now = tz.now() + for track in tracks: + pos = log.date + tz.timedelta(seconds = track.position) + if pos < now: + self.log( + type = Log.Type.play, + source = log.source, + date = pos, + related = track + ) + + def sync_playlists(self): + """ + Synchronize updated playlists + """ + now = tz.now() + if self.sync_next and self.sync_next < now: + return + + self.sync_next = now + tz.timedelta(seconds = self.sync_timeout) + + for source in self.station.sources: + if source == self.station.dealer: + continue + playlist = [ sound.path for sound in + source.program.sound_set.all() ] + source.playlist = playlist + + def trace_canceled(self): + """ + Check diffusions that should have been played but did not start, + and cancel them + """ + if not self.cancel_timeout: + return + + diffs = Diffusions.objects.get_at().filter( + type = Diffusion.Type.normal, + sound__type = Sound.Type.archive, + ) + logs = station.get_played(models = Diffusion) + + date = tz.now() - datetime.timedelta(seconds = self.cancel_timeout) + for diff in diffs: + if logs.filter(related = diff): + continue + if diff.start < now: + diff.type = Diffusion.Type.canceled + diff.save() + self.log( + type = Log.Type.other, + related = diff, + comment = 'Diffusion canceled after {} seconds' \ + .format(self.cancel_timeout) + ) + + def __current_diff(self): + """ + Return a tuple with the currently running diffusion and the items + that still have to be played. If there is not, return None + """ + station = self.station + now = tz.make_aware(tz.datetime.now()) + + diff_log = station.get_played(models = Diffusion) \ + .order_by('date').last() + if not diff_log or \ + not diff_log.related.is_date_in_range(now): + return None, [] + + # sound has switched? assume it has been (forced to) stopped + sounds = station.get_played(models = Sound) \ + .filter(date__gte = diff_log.date) \ + .order_by('date') + + if sounds.last() and sounds.last().source != diff_log.source: + return diff_log, [] + + # last diff is still playing: get the remaining playlist + sounds = sounds.filter( + source = diff_log.source, pk__gt = diff_log.pk + ) + sounds = [ + sound.related.path for sound in sounds + if sound.related.type != Sound.Type.removed + ] + + return ( + diff_log.related, + [ path for path in diff_log.related.playlist + if path not in sounds ] + ) + + def __next_diff(self, diff): + """ + Return the tuple with the next diff that should be played and + the playlist + + Note: diff is a log + """ + station = self.station + now = tz.now() + + args = {'start__gt': diff.date } if diff else {} + diff = Diffusion.objects.get_at(now).filter( + type = Diffusion.Type.normal, + sound__type = Sound.Type.archive, + **args + ).distinct().order_by('start').first() + return (diff, diff and diff.playlist or []) + + def handle(self): + """ + Handle scheduled diffusion, trigger if needed, preload playlists + and so on. + """ + station = self.station + dealer = station.dealer + if not dealer: + return + now = tz.now() + + # current and next diffs + diff, playlist = self.__current_diff() + dealer.active = bool(playlist) + + next_diff, next_playlist = self.__next_diff(diff) + playlist += next_playlist + + # playlist update + if dealer.playlist != playlist: + dealer.playlist = playlist + if next_diff: + self.log( + type = Log.Type.load, + source = dealer.id, + date = now, + related = next_diff + ) + + # dealer.on when next_diff start <= now + if next_diff and not dealer.active and \ + next_diff.start <= now: + dealer.active = True + self.log( + type = Log.Type.play, + source = dealer.id, + date = now, + related = next_diff, + ) + + +class Command (BaseCommand): + help= __doc__ + + def add_arguments (self, parser): + parser.formatter_class=RawTextHelpFormatter + group = parser.add_argument_group('actions') + group.add_argument( + '-c', '--config', action='store_true', + help='generate configuration files for the stations' + ) + group.add_argument( + '-m', '--monitor', action='store_true', + help='monitor the scheduled diffusions and log what happens' + ) + group.add_argument( + '-r', '--run', action='store_true', + help='run the required applications for the stations' + ) + + group = parser.add_argument_group('options') + group.add_argument( + '-d', '--delay', type=int, + default=1000, + help='time to sleep in MILLISECONDS between two updates when we ' + 'monitor' + ) + group.add_argument( + '-s', '--station', type=str, action='append', + help='name of the station to monitor instead of monitoring ' + 'all stations' + ) + group.add_argument( + '-t', '--timeout', type=int, + default=600, + help='time to wait in SECONDS before canceling a diffusion that ' + 'has not been ran but should have been. If 0, does not ' + 'check' + ) + + def handle (self, *args, + config = None, run = None, monitor = None, + station = [], delay = 1000, timeout = 600, + **options): + + stations = Station.objects.filter(name__in = station)[:] \ + if station else Station.objects.all()[:] + + for station in stations: + # station.prepare() + if config and not run: # no need to write it twice + station.streamer.push() + if run: + station.streamer.process_run() + + if monitor: + monitors = [ + Monitor(station, cancel_timeout = timeout) + for station in stations + ] + delay = delay / 1000 + while True: + for monitor in monitors: + monitor.monitor() + time.sleep(delay) + + if run: + for station in stations: + station.controller.process_wait() + diff --git a/aircox/models.py b/aircox/models.py new file mode 100755 index 0000000..e981fed --- /dev/null +++ b/aircox/models.py @@ -0,0 +1,1096 @@ +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.utils as utils +import aircox.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.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 +# +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/aircox/settings.py b/aircox/settings.py new file mode 100755 index 0000000..0cf7e63 --- /dev/null +++ b/aircox/settings.py @@ -0,0 +1,63 @@ +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/aircox/templates/aircox/controllers/liquidsoap.liq b/aircox/templates/aircox/controllers/liquidsoap.liq new file mode 100644 index 0000000..014bd6a --- /dev/null +++ b/aircox/templates/aircox/controllers/liquidsoap.liq @@ -0,0 +1,150 @@ +{% 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/aircox/templates/aircox/controllers/monitor.html b/aircox/templates/aircox/controllers/monitor.html new file mode 100644 index 0000000..c7e2d0a --- /dev/null +++ b/aircox/templates/aircox/controllers/monitor.html @@ -0,0 +1,125 @@ +{% 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/aircox/tests.py b/aircox/tests.py new file mode 100755 index 0000000..f3fb2ab --- /dev/null +++ b/aircox/tests.py @@ -0,0 +1,66 @@ +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.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/aircox/urls.py b/aircox/urls.py new file mode 100644 index 0000000..43edb47 --- /dev/null +++ b/aircox/urls.py @@ -0,0 +1,9 @@ + +from django.conf.urls import include, url +import aircox.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/aircox/utils.py b/aircox/utils.py new file mode 100644 index 0000000..5d4482c --- /dev/null +++ b/aircox/utils.py @@ -0,0 +1,22 @@ +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/aircox/views.py b/aircox/views.py new file mode 100755 index 0000000..f5153bb --- /dev/null +++ b/aircox/views.py @@ -0,0 +1,126 @@ +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.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('') + + + + + diff --git a/aircox_cms/__init__.py b/aircox_cms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cms/admin.py b/aircox_cms/admin.py similarity index 100% rename from cms/admin.py rename to aircox_cms/admin.py diff --git a/aircox_cms/apps.py b/aircox_cms/apps.py new file mode 100644 index 0000000..7ef3fea --- /dev/null +++ b/aircox_cms/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CmsConfig(AppConfig): + name = 'cms' diff --git a/cms/forms.py b/aircox_cms/forms.py similarity index 100% rename from cms/forms.py rename to aircox_cms/forms.py diff --git a/cms/locale/fr/LC_MESSAGES/django.po b/aircox_cms/locale/fr/LC_MESSAGES/django.po similarity index 57% rename from cms/locale/fr/LC_MESSAGES/django.po rename to aircox_cms/locale/fr/LC_MESSAGES/django.po index d2777ee..797c238 100644 --- a/cms/locale/fr/LC_MESSAGES/django.po +++ b/aircox_cms/locale/fr/LC_MESSAGES/django.po @@ -18,693 +18,693 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: cms/forms.py:17 +#: aircox_cms/forms.py:17 msgid "your name" msgstr "" -#: cms/forms.py:20 +#: aircox_cms/forms.py:20 msgid "your email (optional)" msgstr "" -#: cms/forms.py:23 +#: aircox_cms/forms.py:23 msgid "your website (optional)" msgstr "" -#: cms/forms.py:26 +#: aircox_cms/forms.py:26 msgid "your comment" msgstr "" -#: cms/forms.py:39 +#: aircox_cms/forms.py:39 msgid "You are a bot, that is not cool" msgstr "" -#: cms/forms.py:42 +#: aircox_cms/forms.py:42 msgid "No publication found for this comment" msgstr "" -#: cms/models.py:44 +#: aircox_cms/models.py:44 msgid "favicon" msgstr "" -#: cms/models.py:46 +#: aircox_cms/models.py:46 msgid "small logo for the website displayed in the browser" msgstr "" -#: cms/models.py:49 cms/models.py:249 +#: aircox_cms/models.py:49 aircox_cms/models.py:249 msgid "tags" msgstr "" -#: cms/models.py:52 +#: aircox_cms/models.py:52 msgid "tags describing the website; used for referencing" msgstr "" -#: cms/models.py:55 +#: aircox_cms/models.py:55 msgid "public description" msgstr "" -#: cms/models.py:58 +#: aircox_cms/models.py:58 msgid "public description of the website; used for referencing" msgstr "" -#: cms/models.py:62 +#: aircox_cms/models.py:62 msgid "page for lists" msgstr "" -#: cms/models.py:63 +#: aircox_cms/models.py:63 msgid "page used to display the results of a search and other lists" msgstr "" -#: cms/models.py:70 cms/models.py:74 +#: aircox_cms/models.py:70 aircox_cms/models.py:74 msgid "publish comments automatically without verifying" msgstr "" -#: cms/models.py:77 +#: aircox_cms/models.py:77 msgid "success message" msgstr "" -#: cms/models.py:78 +#: aircox_cms/models.py:78 msgid "Your comment has been successfully posted!" msgstr "" -#: cms/models.py:79 +#: aircox_cms/models.py:79 msgid "message to display when a post has been posted" msgstr "" -#: cms/models.py:82 +#: aircox_cms/models.py:82 msgid "waiting message" msgstr "" -#: cms/models.py:83 +#: aircox_cms/models.py:83 msgid "Your comment is awaiting for approval." msgstr "" -#: cms/models.py:84 +#: aircox_cms/models.py:84 msgid "message to display when a post waits to be reviewed" msgstr "" -#: cms/models.py:87 +#: aircox_cms/models.py:87 msgid "error message" msgstr "" -#: cms/models.py:88 +#: aircox_cms/models.py:88 msgid "We could not save your message. Please correct the error(s) below." msgstr "" -#: cms/models.py:89 +#: aircox_cms/models.py:89 msgid "message to display there is an error an incomplete form." msgstr "" -#: cms/models.py:93 +#: aircox_cms/models.py:93 msgid "automatic publications" msgstr "" -#: cms/models.py:96 +#: aircox_cms/models.py:96 msgid "" "Create automatically new publications for new programs and diffusions in the " "timetable. If set, please complete other options of this panel" msgstr "" -#: cms/models.py:103 +#: aircox_cms/models.py:103 msgid "default program parent page" msgstr "" -#: cms/models.py:106 +#: aircox_cms/models.py:106 msgid "" "Default parent page for program's pages. It is used to assign a page to the " "publication of a newly created program and can be changed later" msgstr "" -#: cms/models.py:122 +#: aircox_cms/models.py:122 msgid "promotion" msgstr "" -#: cms/models.py:129 cms/templates/cms/snippets/comments.html:6 +#: aircox_cms/models.py:129 aircox_cms/templates/aircox_cms/snippets/comments.html:6 msgid "Comments" msgstr "" -#: cms/models.py:133 +#: aircox_cms/models.py:133 msgid "Programs and controls" msgstr "" -#: cms/models.py:137 +#: aircox_cms/models.py:137 msgid "website settings" msgstr "" -#: cms/models.py:149 +#: aircox_cms/models.py:149 msgid "public" msgstr "" -#: cms/models.py:153 +#: aircox_cms/models.py:153 msgid "author" msgstr "" -#: cms/models.py:157 cms/models.py:365 +#: aircox_cms/models.py:157 aircox_cms/models.py:365 msgid "email" msgstr "" -#: cms/models.py:161 +#: aircox_cms/models.py:161 msgid "website" msgstr "" -#: cms/models.py:165 cms/models.py:212 +#: aircox_cms/models.py:165 aircox_cms/models.py:212 msgid "date" msgstr "" -#: cms/models.py:169 +#: aircox_cms/models.py:169 msgid "comment" msgstr "" #. Translators: text shown in the comments list (in admin) -#: cms/models.py:175 +#: aircox_cms/models.py:175 #, python-brace-format msgid "{date}, {author}: {content}..." msgstr "" -#: cms/models.py:218 +#: aircox_cms/models.py:218 msgid "publish as program" msgstr "" -#: cms/models.py:221 +#: aircox_cms/models.py:221 msgid "use this program as the author of the publication" msgstr "" -#: cms/models.py:224 +#: aircox_cms/models.py:224 msgid "focus" msgstr "" -#: cms/models.py:226 +#: aircox_cms/models.py:226 msgid "the publication is highlighted;" msgstr "" -#: cms/models.py:229 cms/models.py:231 +#: aircox_cms/models.py:229 aircox_cms/models.py:231 msgid "allow comments" msgstr "" -#: cms/models.py:237 +#: aircox_cms/models.py:237 msgid "cover" msgstr "" -#: cms/models.py:241 +#: aircox_cms/models.py:241 msgid "image to use as cover of the publication" msgstr "" -#: cms/models.py:244 +#: aircox_cms/models.py:244 msgid "summary" msgstr "" -#: cms/models.py:246 +#: aircox_cms/models.py:246 msgid "summary of the publication" msgstr "" -#: cms/models.py:255 cms/models.py:256 +#: aircox_cms/models.py:255 aircox_cms/models.py:256 msgid "Publication" msgstr "" -#: cms/models.py:263 cms/models.py:270 cms/models.py:590 cms/models.py:619 +#: aircox_cms/models.py:263 aircox_cms/models.py:270 aircox_cms/models.py:590 aircox_cms/models.py:619 msgid "Content" msgstr "" -#: cms/models.py:271 +#: aircox_cms/models.py:271 msgid "Links" msgstr "" -#: cms/models.py:358 +#: aircox_cms/models.py:358 msgid "program" msgstr "" -#: cms/models.py:368 +#: aircox_cms/models.py:368 msgid "email is public" msgstr "" -#: cms/models.py:370 +#: aircox_cms/models.py:370 msgid "the email addess is accessible to the public" msgstr "" -#: cms/models.py:374 +#: aircox_cms/models.py:374 msgid "Program" msgstr "" -#: cms/models.py:375 cms/wagtail_hooks.py:20 cms/wagtail_hooks.py:177 +#: aircox_cms/models.py:375 aircox_cms/wagtail_hooks.py:20 aircox_cms/wagtail_hooks.py:177 msgid "Programs" msgstr "" -#: cms/models.py:444 +#: aircox_cms/models.py:444 msgid "diffusion" msgstr "" -#: cms/models.py:453 +#: aircox_cms/models.py:453 msgid "publish archive" msgstr "" -#: cms/models.py:455 +#: aircox_cms/models.py:455 msgid "publish the podcast of the complete diffusion" msgstr "" -#: cms/models.py:459 +#: aircox_cms/models.py:459 msgid "Diffusion" msgstr "" -#: cms/models.py:460 cms/wagtail_hooks.py:28 +#: aircox_cms/models.py:460 aircox_cms/wagtail_hooks.py:28 msgid "Diffusions" msgstr "" -#: cms/models.py:463 +#: aircox_cms/models.py:463 msgid "Tracks" msgstr "" -#: cms/models.py:503 +#: aircox_cms/models.py:503 #, python-format msgid "Rerun of %(date)s" msgstr "" -#: cms/models.py:507 +#: aircox_cms/models.py:507 msgid "Cancelled" msgstr "" -#: cms/models.py:570 cms/models.py:607 +#: aircox_cms/models.py:570 aircox_cms/models.py:607 msgid "body" msgstr "" -#: cms/models.py:572 cms/models.py:609 +#: aircox_cms/models.py:572 aircox_cms/models.py:609 msgid "add an extra description for this list" msgstr "" -#: cms/models.py:575 +#: aircox_cms/models.py:575 msgid "list from the request" msgstr "" -#: cms/models.py:578 +#: aircox_cms/models.py:578 msgid "" "if set, the page print a list based on the request made by the website " "visitor, and its title will be adapted to this request. Can be usefull for " "search pages, etc. and should only be set on one page." msgstr "" -#: cms/models.py:594 cms/models.py:595 +#: aircox_cms/models.py:594 aircox_cms/models.py:595 msgid "Generic Page" msgstr "" -#: cms/models.py:652 cms/sections.py:859 +#: aircox_cms/models.py:652 aircox_cms/sections.py:859 msgid "station" msgstr "" -#: cms/models.py:655 cms/sections.py:862 +#: aircox_cms/models.py:655 aircox_cms/sections.py:862 msgid "(required) the station on which the logs happened" msgstr "" -#: cms/models.py:658 +#: aircox_cms/models.py:658 msgid "maximum age" msgstr "" -#: cms/models.py:660 +#: aircox_cms/models.py:660 msgid "maximum days in the past allowed to be shown. 0 means no limit" msgstr "" -#: cms/models.py:665 cms/models.py:666 +#: aircox_cms/models.py:665 aircox_cms/models.py:666 msgid "Logs" msgstr "" -#: cms/models.py:672 +#: aircox_cms/models.py:672 msgid "Configuration" msgstr "" -#: cms/models.py:707 cms/models.py:708 +#: aircox_cms/models.py:707 aircox_cms/models.py:708 msgid "Timetable" msgstr "" -#: cms/sections.py:84 +#: aircox_cms/sections.py:84 msgid "url" msgstr "" -#: cms/sections.py:86 +#: aircox_cms/sections.py:86 msgid "URL of the link" msgstr "" -#: cms/sections.py:94 +#: aircox_cms/sections.py:94 msgid "Use a page instead of a URL" msgstr "" -#: cms/sections.py:98 +#: aircox_cms/sections.py:98 msgid "icon" msgstr "" -#: cms/sections.py:102 +#: aircox_cms/sections.py:102 msgid "icon to display before the url" msgstr "" -#: cms/sections.py:110 +#: aircox_cms/sections.py:110 msgid "text" msgstr "" -#: cms/sections.py:113 +#: aircox_cms/sections.py:113 msgid "text to display of the link" msgstr "" -#: cms/sections.py:125 +#: aircox_cms/sections.py:125 msgid "link" msgstr "" -#: cms/sections.py:156 +#: aircox_cms/sections.py:156 msgid "filter by date" msgstr "" -#: cms/sections.py:163 +#: aircox_cms/sections.py:163 msgid "filter by type" msgstr "" -#: cms/sections.py:166 +#: aircox_cms/sections.py:166 msgid "if set, select only elements that are of this type" msgstr "" -#: cms/sections.py:171 +#: aircox_cms/sections.py:171 msgid "filter by a related page" msgstr "" -#: cms/sections.py:174 +#: aircox_cms/sections.py:174 msgid "if set, select children or siblings related to this page" msgstr "" -#: cms/sections.py:177 +#: aircox_cms/sections.py:177 msgid "select siblings of related" msgstr "" -#: cms/sections.py:179 +#: aircox_cms/sections.py:179 msgid "if selected select related publications that are siblings of this one" msgstr "" -#: cms/sections.py:183 +#: aircox_cms/sections.py:183 msgid "ascending order" msgstr "" -#: cms/sections.py:185 +#: aircox_cms/sections.py:185 msgid "if selected sort list in the ascending order by date" msgstr "" -#: cms/sections.py:196 +#: aircox_cms/sections.py:196 msgid "filters" msgstr "" -#: cms/sections.py:200 +#: aircox_cms/sections.py:200 msgid "sorting" msgstr "" -#: cms/sections.py:360 +#: aircox_cms/sections.py:360 msgid "navigation days count" msgstr "" -#: cms/sections.py:362 +#: aircox_cms/sections.py:362 msgid "number of days to display in the navigation header when we use dates" msgstr "" -#: cms/sections.py:366 +#: aircox_cms/sections.py:366 msgid "navigation per week" msgstr "" -#: cms/sections.py:368 +#: aircox_cms/sections.py:368 msgid "" "if selected, show dates navigation per weeks instead of show days equally " "around the current date" msgstr "" -#: cms/sections.py:379 +#: aircox_cms/sections.py:379 msgid "Navigation" msgstr "" -#: cms/sections.py:450 +#: aircox_cms/sections.py:450 msgid "name" msgstr "" -#: cms/sections.py:453 +#: aircox_cms/sections.py:453 msgid "name of this section (not displayed)" msgstr "" -#: cms/sections.py:456 +#: aircox_cms/sections.py:456 msgid "position" msgstr "" -#: cms/sections.py:459 +#: aircox_cms/sections.py:459 msgid "name of the template block in which the section must be set" msgstr "" -#: cms/sections.py:464 +#: aircox_cms/sections.py:464 msgid "model" msgstr "" -#: cms/sections.py:466 +#: aircox_cms/sections.py:466 msgid "" "this section is displayed only when the current page or publication is of " "this type" msgstr "" -#: cms/sections.py:472 +#: aircox_cms/sections.py:472 msgid "page" msgstr "" -#: cms/sections.py:474 +#: aircox_cms/sections.py:474 msgid "this section is displayed only on this page" msgstr "" -#: cms/sections.py:482 cms/sections.py:581 +#: aircox_cms/sections.py:482 aircox_cms/sections.py:581 msgid "General" msgstr "" -#: cms/sections.py:483 +#: aircox_cms/sections.py:483 msgid "Section Items" msgstr "" -#: cms/sections.py:517 +#: aircox_cms/sections.py:517 msgid "item" msgstr "" -#: cms/sections.py:561 +#: aircox_cms/sections.py:561 msgid "title" msgstr "" -#: cms/sections.py:566 +#: aircox_cms/sections.py:566 msgid "show title" msgstr "" -#: cms/sections.py:568 +#: aircox_cms/sections.py:568 msgid "if set show a title at the head of the section" msgstr "" -#: cms/sections.py:571 +#: aircox_cms/sections.py:571 msgid "CSS class" msgstr "" -#: cms/sections.py:574 +#: aircox_cms/sections.py:574 msgid "section container's \"class\" attribute" msgstr "" -#: cms/sections.py:643 +#: aircox_cms/sections.py:643 msgid "is related" msgstr "" -#: cms/sections.py:646 +#: aircox_cms/sections.py:646 msgid "" "if set, section is related to the page being processed e.g rendering a list " "of links will use thoses of the publication instead of an assigned one." msgstr "" -#: cms/sections.py:691 +#: aircox_cms/sections.py:691 msgid "image" msgstr "" -#: cms/sections.py:695 +#: aircox_cms/sections.py:695 msgid "" "If this item is related to the current page, this image will be used only " "when the page has not a cover" msgstr "" -#: cms/sections.py:700 +#: aircox_cms/sections.py:700 msgid "width" msgstr "" -#: cms/sections.py:702 +#: aircox_cms/sections.py:702 msgid "if set and > 0, set a maximum width for the image" msgstr "" -#: cms/sections.py:705 +#: aircox_cms/sections.py:705 msgid "height" msgstr "" -#: cms/sections.py:707 +#: aircox_cms/sections.py:707 msgid "if set 0 and > 0, set a maximum height for the image" msgstr "" -#: cms/sections.py:710 +#: aircox_cms/sections.py:710 msgid "resize mode" msgstr "" -#: cms/sections.py:713 +#: aircox_cms/sections.py:713 msgid "if the image is resized, set the resizing mode" msgstr "" -#: cms/sections.py:722 +#: aircox_cms/sections.py:722 msgid "Resizing" msgstr "" -#: cms/sections.py:776 +#: aircox_cms/sections.py:776 msgid "links" msgstr "" -#: cms/sections.py:777 +#: aircox_cms/sections.py:777 msgid "" "If the list is related to the current page, theses links will be used when " "there is no links found for this publication" msgstr "" -#: cms/sections.py:799 +#: aircox_cms/sections.py:799 msgid "focus available" msgstr "" -#: cms/sections.py:801 +#: aircox_cms/sections.py:801 msgid "if true, highlight the first focused article found" msgstr "" -#: cms/sections.py:804 cms/sections.py:865 +#: aircox_cms/sections.py:804 aircox_cms/sections.py:865 msgid "count" msgstr "" -#: cms/sections.py:806 +#: aircox_cms/sections.py:806 msgid "number of items to display in the list" msgstr "" -#: cms/sections.py:809 +#: aircox_cms/sections.py:809 msgid "text of the url" msgstr "" -#: cms/sections.py:812 +#: aircox_cms/sections.py:812 msgid "" "use this text to display an URL to the complete list. If empty, does not " "print an address" msgstr "" -#: cms/sections.py:821 +#: aircox_cms/sections.py:821 msgid "Rendering" msgstr "" -#: cms/sections.py:867 +#: aircox_cms/sections.py:867 msgid "number of items to display in the list (max 100)" msgstr "" -#: cms/sections.py:871 +#: aircox_cms/sections.py:871 msgid "list of logs" msgstr "" -#: cms/sections.py:872 +#: aircox_cms/sections.py:872 msgid "lists of logs" msgstr "" -#: cms/sections.py:912 +#: aircox_cms/sections.py:912 msgid "Section: Timetable" msgstr "" -#: cms/sections.py:913 +#: aircox_cms/sections.py:913 msgid "Sections: Timetable" msgstr "" -#: cms/sections.py:936 +#: aircox_cms/sections.py:936 msgid "Section: publication's info" msgstr "" -#: cms/sections.py:937 +#: aircox_cms/sections.py:937 msgid "Sections: publication's info" msgstr "" -#: cms/sections.py:942 +#: aircox_cms/sections.py:942 msgid "default text" msgstr "" -#: cms/sections.py:944 +#: aircox_cms/sections.py:944 msgid "search" msgstr "" -#: cms/sections.py:945 +#: aircox_cms/sections.py:945 msgid "text to display when the search field is empty" msgstr "" -#: cms/sections.py:949 +#: aircox_cms/sections.py:949 msgid "Section: search field" msgstr "" -#: cms/sections.py:950 +#: aircox_cms/sections.py:950 msgid "Sections: search field" msgstr "" -#: cms/sections.py:965 +#: aircox_cms/sections.py:965 msgid "live title" msgstr "" -#: cms/sections.py:967 +#: aircox_cms/sections.py:967 msgid "text to display when it plays live" msgstr "" -#: cms/sections.py:970 +#: aircox_cms/sections.py:970 msgid "audio streams" msgstr "" -#: cms/sections.py:971 +#: aircox_cms/sections.py:971 msgid "one audio stream per line" msgstr "" -#: cms/sections.py:975 +#: aircox_cms/sections.py:975 msgid "Section: Player" msgstr "" -#: cms/templates/cms/diffusion_page.html:8 +#: aircox_cms/templates/aircox_cms/diffusion_page.html:8 msgid "Playlist" msgstr "" -#: cms/templates/cms/diffusion_page.html:22 +#: aircox_cms/templates/aircox_cms/diffusion_page.html:22 msgid "Dates of diffusion" msgstr "" -#: cms/templates/cms/diffusion_page.html:36 +#: aircox_cms/templates/aircox_cms/diffusion_page.html:36 msgid "Podcasts" msgstr "" -#: cms/templates/cms/event_page.html:6 +#: aircox_cms/templates/aircox_cms/event_page.html:6 msgid "Practical information" msgstr "" -#: cms/templates/cms/event_page.html:10 +#: aircox_cms/templates/aircox_cms/event_page.html:10 msgid "Date" msgstr "" -#: cms/templates/cms/event_page.html:13 +#: aircox_cms/templates/aircox_cms/event_page.html:13 msgid "Place" msgstr "" -#: cms/templates/cms/event_page.html:15 +#: aircox_cms/templates/aircox_cms/event_page.html:15 msgid "Price" msgstr "" -#: cms/templates/cms/generic_page.html:16 +#: aircox_cms/templates/aircox_cms/generic_page.html:16 #, python-format msgid "Search in publications for %(terms)s" msgstr "" -#: cms/templates/cms/generic_page.html:20 +#: aircox_cms/templates/aircox_cms/generic_page.html:20 #, python-format msgid "" "\n" " Related to %(title)s" msgstr "" -#: cms/templates/cms/generic_page.html:24 +#: aircox_cms/templates/aircox_cms/generic_page.html:24 msgid "All the publications" msgstr "" -#: cms/templates/cms/generic_page.html:41 +#: aircox_cms/templates/aircox_cms/generic_page.html:41 msgid "More about it" msgstr "" -#: cms/templates/cms/program_page.html:12 +#: aircox_cms/templates/aircox_cms/program_page.html:12 msgid "Schedule" msgstr "" -#: cms/templates/cms/program_page.html:18 +#: aircox_cms/templates/aircox_cms/program_page.html:18 #, python-format msgid "" "\n" @@ -712,110 +712,110 @@ msgid "" " " msgstr "" -#: cms/templates/cms/program_page.html:24 +#: aircox_cms/templates/aircox_cms/program_page.html:24 msgid "Rerun" msgstr "" -#: cms/templates/cms/program_page.html:30 +#: aircox_cms/templates/aircox_cms/program_page.html:30 msgid "This program is no longer active" msgstr "" -#: cms/templates/cms/publication.html:33 +#: aircox_cms/templates/aircox_cms/publication.html:33 msgid "Go back to the publication" msgstr "" -#: cms/templates/cms/sections/section_publication_info.html:15 +#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:15 msgid "Published by" msgstr "" -#: cms/templates/cms/sections/section_publication_info.html:22 +#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:22 msgid "Published on " msgstr "" -#: cms/templates/cms/sections/section_publication_info.html:30 +#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:30 msgid "Tags" msgstr "" -#: cms/templates/cms/sections/section_publication_info.html:39 +#: aircox_cms/templates/aircox_cms/sections/section_publication_info.html:39 msgid "Share" msgstr "" -#: cms/templates/cms/snippets/comments.html:21 +#: aircox_cms/templates/aircox_cms/snippets/comments.html:21 msgid "show more options" msgstr "" -#: cms/templates/cms/snippets/comments.html:34 +#: aircox_cms/templates/aircox_cms/snippets/comments.html:34 msgid "Post!" msgstr "" -#: cms/templates/cms/snippets/date_list.html:8 +#: aircox_cms/templates/aircox_cms/snippets/date_list.html:8 msgid "previous days" msgstr "" -#: cms/templates/cms/snippets/date_list.html:20 +#: aircox_cms/templates/aircox_cms/snippets/date_list.html:20 msgid "next days" msgstr "" -#: cms/templates/cms/snippets/list.html:24 +#: aircox_cms/templates/aircox_cms/snippets/list.html:24 msgid "previous page" msgstr "" -#: cms/templates/cms/snippets/list.html:54 +#: aircox_cms/templates/aircox_cms/snippets/list.html:54 msgid "next page" msgstr "" -#: cms/templates/cms/snippets/player.html:5 +#: aircox_cms/templates/aircox_cms/snippets/player.html:5 msgid "Your browser does not support the audio element." msgstr "" -#: cms/templates/cms/snippets/player.html:15 +#: aircox_cms/templates/aircox_cms/snippets/player.html:15 msgid "play" msgstr "" -#: cms/templates/cms/snippets/player.html:17 +#: aircox_cms/templates/aircox_cms/snippets/player.html:17 msgid "pause" msgstr "" -#: cms/templates/cms/snippets/player.html:19 +#: aircox_cms/templates/aircox_cms/snippets/player.html:19 msgid "loading..." msgstr "" -#: cms/templates/cms/snippets/player.html:27 +#: aircox_cms/templates/aircox_cms/snippets/player.html:27 msgid "add to the player" msgstr "" -#: cms/templates/cms/snippets/player.html:28 +#: aircox_cms/templates/aircox_cms/snippets/player.html:28 msgid "more informations" msgstr "" -#: cms/templates/cms/snippets/player.html:29 +#: aircox_cms/templates/aircox_cms/snippets/player.html:29 msgid "remove this sound" msgstr "" -#: cms/templates/cms/snippets/player.html:44 +#: aircox_cms/templates/aircox_cms/snippets/player.html:44 msgid "enable and disable single mode" msgstr "" -#: cms/templates/cms/snippets/sound_list_item.html:39 +#: aircox_cms/templates/aircox_cms/snippets/sound_list_item.html:39 msgid "add this sound to the playlist" msgstr "" -#: cms/wagtail_hooks.py:36 +#: aircox_cms/wagtail_hooks.py:36 msgid "Schedules" msgstr "" -#: cms/wagtail_hooks.py:44 +#: aircox_cms/wagtail_hooks.py:44 msgid "Streams" msgstr "" -#: cms/wagtail_hooks.py:51 +#: aircox_cms/wagtail_hooks.py:51 msgid "Advanced" msgstr "" -#: cms/wagtail_hooks.py:60 +#: aircox_cms/wagtail_hooks.py:60 msgid "Sounds" msgstr "" -#: cms/wagtail_hooks.py:145 +#: aircox_cms/wagtail_hooks.py:145 msgid "Today's Diffusions" msgstr "" diff --git a/cms/management/commands/programs_to_cms.py b/aircox_cms/management/commands/programs_to_cms.py similarity index 96% rename from cms/management/commands/programs_to_cms.py rename to aircox_cms/management/commands/programs_to_cms.py index ddeac21..d576793 100644 --- a/cms/management/commands/programs_to_cms.py +++ b/aircox_cms/management/commands/programs_to_cms.py @@ -13,8 +13,8 @@ from django.core.management.base import BaseCommand, CommandError from django.contrib.contenttypes.models import ContentType from django.utils import timezone as tz -from aircox.programs.models import Program, Diffusion -from aircox.cms.models import WebsiteSettings, ProgramPage, DiffusionPage +from aircox.models import Program, Diffusion +from aircox_cms.models import WebsiteSettings, ProgramPage, DiffusionPage logger = logging.getLogger('aircox.tools') diff --git a/cms/models.py b/aircox_cms/models.py similarity index 96% rename from cms/models.py rename to aircox_cms/models.py index a782f86..1b3d497 100644 --- a/cms/models.py +++ b/aircox_cms/models.py @@ -27,16 +27,16 @@ from taggit.models import TaggedItemBase # comment clean-up import bleach -import aircox.programs.models as programs -import aircox.cms.settings as settings +import aircox.models +import aircox_cms.settings as settings -from aircox.cms.utils import image_url -from aircox.cms.sections import * +from aircox_cms.utils import image_url +from aircox_cms.sections import * @register_setting class WebsiteSettings(BaseSetting): - # TODO: #Station assign a website to a programs.model.station when it will + # TODO: #Station assign a website to a aircox.models.model.station when it will # exist. Update all dependent code such as signal handling # general website information @@ -314,7 +314,7 @@ class Publication(Page): super().save(*args, **kwargs) def get_context(self, request, *args, **kwargs): - from aircox.cms.forms import CommentForm + from aircox_cms.forms import CommentForm context = super().get_context(request, *args, **kwargs) view = request.GET.get('view') page = request.GET.get('page') @@ -330,7 +330,7 @@ class Publication(Page): return context def serve(self, request): - from aircox.cms.forms import CommentForm + from aircox_cms.forms import CommentForm if request.POST and 'comment' in request.POST['type']: settings = WebsiteSettings.for_site(request.site) comment_form = CommentForm(request.POST) @@ -354,7 +354,7 @@ class Publication(Page): class ProgramPage(Publication): program = models.ForeignKey( - programs.Program, + aircox.models.Program, verbose_name = _('program'), related_name = 'page', on_delete=models.SET_NULL, @@ -403,7 +403,7 @@ class ProgramPage(Publication): @property def next(self): now = tz.now() - diffs = programs.Diffusion.objects \ + diffs = aircox.models.Diffusion.objects \ .filter(end__gte = now, program = self.program) \ .order_by('start').prefetch_related('page') return self.diffs_to_page(diffs) @@ -411,13 +411,13 @@ class ProgramPage(Publication): @property def prev(self): now = tz.now() - diffs = programs.Diffusion.objects \ + diffs = aircox.models.Diffusion.objects \ .filter(end__lte = now, program = self.program) \ .order_by('-start').prefetch_related('page') return self.diffs_to_page(diffs) -class Track(programs.Track,Orderable): +class Track(aircox.models.Track,Orderable): sort_order_field = 'position' diffusion = ParentalKey('DiffusionPage', @@ -440,7 +440,7 @@ class DiffusionPage(Publication): order_field = 'diffusion__start' diffusion = models.ForeignKey( - programs.Diffusion, + aircox.models.Diffusion, verbose_name = _('diffusion'), related_name = 'page', on_delete=models.SET_NULL, @@ -547,7 +547,7 @@ class DiffusionPage(Publication): # update podcasts' attributes for podcast in self.diffusion.sound_set \ - .exclude(type = programs.Sound.Type.removed): + .exclude(type = aircox.models.Sound.Type.removed): publish = self.live and self.publish_archive \ if podcast.type == podcast.Type.archive else self.live @@ -645,10 +645,10 @@ class DatedListPage(DatedListBase,Page): class LogsPage(DatedListPage): - template = 'cms/dated_list_page.html' + template = 'aircox_cms/dated_list_page.html' station = models.ForeignKey( - programs.Station, + aircox.models.Station, verbose_name = _('station'), null = True, on_delete=models.SET_NULL, @@ -701,7 +701,7 @@ class LogsPage(DatedListPage): class TimetablePage(DatedListPage): - template = 'cms/dated_list_page.html' + template = 'aircox_cms/dated_list_page.html' class Meta: verbose_name = _('Timetable') @@ -710,7 +710,7 @@ class TimetablePage(DatedListPage): def get_queryset(self, request, context): diffs = [] for date in context['nav_dates']['dates']: - items = programs.Diffusion.objects.get_at(date).order_by('start') + items = aircox.models.Diffusion.objects.get_at(date).order_by('start') items = [ DiffusionPage.as_item(item) for item in items ] diffs.append((date, items)) return diffs diff --git a/cms/sections.py b/aircox_cms/sections.py similarity index 97% rename from cms/sections.py rename to aircox_cms/sections.py index 0bc7480..7e45ce8 100644 --- a/cms/sections.py +++ b/aircox_cms/sections.py @@ -33,13 +33,13 @@ from modelcluster.tags import ClusterTaggableManager from taggit.models import TaggedItemBase # aircox -import aircox.programs.models as programs +import aircox.models def related_pages_filter(reset_cache=False): """ Return a dict that can be used to filter foreignkey to pages' - subtype declared in aircox.cms.models. + subtype declared in aircox_cms.models. This value is stored in cache, but it is possible to reset the cache using the `reset_cache` parameter. @@ -47,7 +47,7 @@ def related_pages_filter(reset_cache=False): if not reset_cache and hasattr(related_pages_filter, 'cache'): return related_pages_filter.cache - import aircox.cms.models as cms + import aircox_cms.models as cms import inspect related_pages_filter.cache = { 'model__in': list(name.lower() for name, member in @@ -206,7 +206,7 @@ class ListBase(models.Model): Get queryset based on the arguments. This class is intended to be reusable by other classes if needed. """ - from aircox.cms.models import Publication + from aircox_cms.models import Publication related = self.related and self.related.specific # model @@ -249,7 +249,7 @@ class ListBase(models.Model): If there is related field use it to get the page, otherwise use the given list_page or the first GenericPage it finds. """ - import aircox.cms.models as models + import aircox_cms.models as models params = { 'date_filter': self.get_date_filter_display(), @@ -545,7 +545,7 @@ class SectionItemMeta(models.base.ModelBase): try: get_template(cl.template) except TemplateDoesNotExist: - cl.template = 'cms/sections/section_item.html' + cl.template = 'aircox_cms/sections/section_item.html' return cl @register_snippet @@ -822,7 +822,7 @@ class SectionList(ListBase, SectionRelativeItem): ] + ListBase.panels def get_context(self, request, page): - from aircox.cms.models import Publication + from aircox_cms.models import Publication context = super().get_context(request, page) if self.is_related: @@ -855,7 +855,7 @@ class SectionList(ListBase, SectionRelativeItem): @register_snippet class SectionLogsList(SectionItem): station = models.ForeignKey( - programs.Station, + aircox.models.Station, verbose_name = _('station'), null = True, on_delete=models.SET_NULL, @@ -882,9 +882,9 @@ class SectionLogsList(SectionItem): Return a log object as a DiffusionPage or ListItem. Supports: Log/Track, Diffusion """ - from aircox.cms.models import DiffusionPage + from aircox_cms.models import DiffusionPage print(log, type(log)) - if type(log) == programs.Diffusion: + if type(log) == aircox.models.Diffusion: return DiffusionPage.as_item(log) return ListItem( title = '{artist} -- {title}'.format( @@ -915,10 +915,10 @@ class SectionTimetable(SectionItem,DatedListBase): panels = SectionItem.panels + DatedListBase.panels def get_queryset(self, context): - from aircox.cms.models import DiffusionPage + from aircox_cms.models import DiffusionPage diffs = [] for date in context['nav_dates']['dates']: - items = programs.Diffusion.objects.get_at(date).order_by('start') + items = aircox.models.Diffusion.objects.get_at(date).order_by('start') items = [ DiffusionPage.as_item(item) for item in items ] diffs.append((date, items)) return diffs @@ -954,7 +954,7 @@ class SectionSearchField(SectionItem): ] def get_context(self, request, page): - from aircox.cms.models import GenericPage + from aircox_cms.models import GenericPage context = super().get_context(request, page) return context diff --git a/cms/settings.py b/aircox_cms/settings.py similarity index 100% rename from cms/settings.py rename to aircox_cms/settings.py diff --git a/aircox_cms/signals.py b/aircox_cms/signals.py new file mode 100644 index 0000000..fcbf3b9 --- /dev/null +++ b/aircox_cms/signals.py @@ -0,0 +1,13 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +import aircox.models + + +@receiver(post_save, sender=programs.Program) +def on_new_program(sender, instance, created, *args): + import aircox_cms.models as models + if not created or instance.page.count(): + return + + diff --git a/cms/static/cms/css/cms.css b/aircox_cms/static/aircox_cms/css/cms.css similarity index 100% rename from cms/static/cms/css/cms.css rename to aircox_cms/static/aircox_cms/css/cms.css diff --git a/cms/static/cms/css/layout.css b/aircox_cms/static/aircox_cms/css/layout.css similarity index 100% rename from cms/static/cms/css/layout.css rename to aircox_cms/static/aircox_cms/css/layout.css diff --git a/cms/static/cms/css/theme.css b/aircox_cms/static/aircox_cms/css/theme.css similarity index 100% rename from cms/static/cms/css/theme.css rename to aircox_cms/static/aircox_cms/css/theme.css diff --git a/cms/static/cms/images/LICENSE.TXT b/aircox_cms/static/aircox_cms/images/LICENSE.TXT similarity index 100% rename from cms/static/cms/images/LICENSE.TXT rename to aircox_cms/static/aircox_cms/images/LICENSE.TXT diff --git a/cms/static/cms/images/README.md b/aircox_cms/static/aircox_cms/images/README.md similarity index 100% rename from cms/static/cms/images/README.md rename to aircox_cms/static/aircox_cms/images/README.md diff --git a/cms/static/cms/images/add.png b/aircox_cms/static/aircox_cms/images/add.png similarity index 100% rename from cms/static/cms/images/add.png rename to aircox_cms/static/aircox_cms/images/add.png diff --git a/cms/static/cms/images/clock.png b/aircox_cms/static/aircox_cms/images/clock.png similarity index 100% rename from cms/static/cms/images/clock.png rename to aircox_cms/static/aircox_cms/images/clock.png diff --git a/cms/static/cms/images/comments.png b/aircox_cms/static/aircox_cms/images/comments.png similarity index 100% rename from cms/static/cms/images/comments.png rename to aircox_cms/static/aircox_cms/images/comments.png diff --git a/cms/static/cms/images/facebook.png b/aircox_cms/static/aircox_cms/images/facebook.png similarity index 100% rename from cms/static/cms/images/facebook.png rename to aircox_cms/static/aircox_cms/images/facebook.png diff --git a/cms/static/cms/images/feed.png b/aircox_cms/static/aircox_cms/images/feed.png similarity index 100% rename from cms/static/cms/images/feed.png rename to aircox_cms/static/aircox_cms/images/feed.png diff --git a/cms/static/cms/images/gplus.png b/aircox_cms/static/aircox_cms/images/gplus.png similarity index 100% rename from cms/static/cms/images/gplus.png rename to aircox_cms/static/aircox_cms/images/gplus.png diff --git a/cms/static/cms/images/grow.png b/aircox_cms/static/aircox_cms/images/grow.png similarity index 100% rename from cms/static/cms/images/grow.png rename to aircox_cms/static/aircox_cms/images/grow.png diff --git a/cms/static/cms/images/home.png b/aircox_cms/static/aircox_cms/images/home.png similarity index 100% rename from cms/static/cms/images/home.png rename to aircox_cms/static/aircox_cms/images/home.png diff --git a/cms/static/cms/images/list.png b/aircox_cms/static/aircox_cms/images/list.png similarity index 100% rename from cms/static/cms/images/list.png rename to aircox_cms/static/aircox_cms/images/list.png diff --git a/cms/static/cms/images/listen.png b/aircox_cms/static/aircox_cms/images/listen.png similarity index 100% rename from cms/static/cms/images/listen.png rename to aircox_cms/static/aircox_cms/images/listen.png diff --git a/cms/static/cms/images/loading.png b/aircox_cms/static/aircox_cms/images/loading.png similarity index 100% rename from cms/static/cms/images/loading.png rename to aircox_cms/static/aircox_cms/images/loading.png diff --git a/cms/static/cms/images/mail.png b/aircox_cms/static/aircox_cms/images/mail.png similarity index 100% rename from cms/static/cms/images/mail.png rename to aircox_cms/static/aircox_cms/images/mail.png diff --git a/cms/static/cms/images/on_air.png b/aircox_cms/static/aircox_cms/images/on_air.png similarity index 100% rename from cms/static/cms/images/on_air.png rename to aircox_cms/static/aircox_cms/images/on_air.png diff --git a/cms/static/cms/images/pause.png b/aircox_cms/static/aircox_cms/images/pause.png similarity index 100% rename from cms/static/cms/images/pause.png rename to aircox_cms/static/aircox_cms/images/pause.png diff --git a/cms/static/cms/images/play.png b/aircox_cms/static/aircox_cms/images/play.png similarity index 100% rename from cms/static/cms/images/play.png rename to aircox_cms/static/aircox_cms/images/play.png diff --git a/cms/static/cms/images/search.png b/aircox_cms/static/aircox_cms/images/search.png similarity index 100% rename from cms/static/cms/images/search.png rename to aircox_cms/static/aircox_cms/images/search.png diff --git a/cms/static/cms/images/share.png b/aircox_cms/static/aircox_cms/images/share.png similarity index 100% rename from cms/static/cms/images/share.png rename to aircox_cms/static/aircox_cms/images/share.png diff --git a/cms/static/cms/images/tiles_large.png b/aircox_cms/static/aircox_cms/images/tiles_large.png similarity index 100% rename from cms/static/cms/images/tiles_large.png rename to aircox_cms/static/aircox_cms/images/tiles_large.png diff --git a/cms/static/cms/images/tumblr.png b/aircox_cms/static/aircox_cms/images/tumblr.png similarity index 100% rename from cms/static/cms/images/tumblr.png rename to aircox_cms/static/aircox_cms/images/tumblr.png diff --git a/cms/static/cms/images/twitter.png b/aircox_cms/static/aircox_cms/images/twitter.png similarity index 100% rename from cms/static/cms/images/twitter.png rename to aircox_cms/static/aircox_cms/images/twitter.png diff --git a/cms/static/cms/js/player.js b/aircox_cms/static/aircox_cms/js/player.js similarity index 100% rename from cms/static/cms/js/player.js rename to aircox_cms/static/aircox_cms/js/player.js diff --git a/cms/static/cms/js/utils.js b/aircox_cms/static/aircox_cms/js/utils.js similarity index 100% rename from cms/static/cms/js/utils.js rename to aircox_cms/static/aircox_cms/js/utils.js diff --git a/cms/templates/cms/base_site.html b/aircox_cms/templates/aircox_cms/base_site.html similarity index 86% rename from cms/templates/cms/base_site.html rename to aircox_cms/templates/aircox_cms/base_site.html index f22cf5b..5299db2 100644 --- a/cms/templates/cms/base_site.html +++ b/aircox_cms/templates/aircox_cms/base_site.html @@ -19,14 +19,14 @@ {% endwith %} {% block css %} - - + + {% block css_extras %}{% endblock %} {% endblock %} - - + + {{ page.title }} diff --git a/cms/templates/cms/dated_list_page.html b/aircox_cms/templates/aircox_cms/dated_list_page.html similarity index 69% rename from cms/templates/cms/dated_list_page.html rename to aircox_cms/templates/aircox_cms/dated_list_page.html index d22c147..b272572 100644 --- a/cms/templates/cms/dated_list_page.html +++ b/aircox_cms/templates/aircox_cms/dated_list_page.html @@ -1,4 +1,4 @@ -{% extends "cms/base_site.html" %} +{% extends "aircox_cms/base_site.html" %} {# display a timetable of planified diffusions by days #} {% load wagtailcore_tags %} @@ -10,6 +10,6 @@ {% endif %} -{% include "cms/snippets/date_list.html" %} +{% include "aircox_cms/snippets/date_list.html" %} {% endblock %} diff --git a/cms/templates/cms/diffusion_page.html b/aircox_cms/templates/aircox_cms/diffusion_page.html similarity index 94% rename from cms/templates/cms/diffusion_page.html rename to aircox_cms/templates/aircox_cms/diffusion_page.html index a4d98a0..5a9625c 100644 --- a/cms/templates/cms/diffusion_page.html +++ b/aircox_cms/templates/aircox_cms/diffusion_page.html @@ -1,4 +1,4 @@ -{% extends "cms/publication.html" %} +{% extends "aircox_cms/publication.html" %} {% load i18n %} {% block content_extras %} @@ -35,7 +35,7 @@

{% trans "Podcasts" %}

- {% include 'cms/snippets/player.html' %} + {% include 'aircox_cms/snippets/player.html' %} - +

{{ item.name }}

- {% trans
{% endif %} diff --git a/cms/templatetags/aircox_cms.py b/aircox_cms/templatetags/aircox_cms.py similarity index 100% rename from cms/templatetags/aircox_cms.py rename to aircox_cms/templatetags/aircox_cms.py diff --git a/aircox_cms/tests.py b/aircox_cms/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/aircox_cms/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/cms/utils.py b/aircox_cms/utils.py similarity index 100% rename from cms/utils.py rename to aircox_cms/utils.py diff --git a/cms/views.py b/aircox_cms/views.py similarity index 100% rename from cms/views.py rename to aircox_cms/views.py diff --git a/cms/wagtail_hooks.py b/aircox_cms/wagtail_hooks.py similarity index 89% rename from cms/wagtail_hooks.py rename to aircox_cms/wagtail_hooks.py index 8795242..74cb321 100644 --- a/cms/wagtail_hooks.py +++ b/aircox_cms/wagtail_hooks.py @@ -11,12 +11,12 @@ from wagtail.contrib.modeladmin.options import \ ModelAdmin, ModelAdminGroup, modeladmin_register -import aircox.programs.models as programs -import aircox.cms.models as models +import aircox.models +import aircox_cms.models as models class ProgramAdmin(ModelAdmin): - model = programs.Program + model = aircox.models.Program menu_label = _('Programs') menu_icon = 'pick' menu_order = 200 @@ -24,7 +24,7 @@ class ProgramAdmin(ModelAdmin): search_fields = ('name',) class DiffusionAdmin(ModelAdmin): - model = programs.Diffusion + model = aircox.models.Diffusion menu_label = _('Diffusions') menu_icon = 'date' menu_order = 200 @@ -32,7 +32,7 @@ class DiffusionAdmin(ModelAdmin): list_filter = ('frequency', 'start', 'program') class ScheduleAdmin(ModelAdmin): - model = programs.Schedule + model = aircox.models.Schedule menu_label = _('Schedules') menu_icon = 'time' menu_order = 200 @@ -40,7 +40,7 @@ class ScheduleAdmin(ModelAdmin): list_filter = ('frequency', 'date', 'duration', 'program') class StreamAdmin(ModelAdmin): - model = programs.Stream + model = aircox.models.Stream menu_label = _('Streams') menu_icon = 'time' menu_order = 200 @@ -56,7 +56,7 @@ modeladmin_register(AdvancedAdminGroup) class SoundAdmin(ModelAdmin): - model = programs.Sound + model = aircox.models.Sound menu_label = _('Sounds') menu_icon = 'media' menu_order = 350 @@ -73,7 +73,7 @@ modeladmin_register(SoundAdmin) def editor_css(): return format_html( '', - static('cms/css/cms.css') + static('aircox_cms/css/cms.css') ) @@ -126,8 +126,8 @@ class DiffusionsMenu(GenericMenu): page_model = models.DiffusionPage def get_queryset(self): - return programs.Diffusion.objects.filter( - type = programs.Diffusion.Type.normal, + return aircox.models.Diffusion.objects.filter( + type = aircox.models.Diffusion.Type.normal, start__contains = tz.now().date(), initial__isnull = True, ).order_by('start') @@ -149,12 +149,12 @@ def register_programs_menu_item(): class ProgramsMenu(GenericMenu): """ - Menu to display all active programs. + Menu to display all active programs """ page_model = models.DiffusionPage def get_queryset(self): - return programs.Program.objects \ + return aircox.models.Program.objects \ .filter(active = True, page__isnull = False) \ .filter(stream__isnull = True) \ .order_by('name') @@ -164,7 +164,7 @@ class ProgramsMenu(GenericMenu): def get_parent(self, item): # TODO: #Station / get current site - from aircox.cms.models import WebsiteSettings + from aircox_cms.models import WebsiteSettings settings = WebsiteSettings.objects.first() if not settings: return diff --git a/cms/templates/cms/sections/section_timetable.html b/cms/templates/cms/sections/section_timetable.html deleted file mode 100644 index 8a8063f..0000000 --- a/cms/templates/cms/sections/section_timetable.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "cms/sections/section_item.html" %} - -{% block content %} -{% include "cms/snippets/date_list.html" %} -{% endblock %} - diff --git a/instance/__init__.py b/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/instance/base_settings.py b/instance/base_settings.py new file mode 100644 index 0000000..8b2f60d --- /dev/null +++ b/instance/base_settings.py @@ -0,0 +1,143 @@ +import os +import sys +sys.path.insert(1, os.path.dirname(os.path.realpath(__file__))) + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +PROJECT_ROOT = os.path.normpath(os.path.dirname(__file__) + "/..") + +STATIC_URL = '/static/' +MEDIA_URL = '/media/' +SITE_MEDIA_URL = '/media/' +STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') +MEDIA_ROOT = os.path.join(STATIC_ROOT, 'media') + +# Internationalization +USE_I18N = True +USE_L10N = True +USE_TZ = True + +LANGUAGE_CODE = os.environ.get('LANG') or 'en_US' +TIME_ZONE = os.environ.get('TZ') or 'Europe/Brussels' + +try: + import locale + locale.setlocale(locale.LC_ALL, LANGUAGE_CODE) +except: + print( + 'Can not set locale {LC}. Is it available on you system? Hint: ' + 'Check /etc/locale.gen and rerun locale-gen as sudo if needed.' + .format(LC = LANGUAGE_CODE) + ) + pass + +# Application definition +INSTALLED_APPS = ( + 'wagtail.wagtailforms', + 'wagtail.wagtailredirects', + 'wagtail.wagtailembeds', + 'wagtail.wagtailsites', + 'wagtail.wagtailusers', + 'wagtail.wagtailsnippets', + 'wagtail.wagtaildocs', + 'wagtail.wagtailimages', + 'wagtail.wagtailsearch', + 'wagtail.wagtailadmin', + 'wagtail.wagtailcore', + 'wagtail.contrib.settings', + 'wagtail.contrib.modeladmin', + + 'modelcluster', + 'taggit', + 'honeypot', + + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.admin', + + 'aircox', + 'aircox_cms', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.gzip.GZipMiddleware', + 'htmlmin.middleware.HtmlMinifyMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.security.SecurityMiddleware', + + 'wagtail.wagtailcore.middleware.SiteMiddleware', + 'wagtail.wagtailredirects.middleware.RedirectMiddleware', +) + + +ROOT_URLCONF = 'instance.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': (os.path.join(PROJECT_ROOT, 'templates'),), + # 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': ( + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + + 'wagtail.contrib.settings.context_processors.settings', + ), + 'builtins': [ + 'overextends.templatetags.overextends_tags' + ], + 'loaders': ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ), + }, + }, +] + + +WSGI_APPLICATION = 'instance.wsgi.application' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'aircox.core': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + 'aircox.test': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + 'aircox.tools': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + }, +} + + +# Wagtail +WAGTAIL_SITE_NAME = 'Aircox' + + diff --git a/instance/dev.py b/instance/dev.py new file mode 100644 index 0000000..c123ad9 --- /dev/null +++ b/instance/dev.py @@ -0,0 +1,29 @@ +import os + +LOCALE_PATHS = ['aircox/locale', 'aircox_cms/locale'] + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'aircox.core': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + 'aircox.test': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + 'aircox.tools': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + }, +} + + diff --git a/instance/prod.py b/instance/prod.py new file mode 100644 index 0000000..c886c5e --- /dev/null +++ b/instance/prod.py @@ -0,0 +1,28 @@ +import os + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'aircox.core': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + 'aircox.test': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + 'aircox.tools': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + }, + }, +} + + + diff --git a/instance/sample_settings.py b/instance/sample_settings.py new file mode 100644 index 0000000..d2b3b13 --- /dev/null +++ b/instance/sample_settings.py @@ -0,0 +1,58 @@ +""" +Sample file for the settings.py + +Environment variables: + * AIRCOX_DEBUG: enable/disable debugging + * TZ: [in base_settings] timezone (default: 'Europe/Brussels') + * LANG: [in base_settings] language code + +Note that: + - SECRET_KEY + - ALLOWED_HOSTS + - DATABASES + + are not defined in base_settings and must be defined here. + +You can also take a look at `base_settings` for more information. + +""" +import os +# If Aircox is not installed as a regular python module, you can use: +# import sys +# sys.path.append('/path/to/aircox_parent_folder/') + + +from .base_settings import * + +DEBUG = False +if 'AIRCOX_DEBUG' in os.environ: + DEBUG = (os.environ['AIRCOX_DEBUG'].lower()) in ('true','1') + +if DEBUG: + from .dev import * + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + 'TIMEZONE': TIME_ZONE, + } + } +else: + from .prod import * + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'aircox', + 'USER': 'aircox', + 'PASSWORD': '', + 'HOST': 'localhost', + 'TIMEZONE': TIME_ZONE, + }, + } + +ALLOWED_HOSTS = ['127.0.0.1:8042'] +SECRET_KEY = '' + +WAGTAIL_SITE_NAME='Aircox' + + diff --git a/instance/urls.py b/instance/urls.py new file mode 100644 index 0000000..ef3532b --- /dev/null +++ b/instance/urls.py @@ -0,0 +1,47 @@ +"""aircox URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.8/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Add an import: from blog import urls as blog_urls + 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) +""" +from django.conf.urls import include, url +from django.contrib import admin +from instance import settings + +from wagtail.wagtailadmin import urls as wagtailadmin_urls +from wagtail.wagtaildocs import urls as wagtaildocs_urls +from wagtail.wagtailcore import urls as wagtail_urls +from wagtail.wagtailimages.views.serve import ServeView + +import aircox.urls + + +urlpatterns = [ + url(r'^admin/', include(admin.site.urls)), + url(r'^aircox/', include(aircox.urls.urls)), + + # cms + url(r'^cms/', include(wagtailadmin_urls)), + url(r'^documents/', include(wagtaildocs_urls)), + url( r'^images/([^/]*)/(\d*)/([^/]*)/[^/]*$', ServeView.as_view(), + name='wagtailimages_serve'), +] + +if settings.DEBUG: + from django.views.static import serve + urlpatterns.append( + url(r'^media/(?P.*)$', serve, + {'document_root': settings.MEDIA_ROOT, 'show_indexes':True} + ) + ) + +urlpatterns.append(url(r'', include(wagtail_urls))) diff --git a/instance/wsgi.py b/instance/wsgi.py new file mode 100644 index 0000000..0bd3191 --- /dev/null +++ b/instance/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for aircox project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "instance.settings") +application = get_wsgi_application() + diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8ddb963 --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "instance.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/notes.md b/notes.md index ab5028e..07a4766 100644 --- a/notes.md +++ b/notes.md @@ -6,10 +6,18 @@ This file is used as a reminder, can be used as crappy documentation too. - metaclass: `class_name + 'Meta'` - base classes: `class_name + 'Base'` +* import and naming: + - the imported "models" file in the same application is named "models" + - the imported "models" file from another application is named with the application's name + - to avoid conflict: + - django's settings can be named "main_settings" + ## aircox.cms * icons: cropped to 32x32 * cover in list items: cropped 64x64 + + # Long term TODO - debug/prod configuration @@ -27,4 +35,19 @@ cms: - player support diffusions with multiple archive files - comments -> remove/edit by the author +# Instance's TODO +- menu_top .sections: + - display inline block + - search on the right +- lists > items style +- 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? + + diff --git a/scripts/cron_diffusions b/scripts/cron_diffusions new file mode 100755 index 0000000..648ccbf --- /dev/null +++ b/scripts/cron_diffusions @@ -0,0 +1,8 @@ +#! /bin/sh + +# aircox daily tasks: +# - diffusions monitoring for the current month +/srv/apps/aircox/manage.py diffusions_monitor --update --clean --check +# - diffusions monitoring for the next month +/srv/apps/aircox/manage.py diffusions_monitor --update --next-month + diff --git a/scripts/nginx_aircox b/scripts/nginx_aircox new file mode 100644 index 0000000..7ea73a7 --- /dev/null +++ b/scripts/nginx_aircox @@ -0,0 +1,27 @@ +# Sample configuration file for Nginx. +# +# The binding is done to a Gunicorn's instance (cf. supervisor scripts), +# on local port 8042 # and assumes that static files are in +# /srv/apps/aircox/static. +# +# You want to change the server_name and static location to suit your needs +# +server { + server_name aircox.somewhere.net; + listen 80; + + location / { + proxy_pass http://127.0.0.1:8042/; + proxy_read_timeout 300; + proxy_redirect off; + proxy_buffering off; + proxy_store off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /static/ { + alias /srv/apps/aircox/static/ ; + } +} + diff --git a/scripts/supervisord_aircox b/scripts/supervisord_aircox new file mode 100755 index 0000000..9657e82 --- /dev/null +++ b/scripts/supervisord_aircox @@ -0,0 +1,44 @@ +; Supervisor sample config file for Aircox. +; +; It assumes that the instance is installed in the directory +; "/srv/apps/aircox". It requires Gunicorn in order to run the +; WSGI server. +; +; * aircox_server: WSGI server instance using Gunicorn for production; +; Note that it does not serve static files. +; * aircox_sounds_monitor: sounds scanning, monitoring, quality-check, +; and synchronisation with the database. +; * aircox_controllers: audio stream generation and monitoring; create +; config and playlists, and run the required programs. +; note: must be restarted after changes in controller's sources. +; + +[program:aircox_server] +command = gunicorn --bind 127.0.0.1:8042 instance.wsgi:application +directory = /srv/apps/aircox +user = aircox +autostart = true +autorestart = true +stdout_logfile = /srv/apps/aircox/logs/server.log +redirect_stderr = true +environment=AIRCOX_DEBUG="False" + +[program:aircox_sounds_monitor] +command = /srv/apps/aircox/manage.py sounds_monitor -qsm +directory = /srv/apps/aircox +user = aircox +autostart = true +autorestart = true +stdout_logfile = /srv/apps/aircox/logs/sounds_monitor.log +redirect_stderr = true + +[program:aircox_controllers] +command = /srv/apps/aircox/manage.py controllers -crm +directory = /srv/apps/aircox +user = aircox +autostart = true +autorestart = true +stdout_logfile = /srv/apps/aircox/logs/controllers.log +redirect_stderr = true + +