code quality

This commit is contained in:
bkfox
2023-03-13 17:47:00 +01:00
parent 934817da8a
commit 112770eddf
162 changed files with 4798 additions and 4069 deletions

View File

@ -15,4 +15,3 @@ This application allows to:
- generate config file and playlists: regular Django template file in `scripts/station.liq`;
- monitor what is being played and what has to be played using Telnet to communicate
with Liquidsoap process;

View File

@ -2,6 +2,4 @@ from django.apps import AppConfig
class AircoxStreamerConfig(AppConfig):
name = 'aircox_streamer'
name = "aircox_streamer"

View File

@ -1,24 +1,22 @@
import socket
import re
import json
import re
import socket
response_re = re.compile(r'(.*)\s+END\s*$')
response_re = re.compile(r"(.*)\s+END\s*$")
key_val_re = re.compile(r'(?P<key>[^=]+)="?(?P<value>([^"]|\\")+)"?')
class Connector:
"""Connection to AF_UNIX or AF_INET, get and send data.
Received data can be parsed from list of `key=value` or JSON.
"""
Connection to AF_UNIX or AF_INET, get and send data. Received
data can be parsed from list of `key=value` or JSON.
"""
socket = None
""" The socket """
"""The socket."""
address = None
"""
String to a Unix domain socket file, or a tuple (host, port) for
TCP/IP connection
"""
"""String to a Unix domain socket file, or a tuple (host, port) for TCP/IP
connection."""
@property
def is_open(self):
@ -32,12 +30,13 @@ class Connector:
if self.is_open:
return
family = socket.AF_UNIX if isinstance(self.address, str) else \
socket.AF_INET
family = (
socket.AF_UNIX if isinstance(self.address, str) else socket.AF_INET
)
try:
self.socket = socket.socket(family, socket.SOCK_STREAM)
self.socket.connect(self.address)
except:
except Exception:
self.close()
return -1
@ -50,27 +49,32 @@ class Connector:
if self.open():
return None
data = bytes(''.join([str(d) for d in data]) + '\n', encoding='utf-8')
data = bytes("".join([str(d) for d in data]) + "\n", encoding="utf-8")
try:
self.socket.sendall(data)
data = ''
data = ""
while not response_re.search(data):
data += self.socket.recv(1024).decode('utf-8')
data += 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
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
except:
except Exception:
self.close()
if try_count > 0:
return self.send(data, try_count - 1)
def parse(self, value):
return {
line.groupdict()['key']: line.groupdict()['value']
for line in (key_val_re.search(line) for line in value.split('\n'))
line.groupdict()["key"]: line.groupdict()["value"]
for line in (key_val_re.search(line) for line in value.split("\n"))
if line
}
@ -79,5 +83,5 @@ class Connector:
if value[0] == '"' and value[-1] == '"':
value = value[1:-1]
return json.loads(value) if value else None
except:
except Exception:
return None

View File

@ -7,20 +7,23 @@ import subprocess
import psutil
import tzlocal
from django.template.loader import render_to_string
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from aircox import settings
from aircox.models import Station, Sound, Port
from aircox.utils import to_seconds
from .connector import Connector
__all__ = ['BaseMetadata', 'Request', 'Streamer', 'Source',
'PlaylistSource', 'QueueSource']
__all__ = [
"BaseMetadata",
"Request",
"Streamer",
"Source",
"PlaylistSource",
"QueueSource",
]
# TODO: for the moment, update in station and program names do not update the
# related fields.
@ -30,24 +33,24 @@ __all__ = ['BaseMetadata', 'Request', 'Streamer', 'Source',
# correctly.
local_tz = tzlocal.get_localzone()
logger = logging.getLogger('aircox')
logger = logging.getLogger("aircox")
class BaseMetadata:
""" Base class for handling request metadata. """
controller = None
""" Controller """
rid = None
""" Request id """
uri = None
""" Request uri """
status = None
""" Current playing status """
request_status = None
""" Requests' status """
air_time = None
""" Launch datetime """
"""Base class for handling request metadata."""
controller = None
"""Controller."""
rid = None
"""Request id."""
uri = None
"""Request uri."""
status = None
"""Current playing status."""
request_status = None
"""Requests' status."""
air_time = None
"""Launch datetime."""
def __init__(self, controller=None, rid=None, data=None):
self.controller = controller
@ -57,45 +60,46 @@ class BaseMetadata:
@property
def is_playing(self):
return self.status == 'playing'
return self.status == "playing"
@property
def status_verbose(self):
return self.validate_status(self.status, True)
def fetch(self):
data = self.controller.send('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, i18n=False):
on_air = self.controller.source
if on_air and status == 'playing' and (on_air == self or
on_air.rid == self.rid):
return _('playing') if i18n else 'playing'
elif status == 'playing':
return _('paused') if i18n else 'paused'
if (
on_air
and status == "playing"
and (on_air == self or on_air.rid == self.rid)
):
return _("playing") if i18n else "playing"
elif status == "playing":
return _("paused") if i18n else "paused"
else:
return _('stopped') if i18n else 'stopped'
return _("stopped") if i18n else "stopped"
def validate_air_time(self, air_time):
if air_time:
air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S')
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
declared)
"""
"""Validate provided data and set as attribute (must already be
declared)"""
for key, value in data.items():
if hasattr(self, key) and not callable(getattr(self, key)):
setattr(self, key, value)
self.uri = data.get('initial_uri')
self.uri = data.get("initial_uri")
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')
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):
@ -108,47 +112,45 @@ class Streamer:
process = None
station = None
template_name = 'aircox_streamer/scripts/station.liq'
template_name = "aircox_streamer/scripts/station.liq"
path = None
""" Config path """
"""Config path."""
sources = None
""" List of all monitored sources """
"""List of all monitored sources."""
source = None
""" Current source being played on air """
"""Current source being played on air."""
# note: we disable on_air rids since we don't have use of it for the
# moment
# on_air = None
# """ On-air request ids (rid) """
inputs = None
""" Queryset to input ports """
"""Queryset to input ports."""
outputs = None
""" Queryset to output ports """
"""Queryset to output ports."""
def __init__(self, station, connector=None):
self.station = station
self.inputs = self.station.port_set.active().input()
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(os.path.join(station.path, 'station.sock'))
self.id = self.station.slug.replace("-", "_")
self.path = os.path.join(station.path, "station.liq")
self.connector = Connector(os.path.join(station.path, "station.sock"))
self.init_sources()
@property
def socket_path(self):
""" Path to Unix socket file """
"""Path to Unix socket file."""
return self.connector.address
@property
def is_ready(self):
"""
If external program is ready to use, returns True
"""
return self.send('list') != ''
"""If external program is ready to use, returns True."""
return self.send("list") != ""
@property
def is_running(self):
""" True if holds a running process """
"""True if holds a running process."""
if self.process is None:
return False
@ -157,7 +159,7 @@ class Streamer:
return True
self.process = None
logger.debug('process died with return code %s' % returncode)
logger.debug("process died with return code %s" % returncode)
return False
@property
@ -170,67 +172,83 @@ class Streamer:
# Sources and config ###############################################
def send(self, *args, **kwargs):
return self.connector.send(*args, **kwargs) or ''
return self.connector.send(*args, **kwargs) or ""
def init_sources(self):
streams = self.station.program_set.filter(stream__isnull=False)
self.dealer = QueueSource(self, 'dealer')
self.dealer = QueueSource(self, "dealer")
self.sources = [self.dealer] + [
PlaylistSource(self, program=program) for program in streams
]
def make_config(self):
""" Make configuration files and directory (and sync sources) """
data = render_to_string(self.template_name, {
'station': self.station,
'streamer': self,
'settings': settings,
})
data = re.sub('[\t ]+\n', '\n', data)
data = re.sub('\n{3,}', '\n\n', data)
"""Make configuration files and directory (and sync sources)"""
data = render_to_string(
self.template_name,
{
"station": self.station,
"streamer": self,
"settings": settings,
},
)
data = re.sub("[\t ]+\n", "\n", data)
data = re.sub("\n{3,}", "\n\n", data)
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, 'w+') as file:
with open(self.path, "w+") as file:
file.write(data)
self.sync()
def sync(self):
""" Sync all sources. """
"""Sync all sources."""
for source in self.sources:
source.sync()
def fetch(self):
""" Fetch data from liquidsoap """
"""Fetch data from liquidsoap."""
for source in self.sources:
source.fetch()
# request.on_air is not ordered: we need to do it manually
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)
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):
return ['liquidsoap', '-v', self.path]
return ["liquidsoap", "-v", self.path]
def check_zombie_process(self):
if not os.path.exists(self.socket_path):
return
conns = [conn for conn in psutil.net_connections(kind='unix')
if conn.laddr == self.socket_path]
conns = [
conn
for conn in psutil.net_connections(kind="unix")
if conn.laddr == self.socket_path
]
for conn in conns:
if conn.pid is not None:
os.kill(conn.pid, signal.SIGKILL)
def run_process(self):
"""
Execute the external application with corresponding informations.
"""Execute the external application with corresponding informations.
This function must make sure that all needed files have been generated.
This function must make sure that all needed files have been
generated.
"""
if self.process:
return
@ -245,15 +263,16 @@ class Streamer:
def kill_process(self):
if self.process:
logger.debug("kill process %s: %s", self.process.pid,
' '.join(self.get_process_args()))
logger.debug(
"kill process %s: %s",
self.process.pid,
" ".join(self.get_process_args()),
)
self.process.kill()
self.process = None
def wait_process(self):
"""
Wait for the process to terminate if there is a process
"""
"""Wait for the process to terminate if there is a process."""
if self.process:
self.process.wait()
self.process = None
@ -261,12 +280,12 @@ class Streamer:
class Source(BaseMetadata):
controller = None
""" parent controller """
"""Parent controller."""
id = None
""" source id """
"""Source id."""
remaining = 0.0
""" remaining time """
status = 'stopped'
"""Remaining time."""
status = "stopped"
@property
def station(self):
@ -277,66 +296,67 @@ class Source(BaseMetadata):
self.id = id
def sync(self):
""" Synchronize what should be synchronized """
"""Synchronize what should be synchronized."""
def fetch(self):
try:
data = self.controller.send(self.id, '.remaining')
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)
data = self.controller.send(self.id, ".get", parse=True)
if data:
self.validate(data if data and isinstance(data, dict) else {})
def skip(self):
""" Skip the current source sound """
self.controller.send(self.id, '.skip')
"""Skip the current source sound."""
self.controller.send(self.id, ".skip")
def restart(self):
""" Restart current sound """
"""Restart current sound."""
# seek 10 hours back since there is not possibility to get current pos
self.seek(-216000*10)
self.seek(-216000 * 10)
def seek(self, n):
""" Seeks into the sound. """
self.controller.send(self.id, '.seek ', str(n))
"""Seeks into the sound."""
self.controller.send(self.id, ".seek ", str(n))
class PlaylistSource(Source):
""" Source handling playlists (program streams) """
"""Source handling playlists (program streams)"""
path = None
""" Path to playlist """
"""Path to playlist."""
program = None
""" Related program """
"""Related program."""
playlist = None
""" The playlist """
"""The playlist."""
def __init__(self, controller, id=None, program=None, **kwargs):
id = program.slug.replace('-', '_') if id is None else id
id = program.slug.replace("-", "_") if id is None else id
self.program = program
super().__init__(controller, id=id, **kwargs)
self.path = os.path.join(self.station.path, self.id + '.m3u')
self.path = os.path.join(self.station.path, self.id + ".m3u")
def get_sound_queryset(self):
""" Get playlist's sounds queryset """
"""Get playlist's sounds queryset."""
return self.program.sound_set.archive()
def get_playlist(self):
""" Get playlist from db """
"""Get playlist from db."""
return self.get_sound_queryset().playlist()
def write_playlist(self, playlist=[]):
""" Write playlist to file. """
"""Write playlist to file."""
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, 'w') as file:
file.write('\n'.join(playlist or []))
with open(self.path, "w") as file:
file.write("\n".join(playlist or []))
def stream(self):
""" Return program's stream info if any (or None) as dict. """
"""Return program's stream info if any (or None) as dict."""
# used in templates
# TODO: multiple streams
stream = self.program.stream_set.all().first()
@ -344,9 +364,9 @@ class PlaylistSource(Source):
return
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
"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,
}
def sync(self):
@ -356,31 +376,29 @@ class PlaylistSource(Source):
class QueueSource(Source):
queue = None
""" Source's queue (excluded on_air request) """
"""Source's queue (excluded on_air request)"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def push(self, *paths):
""" Add the provided paths to source's play queue """
"""Add the provided paths to source's play queue."""
for path in paths:
self.controller.send(self.id, '_queue.push ', path)
self.controller.send(self.id, "_queue.push ", path)
def fetch(self):
super().fetch()
queue = self.controller.send(self.id, '_queue.queue').strip()
queue = self.controller.send(self.id, "_queue.queue").strip()
if not queue:
self.queue = []
return
self.queue = queue.split(' ')
self.queue = queue.split(" ")
@property
def requests(self):
""" Queue as requests metadata """
"""Queue as requests metadata."""
requests = [Request(self.controller, rid) for rid in self.queue]
for request in requests:
request.fetch()
return requests

View File

@ -1,11 +1,13 @@
"""
Handle the audio streamer and controls it as we want it to be. It is
used to:
"""Handle the audio streamer and controls it as we want it to be. It is used
to:
- generate config files and playlists;
- monitor Liquidsoap, logs and scheduled programs;
- cancels Diffusions that have an archive but could not have been played;
- run Liquidsoap
"""
import time
# TODO:
# x controllers: remaining
# x diffusion conflicts
@ -14,17 +16,12 @@ used to:
# - handle restart after failure
# - is stream restart after live ok?
from argparse import RawTextHelpFormatter
import time
import pytz
from django.db.models import Q
from django.core.management.base import BaseCommand
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.models import Diffusion, Log, Sound, Station, Track
from aircox_streamer.controllers import Streamer
# force using UTC
@ -32,8 +29,7 @@ tz.activate(pytz.UTC)
class Monitor:
"""
Log and launch diffusions for the given station.
"""Log and launch diffusions for the given station.
Monitor should be able to be used after a crash a go back
where it was playing, so we heavily use logs to be able to
@ -44,20 +40,21 @@ class Monitor:
- scheduled diffusions
- tracks for sounds of streamed programs
"""
streamer = None
""" Streamer controller """
"""Streamer controller."""
delay = None
""" Timedelta: minimal delay between two call of monitor. """
logs = None
""" Queryset to station's logs (ordered by -pk) """
"""Queryset to station's logs (ordered by -pk)"""
cancel_timeout = 20
""" Timeout in minutes before cancelling a diffusion. """
"""Timeout in minutes before cancelling a diffusion."""
sync_timeout = 5
""" Timeout in minutes between two streamer's sync. """
"""Timeout in minutes between two streamer's sync."""
sync_next = None
""" Datetime of the next sync. """
"""Datetime of the next sync."""
last_sound_logs = None
""" Last logged sounds, as ``{source_id: log}``. """
"""Last logged sounds, as ``{source_id: log}``."""
@property
def station(self):
@ -65,12 +62,12 @@ class Monitor:
@property
def last_log(self):
""" Last log of monitored station. """
"""Last log of monitored station."""
return self.logs.first()
@property
def last_diff_start(self):
""" Log of last triggered item (sound or diffusion). """
"""Log of last triggered item (sound or diffusion)."""
return self.logs.start().with_diff().first()
def __init__(self, streamer, delay, cancel_timeout, **kwargs):
@ -83,12 +80,13 @@ class Monitor:
self.init_last_sound_logs()
def get_logs_queryset(self):
""" Return queryset to assign as `self.logs` """
return self.station.log_set.select_related('diffusion', 'sound', 'track') \
.order_by('-pk')
"""Return queryset to assign as `self.logs`"""
return self.station.log_set.select_related(
"diffusion", "sound", "track"
).order_by("-pk")
def init_last_sound_logs(self, key=None):
""" Retrieve last logs and initialize `last_sound_logs` """
"""Retrieve last logs and initialize `last_sound_logs`"""
logs = {}
for source in self.streamer.sources:
qs = self.logs.filter(source=source.id, sound__isnull=False)
@ -96,7 +94,7 @@ class Monitor:
self.last_sound_logs = logs
def monitor(self):
""" Run all monitoring functions once. """
"""Run all monitoring functions once."""
if not self.streamer.is_ready:
return
@ -128,15 +126,15 @@ class Monitor:
if log:
self.trace_tracks(log)
else:
print('no source or sound for stream; source = ', source)
print("no source or sound for stream; source = ", source)
self.handle_diffusions()
self.sync()
def log(self, source, **kwargs):
""" Create a log using **kwargs, and print info """
kwargs.setdefault('station', self.station)
kwargs.setdefault('date', tz.now())
"""Create a log using **kwargs, and print info."""
kwargs.setdefault("station", self.station)
kwargs.setdefault("date", tz.now())
log = Log(source=source, **kwargs)
log.save()
log.print()
@ -146,7 +144,7 @@ class Monitor:
return log
def trace_sound(self, source):
""" Return on air sound log (create if not present). """
"""Return on air sound log (create if not present)."""
air_uri, air_time = source.uri, source.air_time
last_log = self.last_sound_logs.get(source.id)
if last_log and last_log.sound.file.path == source.uri:
@ -169,24 +167,31 @@ class Monitor:
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()
diff = (
Diffusion.objects.episode(id=sound.episode_id)
.on_air()
.now(air_time)
.first()
)
# log sound on air
return self.log(type=Log.TYPE_ON_AIR, date=source.air_time,
source=source.id, sound=sound, diffusion=diff,
comment=air_uri)
return self.log(
type=Log.TYPE_ON_AIR,
date=source.air_time,
source=source.id,
sound=sound,
diffusion=diff,
comment=air_uri,
)
def trace_tracks(self, log):
"""
Log tracks for the given sound log (for streamed programs only).
"""
"""Log tracks for the given sound log (for streamed programs only)."""
if log.diffusion:
return
tracks = Track.objects \
.filter(sound__id=log.sound_id, timestamp__isnull=False)\
.order_by('timestamp')
tracks = Track.objects.filter(
sound__id=log.sound_id, timestamp__isnull=False
).order_by("timestamp")
if not tracks.exists():
return
@ -197,14 +202,17 @@ class Monitor:
pos = log.date + tz.timedelta(seconds=track.timestamp)
if pos > now:
break
self.log(type=Log.TYPE_ON_AIR, date=pos, source=log.source,
track=track, comment=track)
self.log(
type=Log.TYPE_ON_AIR,
date=pos,
source=log.source,
track=track,
comment=track,
)
def handle_diffusions(self):
"""
Handle scheduled diffusion, trigger if needed, preload playlists
and so on.
"""
"""Handle scheduled diffusion, trigger if needed, preload playlists and
so on."""
# TODO: program restart
# Diffusion conflicts are handled by the way a diffusion is defined
@ -227,9 +235,13 @@ class Monitor:
# ```
#
now = tz.now()
diff = Diffusion.objects.station(self.station).on_air().now(now) \
.filter(episode__sound__type=Sound.TYPE_ARCHIVE) \
.first()
diff = (
Diffusion.objects.station(self.station)
.on_air()
.now(now)
.filter(episode__sound__type=Sound.TYPE_ARCHIVE)
.first()
)
# Can't use delay: diffusion may start later than its assigned start.
log = None if not diff else self.logs.start().filter(diffusion=diff)
if not diff or log:
@ -237,8 +249,11 @@ class Monitor:
dealer = self.streamer.dealer
# start
if not dealer.queue and dealer.rid is None or \
dealer.remaining < self.delay.total_seconds():
if (
not dealer.queue
and dealer.rid is None
or dealer.remaining < self.delay.total_seconds()
):
self.start_diff(dealer, diff)
# cancel
@ -248,17 +263,25 @@ class Monitor:
def start_diff(self, source, diff):
playlist = Sound.objects.episode(id=diff.episode_id).playlist()
source.push(*playlist)
self.log(type=Log.TYPE_START, source=source.id, diffusion=diff,
comment=str(diff))
self.log(
type=Log.TYPE_START,
source=source.id,
diffusion=diff,
comment=str(diff),
)
def cancel_diff(self, source, diff):
diff.type = Diffusion.TYPE_CANCEL
diff.save()
self.log(type=Log.TYPE_CANCEL, source=source.id, diffusion=diff,
comment=str(diff))
self.log(
type=Log.TYPE_CANCEL,
source=source.id,
diffusion=diff,
comment=str(diff),
)
def sync(self):
""" Update sources' playlists. """
"""Update sources' playlists."""
now = tz.now()
if self.sync_next is not None and now < self.sync_next:
return
@ -269,55 +292,82 @@ class Monitor:
source.sync()
class Command (BaseCommand):
class Command(BaseCommand):
help = __doc__
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
group = parser.add_argument_group('actions')
group = parser.add_argument_group("actions")
group.add_argument(
'-c', '--config', action='store_true',
help='generate configuration files for the stations'
"-c",
"--config",
action="store_true",
help="generate configuration files for the stations",
)
group.add_argument(
'-m', '--monitor', action='store_true',
help='monitor the scheduled diffusions and log what happens'
"-m",
"--monitor",
action="store_true",
help="monitor the scheduled diffusions and log what happens",
)
group.add_argument(
'-r', '--run', action='store_true',
help='run the required applications for the stations'
"-r",
"--run",
action="store_true",
help="run the required applications for the stations",
)
group = parser.add_argument_group('options')
group = parser.add_argument_group("options")
group.add_argument(
'-d', '--delay', type=int,
"-d",
"--delay",
type=int,
default=1000,
help='time to sleep in MILLISECONDS between two updates when we '
'monitor. This influence the delay before a diffusion is '
'launched.'
help="time to sleep in MILLISECONDS between two updates when we "
"monitor. This influence the delay before a diffusion is "
"launched.",
)
group.add_argument(
'-s', '--station', type=str, action='append',
help='name of the station to monitor instead of monitoring '
'all stations'
"-s",
"--station",
type=str,
action="append",
help="name of the station to monitor instead of monitoring "
"all stations",
)
group.add_argument(
'-t', '--timeout', type=float,
"-t",
"--timeout",
type=float,
default=Monitor.cancel_timeout,
help='time to wait in MINUTES before canceling a diffusion that '
'should have ran but did not. '
help="time to wait in MINUTES before canceling a diffusion that "
"should have ran but did not. ",
)
# TODO: sync-timeout, cancel-timeout
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()
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]
for streamer in streamers:
if not streamer.outputs:
raise RuntimeError("Streamer {} has no outputs".format(streamer.id))
raise RuntimeError(
"Streamer {} has no outputs".format(streamer.id)
)
if config:
streamer.make_config()
if run:
@ -326,8 +376,9 @@ class Command (BaseCommand):
if monitor:
delay = tz.timedelta(milliseconds=delay)
timeout = tz.timedelta(minutes=timeout)
monitors = [Monitor(streamer, delay, timeout)
for streamer in streamers]
monitors = [
Monitor(streamer, delay, timeout) for streamer in streamers
]
while not run or streamer.is_running:
for monitor in monitors:

View File

@ -1,23 +1,24 @@
from django.urls import reverse
from rest_framework import serializers
from .controllers import QueueSource, PlaylistSource
__all__ = ['RequestSerializer', 'StreamerSerializer', 'SourceSerializer',
'PlaylistSerializer', 'QueueSourceSerializer']
__all__ = [
"RequestSerializer",
"StreamerSerializer",
"SourceSerializer",
"PlaylistSerializer",
"QueueSourceSerializer",
]
# TODO: use models' serializers
class BaseSerializer(serializers.Serializer):
url_ = serializers.SerializerMethodField('get_url')
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))
kwargs.setdefault("pk", getattr(obj, "id", None))
return reverse(self.url_name, kwargs=kwargs)
@ -42,7 +43,7 @@ class SourceSerializer(BaseMetadataSerializer):
remaining = serializers.FloatField()
def get_url(self, obj, **kwargs):
kwargs['station_pk'] = obj.station.pk
kwargs["station_pk"] = obj.station.pk
return super().get_url(obj, **kwargs)
def get_status_verbose(self, obj, **kwargs):
@ -50,26 +51,26 @@ class SourceSerializer(BaseMetadataSerializer):
class PlaylistSerializer(SourceSerializer):
program = serializers.CharField(source='program.id')
program = serializers.CharField(source="program.id")
url_name = "admin:api:streamer-playlist-detail"
url_name = 'admin:api:streamer-playlist-detail'
class QueueSourceSerializer(SourceSerializer):
queue = serializers.ListField(child=RequestSerializer(), source='requests')
queue = serializers.ListField(child=RequestSerializer(), source="requests")
url_name = 'admin:api:streamer-queue-detail'
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)
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'
url_name = "admin:api:streamer-detail"
def get_url(self, obj, **kwargs):
kwargs['pk'] = obj.station.pk
kwargs["pk"] = obj.station.pk
return super().get_url(obj, **kwargs)

View File

@ -126,5 +126,3 @@ output.{{ output.get_type_display }}(
)
{% endfor %}
{% endblock %}

View File

@ -132,4 +132,3 @@
</table>
</div>
</div></section>

View File

@ -43,4 +43,3 @@ aircox.init({apiUrl: "{% url "admin:api:streamer-list" %}"},
</a-streamer>
</div>
{% endblock %}

View File

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

View File

@ -1,24 +1,33 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from . import viewsets
from aircox.viewsets import SoundViewSet
from . import viewsets
from .views import StreamerAdminMixin
admin.site.route_view(
"tools/streamer",
StreamerAdminMixin.as_view(),
"tools-streamer",
label=_("Streamer Monitor"),
)
admin.site.route_view('tools/streamer', StreamerAdminMixin.as_view(),
'tools-streamer', label=_('Streamer Monitor'))
streamer_prefix = 'streamer/(?P<station_pk>[0-9]+)/'
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')
router.register('sound', SoundViewSet, basename='sound')
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")
router.register("sound", SoundViewSet, basename="sound")
urls = []

View File

@ -5,7 +5,5 @@ from aircox.views.admin import AdminMixin
class StreamerAdminMixin(AdminMixin, TemplateView):
template_name = 'aircox_streamer/streamer.html'
title = _('Streamer Monitor')
template_name = "aircox_streamer/streamer.html"
title = _("Streamer Monitor")

View File

@ -1,7 +1,6 @@
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
@ -9,23 +8,34 @@ from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from aircox.models import Sound, Station
from aircox.serializers import SoundSerializer
from . import controllers
from .serializers import *
from .serializers import (
PlaylistSerializer,
QueueSourceSerializer,
RequestSerializer,
SourceSerializer,
StreamerSerializer,
)
__all__ = ['Streamers', 'BaseControllerAPIView',
'RequestViewSet', 'StreamerViewSet', 'SourceViewSet',
'PlaylistSourceViewSet', 'QueueSourceViewSet']
__all__ = [
"Streamers",
"BaseControllerAPIView",
"RequestViewSet",
"StreamerViewSet",
"SourceViewSet",
"PlaylistSourceViewSet",
"QueueSourceViewSet",
]
class Streamers:
date = None
""" next update datetime """
"""Next update datetime."""
streamers = None
""" stations by station id """
"""Stations by station id."""
timeout = None
""" timedelta to next update """
"""Timedelta to next update."""
def __init__(self, timeout=None):
self.timeout = timeout or tz.timedelta(seconds=2)
@ -34,14 +44,19 @@ class Streamers:
# FIXME: cf. TODO in aircox.controllers about model updates
stations = Station.objects.active()
if self.streamers is None or force:
self.streamers = {station.pk: controllers.Streamer(station)
for station in stations}
self.streamers = {
station.pk: controllers.Streamer(station)
for station in stations
}
return
streamers = self.streamers
self.streamers = {station.pk: controllers.Streamer(station)
if station.pk in streamers else streamers[station.pk]
for station in stations}
self.streamers = {
station.pk: controllers.Streamer(station)
if station.pk in streamers
else streamers[station.pk]
for station in stations
}
def fetch(self):
if self.streamers is None:
@ -81,7 +96,7 @@ class BaseControllerAPIView(viewsets.ViewSet):
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')
raise Http404("station not found")
return streamers[id]
def get_serializer(self, **kwargs):
@ -108,13 +123,13 @@ class StreamerViewSet(BaseControllerAPIView):
return Response(self.serialize(self.streamer))
def list(self, request, pk=None):
return Response({
'results': self.serialize(streamers.values(), many=True)
})
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)
kwargs.setdefault("station_pk", pk)
self.streamer = self.get_streamer(request, **kwargs)
self.object = self.streamer
return super().dispatch(request, *args, **kwargs)
@ -128,10 +143,11 @@ class SourceViewSet(BaseControllerAPIView):
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.id == pk), None)
source = next(
(source for source in self.get_sources() if source.id == pk), None
)
if source is None:
raise Http404('source `%s` not found' % pk)
raise Http404("source `%s` not found" % pk)
return source
def retrieve(self, request, pk=None):
@ -139,9 +155,9 @@ class SourceViewSet(BaseControllerAPIView):
return Response(self.serialize())
def list(self, request):
return Response({
'results': 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)
@ -149,21 +165,21 @@ class SourceViewSet(BaseControllerAPIView):
source.fetch()
return Response(self.serialize(source))
@action(detail=True, methods=['POST'])
@action(detail=True, methods=["POST"])
def sync(self, request, pk):
return self._run(pk, lambda s: s.sync())
@action(detail=True, methods=['POST'])
@action(detail=True, methods=["POST"])
def skip(self, request, pk):
return self._run(pk, lambda s: s.skip())
@action(detail=True, methods=['POST'])
@action(detail=True, methods=["POST"])
def restart(self, request, pk):
return self._run(pk, lambda s: s.restart())
@action(detail=True, methods=['POST'])
@action(detail=True, methods=["POST"])
def seek(self, request, pk):
count = request.POST['seek']
count = request.POST["seek"]
return self._run(pk, lambda s: s.seek(count))
@ -179,13 +195,14 @@ class QueueSourceViewSet(SourceViewSet):
def get_sound_queryset(self):
return Sound.objects.station(self.request.station).archive()
@action(detail=True, methods=['POST'])
@action(detail=True, methods=["POST"])
def push(self, request, pk):
if not request.data.get('sound_id'):
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'])
sound = get_object_or_404(
self.get_sound_queryset(), pk=request.data["sound_id"]
)
return self._run(
pk, lambda s: s.push(sound.file.path) if sound.file.path else None)
pk, lambda s: s.push(sound.file.path) if sound.file.path else None
)