remove liquidsoap application

This commit is contained in:
bkfox 2016-07-12 23:17:12 +02:00
parent 765846547d
commit 2e985c9b1e
14 changed files with 1 additions and 1035 deletions

View File

@ -12,7 +12,7 @@ Platform to manage a radio, schedules, website, and so on. We use the power of D
## Applications
* **programs**: managing stations, programs, schedules and diffusions. This is the core application, that handle most of the work;
* **liquidsoap**: generate configuration and controls over liquidsoap. We use one instance of liquidsoap per station;
* **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**: cms manager with reusable tools (can be used in another website application);
* **website**: set of common models, sections, and other items ready to be used for a website;

View File

@ -1,14 +0,0 @@
# 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

@ -1,8 +0,0 @@
from django.contrib import admin
import aircox.liquidsoap.models as models
@admin.register(models.Output)
class OutputAdmin (admin.ModelAdmin):
list_display = ('id', 'type')

View File

@ -1,257 +0,0 @@
"""
Main tool to work with liquidsoap. We can:
- monitor Liquidsoap's sources and do logs, print what's on air.
- generate configuration files and playlists for a given station
"""
import os
import time
import re
import subprocess
import atexit
from argparse import RawTextHelpFormatter
from django.conf import settings as main_settings
from django.core.management.base import BaseCommand, CommandError
from django.template.loader import render_to_string
from django.utils import timezone as tz
import aircox.programs.models as programs
import aircox.programs.settings as programs_settings
from aircox.programs.utils import to_timedelta
import aircox.liquidsoap.settings as settings
import aircox.liquidsoap.utils as utils
class Monitor:
@classmethod
def run (cl, controller):
"""
Run once the monitor on the controller
"""
if not controller.connector.available and controller.connector.open():
return
cl.run_source(controller.master)
cl.run_dealer(controller)
cl.run_source(controller.dealer)
for stream in controller.streams.values():
cl.run_source(stream)
@staticmethod
def log (**kwargs):
"""
Create a log using **kwargs, and print info
"""
log = programs.Log(**kwargs)
log.save()
log.print()
@classmethod
def __get_prev_diff(cl, source, played_sounds = True):
diff_logs = programs.Log.get_for_related_model(programs.Diffusion) \
.filter(source = source.id) \
.order_by('-date')
if played_sounds:
sound_logs = programs.Log.get_for_related_model(programs.Sound) \
.filter(source = source.id) \
.order_by('-date')
if not diff_logs:
return
diff = diff_logs[0].related_object
playlist = diff.playlist
if played_sounds:
diff.played = [ sound.related_object.path
for sound in sound_logs[0:len(playlist)]
if sound.type = program.Logs.Type.switch ]
return diff
@classmethod
def run_dealer(cl, controller):
# - this function must recover last state in case of crash
# -> don't store data out of hdd
# - construct gradually the playlist and update it if needed
# -> we force liquidsoap to preload tracks of next diff
# - dealer.on while last logged diff is playing, otherwise off
# - when next diff is now and last diff no more active, play it
# -> log and dealer.on
dealer = controller.dealer
now = tz.make_aware(tz.datetime.now())
playlist = []
# - the last logged diff is the last one played, it can be playing
# -> no sound left or the diff is not more current: dealer.off
# -> otherwise, ensure dealer.on
# - played sounds are logged in run_source
prev_diff = cl.__get_prev_diff(dealer)
if prev_diff and prev_diff.is_date_in_my_range(now):
playlist = [ path for path in prev_diff.playlist
if path not in prev_diff.played ]
dealer.on = bool(playlist)
else:
playlist = []
dealer.on = False
# - preload next diffusion's tracks
args = {'start__gt': prev_diff.start } if prev_diff else {}
next_diff = programs.Diffusion.get(
now, now = True,
type = programs.Diffusion.Type.normal,
**args
)
if next_diff:
for diff in next_diffs:
if not diff.playlist:
continue
next_diff = diff
playlist += next_diff.playlist
break
# playlist update
if dealer.playlist != playlist:
dealer.playlist = playlist
if next_diff:
cl.log(
type = programs.Log.Type.load,
source = dealer.id,
date = now,
related_object = next_diff
)
# dealer.on when next_diff.start <= now
if next_diff and not dealer.on and next_diff.start <= now:
dealer.on = True
for source in controller.streams.values():
source.skip()
cl.log(
type = programs.Log.Type.play,
source = dealer.id,
date = now,
related_object = next_diff,
)
@classmethod
def run_source (cl, source):
"""
Keep trace of played sounds on the given source.
"""
# TODO: repetition of the same sound out of an interval of time
last_log = programs.Log.objects.filter(
source = source.id,
).prefetch_related('related_object').order_by('-date')
on_air = source.current_sound
if not on_air:
return
if last_log:
now = tz.datetime.now()
last_log = last_log[0]
last_obj = last_log.related_object
if type(last_obj) == programs.Sound and on_air == last_obj.path:
#if not last_obj.duration or \
# now < last_log.date + to_timedelta(last_obj.duration):
return
sound = programs.Sound.objects.filter(path = on_air)
kwargs = {
'type': programs.Log.Type.play,
'source': source.id,
'date': tz.make_aware(tz.datetime.now()),
}
if sound:
kwargs['related_object'] = sound[0]
else:
kwargs['comment'] = on_air
cl.log(**kwargs)
class Command (BaseCommand):
help= __doc__
output_dir = settings.AIRCOX_LIQUIDSOAP_MEDIA
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'-e', '--exec', action='store_true',
help='run liquidsoap on exit'
)
group.add_argument(
'-s', '--station', type=str,
default = 'aircox',
help='use this name as station name (default is "aircox")'
)
group = parser.add_argument_group('actions')
group.add_argument(
'-d', '--delay', type=int,
default=1000,
help='time to sleep in milliseconds between two updates when we '
'monitor'
)
group.add_argument(
'-m', '--monitor', action='store_true',
help='run in monitor mode'
)
group.add_argument(
'-o', '--on_air', action='store_true',
help='print what is on air'
)
group.add_argument(
'-r', '--run', action='store_true',
help='run liquidsoap with the generated configuration'
)
group.add_argument(
'-w', '--write', action='store_true',
help='write configuration and playlist'
)
def handle (self, *args, **options):
run = options.get('run')
monitor = options.get('on_air') or options.get('monitor')
self.controller = utils.Controller(
station = options.get('station'),
connector = monitor
)
# actions
if options.get('write') or run:
self.handle_write()
if run:
self.handle_run()
if monitor:
self.handle_monitor(options)
# post
if run:
for controller in self.controllers:
controller.process.wait()
def handle_write (self):
self.controller.write()
def handle_run (self):
self.controller.process = \
subprocess.Popen(
['liquidsoap', '-v', self.controller.config_path],
stderr=subprocess.STDOUT
)
atexit.register(self.controller.process.terminate)
def handle_monitor (self, options):
self.controller.update()
if options.get('on_air'):
print(self.controller.id, self.controller.on_air)
return
if options.get('monitor'):
delay = options.get('delay') / 1000
while True:
Monitor.run(self.controller)
time.sleep(delay)
return

View File

@ -1,26 +0,0 @@
from enum import Enum, IntEnum
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
class Output (models.Model):
# Note: we don't translate the names since it is project names.
class Type(IntEnum):
jack = 0x00
alsa = 0x01
icecast = 0x02
type = models.SmallIntegerField(
_('output type'),
choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ],
blank = True, null = True
)
settings = models.TextField(
_('output settings'),
help_text = _('list of comma separated params available; '
'this is put in the output config as raw code'),
blank = True, null = True
)

View File

@ -1,23 +0,0 @@
from django.conf import settings
def ensure (key, default):
globals()[key] = getattr(settings, key, default)
# 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 and socket. Each station has a subdir
# with the station's slug as name.
ensure('AIRCOX_LIQUIDSOAP_MEDIA', '/tmp')

View File

@ -1,134 +0,0 @@
{% 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/source.html' %}
{% endwith %}
{% with source=controller.dealer %}
{% include 'aircox/liquidsoap/source.html' %}
{% endwith %}
{% for source in controller.streams.values %}
{% include 'aircox/liquidsoap/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

@ -1,14 +0,0 @@
{% 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

@ -1,126 +0,0 @@
{% comment %}
Base configuration file to configure a station on liquidsoap.
A Station is composed of multiple streams, that includes:
- controller.dealer: used to play scheduled programs;
- controller.streams: streams configured to play random sounds;
# Interactive elements:
An interactive element is accessible to the people, in order to:
- get metadata
- skip the current sound
- enable/disable it, only for the dealer
- {controller.id}: the station itself
- {controller.id}_streams: all the streams of a controller
- {source.id}: for each stream of a controller + dealer
# 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 %}
{% comment %}
An interactive source is a source that:
- is skippable through the given id on external interfaces
- store metadata
{% endcomment %}
def interactive_source (id, s) =
s = store_metadata(id=id, size=1, s)
add_skip_command(s)
s
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",
# file)
s = playlist(id = '#{id}_playlist', mode = "random", file)
interactive_source(id, s)
end
{% block extra_funcs %}
{% endblock %}
\
{# config #}
set("server.socket", true)
set("server.socket.path", "{{ controller.socket_path }}")
{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %}
set("{{ key|safe }}", {{ value|safe }})
{% endfor %}
{% block extra_config %}
{% endblock %}
{# station #}
{{ controller.id }} = interactive_source (
"{{ controller.id }}",
fallback(track_sensitive = false, [
{# dealer #}
{% with source=controller.dealer %}
{% if source %}
at(interactive.bool('{{ source.id }}_on', false),
interactive_source('{{ source.id }}', playlist.once(
reload_mode='watch',
"{{ source.path }}",
))
),
{% endif %}
{% endwith %}
{# streams #}
interactive_source("{{ controller.id }}_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 %}
])),
{# fallback #}
{% if controller.station.fallback %}
single("{{ controller.station.fallback }}"),
{% else %}
blank(id="scheize_blank", duration=0.1),
{% endif %}
])
)
\
{% block outputs %}
{% for output in controller.outputs %}
output.{{ output.get_type_display }}(
{{ controller.id }}
{% if controller.settings %},
{{ controller.settings }}
{% endif %}
)
{% endfor %}
{% block extra_output %}
{% endblock %}
{% endfor %}

View File

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

View File

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

View File

@ -1,356 +0,0 @@
import os
import socket
import re
import json
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.text import slugify
from django.conf import settings as main_settings
from django.template.loader import render_to_string
import aircox.programs.models as programs
import aircox.programs.settings as programs_settings
import aircox.liquidsoap.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 = None
@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
class BaseSource:
id = None
name = None
controller = None
metadata = None
def __init__(self, controller, id, name):
self.id = id
self.name = name
self.controller = controller
def _send(self, *args, **kwargs):
return self.controller.connector.send(*args, **kwargs)
@property
def current_sound(self):
self.update()
return self.metadata.get('initial_uri') if self.metadata else {}
def skip(self):
"""
Skip a given source. If no source, use master.
"""
self._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 None:
r = self._send(self.id, '.get', parse=True)
return self.update(metadata = r or {})
source = metadata.get('source') or ''
if hasattr(self, 'program') and self.program \
and not source.startswith(self.id):
return -1
self.metadata = metadata
return
class Source(BaseSource):
__playlist = None # playlist file
program = None # related program (if given)
is_dealer = False # Source is a dealer
metadata = None
def __init__(self, controller, program = None, is_dealer = None):
if is_dealer:
id, name = '{}_dealer'.format(controller.id), \
'Dealer'
self.is_dealer = True
else:
id, name = '{}_stream_{}'.format(controller.id, program.id), \
program.name
super().__init__(controller, id, name)
self.program = program
self.path = os.path.join(settings.AIRCOX_LIQUIDSOAP_MEDIA,
controller.id,
self.id + '.m3u')
if program:
self.playlist_from_db()
@property
def on(self):
"""
Switch on-off;
"""
if not self.is_dealer:
raise RuntimeError('only dealers can do that')
r = self._send('var.get ', self.id, '_on')
return (r == 'true')
@on.setter
def on(self, value):
if not self.is_dealer:
raise RuntimeError('only dealers can do that')
return self._send('var.set ', self.id, '_on', '=',
'true' if value else 'false')
@property
def playlist(self):
return self.__playlist
@playlist.setter
def playlist(self, value):
self.__playlist = value
self.write()
def write(self):
"""
Write stream's data (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 playlist_from_db(self):
"""
Update content from the database using the source's program
"""
sounds = programs.Sound.objects.filter(
type = programs.Sound.Type['archive'],
path__startswith = os.path.join(
programs_settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
self.program.path
),
# good_quality = True
removed = False
)
self.playlist = [sound.path for sound in sounds]
def stream_info(self):
"""
Return a dict with info related to the program's stream.
"""
if not self.program:
return
stream = programs.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 0
}
class Master (BaseSource):
"""
A master Source based on a given station
"""
def update(self, metadata = None):
if metadata is not None:
return super().update(metadata)
r = self._send('request.on_air')
r = self._send('request.metadata ', r, parse = True)
return self.update(metadata = r or {})
class Controller:
"""
Main class controller for station and sources (streams and dealer)
"""
id = None
name = None
path = None
connector = None
master = None # master source
dealer = None # dealer source
streams = None # streams streams
# FIXME: used nowhere except in liquidsoap cli to get on air item but is not
# correct
@property
def on_air(self):
return self.master
@property
def socket_path(self):
"""
Connector's socket path
"""
return os.path.join(self.path, 'station.sock')
@property
def config_path(self):
"""
Connector's socket path
"""
return os.path.join(self.path, 'station.liq')
def __init__(self, station, connector = True, update = False):
"""
Params:
- station: managed station
- connector: if true, create a connector, else do not
Initialize a master, a dealer and all streams that are connected
to the given station; We ensure the existence of the controller's
files dir.
"""
self.id = slugify(station)
self.name = station
self.path = os.path.join(settings.AIRCOX_LIQUIDSOAP_MEDIA, self.id)
self.outputs = models.Output.objects.all()
self.connector = connector and Connector(self.socket_path)
self.master = Master(self)
self.dealer = Source(self, is_dealer = True)
self.streams = {
source.id : source
for source in [
Source(self, program)
for program in programs.Program.objects.filter(active = True)
if program.stream_set.count()
]
}
if update:
self.update()
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 update(self):
"""
Fetch and update all streams metadata.
"""
self.master.update()
self.dealer.update()
for source in self.streams.values():
source.update()
def write(self, playlist = True, config = True):
"""
Write stream's playlists, and config
"""
if playlist:
for source in self.streams.values():
source.write()
self.dealer.write()
if not config:
return
context = {
'controller': self,
'settings': settings,
}
# FIXME: remove this crappy thing
data = render_to_string('aircox/liquidsoap/station.liq', context)
data = re.sub(r'\s*\\\n', r'#\\n#', data)
data = data.replace('\n', '')
data = re.sub(r'#\\n#', '\n', data)
with open(self.config_path, 'w+') as file:
file.write(data)

View File

@ -1,64 +0,0 @@
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 = None
def get_monitor():
global view_monitor
if not view_monitor:
view_monitor = utils.Monitor()
return view_monitor
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):
get_monitor().update()
return {
'request': self.request,
'monitor': get_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(get_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)