merge aircox and aircox_instance
142
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.
|
||||
|
||||
|
||||
|
|
33
aircox/README.md
Normal file
|
@ -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
|
||||
|
0
aircox/__init__.py
Executable file
193
aircox/admin.py
Executable file
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
90
aircox/connector.py
Normal file
|
@ -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<key>[^=]+)="?(?P<value>([^"]|\\")+)"?', 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
|
||||
|
||||
|
||||
|
||||
|
359
aircox/controllers.py
Normal file
|
@ -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
|
||||
}
|
||||
|
317
aircox/locale/fr/LC_MESSAGES/django.po
Normal file
|
@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\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 ""
|
0
aircox/management/__init__.py
Normal file
0
aircox/management/commands/__init__.py
Normal file
184
aircox/management/commands/diffusions_monitor.py
Normal file
|
@ -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)
|
||||
|
142
aircox/management/commands/import_playlist.py
Normal file
|
@ -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
|
||||
)
|
||||
)
|
||||
|
383
aircox/management/commands/sounds_monitor.py
Normal file
|
@ -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<year>[0-9]{4})'
|
||||
'(?P<month>[0-9]{2})'
|
||||
'(?P<day>[0-9]{2})'
|
||||
'(_(?P<n>[0-9]+))?'
|
||||
'_?(?P<name>.*)$',
|
||||
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)
|
||||
|
||||
|
174
aircox/management/commands/sounds_quality_check.py
Normal file
|
@ -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<value>\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)
|
||||
|
346
aircox/management/commands/streamer.py
Normal file
|
@ -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()
|
||||
|
1096
aircox/models.py
Executable file
63
aircox/settings.py
Executable file
|
@ -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')
|
||||
|
||||
|
150
aircox/templates/aircox/controllers/liquidsoap.liq
Normal file
|
@ -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 %}
|
||||
|
125
aircox/templates/aircox/controllers/monitor.html
Normal file
|
@ -0,0 +1,125 @@
|
|||
{% load i18n %}
|
||||
|
||||
<style>
|
||||
section.station {
|
||||
padding: 0.4em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
section.station header {
|
||||
margin: 0.4em 0em;
|
||||
}
|
||||
|
||||
section.station header > * {
|
||||
margin: 0em 0.2em;
|
||||
}
|
||||
|
||||
section.station h1 {
|
||||
display: inline;
|
||||
margin: 0px;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
section.station button {
|
||||
float: right;
|
||||
}
|
||||
|
||||
section.station .sources {
|
||||
border: 1px grey solid;
|
||||
}
|
||||
|
||||
section.station .source {
|
||||
margin: 0.2em 0em;
|
||||
}
|
||||
|
||||
section.station .name {
|
||||
display: inline-block;
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
section.station .file {
|
||||
color: #007EDF;
|
||||
}
|
||||
|
||||
section.station .source.current:before {
|
||||
content: '▶';
|
||||
color: red;
|
||||
margin: 0em 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script>
|
||||
var Monitor = {
|
||||
get_token: function () {
|
||||
return document.cookie.replace(/.*csrftoken=([^;]+)(;.*|$)/, '$1');
|
||||
},
|
||||
|
||||
post: function(station, source, action) {
|
||||
var params = 'station=' + station + '&&action=' + action;
|
||||
if(source)
|
||||
params += '&&source=' + source;
|
||||
|
||||
var req = new XMLHttpRequest()
|
||||
req.open('POST', '{% url 'aircox.monitor' %}', false);
|
||||
req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||
req.setRequestHeader("Content-length", params.length);
|
||||
req.setRequestHeader("Connection", "close");
|
||||
req.setRequestHeader("X-CSRFToken", this.get_token());
|
||||
req.send(params);
|
||||
this.update();
|
||||
},
|
||||
|
||||
skip: function(station, source) {
|
||||
this.post(station, source, 'skip');
|
||||
},
|
||||
|
||||
update: function(timeout) {
|
||||
var req = new XMLHttpRequest()
|
||||
req.open('GET', '{% url 'aircox.monitor' %}', true);
|
||||
req.onreadystatechange = function() {
|
||||
if(req.readyState != 4 || (req.status != 200 && req.status != 0))
|
||||
return;
|
||||
|
||||
var doc = document.implementation.createHTMLDocument('xhr')
|
||||
.documentElement;
|
||||
doc.innerHTML = req.responseText;
|
||||
|
||||
document.getElementById('stations').innerHTML =
|
||||
doc.querySelector('#stations').innerHTML;
|
||||
|
||||
if(timeout)
|
||||
window.setTimeout(
|
||||
function() { Monitor.update(timeout);}, timeout
|
||||
);
|
||||
};
|
||||
req.send();
|
||||
},
|
||||
}
|
||||
|
||||
Monitor.update(1000);
|
||||
</script>
|
||||
|
||||
<div id='stations'>
|
||||
{% for station in stations %}
|
||||
<section class="station">
|
||||
<header>
|
||||
<h1>{{ station.name }}</h1>
|
||||
<button onclick="Monitor.skip('{{ station.name }}');">{% trans "skip" %}</button>
|
||||
<button onclick="Monitor.update();">{% trans "update" %}</button>
|
||||
</header>
|
||||
<div class="sources">
|
||||
{% for source in station.all_sources %}
|
||||
{% if source.controller.current_sound %}
|
||||
<div class="source{% if source == station.controller.current_source %} current{% endif %}">
|
||||
<span class="name">{{ source.name }}</span>
|
||||
<span class="file">{{ source.controller.current_sound }}</span>
|
||||
<button onclick="Monitor.skip('{{ station.name }}','{{ source.name }}');">
|
||||
{% trans "skip" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
66
aircox/tests.py
Executable file
|
@ -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
|
||||
|
||||
|
||||
|
9
aircox/urls.py
Normal file
|
@ -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')
|
||||
]
|
||||
|
22
aircox/utils.py
Normal file
|
@ -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)
|
||||
|
126
aircox/views.py
Executable file
|
@ -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('')
|
||||
|
||||
|
||||
|
||||
|
||||
|
0
aircox_cms/__init__.py
Normal file
5
aircox_cms/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CmsConfig(AppConfig):
|
||||
name = 'cms'
|
|
@ -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 <i>%(terms)s</i>"
|
||||
msgstr ""
|
||||
|
||||
#: cms/templates/cms/generic_page.html:20
|
||||
#: aircox_cms/templates/aircox_cms/generic_page.html:20
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
" Related to <a href=\"%(url)s\">%(title)s</a>"
|
||||
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 <code>audio</code> 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 ""
|
|
@ -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')
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
13
aircox_cms/signals.py
Normal file
|
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 701 B After Width: | Height: | Size: 701 B |
Before Width: | Height: | Size: 545 B After Width: | Height: | Size: 545 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 952 B |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 311 B |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 316 B After Width: | Height: | Size: 316 B |
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 696 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -19,14 +19,14 @@
|
|||
<link rel="icon" href="{{ favicon.url }}" />
|
||||
{% endwith %}
|
||||
{% block css %}
|
||||
<link rel="stylesheet" href="{% static 'cms/css/layout.css' %}" type="text/css" />
|
||||
<link rel="stylesheet" href="{% static 'cms/css/theme.css' %}" type="text/css" />
|
||||
<link rel="stylesheet" href="{% static 'aircox_cms/css/layout.css' %}" type="text/css" />
|
||||
<link rel="stylesheet" href="{% static 'aircox_cms/css/theme.css' %}" type="text/css" />
|
||||
|
||||
{% block css_extras %}{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
<script src="{% static 'cms/js/utils.js' %}"></script>
|
||||
<script src="{% static 'cms/js/player.js' %}"></script>
|
||||
<script src="{% static 'aircox_cms/js/utils.js' %}"></script>
|
||||
<script src="{% static 'aircox_cms/js/player.js' %}"></script>
|
||||
|
||||
<title>{{ page.title }}</title>
|
||||
</head>
|
|
@ -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 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "cms/snippets/date_list.html" %}
|
||||
{% include "aircox_cms/snippets/date_list.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/publication.html" %}
|
||||
{% extends "aircox_cms/publication.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content_extras %}
|
||||
|
@ -35,7 +35,7 @@
|
|||
<section class="podcasts list">
|
||||
<h2>{% trans "Podcasts" %}</h2>
|
||||
<div id="player_diff_{{ page.id }}" class="player">
|
||||
{% include 'cms/snippets/player.html' %}
|
||||
{% include 'aircox_cms/snippets/player.html' %}
|
||||
|
||||
<script>
|
||||
var podcasts = new Player('player_diff_{{ page.id }}', undefined, true)
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/publication.html" %}
|
||||
{% extends "aircox_cms/publication.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/base_site.html" %}
|
||||
{% extends "aircox_cms/base_site.html" %}
|
||||
{# generic page to display list of articles #}
|
||||
|
||||
{% load i18n %}
|
||||
|
@ -48,7 +48,7 @@
|
|||
{% endwith %}
|
||||
|
||||
{% with list_paginator=paginator %}
|
||||
{% include "cms/snippets/list.html" %}
|
||||
{% include "aircox_cms/snippets/list.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="body">
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/publication.html" %}
|
||||
{% extends "aircox_cms/publication.html" %}
|
||||
{# generic page to display programs #}
|
||||
|
||||
{% load i18n %}
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/base_site.html" %}
|
||||
{% extends "aircox_cms/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% load wagtailcore_tags %}
|
||||
|
@ -34,7 +34,7 @@
|
|||
</section>
|
||||
|
||||
{% with list_paginator=paginator %}
|
||||
{% include "cms/snippets/list.html" %}
|
||||
{% include "aircox_cms/snippets/list.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{# detail view #}
|
||||
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
|
||||
<section class="comments">
|
||||
{% include "cms/snippets/comments.html" %}
|
||||
{% include "aircox_cms/snippets/comments.html" %}
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
{% load wagtailimages_tags %}
|
||||
|
||||
{% block content %}
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
{% load wagtailimages_tags %}
|
||||
|
||||
{% block content %}
|
|
@ -1,14 +1,14 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% if focus %}
|
||||
{% with item=focus item_big_cover=True %}
|
||||
{% include "cms/snippets/list_item.html" %}
|
||||
{% include "aircox_cms/snippets/list_item.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% with url=url url_text=self.url_text %}
|
||||
{% include "cms/snippets/list.html" %}
|
||||
{% include "aircox_cms/snippets/list.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% endblock %}
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% with item_date_format="H:i" list_css_class="date_list" list_no_cover=True %}
|
||||
{% for item in object_list %}
|
||||
{% include "cms/snippets/date_list_item.html" %}
|
||||
{% include "aircox_cms/snippets/date_list_item.html" %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
|
@ -1,9 +1,9 @@
|
|||
{% extends 'cms/sections/section_item.html' %}
|
||||
{% extends 'aircox_cms/sections/section_item.html' %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div id="player" class="player">
|
||||
{% include "cms/snippets/player.html" %}
|
||||
{% include "aircox_cms/snippets/player.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
|
@ -9,7 +9,7 @@
|
|||
<div class="author">
|
||||
{% if page.publish_as %}
|
||||
{% with item=page.publish_as item_date_format='' %}
|
||||
{% include "cms/snippets/list_item.html" %}
|
||||
{% include "aircox_cms/snippets/list_item.html" %}
|
||||
{% endwith %}
|
||||
{% elif page.owner %}
|
||||
{% trans "Published by" %}
|
||||
|
@ -36,23 +36,23 @@
|
|||
{% endwith %}
|
||||
|
||||
<div class="share">
|
||||
<img src="{% static "cms/images/share.png" %}" alt="{% trans "Share" %}"
|
||||
<img src="{% static "aircox_cms/images/share.png" %}" alt="{% trans "Share" %}"
|
||||
class="small_icon">
|
||||
<a href="mailto:?&body={{ page.full_url|urlencode }}"
|
||||
target="new">
|
||||
<img src="{% static "cms/images/mail.png" %}" alt="Mail" class="small_icon">
|
||||
<img src="{% static "aircox_cms/images/mail.png" %}" alt="Mail" class="small_icon">
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ page.full_url|urlencode }}"
|
||||
target="new">
|
||||
<img src="{% static "cms/images/facebook.png" %}" alt="Facebook" class="small_icon">
|
||||
<img src="{% static "aircox_cms/images/facebook.png" %}" alt="Facebook" class="small_icon">
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?text={{ page.full_url|urlencode }}"
|
||||
target="new">
|
||||
<img src="{% static "cms/images/twitter.png" %}" alt="Twitter" class="small_icon">
|
||||
<img src="{% static "aircox_cms/images/twitter.png" %}" alt="Twitter" class="small_icon">
|
||||
</a>
|
||||
<a href="https://plus.google.com/share?url={{ page.full_url|urlencode }}"
|
||||
target="new">
|
||||
<img src="{% static "cms/images/gplus.png" %}" alt="Google Plus" class="small_icon">
|
||||
<img src="{% static "aircox_cms/images/gplus.png" %}" alt="Google Plus" class="small_icon">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
|||
{% block content %}
|
||||
{% with list_page=settings.cms.WebsiteSettings.list_page %}
|
||||
<form action="{{ list_page.url }}" method="GET">
|
||||
<img src="{% static "cms/images/search.png" %}" class="icon"/>
|
||||
<img src="{% static "aircox_cms/images/search.png" %}" class="icon"/>
|
||||
<input type="text" name="search" placeholder="{{ self.default_text }}">
|
||||
<input type="submit" style="display: none;">
|
||||
</form>
|
|
@ -0,0 +1,6 @@
|
|||
{% extends "aircox_cms/sections/section_item.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "aircox_cms/snippets/date_list.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
{% load honeypot %}
|
||||
|
||||
{% if comment_form or page.comments %}
|
||||
<h2><img src="{% static "cms/images/comments.png" %}" class="icon">{% trans "Comments" %}</h2>
|
||||
<h2><img src="{% static "aircox_cms/images/comments.png" %}" class="icon">{% trans "Comments" %}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if comment_form %}
|
|
@ -30,7 +30,7 @@
|
|||
<h2>{{ day|date:'l d F' }}</h2>
|
||||
{% with item_date_format="H:i" %}
|
||||
{% for item in list %}
|
||||
{% include "cms/snippets/date_list_item.html" %}
|
||||
{% include "aircox_cms/snippets/date_list_item.html" %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</ul>
|
|
@ -11,7 +11,7 @@ Options:
|
|||
<ul class="list {{ list_css_class|default:'' }}">
|
||||
{% for page in object_list %}
|
||||
{% with item=page.specific %}
|
||||
{% include "cms/snippets/list_item.html" %}
|
||||
{% include "aircox_cms/snippets/list_item.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
|
@ -33,7 +33,7 @@ Options:
|
|||
{% with date_format=item_date_format|default:'l d F, H:i' %}
|
||||
<time datetime="{{ item.date }}">
|
||||
{% if item.diffusion %}
|
||||
<img src="{% static "cms/images/clock.png" %}" class="small_icon">
|
||||
<img src="{% static "aircox_cms/images/clock.png" %}" class="small_icon">
|
||||
{{ item.diffusion.start|date:date_format }}
|
||||
{% else %}
|
||||
{{ item.date|date:date_format }}
|
|
@ -11,11 +11,11 @@
|
|||
<div class="playlist">
|
||||
<li class='item list_item flex_row' style="display: none;">
|
||||
<div class="button">
|
||||
<img src="{% static "cms/images/play.png" %}" class="play"
|
||||
<img src="{% static "aircox_cms/images/play.png" %}" class="play"
|
||||
title="{% trans "play" %}" />
|
||||
<img src="{% static "cms/images/pause.png" %}" class="pause"
|
||||
<img src="{% static "aircox_cms/images/pause.png" %}" class="pause"
|
||||
title="{% trans "pause" %}" />
|
||||
<img src="{% static "cms/images/loading.png" %}" class="loading"
|
||||
<img src="{% static "aircox_cms/images/loading.png" %}" class="loading"
|
||||
title="{% trans "loading..." %}" />
|
||||
</div>
|
||||
|
|
@ -24,7 +24,7 @@ function add_sound_{{ item.id }}(event) {
|
|||
</script>
|
||||
|
||||
<a class="list_item sound flex_row" onclick="add_sound_{{ item.id }}(event)">
|
||||
<img src="{% static "cms/images/listen.png" %}" class="icon"/>
|
||||
<img src="{% static "aircox_cms/images/listen.png" %}" class="icon"/>
|
||||
<h3 class="flex_item">{{ item.name }}</h3>
|
||||
|
||||
<time class="info">
|
||||
|
@ -35,7 +35,7 @@ function add_sound_{{ item.id }}(event) {
|
|||
{% endif %}
|
||||
</time>
|
||||
|
||||
<img src="{% static "cms/images/add.png" %}" class="icon"
|
||||
<img src="{% static "aircox_cms/images/add.png" %}" class="icon"
|
||||
data-action='add' alt="{% trans "add this sound to the playlist" %}"/>
|
||||
</a>
|
||||
{% endif %}
|
3
aircox_cms/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -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(
|
||||
'<link rel="stylesheet" href="{}">',
|
||||
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
|
|
@ -1,6 +0,0 @@
|
|||
{% extends "cms/sections/section_item.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cms/snippets/date_list.html" %}
|
||||
{% endblock %}
|
||||
|
0
instance/__init__.py
Normal file
143
instance/base_settings.py
Normal file
|
@ -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'
|
||||
|
||||
|
29
instance/dev.py
Normal file
|
@ -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'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
28
instance/prod.py
Normal file
|
@ -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'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
58
instance/sample_settings.py
Normal file
|
@ -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'
|
||||
|
||||
|
47
instance/urls.py
Normal file
|
@ -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<path>.*)$', serve,
|
||||
{'document_root': settings.MEDIA_ROOT, 'show_indexes':True}
|
||||
)
|
||||
)
|
||||
|
||||
urlpatterns.append(url(r'', include(wagtail_urls)))
|
16
instance/wsgi.py
Normal file
|
@ -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()
|
||||
|
10
manage.py
Executable file
|
@ -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)
|
23
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?
|
||||
|
||||
|
||||
|
||||
|
|
8
scripts/cron_diffusions
Executable file
|
@ -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
|
||||
|
27
scripts/nginx_aircox
Normal file
|
@ -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/ ;
|
||||
}
|
||||
}
|
||||
|
44
scripts/supervisord_aircox
Executable file
|
@ -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
|
||||
|
||||
|