start to restructure the project tree

This commit is contained in:
bkfox
2015-12-01 16:16:45 +01:00
parent cc5c53281e
commit 380efcbbcd
64 changed files with 68 additions and 819 deletions

View File

@ -0,0 +1,14 @@
# Aircox LiquidSoap
This application makes the bridge between Aircox and LiquidSoap. It can monitor scheduled and streamed programs and offer some controls on LiquidSoap.
## manage.py's commands
** liquidsoap **: monitor LiquidSoap, logs what is playing on the different sources, and plays scheduled diffusions;
** liquidsoap_files**: generates playlists and LiquidSoap config based on Programs' parameters;
## Requirements
* Liquidsoap
* requirements.txt for python's dependencies

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,55 @@
"""
Monitor Liquidsoap's sources, logs, and even print what's on air.
"""
import time
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
import aircox.liquidsoap.settings as settings
import aircox.liquidsoap.utils as utils
import aircox.programs.models as models
class Command (BaseCommand):
help= __doc__
output_dir = settings.AIRCOX_LIQUIDSOAP_MEDIA
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'-o', '--on_air', action='store_true',
help='Print what is on air'
)
parser.add_argument(
'-m', '--monitor', action='store_true',
help='Runs in monitor mode'
)
parser.add_argument(
'-d', '--delay', type=int,
default=1000,
help='Time to sleep in milliseconds before update on monitor'
)
def handle (self, *args, **options):
connector = utils.Connector()
self.monitor = utils.Monitor(connector)
self.monitor.update()
if options.get('on_air'):
for id, controller in self.monitor.controller.items():
print(id, controller.on_air)
if options.get('monitor'):
delay = options.get('delay') / 1000
while True:
for controller in self.monitor.controllers.values():
try:
controller.monitor()
except Exception, e:
print(e)
time.sleep(delay)

View File

@ -0,0 +1,119 @@
"""
Generate configuration files and playlists for liquidsoap using settings, streams and
so on
"""
import os
import re
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from django.views.generic.base import View
from django.template.loader import render_to_string
import aircox.liquidsoap.settings as settings
import aircox.liquidsoap.utils as utils
import aircox.programs.settings as programs_settings
import aircox.programs.models as models
class Command (BaseCommand):
help= __doc__
output_dir = settings.AIRCOX_LIQUIDSOAP_MEDIA
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'output', metavar='PATH', type=str, nargs='?',
help='force output to file (- to stdout) for single actions; to a '
'given dir when using --all')
parser.add_argument(
'-c', '--config', action='store_true',
help='Generate liquidsoap config file'
)
parser.add_argument(
'-d', '--diffusion', action='store_true',
help='Generate the playlist for the current scheduled diffusion'
)
parser.add_argument(
'-s', '--stream', type=int,
help='Generate the playlist of a stream with the given id'
)
parser.add_argument(
'-S', '--streams', action='store_true',
help='Generate all stream playlists'
)
parser.add_argument(
'-a', '--all', action='store_true',
help='Generate all playlists (stream and scheduled diffusion) '
'and config file'
)
def handle (self, *args, **options):
output = options.get('output') or None
if options.get('config'):
data = self.get_config(output = output)
return
if options.get('stream'):
stream = options['stream']
if type(stream) is int:
stream = models.Stream.objects.get(id = stream,
program__active = True)
data = self.get_playlist(stream, output = output)
return
if options.get('all') or options.get('streams'):
if output:
if not os.path.isdir(output):
raise CommandError('given output is not a directory')
self.output_dir = output
if options.get('all'):
self.handle(config = True)
for stream in models.Stream.objects.filter(program__active = True):
self.handle(stream = stream)
self.output_dir = settings.AIRCOX_LIQUIDSOAP_MEDIA
return
raise CommandError('nothing to do')
def print (self, data, path, default):
if path and path == '-':
print(data)
return
if not path:
path = os.path.join(self.output_dir, default)
with open(path, 'w+') as file:
file.write(data)
def get_config (self, output = None):
context = {
'monitor': utils.Monitor(),
'settings': settings,
}
data = render_to_string('aircox_liquidsoap/config.liq', context)
data = re.sub(r'\s*\\\n', r'#\\n#', data)
data = data.replace('\n', '')
data = re.sub(r'#\\n#', '\n', data)
self.print(data, output, 'aircox.liq')
def get_playlist (self, stream = None, output = None):
program = stream.program
source = utils.Source(program = program)
sounds = models.Sound.objects.filter(
# good_quality = True,
type = models.Sound.Type['archive'],
path__startswith = os.path.join(
programs_settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
program.path
)
)
data = '\n'.join(sound.path for sound in sounds)
self.print(data, output, utils.Source(program = program).path)

View File

@ -0,0 +1,3 @@
from django.db import models

View File

@ -0,0 +1,24 @@
from django.conf import settings
def ensure (key, default):
globals()[key] = getattr(settings, key, default)
ensure('AIRCOX_LIQUIDSOAP_SOCKET', '/tmp/liquidsoap.sock')
# dict of values to set (do not forget to escape chars)
ensure('AIRCOX_LIQUIDSOAP_SET', {
'log.file.path': '"/tmp/liquidsoap.log"',
})
# security source: used when no source are available
ensure('AIRCOX_LIQUIDSOAP_SECURITY_SOURCE', '/media/data/musique/creation/Mega Combi/MegaCombi241-PT134-24062015_Comme_des_lyca_ens.mp3')
# start the server on monitor if not present
ensure('AIRCOX_LIQUIDSOAP_AUTOSTART', True)
# output directory for the generated files
ensure('AIRCOX_LIQUIDSOAP_MEDIA', '/tmp')

View File

@ -0,0 +1,14 @@
{% with metadata=source.metadata %}
<div class="source {% if metadata.initial_uri == controller.master.metadata.initial_uri %}on_air{% endif %}">
<h2>{{ source.name }}</h2>
<time>{{ metadata.on_air }}</time>
<span class="path">{{ metadata.initial_uri }}</span>
<span class="status" status="{{ metadata.status }}">{{ metadata.status }}</span>
<button onclick="liquid_action('{{controller.id}}','{{source.id}}','skip');">
skip
</button>
</div>
{% endwith %}

View File

@ -0,0 +1,26 @@
{# Utilities #}
def interactive_source (id, s, ) = \
s = store_metadata(id=id, size=1, s) \
add_skip_command(s) \
s \
end \
\
def stream (id, file) = \
s = playlist(id = '#{id}_playlist', mode = "random", file) \
interactive_source(id, s) \
end \
\
{# Config #}
set("server.socket", true) \
set("server.socket.path", "{{ settings.AIRCOX_LIQUIDSOAP_SOCKET }}") \
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
set("{{ key|safe }}", {{ value|safe }}) \
{% endfor %}
\
\
{% for controller in monitor.controllers.values %}
{% include 'aircox_liquidsoap/station.liq' %} \
{{ controller.id }} = make_station_{{ controller.id }}() \
output.alsa({{ controller.id }}) \
{% endfor %}

View File

@ -0,0 +1,134 @@
{% if not embed %}
<html>
<head>
<style>
.station {
margin: 2em;
border: 1px grey solid;
}
.sources {
padding: 0.5em;
box-shadow: inset 0.1em 0.1em 0.5em rgba(0, 0, 0, 0.5);
}
.station h1 {
font-size: 1.2em;
margin: 0.2em;
}
.source {
border-left: 0.5em solid grey;
font-size: 0.9em;
}
.on_air {
display: block;
border-left: 0.5em solid #f00;
}
.source h2 {
display: inline-block;
min-width: 10em;
font-size: 1em;
margin: 0.2em;
}
.source time {
display: inline-block;
margin-right: 2em;
}
.source span {
font-size: 1em;
}
.error {
padding: 0.2em;
color: red;
font-weight: bold;
}
</style>
<script>
function get_token() {
return document.cookie.replace(/.*csrftoken=([^;]+)(;.*|$)/, '$1');
}
function liquid_action (controller, source, action) {
params = 'controller=' + controller + '&&source=' + source +
'&&action=' + action;
req = new XMLHttpRequest()
req.open('POST', '{% url 'liquid-controller' %}', false);
req.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
req.setRequestHeader("Content-length", params.length);
req.setRequestHeader("Connection", "close");
req.setRequestHeader("X-CSRFToken", get_token());
req.send(params);
liquid_update()
}
function liquid_update (update) {
req = new XMLHttpRequest()
req.open('GET', '{% url 'liquid-controller' %}?embed', true);
req.onreadystatechange = function() {
if(req.readyState != 4 || (req.status != 200 && req.status != 0))
return;
document.getElementById('liquid-stations').innerHTML =
req.responseText;
if(update)
window.setTimeout(function() { liquid_update(update);}, 5000);
};
req.send();
}
liquid_update(true);
</script>
</head>
<body>
<main id="liquid-stations">
{% endif %}
{% for c_id, controller in monitor.controllers.items %}
{% with on_air=controller.on_air %}
<div id="{{ c_id }}" class="station">
<header>
{% if not controller.connector.available %}
<span class="error" style="float:right;">disconnected</span>
{% endif %}
<h1>
{{ controller.station.name }}
</h1>
</header>
<div class="sources">
{% with source=controller.master %}
{% include 'aircox_liquidsoap/base_source.html' %}
{% endwith %}
{% with source=controller.dealer %}
{% include 'aircox_liquidsoap/base_source.html' %}
{% endwith %}
{% for source in controller.streams.values %}
{% include 'aircox_liquidsoap/base_source.html' %}
{% endfor %}
</div>
<div class="next">
{% for diffusion in controller.next_diffusions %}
{{ diffusion }}
{% endfor %}
</div>
</div>
{% endwith %}
{% endfor %}
{% if not embed %}
</main>
</body>
</html>
{% endif %}

View File

@ -0,0 +1,54 @@
{% comment %}
A station has multiple sources:
- dealer: a controlled playlist playing once each track, that reloads on file
change. This is used for scheduled sounds.
- streams: a rotate source with all playlists
- single: security song
{% endcomment %}
def make_station_{{ controller.id }} () = \
{# dealer #}
{% with source=controller.dealer %}
{% if source %}
dealer = interactive_source('{{ source.id }}', playlist.once( \
reload_mode='watch', \
"{{ source.path }}", \
)) \
\
dealer_on = interactive.bool("{{ source.id }}_on", false) \
{% endif %}
{% endwith %}
\
{# streams #}
streams = interactive_source("streams", rotate([ \
{% for source in controller.streams.values %}
{% with info=source.stream_info %}
{% if info.delay %}
delay({{ info.delay }}., stream("{{ source.id }}", "{{ source.path }}")), \
{% elif info.begin and info.end %}
at({ {{info.begin}}-{{info.end}} }, stream("{{ source.id }}", "{{ source.path }}")), \
{% endif %}
{% endwith %}
{% endfor %}
{% for source in controller.streams.values %}
{% if not source.stream_info %}
stream("{{ source.id }}", "{{ source.path }}"), \
{% endif %}
{% endfor %}
])) \
\
{# station #}
interactive_source ( \
"{{ controller.id }}", \
fallback(track_sensitive = false, [ \
at(dealer_on, dealer), \
streams, \
{% if controller.station.fallback %}
single("{{ controller.station.fallback }}"), \
{% else %}
blank(), \
{% endif %}
]) \
) \
end \

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,9 @@
from django.conf.urls import url
import aircox.liquidsoap.views as views
urlpatterns = [
url('^controller/', views.LiquidControl.as_view(), name = 'liquid-controller'),
]

441
aircox/liquidsoap/utils.py Normal file
View File

@ -0,0 +1,441 @@
import os
import socket
import re
import json
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils import timezone as tz
from aircox.programs.utils import to_timedelta
import aircox.programs.models as models
import aircox.liquidsoap.settings as settings
class Connector:
"""
Telnet connector utility.
address: a string to the unix domain socket file, or a tuple
(host, port) for TCP/IP connection
"""
__socket = None
__available = False
address = settings.AIRCOX_LIQUIDSOAP_SOCKET
@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:
# print('can not connect to liquidsoap socket {}'.format(self.address))
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('unicode_escape')
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
class Source:
"""
A structure that holds informations about a LiquidSoap source.
"""
controller = None
program = None
metadata = None
def __init__ (self, controller = None, program = None):
self.controller = controller
self.program = program
@property
def station (self):
"""
Proxy to self.(program|controller).station
"""
return self.program.station if self.program else \
self.controller.station
@property
def connector (self):
"""
Proxy to self.controller.connector
"""
return self.controller.connector
@property
def id (self):
"""
Identifier for the source, scoped in the station's one
"""
postfix = ('_stream_' + str(self.program.id)) if self.program else ''
return self.station.slug + postfix
@property
def name (self):
"""
Name of the related object (program or station)
"""
if self.program:
return self.program.name
return self.station.name
@property
def path (self):
"""
Path to the playlist
"""
return os.path.join(
settings.AIRCOX_LIQUIDSOAP_MEDIA,
self.id + '.m3u'
)
@property
def playlist (self):
"""
Get or set the playlist as an array, and update it into
the corresponding file.
"""
try:
with open(self.path, 'r') as file:
return file.readlines()
except:
return []
@playlist.setter
def playlist (self, sounds):
with open(self.path, 'w') as file:
file.write('\n'.join(sounds))
self.connector.send(self.name, '_playlist.reload')
@property
def current_sound (self):
self.update()
return self.metadata.get('initial_uri') if self.metadata else {}
def stream_info (self):
"""
Return a dict with info related to the program's stream
"""
if not self.program:
return
stream = models.Stream.objects.get(program = self.program)
if 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 None
}
def skip (self):
"""
Skip a given source. If no source, use master.
"""
self.connector.send(self.id, '.skip')
def update (self, metadata = None):
"""
Update metadata with the given metadata dict or request them to
liquidsoap if nothing is given.
Return -1 in case no update happened
"""
if metadata is not None:
source = metadata.get('source') or ''
if self.program and not source.startswith(self.id):
return -1
self.metadata = metadata
return
# r = self.connector.send('var.get ', self.id + '_meta', parse_json=True)
r = self.connector.send(self.id, '.get', parse=True)
return self.update(metadata = r or {})
class Master (Source):
"""
A master Source
"""
def update (self, metadata = None):
if metadata is not None:
return super().update(metadata)
r = self.connector.send('request.on_air')
r = self.connector.send('request.metadata ', r, parse = True)
return self.update(metadata = r or {})
class Dealer (Source):
"""
The Dealer source is a source that is used for scheduled diffusions and
manual sound diffusion.
"""
name = _('Dealer')
@property
def id (self):
return self.station.slug + '_dealer'
def stream_info (self):
pass
@property
def on (self):
r = self.connector.send('var.get ', self.id, '_on')
return (r == 'true')
@on.setter
def on (self, value):
return self.connector.send('var.set ', self.id, '_on',
'=', 'true' if value else 'false')
@property
def playlist (self):
try:
with open(self.path, 'r') as file:
return file.readlines()
except:
return []
@playlist.setter
def playlist (self, sounds):
with open(self.path, 'w') as file:
file.write('\n'.join(sounds))
def __get_next (self, date, on_air):
"""
Return which diffusion should be played now and not playing
"""
r = [ models.Diffusion.get_prev(self.station, date),
models.Diffusion.get_next(self.station, date) ]
r = [ diffusion.prefetch_related('sounds')[0]
for diffusion in r if diffusion.count() ]
for diffusion in r:
duration = to_timedelta(diffusion.archives_duration())
end_at = diffusion.date + duration
if end_at < date:
continue
diffusion.playlist = [ sound.path
for sound in diffusion.get_archives() ]
if diffusion.playlist and on_air not in diffusion.playlist:
return diffusion
def monitor (self):
"""
Monitor playlist (if it is time to load) and if it time to trigger
the button to start a diffusion.
"""
playlist = self.playlist
on_air = self.current_sound
now = tz.make_aware(tz.datetime.now())
diff = self.__get_next(now, on_air)
if not diff:
return # there is nothing we can do
# playlist reload
if self.playlist != diff.playlist:
if not playlist or on_air == playlist[-1] or \
on_air not in playlist:
self.on = False
self.playlist = diff.playlist
# run the diff
if self.playlist == diff.playlist and diff.date <= now:
self.on = True
for source in self.controller.streams.values():
source.skip()
self.controller.log(
source = self.id,
date = now,
comment = 'trigger the scheduled diffusion to liquidsoap; '
'skip all other streams',
related_object = diff,
)
class Controller:
connector = None
station = None # the related station
master = None # master source (station's source)
dealer = None # dealer source
streams = None # streams streams
@property
def on_air (self):
return self.master
@property
def id (self):
return self.master and self.master.id
@property
def name (self):
return self.master and self.master.name
def __init__ (self, station, connector = None):
"""
use_connector: avoids the creation of a Connector, in case it is not needed
"""
self.connector = connector
self.station = station
self.station.controller = self
self.master = Master(self)
self.dealer = Dealer(self)
self.streams = {
source.id : source
for source in [
Source(self, program)
for program in models.Program.objects.filter(station = station,
active = True)
if program.stream_set.count()
]
}
def get (self, source_id):
"""
Get a source by its id
"""
if source_id == self.master.id:
return self.master
if source_id == self.dealer.id:
return self.dealer
return self.streams.get(source_id)
def log (self, **kwargs):
"""
Create a log using **kwargs, and print info
"""
log = models.Log(**kwargs)
log.save()
log.print()
def update_all (self):
"""
Fetch and update all streams metadata.
"""
self.master.update()
self.dealer.update()
for source in self.streams.values():
source.update()
def __change_log (self, source):
last_log = models.Log.objects.filter(
source = source.id,
).prefetch_related('sound').order_by('-date')
on_air = source.current_sound
if not on_air:
return
if last_log:
last_log = last_log[0]
if last_log.sound and on_air == last_log.sound.path:
return
self.log(
source = source.id,
date = tz.make_aware(tz.datetime.now()),
comment = 'sound has changed',
related_object = models.Sound.objects.get(path = on_air),
)
def monitor (self):
"""
Log changes in the streams, and call dealer.monitor.
"""
if not self.connector.available and self.connector.open():
return
self.dealer.monitor()
self.__change_log(self.dealer)
for source in self.streams.values():
self.__change_log(source)
class Monitor:
"""
Monitor multiple controllers.
"""
controllers = None
def __init__ (self, connector = None):
self.controllers = {
controller.id : controller
for controller in [
Controller(station, connector)
for station in models.Station.objects.filter(active = True)
]
}
def update (self):
for controller in self.controllers.values():
controller.update_all()

View File

@ -0,0 +1,61 @@
import re
from django.views.generic.base import View, TemplateResponseMixin
from django.template.loader import render_to_string
from django.shortcuts import render
from django.http import HttpResponse
import aircox.liquidsoap.settings as settings
import aircox.liquidsoap.utils as utils
import aircox.programs.models as models
view_monitor = utils.Monitor(
utils.Connector(address = settings.AIRCOX_LIQUIDSOAP_SOCKET)
)
class Actions:
@classmethod
def exec (cl, monitor, controller, source, action):
controller = monitor.controllers.get(controller)
source = controller and controller.get(source)
if not controller or not source or \
action.startswith('__') or \
action not in cl.__dict__:
return -1
action = getattr(Actions, action)
return action(monitor, controller, source)
@classmethod
def skip (cl, monitor, controller, source):
source.skip()
class LiquidControl (View):
template_name = 'aircox_liquidsoap/controller.html'
def get_context_data (self, **kwargs):
view_monitor.update()
return {
'request': self.request,
'monitor': view_monitor,
'embed': 'embed' in self.request.GET,
}
def post (self, request = None, **kwargs):
if 'action' in request.POST:
POST = request.POST
controller = POST.get('controller')
source = POST.get('source')
action = POST.get('action')
Actions.exec(view_monitor, controller, source, action)
return HttpResponse('')
def get (self, request = None, **kwargs):
self.request = request
context = self.get_context_data(**kwargs)
return render(request, self.template_name, context)