move files

This commit is contained in:
bkfox
2015-12-22 08:37:17 +01:00
parent 0511ec5bc3
commit 6bb13904da
55 changed files with 0 additions and 0 deletions

14
liquidsoap/README.md Normal file
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

0
liquidsoap/__init__.py Normal file
View File

8
liquidsoap/admin.py Normal file
View File

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

View File

@ -0,0 +1,150 @@
"""
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
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 models
import aircox.programs.settings as programs_settings
import aircox.liquidsoap.settings as settings
import aircox.liquidsoap.utils as utils
class StationConfig:
"""
Configuration and playlist generator for a station.
"""
controller = None
def __init__ (self, station):
self.controller = utils.Controller(station, False)
def handle (self, options):
os.makedirs(self.controller.path, exist_ok = True)
if options.get('config') or options.get('all'):
self.make_config()
if options.get('streams') or options.get('all'):
self.make_playlists()
def make_config (self):
log_script = main_settings.BASE_DIR \
if hasattr(main_settings, 'BASE_DIR') else \
main_settings.PROJECT_ROOT
log_script = os.path.join(log_script, 'manage.py') + \
' liquidsoap_log'
context = {
'controller': self.controller,
'settings': settings,
'log_script': log_script,
}
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.controller.config_path, 'w+') as file:
file.write(data)
def make_playlists (self):
for stream in self.controller.streams.values():
program = stream.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
)
)
with open(stream.path, 'w+') as file:
file.write('\n'.join(sound.path for sound in sounds))
class Command (BaseCommand):
help= __doc__
output_dir = settings.AIRCOX_LIQUIDSOAP_MEDIA
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
group = parser.add_argument_group('monitor')
group.add_argument(
'-o', '--on_air', action='store_true',
help='print what is on air'
)
group.add_argument(
'-m', '--monitor', action='store_true',
help='run in monitor mode'
)
group.add_argument(
'-d', '--delay', type=int,
default=1000,
help='time to sleep in milliseconds before update on monitor'
)
group = parser.add_argument_group('configuration')
parser.add_argument(
'-s', '--station', type=int,
help='generate files for the given station (if not set, do it for'
' all available stations)'
)
parser.add_argument(
'-c', '--config', action='store_true',
help='generate liquidsoap config file'
)
parser.add_argument(
'-S', '--streams', action='store_true',
help='generate all stream playlists'
)
parser.add_argument(
'-a', '--all', action='store_true',
help='shortcut for -cS'
)
def handle (self, *args, **options):
if options.get('station'):
station = models.Station.objects.get(id = options.get('station'))
StationConfig(station).handle(options)
elif options.get('all') or options.get('config') or \
options.get('streams'):
for station in models.Station.objects.filter(active = True):
StationConfig(station).handle(options)
if options.get('on_air') or options.get('monitor'):
self.handle_monitor(options)
def handle_monitor (self, options):
self.monitor = utils.Monitor()
self.monitor.update()
if options.get('on_air'):
for id, controller in self.monitor.controller.items():
print(id, controller.on_air)
return
if options.get('monitor'):
delay = options.get('delay') / 1000
while True:
for controller in self.monitor.controllers.values():
try:
controller.monitor()
except Exception as err:
print(err)
time.sleep(delay)
return

View File

@ -0,0 +1,62 @@
"""
This script is used by liquidsoap in order to log a file change. It should not
be used for other purposes.
"""
import os
from argparse import RawTextHelpFormatter
from django.utils import timezone as tz
from django.core.management.base import BaseCommand, CommandError
import aircox.programs.models as programs
class Command (BaseCommand):
help= __doc__
@staticmethod
def date(s):
try:
return tz.make_aware(tz.datetime.strptime(s, '%Y/%m/%d %H:%M:%S'))
except ValueError:
raise argparse.ArgumentTypeError('Invalid date format')
def add_arguments (self, parser):
parser.formatter_class=RawTextHelpFormatter
parser.add_argument(
'-c', '--comment', type=str,
help='log comment'
)
parser.add_argument(
'-s', '--source', type=str,
required=True,
help='source path'
)
parser.add_argument(
'-p', '--path', type=str,
required=True,
help='sound path to log'
)
parser.add_argument(
'-d', '--date', type=Command.date,
help='set date instead of now (using format "%Y/%m/%d %H:%M:%S")'
)
def handle (self, *args, **options):
comment = options.get('comment') or ''
path = os.path.realpath(options.get('path'))
sound = programs.Sound.objects.filter(path = path)
if sound:
sound = sound[0]
else:
sound = None
comment += '\nunregistered sound: {}'.format(path)
programs.Log(source = options.get('source'),
comment = comment,
related_object = sound).save()

31
liquidsoap/models.py Normal file
View File

@ -0,0 +1,31 @@
from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.programs.models as programs
class Output (models.Model):
# Note: we don't translate the names since it is project names.
Type = {
'jack': 0x00,
'alsa': 0x01,
'icecast': 0x02,
}
station = models.ForeignKey(
programs.Station,
verbose_name = _('station'),
)
type = models.SmallIntegerField(
_('output type'),
choices = [ (y, x) for x,y in Type.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
)

23
liquidsoap/settings.py Normal file
View File

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

@ -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/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

@ -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,81 @@
{# Context: #}
{# - controller: controller used to generate the current file #}
{# - settings: global settings #}
def interactive_source (id, s) = \
def handler(m) = \
file = string.escape(m['filename']) \
system('{{ log_script }} -s "#{id}" -p "#{file}" -c "liquidsoap: play" &') \
end \
\
s = on_track(id=id, handler, 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", \
reload_mode='watch', file) \
interactive_source(id, s) \
end \
\
{# 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 %}
\
{# 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(), \
{% endif %}
]) \
) \
\
{% for output in controller.outputs %}
output.{{ output.get_type_display }}( \
{{ controller.id }}
{% if controller.settings %}, \
{{ controller.settings }}
{% endif %}
) \
{% endfor %}

3
liquidsoap/tests.py Normal file
View File

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

9
liquidsoap/urls.py Normal file
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'),
]

411
liquidsoap/utils.py Normal file
View File

@ -0,0 +1,411 @@
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 programs
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:
# 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.station.slug,
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))
@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 = 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 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.
Since we need to cache buffers for the scheduled track, we use an on-off
switch in order to have no latency and enable preload.
"""
name = _('Dealer')
@property
def id (self):
return self.station.slug + '_dealer'
def stream_info (self):
pass
@property
def on (self):
"""
Switch on-off;
"""
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')
def __get_next (self, date, on_air):
"""
Return which diffusion should be played now and is not playing
"""
r = [ programs.Diffusion.get_prev(self.station, date),
programs.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
class Controller:
"""
Main class controller for station and sources (streams and dealer)
"""
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
@property
def path (self):
"""
Directory path where all station's related files are put.
"""
return os.path.join(settings.AIRCOX_LIQUIDSOAP_MEDIA,
self.station.slug)
@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):
"""
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.station = station
self.station.controller = self
self.outputs = models.Output.objects.filter(station = station)
self.connector = connector and Connector(self.socket_path)
self.master = Master(self)
self.dealer = Dealer(self)
self.streams = {
source.id : source
for source in [
Source(self, program)
for program in programs.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 = programs.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 monitor (self):
"""
Log changes in the streams, and call dealer.monitor.
"""
if not self.connector.available and self.connector.open():
return
self.dealer.monitor()
class Monitor:
"""
Monitor multiple controllers.
"""
controllers = None
def __init__ (self):
self.controllers = {
controller.id : controller
for controller in [
Controller(station, True)
for station in programs.Station.objects.filter(active = True)
]
}
def update (self):
for controller in self.controllers.values():
controller.update_all()

65
liquidsoap/views.py Normal file
View File

@ -0,0 +1,65 @@
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)