forked from rc/aircox
cfr #121 Co-authored-by: Christophe Siraut <d@tobald.eu.org> Co-authored-by: bkfox <thomas bkfox net> Co-authored-by: Thomas Kairos <thomas@bkfox.net> Reviewed-on: rc/aircox#131 Co-authored-by: Chris Tactic <ctactic@noreply.git.radiocampus.be> Co-committed-by: Chris Tactic <ctactic@noreply.git.radiocampus.be>
This commit is contained in:
19
aircox_streamer/conf.py
Normal file
19
aircox_streamer/conf.py
Normal file
@ -0,0 +1,19 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from aircox.conf import BaseSettings
|
||||
|
||||
|
||||
__all__ = ("Settings", "settings")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
WORKING_DIR = os.path.join(tempfile.gettempdir(), "aircox")
|
||||
"""Parent working directory for all stations."""
|
||||
|
||||
def get_dir(self, station, *paths):
|
||||
"""Return working directory for the provided station."""
|
||||
return os.path.join(self.WORKING_DIR, station.slug.replace("-", "_"), *paths)
|
||||
|
||||
|
||||
settings = Settings("AIRCOX_STREAMER")
|
@ -70,14 +70,14 @@ class Connector:
|
||||
data = bytes("".join([str(d) for d in data]) + "\n", encoding="utf-8")
|
||||
try:
|
||||
self.socket.sendall(data)
|
||||
data = ""
|
||||
while not response_re.search(data):
|
||||
data += self.socket.recv(1024).decode("utf-8")
|
||||
resp = ""
|
||||
while not response_re.search(resp):
|
||||
resp += self.socket.recv(1024).decode("utf-8")
|
||||
|
||||
if data:
|
||||
data = response_re.sub(r"\1", data).strip()
|
||||
data = self.parse(data) if parse else self.parse_json(data) if parse_json else data
|
||||
return data
|
||||
if resp:
|
||||
resp = response_re.sub(r"\1", resp).strip()
|
||||
resp = self.parse(resp) if parse else self.parse_json(resp) if parse_json else resp
|
||||
return resp
|
||||
except Exception:
|
||||
self.close()
|
||||
if try_count > 0:
|
||||
|
@ -77,9 +77,12 @@ class Metadata:
|
||||
air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S")
|
||||
return local_tz.localize(air_time)
|
||||
|
||||
def validate(self, data):
|
||||
def validate(self, data, as_dict=False):
|
||||
"""Validate provided data and set as attribute (must already be
|
||||
declared)"""
|
||||
if as_dict and isinstance(data, list):
|
||||
data = {v[0]: v[1] for v in data}
|
||||
|
||||
for key, value in data.items():
|
||||
if hasattr(self, key) and not callable(getattr(self, key)):
|
||||
setattr(self, key, value)
|
||||
|
@ -133,8 +133,10 @@ class Monitor:
|
||||
# get sound
|
||||
diff = None
|
||||
sound = Sound.objects.path(air_uri).first()
|
||||
if sound and sound.episode_id is not None:
|
||||
diff = Diffusion.objects.episode(id=sound.episode_id).on_air().now(air_time).first()
|
||||
if sound:
|
||||
ids = sound.episodesound_set.values_list("episode_id", flat=True)
|
||||
if ids:
|
||||
diff = Diffusion.objects.filter(episode_id__in=ids).on_air().now(air_time).first()
|
||||
|
||||
# log sound on air
|
||||
return self.log(
|
||||
@ -198,7 +200,7 @@ class Monitor:
|
||||
Diffusion.objects.station(self.station)
|
||||
.on_air()
|
||||
.now(now)
|
||||
.filter(episode__sound__type=Sound.TYPE_ARCHIVE)
|
||||
.filter(episode__episodesound__broadcast=True)
|
||||
.first()
|
||||
)
|
||||
# Can't use delay: diffusion may start later than its assigned start.
|
||||
@ -227,7 +229,7 @@ class Monitor:
|
||||
return log
|
||||
|
||||
def start_diff(self, source, diff):
|
||||
playlist = Sound.objects.episode(id=diff.episode_id).playlist()
|
||||
playlist = diff.episode.episodesound_set.all().broadcast().playlist()
|
||||
source.push(*playlist)
|
||||
self.log(
|
||||
type=Log.TYPE_START,
|
||||
|
@ -3,6 +3,7 @@ import tzlocal
|
||||
|
||||
from aircox.utils import to_seconds
|
||||
|
||||
from ..conf import settings
|
||||
from .metadata import Metadata, Request
|
||||
|
||||
|
||||
@ -43,9 +44,9 @@ class Source(Metadata):
|
||||
except ValueError:
|
||||
self.remaining = None
|
||||
|
||||
data = self.controller.send(self.id, ".get", parse=True)
|
||||
data = self.controller.send(f"var.get {self.id}_meta", parse_json=True)
|
||||
if data:
|
||||
self.validate(data if data and isinstance(data, dict) else {})
|
||||
self.validate(data if data and isinstance(data, (dict, list)) else {}, as_dict=True)
|
||||
|
||||
def skip(self):
|
||||
"""Skip the current source sound."""
|
||||
@ -76,11 +77,11 @@ class PlaylistSource(Source):
|
||||
self.program = program
|
||||
|
||||
super().__init__(controller, id=id, **kwargs)
|
||||
self.path = os.path.join(self.station.path, f"{self.id}.m3u")
|
||||
self.path = settings.get_dir(self.station, f"{self.id}.m3u")
|
||||
|
||||
def get_sound_queryset(self):
|
||||
"""Get playlist's sounds queryset."""
|
||||
return self.program.sound_set.archive()
|
||||
return self.program.sound_set.broadcast()
|
||||
|
||||
def get_playlist(self):
|
||||
"""Get playlist from db."""
|
||||
@ -88,6 +89,7 @@ class PlaylistSource(Source):
|
||||
|
||||
def write_playlist(self, playlist=[]):
|
||||
"""Write playlist to file."""
|
||||
playlist = playlist or self.get_playlist()
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
with open(self.path, "w") as file:
|
||||
file.write("\n".join(playlist or []))
|
||||
@ -129,7 +131,7 @@ class QueueSource(Source):
|
||||
def push(self, *paths):
|
||||
"""Add the provided paths to source's play queue."""
|
||||
for path in paths:
|
||||
self.controller.send(f"{self.id}_queue.push {path}")
|
||||
print(self.controller.send(f"{self.id}_queue.push {path}"))
|
||||
|
||||
def fetch(self):
|
||||
super().fetch()
|
||||
|
@ -8,8 +8,7 @@ import subprocess
|
||||
import psutil
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from aircox.conf import settings
|
||||
|
||||
from ..conf import settings
|
||||
from ..connector import Connector
|
||||
from .sources import PlaylistSource, QueueSource
|
||||
|
||||
@ -46,8 +45,8 @@ class Streamer:
|
||||
self.outputs = self.station.port_set.active().output()
|
||||
|
||||
self.id = self.station.slug.replace("-", "_")
|
||||
self.path = os.path.join(station.path, "station.liq")
|
||||
self.connector = connector or Connector(os.path.join(station.path, "station.sock"))
|
||||
self.path = settings.get_dir(station, "station.liq")
|
||||
self.connector = connector or Connector(settings.get_dir(station, "station.sock"))
|
||||
self.init_sources()
|
||||
|
||||
@property
|
||||
@ -96,9 +95,10 @@ class Streamer:
|
||||
data = render_to_string(
|
||||
self.template_name,
|
||||
{
|
||||
"dir": settings.get_dir(self.station),
|
||||
"log_file": settings.get_dir(self.station, "liquidsoap.log"),
|
||||
"station": self.station,
|
||||
"streamer": self,
|
||||
"settings": settings,
|
||||
},
|
||||
)
|
||||
data = re.sub("[\t ]+\n", "\n", data)
|
||||
|
Binary file not shown.
@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-09-12 18:48+0000\n"
|
||||
"POT-Creation-Date: 2024-04-28 18:19+0000\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"
|
||||
@ -18,85 +18,85 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:18
|
||||
msgid "Edit related program"
|
||||
msgstr "Éditer le programme correspondant"
|
||||
#: templates/aircox/widgets/nav.html:7 views.py:10
|
||||
msgid "Streamer"
|
||||
msgstr "Streamer"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:27
|
||||
msgid "Synchronize source with Liquidsoap"
|
||||
msgstr "Synchroniser la source avec Liquidsoap"
|
||||
#: templates/aircox_streamer/source_item.html:19
|
||||
msgid "Edit program"
|
||||
msgstr "Éditer l'émission"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:31
|
||||
msgid "Synchronise"
|
||||
msgstr "Synchroniser"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:34
|
||||
msgid "Restart current track"
|
||||
msgstr "Rejouer le morceau en cours"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:38
|
||||
msgid "Restart"
|
||||
msgstr "Rejouer"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:41
|
||||
msgid "Skip current file"
|
||||
msgstr "Passer le fichier actuel"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:42
|
||||
msgid "Skip"
|
||||
msgstr "Passer"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:51
|
||||
msgid "Add sound"
|
||||
msgstr "Ajouter un son"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:58
|
||||
msgid "Select a sound"
|
||||
msgstr "Sélectionner un son"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:69
|
||||
msgid "Add"
|
||||
msgstr "Ajouter"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:74
|
||||
msgid "Add a sound to the queue (queue may start playing)"
|
||||
msgstr "Ajouter un son à la file de lecture (la file de lecture peut démarer)"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:80
|
||||
msgid "Sounds in queue"
|
||||
msgstr "Sons dans la file de lecture"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:98
|
||||
#: templates/aircox_streamer/source_item.html:29
|
||||
msgid "Status"
|
||||
msgstr "Statut"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:108
|
||||
#: templates/aircox_streamer/source_item.html:39
|
||||
msgid "Air time"
|
||||
msgstr "En antenne depuis"
|
||||
msgstr "Temps d'antenne"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:118
|
||||
#: templates/aircox_streamer/source_item.html:49
|
||||
msgid "Time left"
|
||||
msgstr "Temps restant"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/source_item.html:126
|
||||
msgid "Data source"
|
||||
msgstr "Source de donnée"
|
||||
#: templates/aircox_streamer/source_item.html:57
|
||||
msgid "Source"
|
||||
msgstr "Source"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/streamer.html:23
|
||||
#: templates/aircox_streamer/source_item.html:70
|
||||
msgid "Restart current track"
|
||||
msgstr "Rejouer le morceau en cours"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:74
|
||||
msgid "Restart"
|
||||
msgstr "Rejouer"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:77
|
||||
msgid "Skip current file"
|
||||
msgstr "Passer le fichier actuel"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:78
|
||||
msgid "Skip"
|
||||
msgstr "Passer"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:84
|
||||
msgid "Synchronize source with Liquidsoap"
|
||||
msgstr "Synchroniser la source avec Liquidsoap"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:88
|
||||
msgid "Synchronise"
|
||||
msgstr "Synchroniser"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:94
|
||||
msgid "Add sound"
|
||||
msgstr "Ajouter un son"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:101
|
||||
msgid "Select a sound"
|
||||
msgstr "Sélectionner un son"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:112
|
||||
msgid "Add"
|
||||
msgstr "Ajouter"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:117
|
||||
msgid "Add a sound to the queue (queue may start playing)"
|
||||
msgstr "Ajouter un son à la file de lecture (la file de lecture peut démarer)"
|
||||
|
||||
#: templates/aircox_streamer/source_item.html:123
|
||||
msgid "Sounds in queue"
|
||||
msgstr "Sons dans la file de lecture"
|
||||
|
||||
#: templates/aircox_streamer/streamer.html:19
|
||||
msgid "Reload"
|
||||
msgstr "Recharger"
|
||||
|
||||
#: aircox_streamer/templates/aircox_streamer/streamer.html:30
|
||||
#: aircox_streamer/templates/aircox_streamer/streamer.html:31
|
||||
#: templates/aircox_streamer/streamer.html:26
|
||||
#: templates/aircox_streamer/streamer.html:27
|
||||
msgid "Select a station"
|
||||
msgstr "Sélectionner une station"
|
||||
|
||||
#: aircox_streamer/urls.py:13 aircox_streamer/views.py:10
|
||||
msgid "Streamer Monitor"
|
||||
msgstr "Moniteur de stream"
|
||||
|
||||
#~ msgid "playing"
|
||||
#~ msgstr "en cours de lecture"
|
||||
#~ msgstr "lecture"
|
||||
|
||||
#~ msgid "paused"
|
||||
#~ msgstr "pause"
|
||||
|
@ -53,13 +53,13 @@ class SourceSerializer(MetadataSerializer):
|
||||
class PlaylistSerializer(SourceSerializer):
|
||||
program = serializers.CharField(source="program.id")
|
||||
|
||||
url_name = "admin:api:streamer-playlist-detail"
|
||||
url_name = "streamer:api:streamer-playlist-detail"
|
||||
|
||||
|
||||
class QueueSourceSerializer(SourceSerializer):
|
||||
queue = serializers.ListField(child=RequestSerializer(), source="requests")
|
||||
|
||||
url_name = "admin:api:streamer-queue-detail"
|
||||
url_name = "streamer:api:streamer-queue-detail"
|
||||
|
||||
|
||||
class StreamerSerializer(BaseSerializer):
|
||||
@ -69,7 +69,7 @@ class StreamerSerializer(BaseSerializer):
|
||||
playlists = serializers.ListField(child=PlaylistSerializer())
|
||||
queues = serializers.ListField(child=QueueSourceSerializer())
|
||||
|
||||
url_name = "admin:api:streamer-detail"
|
||||
url_name = "streamer:api:streamer-detail"
|
||||
|
||||
def get_url(self, obj, **kwargs):
|
||||
kwargs["pk"] = obj.station.pk
|
||||
|
9
aircox_streamer/templates/aircox/widgets/nav.html
Normal file
9
aircox_streamer/templates/aircox/widgets/nav.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends "aircox/widgets/nav.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block admin-menu %}
|
||||
{{ block.super }}
|
||||
<a class="dropdown-item" href="{% url "streamer:dashboard-streamer" %}">
|
||||
{% translate "Streamer" %}
|
||||
</a>
|
||||
{% endblock %}
|
@ -10,16 +10,16 @@ Base liquidsoap station configuration.
|
||||
|
||||
{% block functions %}
|
||||
{# Seek function #}
|
||||
def seek(source, t) =
|
||||
def seek(s, t) =
|
||||
t = float_of_string(default=0.,t)
|
||||
ret = source.seek(source,t)
|
||||
ret = source.seek(s,t)
|
||||
log("seek #{ret} seconds.")
|
||||
"#{ret}"
|
||||
end
|
||||
|
||||
{# Transition to live sources #}
|
||||
def to_live(stream, live)
|
||||
stream = fade.final(duration=2., type='log', stream)
|
||||
stream = fade.out(duration=2., type='log', stream)
|
||||
live = fade.initial(duration=2., type='log', live)
|
||||
add(normalize=false, [stream,live])
|
||||
end
|
||||
@ -30,6 +30,17 @@ def to_stream(live, stream)
|
||||
add(normalize=false, [live,stream])
|
||||
end
|
||||
|
||||
{# Skip command #}
|
||||
def add_skip_command(id, s) =
|
||||
def skip(_) =
|
||||
source.skip(s)
|
||||
"Done!"
|
||||
end
|
||||
server.register(namespace=id,
|
||||
usage="skip",
|
||||
description="Skip the current song.",
|
||||
"skip",skip)
|
||||
end
|
||||
|
||||
{% comment %}
|
||||
An interactive source is a source that:
|
||||
@ -45,10 +56,13 @@ def interactive (id, s) =
|
||||
server.register(namespace=id,
|
||||
description="Get source's track remaining time",
|
||||
usage="remaining",
|
||||
"remaining", fun (_) -> begin json_of(source.remaining(s)) end)
|
||||
"remaining", fun (_) -> begin json.stringify(source.remaining(s)) end)
|
||||
|
||||
add_skip_command(id, s)
|
||||
|
||||
s_meta = interactive.string("#{id}_meta", "")
|
||||
s = source.on_metadata(s, fun(meta) -> s_meta.set(json.stringify(meta)))
|
||||
|
||||
s = store_metadata(id=id, size=1, s)
|
||||
add_skip_command(s)
|
||||
s
|
||||
end
|
||||
|
||||
@ -65,15 +79,9 @@ end
|
||||
{% block config %}
|
||||
set("server.socket", true)
|
||||
set("server.socket.path", "{{ 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 %}
|
||||
set("log.file.path", "{{ log_file }}")
|
||||
{% endblock %}
|
||||
|
||||
{% block config_extras %}
|
||||
{% endblock %}
|
||||
|
||||
{% block config_extras %}{% endblock %}
|
||||
|
||||
{% block sources %}
|
||||
{% with source=streamer.dealer %}
|
||||
@ -82,7 +90,6 @@ live = audio_to_stereo(interactive('{{ source.id }}',
|
||||
))
|
||||
{% endwith %}
|
||||
|
||||
|
||||
streams = rotate(id="streams", [
|
||||
{% for source in streamer.sources %}
|
||||
{% if source != streamer.dealer %}
|
||||
|
111
aircox_streamer/templates/aircox_streamer/source_item.html
Normal file → Executable file
111
aircox_streamer/templates/aircox_streamer/source_item.html
Normal file → Executable file
@ -1,8 +1,9 @@
|
||||
{% comment %}List item for a source.{% endcomment %}
|
||||
{% load i18n %}
|
||||
|
||||
<section class="box"><div class="columns is-desktop">
|
||||
<div class="column">
|
||||
<section class="box"><div class="flex-row gap-3">
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<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>
|
||||
@ -13,36 +14,78 @@
|
||||
<small v-if="source.isPaused || source.isPlaying">([[ source.remainingString ]])</small>
|
||||
|
||||
<a v-if="source.data.program !== undefined"
|
||||
:href="'{% url 'admin:aircox_program_change' "$$" %}'.replace('$$', source.data.program)"
|
||||
title="{% translate "Edit related program" %}">
|
||||
:href="'{% url 'aircox:program_edit' "$$" %}'.replace('$$', source.data.program)"
|
||||
title="{% translate "Edit program" %}">
|
||||
<span class="icon">
|
||||
<span class="fas fa-edit"></span>
|
||||
</span>
|
||||
</a>
|
||||
</h5>
|
||||
|
||||
<table class="table bg-transparent">
|
||||
<tbody>
|
||||
<tr><th class="has-text-right ws-nowrap">
|
||||
{% translate "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_verbose || "—" ]]
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="source.data.air_time">
|
||||
<th class="has-text-right ws-nowrap">
|
||||
{% translate "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 ws-nowrap">
|
||||
{% translate "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 ws-nowrap">
|
||||
{% translate "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)) || '—' ]]
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<button class="button" @click="source.sync()"
|
||||
title="{% translate "Synchronize source with Liquidsoap" %}">
|
||||
<span class="icon is-small">
|
||||
<span class="fas fa-sync"></span>
|
||||
</span>
|
||||
<span>{% translate "Synchronise" %}</span>
|
||||
</button>
|
||||
<button class="button" @click="source.restart()"
|
||||
<button class="button smaller mr-2 mb-2" @click="source.restart()"
|
||||
title="{% translate "Restart current track" %}">
|
||||
<span class="icon is-small">
|
||||
<span class="fas fa-step-backward"></span>
|
||||
</span>
|
||||
<span>{% translate "Restart" %}</span>
|
||||
</button>
|
||||
<button class="button" @click="source.skip()"
|
||||
<button class="button smaller mr-2 mb-2" @click="source.skip()"
|
||||
title="{% translate "Skip current file" %}">
|
||||
<span>{% translate "Skip" %}</span>
|
||||
<span class="icon is-small">
|
||||
<span class="fas fa-step-forward"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="button smaller mr-2 mb-2" @click="source.sync()"
|
||||
title="{% translate "Synchronize source with Liquidsoap" %}">
|
||||
<span class="icon is-small">
|
||||
<span class="fas fa-sync"></span>
|
||||
</span>
|
||||
<span>{% translate "Synchronise" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="source.isQueue">
|
||||
@ -89,46 +132,4 @@
|
||||
</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">
|
||||
{% translate "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_verbose || "—" ]]
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="source.data.air_time">
|
||||
<th class="has-text-right has-text-nowrap">
|
||||
{% translate "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">
|
||||
{% translate "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">
|
||||
{% translate "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)) || '—' ]]
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></section>
|
||||
|
@ -1,16 +1,13 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% comment %}Admin tools used to manage the streamer.{% endcomment %}
|
||||
{% extends "aircox/dashboard/base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block init-scripts %}
|
||||
aircox.init({apiUrl: "{% url "admin:api:streamer-list" %}"},
|
||||
{config: window.StreamerApp})
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block title %}{% translate "Streamer" %}{% endblock %}
|
||||
|
||||
{% block content-container %}
|
||||
{{ block.super }}
|
||||
<div id="app">
|
||||
<a-streamer api-url="{% url "admin:api:streamer-list" %}">
|
||||
<div class="container">
|
||||
<a-streamer api-url="{% url "streamer:api:streamer-list" %}">
|
||||
<template v-slot="{streamer,streamers,sources,fetchStreamers,Sound}">
|
||||
<div class="navbar toolbar">
|
||||
<div class="navbar-start">
|
||||
|
@ -66,7 +66,7 @@ class FakeSocket:
|
||||
# -- models
|
||||
@pytest.fixture
|
||||
def station():
|
||||
station = models.Station(name="test", path=working_dir, default=True, active=True)
|
||||
station = models.Station(name="test", default=True, active=True)
|
||||
station.save()
|
||||
return station
|
||||
|
||||
@ -77,7 +77,6 @@ def stations(station):
|
||||
models.Station(
|
||||
name=f"test-{i}",
|
||||
slug=f"test-{i}",
|
||||
path=working_dir,
|
||||
default=(i == 0),
|
||||
active=True,
|
||||
)
|
||||
@ -146,24 +145,28 @@ def episode(program):
|
||||
def sound(program, episode):
|
||||
sound = models.Sound(
|
||||
program=program,
|
||||
episode=episode,
|
||||
name="sound",
|
||||
type=models.Sound.TYPE_ARCHIVE,
|
||||
position=0,
|
||||
broadcast=True,
|
||||
file="sound.mp3",
|
||||
)
|
||||
sound.save(check=False)
|
||||
sound.save(sync=False)
|
||||
return sound
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def episode_sound(episode, sound):
|
||||
obj = models.EpisodeSound(episode=episode, sound=sound, position=0, broadcast=sound.broadcast)
|
||||
obj.save()
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sounds(program):
|
||||
items = [
|
||||
models.Sound(
|
||||
name=f"sound {i}",
|
||||
program=program,
|
||||
type=models.Sound.TYPE_ARCHIVE,
|
||||
position=i,
|
||||
broadcast=True,
|
||||
file=f"sound-{i}.mp3",
|
||||
)
|
||||
for i in range(0, 3)
|
||||
|
@ -20,7 +20,7 @@ def monitor(streamer):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def diffusion(program, episode, sound):
|
||||
def diffusion(program, episode, episode_sound):
|
||||
return baker.make(
|
||||
models.Diffusion,
|
||||
program=program,
|
||||
@ -33,10 +33,10 @@ def diffusion(program, episode, sound):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source(monitor, streamer, sound, diffusion):
|
||||
def source(monitor, streamer, episode_sound, diffusion):
|
||||
source = next(monitor.streamer.playlists)
|
||||
source.uri = sound.file.path
|
||||
source.episode_id = sound.episode_id
|
||||
source.uri = episode_sound.sound.file.path
|
||||
source.episode_id = episode_sound.episode_id
|
||||
source.air_time = diffusion.start + tz.timedelta(seconds=10)
|
||||
return source
|
||||
|
||||
@ -185,7 +185,7 @@ class TestMonitor:
|
||||
monitor.trace_tracks(log)
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_handle_diffusions(self, monitor, streamer, diffusion, sound):
|
||||
def test_handle_diffusions(self, monitor, streamer, diffusion, episode_sound):
|
||||
interface(
|
||||
monitor,
|
||||
{
|
||||
|
@ -67,7 +67,7 @@ class TestPlaylistSource:
|
||||
@pytest.mark.django_db
|
||||
def test_get_sound_queryset(self, playlist_source, sounds):
|
||||
query = playlist_source.get_sound_queryset()
|
||||
assert all(r.program_id == playlist_source.program.pk and r.type == r.TYPE_ARCHIVE for r in query)
|
||||
assert all(r.program_id == playlist_source.program.pk and r.broadcast for r in query)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_playlist(self, playlist_source, sounds):
|
||||
|
42
aircox_streamer/urls.py
Normal file → Executable file
42
aircox_streamer/urls.py
Normal file → Executable file
@ -1,33 +1,25 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from aircox.viewsets import SoundViewSet
|
||||
|
||||
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]+)/"
|
||||
from . import views, viewsets
|
||||
|
||||
|
||||
router = admin.site.router
|
||||
router.register(
|
||||
streamer_prefix + "playlist",
|
||||
viewsets.PlaylistSourceViewSet,
|
||||
basename="streamer-playlist",
|
||||
)
|
||||
router.register(
|
||||
streamer_prefix + "queue",
|
||||
viewsets.QueueSourceViewSet,
|
||||
basename="streamer-queue",
|
||||
)
|
||||
__all__ = ("api", "urls")
|
||||
|
||||
|
||||
prefix = "<int:station_pk>/"
|
||||
|
||||
|
||||
router = DefaultRouter(use_regex_path=False)
|
||||
router.register(prefix + "playlist", viewsets.PlaylistSourceViewSet, basename="streamer-playlist")
|
||||
router.register(prefix + "queue", viewsets.QueueSourceViewSet, basename="streamer-queue")
|
||||
router.register("streamer", viewsets.StreamerViewSet, basename="streamer")
|
||||
router.register("sound", SoundViewSet, basename="sound")
|
||||
|
||||
urls = []
|
||||
api = router.urls
|
||||
urls = [
|
||||
path("api/", include((api, "aircox_streamer"), namespace="api")),
|
||||
path("", views.StreamerView.as_view(), name="dashboard-streamer"),
|
||||
]
|
||||
|
@ -1,13 +1,13 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from aircox.views.admin import AdminMixin
|
||||
from aircox.views.dashboard import DashboardBaseView
|
||||
from .controllers import streamers
|
||||
|
||||
|
||||
class StreamerAdminView(AdminMixin, TemplateView):
|
||||
class StreamerView(DashboardBaseView, TemplateView):
|
||||
template_name = "aircox_streamer/streamer.html"
|
||||
title = _("Streamer Monitor")
|
||||
title = _("Streamer")
|
||||
streamers = streamers
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
|
16
aircox_streamer/viewsets.py
Normal file → Executable file
16
aircox_streamer/viewsets.py
Normal file → Executable file
@ -43,8 +43,10 @@ class ControllerViewSet(viewsets.ViewSet):
|
||||
if station_pk is None:
|
||||
station_pk = self.request.station.pk
|
||||
self.streamers.fetch()
|
||||
if station_pk is None:
|
||||
return None
|
||||
if station_pk not in self.streamers:
|
||||
raise Http404("station not found")
|
||||
raise Http404(f"station not found: {station_pk}")
|
||||
return self.streamers[station_pk]
|
||||
|
||||
def get_serializer(self, **kwargs):
|
||||
@ -58,7 +60,8 @@ class ControllerViewSet(viewsets.ViewSet):
|
||||
return serializer.data
|
||||
|
||||
def dispatch(self, request, *args, station_pk=None, **kwargs):
|
||||
self.streamer = self.get_streamer(station_pk)
|
||||
if not self.streamer:
|
||||
self.streamer = self.get_streamer(station_pk)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
@ -78,7 +81,10 @@ class StreamerViewSet(ControllerViewSet):
|
||||
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)
|
||||
if pk := kwargs.get("station_pk"):
|
||||
kwargs["station_pk"] = int(pk)
|
||||
|
||||
self.streamer = self.get_streamer(**kwargs)
|
||||
self.object = self.streamer
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@ -86,6 +92,8 @@ class StreamerViewSet(ControllerViewSet):
|
||||
class SourceViewSet(ControllerViewSet):
|
||||
serializer_class = SourceSerializer
|
||||
model = controllers.Source
|
||||
lookup_field = "pk"
|
||||
lookup_value_converter = "str"
|
||||
|
||||
def get_sources(self):
|
||||
return (s for s in self.streamer.sources if isinstance(s, self.model))
|
||||
@ -137,7 +145,7 @@ class QueueSourceViewSet(SourceViewSet):
|
||||
model = controllers.QueueSource
|
||||
|
||||
def get_sound_queryset(self, request):
|
||||
return Sound.objects.station(request.station).archive()
|
||||
return Sound.objects.station(request.station)
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
def push(self, request, pk):
|
||||
|
Reference in New Issue
Block a user