streamer as separate application; working streamer monitor interface

This commit is contained in:
bkfox
2019-09-21 17:14:40 +02:00
parent 4e61ec1520
commit d3f39c5ade
39 changed files with 1347 additions and 148 deletions

View File

@ -3,3 +3,5 @@ from django.apps import AppConfig
class AircoxStreamerConfig(AppConfig):
name = 'aircox_streamer'

View File

@ -43,6 +43,8 @@ class BaseMetadata:
""" Request uri """
status = None
""" Current playing status """
request_status = None
""" Requests' status """
air_time = None
""" Launch datetime """
@ -58,10 +60,25 @@ class BaseMetadata:
return self.status == 'playing'
def fetch(self):
data = self.controller.set('request.metadata ', self.rid, parse=True)
data = self.controller.send('request.metadata ', self.rid, parse=True)
if data:
self.validate(data)
def validate_status(self, status):
on_air = self.controller.source
if on_air and status == 'playing' and (on_air == self or
on_air.rid == self.rid):
return 'playing'
elif status == 'playing':
return 'paused'
else:
return 'stopped'
def validate_air_time(self, air_time):
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
return local_tz.localize(air_time)
def validate(self, data):
"""
Validate provided data and set as attribute (must already be
@ -72,12 +89,9 @@ class BaseMetadata:
setattr(self, key, value)
self.uri = data.get('initial_uri')
air_time = data.get('on_air')
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
self.air_time = local_tz.localize(air_time)
else:
self.air_time = None
self.air_time = self.validate_air_time(data.get('on_air'))
self.status = self.validate_status(data.get('status'))
self.request_status = data.get('status')
class Request(BaseMetadata):
@ -142,6 +156,14 @@ class Streamer:
logger.debug('process died with return code %s' % returncode)
return False
@property
def playlists(self):
return (s for s in self.sources if isinstance(s, PlaylistSource))
@property
def queues(self):
return (s for s in self.sources if isinstance(s, QueueSource))
# Sources and config ###############################################
def send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) or ''
@ -180,12 +202,11 @@ class Streamer:
source.fetch()
# request.on_air is not ordered: we need to do it manually
if self.dealer.is_playing:
self.source = self.dealer
return
self.source = next((source for source in self.sources
if source.is_playing), None)
self.source = next(iter(sorted(
(source for source in self.sources
if source.request_status == 'playing' and source.air_time),
key=lambda o: o.air_time, reverse=True
)), None)
# Process ##########################################################
def get_process_args(self):
@ -241,15 +262,12 @@ class Source(BaseMetadata):
""" source id """
remaining = 0.0
""" remaining time """
status = 'stopped'
@property
def station(self):
return self.controller.station
# @property
# def is_on_air(self):
# return self.rid is not None and self.rid in self.controller.on_air
def __init__(self, controller=None, id=None, *args, **kwargs):
super().__init__(controller, *args, **kwargs)
self.id = id
@ -258,9 +276,12 @@ class Source(BaseMetadata):
""" Synchronize what should be synchronized """
def fetch(self):
data = self.controller.send(self.id, '.remaining')
if data:
self.remaining = float(data)
try:
data = self.controller.send(self.id, '.remaining')
if data:
self.remaining = float(data)
except ValueError:
self.remaining = None
data = self.controller.send(self.id, '.get', parse=True)
if data:
@ -332,12 +353,9 @@ class PlaylistSource(Source):
class QueueSource(Source):
queue = None
""" Source's queue (excluded on_air request) """
as_requests = False
""" If True, queue is a list of Request """
def __init__(self, *args, queue_metadata=False, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.queue_metadata = queue_metadata
def push(self, *paths):
""" Add the provided paths to source's play queue """
@ -346,13 +364,19 @@ class QueueSource(Source):
def fetch(self):
super().fetch()
queue = self.controller.send(self.id, '_queue.queue').split(' ')
if not self.as_requests:
self.queue = queue
queue = self.controller.send(self.id, '_queue.queue').strip()
if not queue:
self.queue = []
return
self.queue = [Request(self.controller, rid) for rid in queue]
for request in self.queue:
self.queue = queue.split(' ')
@property
def requests(self):
""" Queue as requests metadata """
requests = [Request(self.controller, rid) for rid in self.queue]
for request in requests:
request.fetch()
return requests

View File

@ -24,7 +24,7 @@ from django.utils import timezone as tz
from aircox.models import Station, Episode, Diffusion, Track, Sound, Log
from aircox.utils import date_range
from aircox_streamer.liquidsoap import Streamer, PlaylistSource
from aircox_streamer.controllers import Streamer
# force using UTC
@ -246,9 +246,8 @@ class Monitor:
self.sync_next = now + tz.timedelta(minutes=self.sync_timeout)
for source in self.streamer.sources:
if isinstance(source, PlaylistSource):
source.sync()
for source in self.streamer.playlists:
source.sync()
class Command (BaseCommand):
@ -291,10 +290,8 @@ class Command (BaseCommand):
)
# TODO: sync-timeout, cancel-timeout
def handle(self, *args,
config=None, run=None, monitor=None,
station=[], delay=1000, timeout=600,
**options):
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()
streamers = [Streamer(station) for station in stations]

View File

@ -1,24 +1,35 @@
from django.urls import reverse
from rest_framework import serializers
from .controllers import QueueSource, PlaylistSource
__all__ = ['RequestSerializer', 'StreamerSerializer', 'SourceSerializer',
'PlaylistSerializer', 'QueueSourceSerializer']
# TODO: use models' serializers
class BaseMetadataSerializer(serializers.Serializer):
class BaseSerializer(serializers.Serializer):
url_ = serializers.SerializerMethodField('get_url')
url_name = None
def get_url(self, obj, **kwargs):
if not obj or not self.url_name:
return
kwargs.setdefault('pk', getattr(obj, 'id', None))
return reverse(self.url_name, kwargs=kwargs)
class BaseMetadataSerializer(BaseSerializer):
rid = serializers.IntegerField()
air_time = serializers.DateTimeField()
uri = serializers.CharField()
class RequestSerializer(serializers.Serializer):
title = serializers.CharField()
artist = serializers.CharField()
class StreamerSerializer(serializers.Serializer):
station = serializers.CharField(source='station.title')
class RequestSerializer(BaseMetadataSerializer):
title = serializers.CharField(required=False)
artist = serializers.CharField(required=False)
class SourceSerializer(BaseMetadataSerializer):
@ -27,14 +38,34 @@ class SourceSerializer(BaseMetadataSerializer):
rid = serializers.IntegerField()
air_time = serializers.DateTimeField()
status = serializers.CharField()
remaining = serializers.FloatField()
def get_url(self, obj, **kwargs):
kwargs['station_pk'] = obj.station.pk
return super().get_url(obj, **kwargs)
class PlaylistSerializer(SourceSerializer):
program = serializers.CharField(source='program.title')
playlist = serializers.ListField(child=serializers.CharField())
url_name = 'admin:api:streamer-playlist-detail'
class QueueSourceSerializer(SourceSerializer):
queue = serializers.ListField(child=RequestSerializer())
queue = serializers.ListField(child=RequestSerializer(), source='requests')
url_name = 'admin:api:streamer-queue-detail'
class StreamerSerializer(BaseSerializer):
id = serializers.IntegerField(source='station.pk')
name = serializers.CharField(source='station.name')
source = serializers.CharField(source='source.id', required=False)
playlists = serializers.ListField(child=PlaylistSerializer())
queues = serializers.ListField(child=QueueSourceSerializer())
url_name = 'admin:api:streamer-detail'
def get_url(self, obj, **kwargs):
kwargs['pk'] = obj.station.pk
return super().get_url(obj, **kwargs)

View File

@ -0,0 +1,121 @@
{% load i18n %}
<section class="box"><div class="columns is-desktop">
<div class="column">
<h5 class='title is-5' :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}">
<span>
<span v-if="source.isPlaying" class="fas fa-play"></span>
<span v-else-if="source.isPaused" class="fas fa-pause"></span>
</span>
[[ source.id ]]
<small v-if="source.isPaused || source.isPlaying">(-[[ source.remainingString ]])</small>
</h5>
<div>
<button class="button" @click="source.sync()"
title="{% trans "Synchronize source with Liquidsoap" %}">
<span class="icon is-small">
<span class="fas fa-sync"></span>
</span>
<span>{% trans "Synchronise" %}</span>
</button>
<button class="button" @click="source.restart()"
title="{% trans "Restart current track" %}">
<span class="icon is-small">
<span class="fas fa-step-backward"></span>
</span>
<span>{% trans "Restart" %}</span>
</button>
<button class="button" @click="source.skip()"
title="{% trans "Skip current file" %}">
<span>{% trans "Skip" %}</span>
<span class="icon is-small">
<span class="fas fa-step-forward"></span>
</span>
</button>
</div>
<div v-if="source.isQueue">
<hr>
<h6 class="title is-6 is-marginless">{% trans "Add sound" %}</h6>
<form class="columns" @submit.prevent="source.push($event.target.elements['sound_id'].value)">
<div class="column field is-small">
<a-autocomplete url="{% url "admin:api:streamer-queue-autocomplete-push" station_pk=station.pk %}?q=${query}"
class="is-fullwidth"
:model="Sound" field="name" value-field="sound_id" value-attr="id"
{# FIXME dirty hack awaiting the vue component #}
placeholder="{% trans "Select a sound" %}"></a-autocomplete>
<p class="help">
{% trans "Add a sound to the queue (queue may start playing)" %}
</p>
{# TODO: help text about how it works #}
</div>
<div class="column control is-one-fifth">
<button type="submit" class="button is-primary">
<span class="icon">
<span class="fas fa-plus"></span>
</span>
<span>{% trans "Add" %}</span>
</button>
</div>
</form>
<div v-if="source.queue.length">
<h6 class="title is-6 is-marginless">{% trans "Sounds in queue" %}</h6>
<table class="table is-fullwidth"><tbody>
<tr v-for="[index, request] in source.queue.entries()">
<td :class="{'has-text-weight-semibold': index==0 }">
<span v-if="index==0" class="far fa-play-circle"></span>
<span>[[ request.data.uri ]]</span>
</td>
</tr>
</tbody></table>
</div>
</div>
</div>
<div class="column is-two-fifths">
<h6 class="subtitle is-6 is-marginless">Metadata</h6>
<table class="table has-background-transparent">
<tbody>
<tr><th class="has-text-right has-text-nowrap">
{% trans "Status" %}
</th>
<td :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}">
<span v-if="source.isPlaying" class="fas fa-play"></span>
<span v-else-if="source.data.status" class="fas fa-pause"></span>
[[ source.data.status || "&mdash;" ]]
</td>
</tr>
<tr v-if="source.data.air_time">
<th class="has-text-right has-text-nowrap">
{% trans "Air time" %}
</th><td>
<span class="far fa-clock"></span>
<time :datetime="source.date">
[[ source.data.air_time.toLocaleDateString() ]],
[[ source.data.air_time.toLocaleTimeString() ]]
</time>
</td>
<tr v-if="source.remaining">
<th class="has-text-right has-text-nowrap">
{% trans "Time left" %}
</th><td>
<span class="far fa-hourglass"></span>
[[ source.remainingString ]]
</td>
</tr>
<tr v-if="source.data.uri">
<th class="has-text-right has-text-nowrap">
{% trans "Data source" %}
</th><td>
<span class="far fa-play-circle"></span>
<template v-if="source.data.uri.length > 64">...</template>[[ (source.data.uri && source.data.uri.slice(-64)) || '&mdash;' ]]
</td>
</tr>
</tbody>
</table>
</div>
</div></section>

View File

@ -0,0 +1,39 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<script src="{% static "aircox/streamer.js" %}"></script>
{% endblock %}
{% block content %}{{ block.super }}
<div id="app" data-api-url="{% url "admin:api:streamer-list" %}">
<div class="navbar toolbar">
<div class="navbar-start">
<span class="navbar-item control">
<button class="button">
<span class="icon is-small">
<span class="fas fa-sync"></span>
</span>
<span>{% trans "Reload" %}</span>
</button>
</span>
</div>
<div class="navbar-end">
<div class="select navbar-item">
<select ref="selectStreamer" @change.native="selectStreamer" class="control"
title="{% trans "Select a station" %}"
aria-label="{% trans "Select a station" %}">
<option v-for="streamer of streamers" :value="streamer.id">[[ streamer.data.name ]]</option>
</select>
</div>
</div>
</div>
<div v-if="streamer">
<template v-for="source in sources">
{% include "aircox_streamer/source_item.html" %}
</template>
</div>
</div>
{% endblock %}

22
aircox_streamer/urls.py Normal file
View File

@ -0,0 +1,22 @@
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from . import viewsets
from .views import StreamerAdminView
admin.site.route_view('tools/streamer', StreamerAdminView.as_view(),
'tools-streamer', label=_('Streamer Monitor'))
streamer_prefix = 'streamer/(?P<station_pk>[0-9]+)/'
router = admin.site.router
router.register(streamer_prefix + 'playlist', viewsets.PlaylistSourceViewSet,
basename='streamer-playlist')
router.register(streamer_prefix + 'queue', viewsets.QueueSourceViewSet,
basename='streamer-queue')
router.register('streamer', viewsets.StreamerViewSet, basename='streamer')
urls = []

View File

@ -1,3 +1,11 @@
from django.shortcuts import render
from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView
from aircox.views.admin import BaseAdminView
class StreamerAdminView(BaseAdminView, TemplateView):
template_name = 'aircox_streamer/streamer.html'
title = _('Streamer Monitor')
# Create your views here.

View File

@ -1,12 +1,16 @@
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone as tz
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from aircox import controllers
from aircox.models import Station
from aircox.models import Sound, Station
from aircox.serializers import SoundSerializer
from . import controllers
from .serializers import *
@ -52,16 +56,17 @@ class Streamers:
self.date = now + self.timeout
def get(self, key, default=None):
self.fetch()
return self.streamers.get(key, default)
def values(self):
self.fetch()
return self.streamers.values()
def __getitem__(self, key):
return self.streamers[key]
def __contains__(self, key):
return key in self.streamers
streamers = Streamers()
@ -70,22 +75,25 @@ class BaseControllerAPIView(viewsets.ViewSet):
permission_classes = (IsAdminUser,)
serializer = None
streamer = None
object = None
def get_streamer(self, pk=None):
streamer = streamers.get(self.request.pk if pk is None else pk)
if not streamer:
def get_streamer(self, request, station_pk=None, **kwargs):
streamers.fetch()
id = int(request.station.pk if station_pk is None else station_pk)
if id not in streamers:
raise Http404('station not found')
return streamer
return streamers[id]
def get_serializer(self, obj, **kwargs):
return self.serializer(obj, **kwargs)
def get_serializer(self, **kwargs):
return self.serializer(self.object, **kwargs)
def serialize(self, obj, **kwargs):
serializer = self.get_serializer(obj, **kwargs)
self.object = obj
serializer = self.get_serializer(**kwargs)
return serializer.data
def dispatch(self, request, *args, **kwargs):
self.streamer = self.get_streamer(request.station.pk)
def dispatch(self, request, *args, station_pk=None, **kwargs):
self.streamer = self.get_streamer(request, station_pk, **kwargs)
return super().dispatch(request, *args, **kwargs)
@ -97,10 +105,19 @@ class StreamerViewSet(BaseControllerAPIView):
serializer = StreamerSerializer
def retrieve(self, request, pk=None):
return self.serialize(self.streamer)
return Response(self.serialize(self.streamer))
def list(self, request):
return self.serialize(streamers.values(), many=True)
def list(self, request, pk=None):
return Response({
'results': self.serialize(streamers.values(), many=True)
})
def dispatch(self, request, *args, pk=None, **kwargs):
if pk is not None:
kwargs.setdefault('station_pk', pk)
self.streamer = self.get_streamer(request, **kwargs)
self.object = self.streamer
return super().dispatch(request, *args, **kwargs)
class SourceViewSet(BaseControllerAPIView):
@ -108,38 +125,46 @@ class SourceViewSet(BaseControllerAPIView):
model = controllers.Source
def get_sources(self):
return (s for s in self.streamer.souces if isinstance(s, self.model))
return (s for s in self.streamer.sources if isinstance(s, self.model))
def get_source(self, pk):
source = next((source for source in self.get_sources()
if source.pk == pk), None)
if source.id == pk), None)
if source is None:
raise Http404('source `%s` not found' % pk)
return source
def retrieve(self, request, pk=None):
source = self.get_source(pk)
return self.serialize(source)
self.object = self.get_source(pk)
return Response(self.serialize())
def list(self, request):
return self.serialize(self.get_sources(), many=True)
return Response({
'results': self.serialize(self.get_sources(), many=True)
})
def _run(self, pk, action):
source = self.object = self.get_source(pk)
action(source)
source.fetch()
return Response(self.serialize(source))
@action(detail=True, methods=['POST'])
def sync(self, request, pk):
self.get_source(pk).sync()
return self._run(pk, lambda s: s.sync())
@action(detail=True, methods=['POST'])
def skip(self, request, pk):
self.get_source(pk).skip()
return self._run(pk, lambda s: s.skip())
@action(detail=True, methods=['POST'])
def restart(self, request, pk):
self.get_source(pk).restart()
return self._run(pk, lambda s: s.restart())
@action(detail=True, methods=['POST'])
def seek(self, request, pk):
count = request.POST['seek']
self.get_source(pk).seek(count)
return self._run(pk, lambda s: s.seek(count))
class PlaylistSourceViewSet(SourceViewSet):
@ -151,8 +176,26 @@ class QueueSourceViewSet(SourceViewSet):
serializer = QueueSourceSerializer
model = controllers.QueueSource
def get_sound_queryset(self):
return Sound.objects.station(self.request.station).archive()
@action(detail=False, url_path='autocomplete/push',
url_name='autocomplete-push')
def autcomplete_push(self, request):
query = request.GET.get('q')
qs = self.get_sound_queryset().search(query)
serializer = SoundSerializer(qs, many=True, context={
'request': self.request
})
return Response({'results': serializer.data})
@action(detail=True, methods=['POST'])
def push(self, request, pk):
self.get_source(pk).push()
if not request.data.get('sound_id'):
raise ValidationError('missing "sound_id" POST data')
sound = get_object_or_404(self.get_sound_queryset(),
pk=request.data['sound_id'])
return self._run(
pk, lambda s: s.push(sound.path) if sound.path else None)