From 8e1d2b67694d9f823e42b2d96dbe765c242b46be Mon Sep 17 00:00:00 2001 From: bkfox Date: Wed, 31 Jul 2019 02:17:30 +0200 Subject: [PATCH] rewrite streamer and controller -- much cleaner and efficient; continue to work on new architecture --- .../admin/__pycache__/episode.cpython-37.pyc | Bin 3443 -> 2919 bytes aircox/admin/diffusion.py | 81 - aircox/admin/episode.py | 15 - aircox/connector.py | 94 +- aircox/controllers.py | 474 ++--- aircox/management/commands/streamer.py | 359 +--- aircox/models.py | 1520 ----------------- .../models/__pycache__/episode.cpython-37.pyc | Bin 10455 -> 9960 bytes aircox/models/__pycache__/log.cpython-37.pyc | Bin 7703 -> 7749 bytes .../models/__pycache__/program.cpython-37.pyc | Bin 15818 -> 16579 bytes .../models/__pycache__/sound.cpython-37.pyc | Bin 8580 -> 9237 bytes .../models/__pycache__/station.cpython-37.pyc | Bin 6334 -> 4735 bytes aircox/models/episode.py | 80 +- aircox/models/log.py | 21 +- aircox/models/program.py | 27 +- aircox/models/sound.py | 58 +- aircox/models/station.py | 62 +- aircox/templates/aircox/config/liquidsoap.liq | 171 -- aircox/templates/aircox/scripts/station.liq | 125 ++ aircox/utils.py | 3 +- 20 files changed, 550 insertions(+), 2540 deletions(-) delete mode 100644 aircox/admin/diffusion.py delete mode 100755 aircox/models.py delete mode 100755 aircox/templates/aircox/config/liquidsoap.liq create mode 100755 aircox/templates/aircox/scripts/station.liq diff --git a/aircox/admin/__pycache__/episode.cpython-37.pyc b/aircox/admin/__pycache__/episode.cpython-37.pyc index 5748b4a95326d14ac3ef0161dd84f8752b40507a..5a51d54fd5e8a904c7d327d1a183775f9c869fff 100644 GIT binary patch delta 266 zcmew?^<0e4iIxF zOhrbMeYhnjS98^~nE-{0Oeb+mF`9uiNEMj_1q?wNK(2vk;9+EAEHVdbS;MUdQmzjo ztS2+`=&*rQ15GpKQB<-4GA)6)m^k{Mzl?MRBk{*MpfYiMG7=Q^dTWYsFjc^RZ)mH!6voau}w+j zDu*KV1Q%a9z=0bV;8*Y$%n#tii33+=;zS%^toe3!9{cUg`p3er^TxemQ5R71we|f& z?X&SfR%jczNlSiMI}^IhHtf=Cb+0hCXYDz(i)Vr?>=`?PRsQ-#e#b@~Mnxzxs)SlX z4QdFXa4NVgX0iB|ok+0+OR?mN8PXolV8e!PPUI;!kK0~o0*A;mLmv&>0p1D84xe+g z=e*>e=|6MPw>m8wnU(|VQR5-u=Dut2ba za1}LB5d_&_I#bxNdf(}GqrmaKrhVXetA7bPBITh-BZ7$rH_)K2-(%Mw*ohc3?AHt> zT2C36vA83|qAQ0puHxow_J-jM#7B6aB34>z)<}VPjv@|&fQPBHwXvH@eeR_>{;JcD zY!38Hsk}QlkoCI|Y7bz|t1^@PW9V2wftWQ>8vrJ~g3;*eJ%5RmD zCyKL80|KfBdf9ApG`=atc#4`J&Q2-d z3qljm`IKKnC)_63NPcT83S}dOr7zEHqPd+s*5_5StO{%;eSMAHOg`#Sp6&+Y[^=]+)="?(?P([^"]|\\")+)"?') + + class Connector: """ - Simple connector class that retrieve/send data through a unix - domain socket file or a TCP/IP connection - - It is able to parse list of `key=value`, and JSON data. + 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 - __available = False + socket = None + """ The socket """ address = None """ - a string to the unix domain socket file, or a tuple (host, port) for + String to a Unix domain socket file, or a tuple (host, port) for TCP/IP connection """ @property - def available(self): - return self.__available + def is_open(self): + return self.socket is not None - def __init__(self, address = None): + def __init__(self, address=None): if address: self.address = address def open(self): - if self.__available: + if self.is_open: return + family = socket.AF_UNIX if isinstance(self.address, str) else \ + socket.AF_INET 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 + self.socket = socket.socket(family, socket.SOCK_STREAM) + self.socket.connect(self.address) except: - self.__available = False + self.close() 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') + def close(self): + self.socket.close() + self.socket = None + # FIXME: return None on failed + def send(self, *data, try_count=1, parse=False, parse_json=False): + if self.open(): + return None + + 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) + self.socket.sendall(data) data = '' - while not reg.search(data): - data += self.__socket.recv(1024).decode('utf-8') + while not response_re.search(data): + data += self.socket.recv(1024).decode('utf-8') if data: - data = reg.sub(r'\1', data) - data = data.strip() - if parse: - data = self.parse(data) - elif parse_json: - data = self.parse_json(data) + 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: - self.__available = False + self.close() 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[^=]+)="?(?P([^"]|\\")+)"?', line) - if not line: - continue - line = line.groupdict() - data[line['key']] = line['value'] - return data + 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')) + if line + } - def parse_json(self, string): + def parse_json(self, value): try: - if string[0] == '"' and string[-1] == '"': - string = string[1:-1] - return json.loads(string) if string else None + if value[0] == '"' and value[-1] == '"': + value = value[1:-1] + return json.loads(value) if value else None except: return None - - - diff --git a/aircox/controllers.py b/aircox/controllers.py index 9b158f8..f0b16d6 100755 --- a/aircox/controllers.py +++ b/aircox/controllers.py @@ -1,153 +1,139 @@ -import atexit, logging, os, re, signal, subprocess +from collections import OrderedDict +import atexit +import logging +import os +import re +import signal +import subprocess +import psutil import tzlocal from django.template.loader import render_to_string from django.utils import timezone as tz -import aircox.models as models -import aircox.settings as settings - -from aircox.connector import Connector +from . import settings +from .models import Port, Station, Sound +from .connector import Connector local_tz = tzlocal.get_localzone() -logger = logging.getLogger('aircox.tools') +logger = logging.getLogger('aircox') class Streamer: - """ - Audio controller of a Station. - """ - station = None - """ - Related station - """ - template_name = 'aircox/config/liquidsoap.liq' - """ - If set, use this template in order to generated the configuration - file in self.path file - """ - path = None - """ - Path of the configuration file. - """ - source = None - """ - Current source object that is responsible of self.sound - """ - process = None - """ - Application's process if ran from Streamer - """ - socket_path = '' - """ - Path to the connector's socket - """ connector = None - """ - Connector to Liquidsoap server - """ + process = None - def __init__(self, station, **kwargs): + station = None + template_name = 'aircox/scripts/station.liq' + path = None + """ Config path """ + sources = None + """ List of all monitored sources """ + source = None + """ Current on air source """ + + def __init__(self, station): self.station = station + self.id = self.station.slug.replace('-', '_') self.path = os.path.join(station.path, 'station.liq') - self.socket_path = os.path.join(station.path, 'station.sock') - self.connector = Connector(self.socket_path) - self.__dict__.update(kwargs) + self.connector = Connector(os.path.join(station.path, 'station.sock')) + self.init_sources() @property - def id(self): - """ - Streamer identifier common in both external app and here - """ - return self.station.slug + def socket_path(self): + """ Path to Unix socket file """ + return self.connector.address - # - # RPC - # - def _send(self, *args, **kwargs): - return self.connector.send(*args, **kwargs) - - def fetch(self): - """ - Fetch data of the children and so on - - The base function just execute the function of all children - sources. The plugin must implement the other extra part - """ - sources = self.station.sources - for source in sources: - source.fetch() - - rid = self._send('request.on_air').split(' ')[0] - if ' ' in rid: - rid = rid[:rid.index(' ')] - if not rid: - return - - data = self._send('request.metadata ', rid, parse = True) - if not data: - return - - self.source = next( - iter(source for source in self.station.sources - if source.rid == rid), - self.source + @property + def inputs(self): + """ Return input ports of the station """ + return self.station.port_set.filter( + direction=Port.Direction.input, + active=True ) - def push(self, config = True): - """ - Update configuration and children's info. + @property + def outputs(self): + """ Return output ports of the station """ + return self.station.port_set.filter( + direction=Port.Direction.output, + active=True, + ) - The base function just execute the function of all children - sources. The plugin must implement the other extra part + @property + def is_ready(self): """ - sources = self.station.sources - for source in sources: - source.push() - - if config and self.path and self.template_name: - 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: - file.write(data) - - # - # Process management - # - def __get_process_args(self): - """ - Get arguments for the executed application. Called by exec, to be - used as subprocess.Popen(__get_process_args()). - If no value is returned, abort the execution. + If external program is ready to use, returns True """ + return self.send('list') != '' + + # Sources and config ############################################### + def send(self, *args, **kwargs): + 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.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) + + os.makedirs(os.path.dirname(self.path), exist_ok=True) + with open(self.path, 'w+') as file: + file.write(data) + + self.sync() + + def sync(self): + """ Sync all sources. """ + for source in self.sources: + source.sync() + + def fetch(self): + """ Fetch data from liquidsoap """ + for source in self.sources: + source.fetch() + + rid = self.send('request.on_air').split(' ') + if rid: + rid = rid[-1] + # data = self._send('request.metadata ', rid, parse=True) + # if not data: + # return + pred = lambda s: s.rid == rid + else: + pred = lambda s: s.is_playing + + self.source = next((source for source in self.sources if pred(source)), + self.source) + + # Process ########################################################## + def get_process_args(self): return ['liquidsoap', '-v', self.path] - def __check_for_zombie(self): - """ - Check if there is a process that has not been killed - """ + def check_zombie_process(self): if not os.path.exists(self.socket_path): return - import psutil - 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 process_run(self): + def run_process(self): """ Execute the external application with corresponding informations. @@ -156,26 +142,24 @@ class Streamer: if self.process: return - self.push() - - args = self.__get_process_args() + args = self.get_process_args() if not args: return - self.__check_for_zombie() + self.check_zombie_process() self.process = subprocess.Popen(args, stderr=subprocess.STDOUT) - atexit.register(lambda: self.process_terminate()) + atexit.register(lambda: self.kill_process()) - def process_terminate(self): + def kill_process(self): if self.process: logger.info("kill process {pid}: {info}".format( - pid = self.process.pid, - info = ' '.join(self.__get_process_args()) + pid=self.process.pid, + info=' '.join(self.get_process_args()) )) self.process.kill() self.process = None - def process_wait(self): + def wait_process(self): """ Wait for the process to terminate if there is a process """ @@ -183,193 +167,96 @@ class Streamer: self.process.wait() self.process = None - def ready(self): - """ - If external program is ready to use, returns True - """ - return self._send('var.list') != '' - class Source: - """ - Controller of a Source. Value are usually updated directly on the - external side. - """ - station = None - connector = None - """ Connector to Liquidsoap server """ - program = None - """ Related program """ - name = '' - """ Name of the source """ - path = '' - """ Path to the playlist file. """ - on_air = None + controller = None + id = None - - # retrieved from fetch - sound = '' - """ (fetched) current sound being played """ + uri = '' rid = None - """ (fetched) current request id of the source in LiquidSoap """ air_time = None - """ (fetched) datetime of last on_air """ + status = None @property - def id(self): - return self.program.slug if self.program else 'dealer' - - def __init__(self, station, **kwargs): - self.station = station - self.connector = self.station.streamer.connector - self.__dict__.update(kwargs) - self.__init_playlist() - if self.program: - self.name = self.program.name - - # - # Playlist - # - __playlist = None - - def __init_playlist(self): - self.__playlist = [] - if not self.path: - self.path = os.path.join(self.station.path, - self.id + '.m3u') - self.from_file() - - if not self.__playlist: - self.from_db() + def station(self): + return self.controller.station @property - def playlist(self): - """ - Current playlist on the Source, list of paths to play - """ - self.fetch() - return self.__playlist + def is_playing(self): + return self.status == 'playing' - @playlist.setter - def playlist(self, value): - value = sorted(value) - if value != self.__playlist: - self.__playlist = value - self.push() + def __init__(self, controller, id=None): + self.controller = controller + self.id = id - def from_db(self, diffusion = None, program = None): - """ - Load a playlist to the controller from the database. If diffusion or - program is given use it, otherwise, try with self.program if exists, or - (if URI, self.url). - - A playlist from a program uses all its available archives. - """ - if diffusion: - self.playlist = diffusion.get_playlist(archive = True) - return - - program = program or self.program - if program: - self.playlist = [ sound.path for sound in - models.Sound.objects.filter( - type = models.Sound.Type.archive, - program = program, - ) - ] - return - - def from_file(self, path = None): - """ - Load a playlist from the given file (if not, use the - controller's one - """ - path = path or self.path - if not os.path.exists(path): - return - - with open(path, 'r') as file: - self.__playlist = file.read() - self.__playlist = self.__playlist.split('\n') \ - if self.__playlist else [] - - # - # RPC & States - # - def _send(self, *args, **kwargs): - return self.connector.send(*args, **kwargs) - - @property - def is_stream(self): - return self.program and not self.program.show - - @property - def is_dealer(self): - return not self.program - - @property - def active(self): - return self._send('var.get ', self.id, '_active') == 'true' - - @active.setter - def active(self, value): - self._send('var.set ', self.id, '_active', '=', - 'true' if value else 'false') + def sync(self): + """ Synchronize what should be synchronized """ + pass def fetch(self): - """ - Get the source information - """ - data = self._send(self.id, '.get', parse = True) - if not data or type(data) != dict: - return + data = self.controller.send(self.id, '.get', parse=True) + self.on_metadata(data if data and isinstance(data, dict) else {}) + def on_metadata(self, data): + """ Update source info from provided request metadata """ self.rid = data.get('rid') - self.sound = data.get('initial_uri') + self.uri = data.get('initial_uri') + self.status = data.get('status') - # get air_time air_time = data.get('on_air') - # try: - air_time = tz.datetime.strptime(air_time, '%Y/%m/%d %H:%M:%S') - self.air_time = local_tz.localize(air_time) - # except: - # pass - - def push(self): - """ - Update data relative to the source on the external program. - By default write the playlist. - """ - os.makedirs(os.path.dirname(self.path), exist_ok = True) - with open(self.path, 'w') as file: - file.write('\n'.join(self.__playlist or [])) + 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 def skip(self): - """ - Skip the current sound in the source - """ - self._send(self.id, '.skip') + """ Skip the current source sound """ + self.controller.send(self.id, '.skip') def restart(self): - """ - Restart the current sound in the source. Since liquidsoap - does not give us current position in stream, it seeks back - max 10 hours in the current sound. - """ - self.seek(-216000*10); + """ Restart current sound """ + # seek 10 hours back since there is not possibility to get current pos + self.seek(-216000*10) def seek(self, n): - """ - Seeks into the sound. Note that liquidsoap seems really slow for that. - """ - self._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) """ + path = None + """ Path to playlist """ + program = None + """ Related program """ + playlist = None + """ The playlist """ + + def __init__(self, controller, id=None, program=None, **kwargs): + 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') + + def get_sound_queryset(self): + """ Get playlist's sounds queryset """ + return self.program.sound_set.archive() + + def load_playlist(self): + """ Load playlist """ + self.playlist = self.get_sound_queryset().paths() + + def write_playlist(self): + """ Write playlist file. """ + os.makedirs(os.path.dirname(self.path), exist_ok=True) + with open(self.path, 'w') as file: + file.write('\n'.join(self.playlist or [])) def stream(self): - """ - Return dict of info for the current Stream program running on - the source. If not, return None. - [ used in the templates ] - """ + """ Return program's stream info if any (or None) as dict. """ + # used in templates # TODO: multiple streams stream = self.program.stream_set.all().first() if not stream or (not stream.begin and not stream.delay): @@ -384,3 +271,14 @@ class Source: 'delay': to_seconds(stream.delay) if stream.delay else 0 } + def sync(self): + self.load_playlist() + self.write_playlist() + + +class QueueSource(Source): + def queue(self, *paths): + """ Add the provided paths to source's play queue """ + for path in paths: + print(self.controller.send(self.id, '_queue.push ', path)) + diff --git a/aircox/management/commands/streamer.py b/aircox/management/commands/streamer.py index dc1f986..c3c66a0 100755 --- a/aircox/management/commands/streamer.py +++ b/aircox/management/commands/streamer.py @@ -6,23 +6,18 @@ used to: - cancels Diffusions that have an archive but could not have been played; - run Liquidsoap """ -import tzlocal -import time -import re - from argparse import RawTextHelpFormatter +import time -from django.conf import settings as main_settings -from django.core.management.base import BaseCommand, CommandError +import pytz +import tzlocal +from django.core.management.base import BaseCommand from django.utils import timezone as tz -from django.utils.functional import cached_property -from django.db import models -from django.db.models import Q -from aircox.models import Station, Diffusion, Track, Sound, Log +from aircox.models import Station, Episode, Diffusion, Track, Sound, Log +from aircox.controllers import Streamer, PlaylistSource # force using UTC -import pytz tz.activate(pytz.UTC) @@ -45,125 +40,91 @@ class Monitor: - scheduled diffusions - tracks for sounds of streamed programs """ - station = None streamer = None - cancel_timeout = 60*10 - """ - Time in seconds before a diffusion that have archives is cancelled - because it has not been played. - """ - sync_timeout = 60*10 - """ - Time in minuts before all stream playlists are checked and updated - """ + """ Streamer controller """ + logs = None + """ Queryset to station's logs (ordered by -pk) """ + cancel_timeout = 20 + """ Timeout in minutes before cancelling a diffusion. """ + sync_timeout = 5 + """ Timeout in minutes between two streamer's sync. """ sync_next = None - """ - Datetime of the next sync - """ - - def get_last_log(self, *args, **kwargs): - return self.log_qs.filter(*args, **kwargs).last() + """ Datetime of the next sync """ @property - def log_qs(self): - return Log.objects.station(self.station) \ - .select_related('diffusion', 'sound') \ - .order_by('pk') + def station(self): + return self.streamer.station @property def last_log(self): - """ - Last log of monitored station - """ - return self.log_qs.last() - - @property - def last_sound(self): - """ - Last sound log of monitored station that occurred on_air - """ - return self.get_last_log(type=Log.Type.on_air, sound__isnull=False) + """ Last log of monitored station. """ + return self.logs.first() @property def last_diff_start(self): - """ - Log of last triggered item (sound or diffusion) - """ - return self.get_last_log(type=Log.Type.start, diffusion__isnull=False) + """ Log of last triggered item (sound or diffusion). """ + return self.logs.start().with_diff().first() - def __init__(self, station, **kwargs): - self.station = station + def __init__(self, streamer, **kwargs): + self.streamer = streamer self.__dict__.update(kwargs) + self.logs = self.get_logs_queryset() + + def get_logs_queryset(self): + """ Return queryset to assign as `self.logs` """ + return self.station.log_set.select_related('diffusion', 'sound') \ + .order_by('-pk') def monitor(self): - """ - Run all monitoring functions. - """ - if not self.streamer: - self.streamer = self.station.streamer - - if not self.streamer.ready(): + """ Run all monitoring functions once. """ + if not self.streamer.is_ready: return self.streamer.fetch() source = self.streamer.source - if source and source.sound: + if source and source.uri: log = self.trace_sound(source) if log: self.trace_tracks(log) else: print('no source or sound for stream; source = ', source) - self.sync_playlists() - self.handle() + self.handle_diffusions() + self.sync() def log(self, date=None, **kwargs): """ Create a log using **kwargs, and print info """ - log = Log(station=self.station, date=date or tz.now(), **kwargs) - if log.type == Log.Type.on_air and log.diffusion is None: - log.collision = Diffusion.objects.station(log.station) \ - .on_air().at(log.date).first() - + kwargs.setdefault('station', self.station) + log = Log(date=date or tz.now(), **kwargs) log.save() log.print() return log def trace_sound(self, source): - """ - Return log for current on_air (create and save it if required). - """ - sound_path = source.sound - air_time = source.air_time + """ Return on air sound log (create if not present). """ + sound_path, air_time = source.uri, source.air_time # check if there is yet a log for this sound on the source delta = tz.timedelta(seconds=5) air_times = (air_time - delta, air_time + delta) - log = self.log_qs.on_air().filter( - source=source.id, sound__path=sound_path, - date__range=air_times, - ).last() + log = self.logs.on_air().filter(source=source.id, + sound__path=sound_path, + date__range=air_times).first() if log: return log # get sound - sound = Sound.objects.filter(path=sound_path) \ - .select_related('diffusion').first() diff = None - if sound and sound.diffusion: - diff = sound.diffusion.original - # check for reruns - if not diff.is_date_in_range(air_time) and not diff.initial: - diff = Diffusion.objects.at(air_time) \ - .on_air().filter(initial=diff).first() + sound = Sound.objects.filter(path=sound_path).first() + if sound and sound.episode_id is not None: + 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, source=source.id, date=source.on_air, - sound=sound, diffusion=diff, - # if sound is removed, we keep sound path info - comment=sound_path, - ) + return self.log(type=Log.Type.on_air, date=source.air_time, + source=source.id, sound=sound, diffusion=diff, + comment=sound_path) def trace_tracks(self, log): """ @@ -172,10 +133,13 @@ class Monitor: if log.diffusion: return - tracks = Track.objects.filter(sound=log.sound, timestamp__isnull=False) + tracks = Track.objects \ + .filter(sound__id=log.sound_id, timestamp__isnull=False)\ + .order_by('timestamp') if not tracks.exists(): return + # exclude already logged tracks tracks = tracks.exclude(log__station=self.station, log__pk__gt=log.pk) now = tz.now() for track in tracks: @@ -183,178 +147,40 @@ class Monitor: if pos > now: break # log track on air - self.log( - type=Log.Type.on_air, source=log.source, - date=pos, track=track, - comment=track, - ) + self.log(type=Log.Type.on_air, date=pos, source=log.source, + track=track, comment=track) - def sync_playlists(self): - """ - Synchronize updated playlists - """ - now = tz.now() - if self.sync_next and self.sync_next < now: - return - - self.sync_next = now + tz.timedelta(seconds=self.sync_timeout) - - for source in self.station.sources: - if source == self.station.dealer: - continue - playlist = source.program.sound_set.all() \ - .filter(type=Sound.Type.archive) \ - .values_list('path', flat=True) - source.playlist = list(playlist) - - def trace_canceled(self): - """ - Check diffusions that should have been played but did not start, - and cancel them - """ - if not self.cancel_timeout: - return - - qs = Diffusions.objects.station(self.station).at().filter( - type=Diffusion.Type.on_air, - sound__type=Sound.Type.archive, - ) - logs = Log.objects.station(station).on_air().with_diff() - - date = tz.now() - datetime.timedelta(seconds=self.cancel_timeout) - for diff in qs: - if logs.filter(diffusion=diff): - continue - if diff.start < now: - diff.type = Diffusion.Type.canceled - diff.save() - # log canceled diffusion - self.log( - type=Log.Type.other, - diffusion=diff, - comment='Diffusion canceled after {} seconds' - .format(self.cancel_timeout) - ) - - def __current_diff(self): - """ - Return a tuple with the currently running diffusion and the items - that still have to be played. If there is not, return None - """ - station = self.station - now = tz.now() - - log = Log.objects.station(station).on_air().with_diff() \ - .select_related('diffusion') \ - .order_by('date').last() - if not log or not log.diffusion.is_date_in_range(now): - # not running anymore - return None, [] - - # last sound source change: end of file reached or forced to stop - sounds = Log.objects.station(station).on_air().with_sound() \ - .filter(date__gte=log.date) \ - .order_by('date') - - if sounds.count() and sounds.last().source != log.source: - return None, [] - - # last diff is still playing: get remaining playlist - sounds = sounds \ - .filter(source=log.source, pk__gt=log.pk) \ - .exclude(sound__type=Sound.Type.removed) - - remaining = log.diffusion.get_sounds(archive=True) \ - .exclude(pk__in=sounds) \ - .values_list('path', flat=True) - return log.diffusion, list(remaining) - - def __next_diff(self, diff): - """ - Return the next diffusion to be played as tuple of (diff, playlist). - If diff is given, it is the one to be played right after it. - """ - station = self.station - kwargs = {'start__gte': diff.end} if diff else {} - qs = Diffusion.objects.station(station) \ - .on_air().at().filter(**kwargs) \ - .distinct().order_by('start') - diff = qs.first() - return (diff, diff and diff.get_playlist(archive=True) or []) - - def handle_pl_sync(self, source, playlist, diff=None, date=None): - """ - Update playlist of a source if required, and handle logging when - it is needed. - - - source: source on which it happens - - playlist: list of sounds to use to update - - diff: related diffusion - """ - if source.playlist == playlist: - return - - source.playlist = playlist - if diff and not diff.is_live(): - # log diffusion archive load - self.log(type=Log.Type.load, - source=source.id, - diffusion=diff, - date=date, - comment='\n'.join(playlist)) - - def handle_diff_start(self, source, diff, date): - """ - Enable dealer in order to play a given diffusion if required, - handle start of diffusion - """ - if not diff or diff.start > date: - return - - # TODO: user has not yet put the diffusion sound when diff started - # => live logged; what we want: if user put a sound after it - # has been logged as live, load and start this sound - - # live: just log it - if diff.is_live(): - diff_ = Log.objects.station(self.station) \ - .filter(diffusion=diff, type=Log.Type.on_air) - if not diff_.count(): - # log live diffusion - self.log(type=Log.Type.on_air, source=source.id, - diffusion=diff, date=date) - return - - # enable dealer - if not source.active: - source.active = True - last_start = self.last_diff_start - if not last_start or last_start.diffusion_id != diff.pk: - # log triggered diffusion - self.log(type=Log.Type.start, source=source.id, - diffusion=diff, date=date) - - def handle(self): + def handle_diffusions(self): """ Handle scheduled diffusion, trigger if needed, preload playlists and so on. """ - station = self.station - dealer = station.dealer - if not dealer: + # TODO: restart + # TODO: handle conflict + cancel + diff = Diffusion.objects.station(self.station).on_air().now() \ + .filter(episode__sound__type=Sound.Type.archive) \ + .first() + log = self.logs.start().filter(diffusion=diff) if diff else None + if not diff or log: return + + playlist = Sound.objects.episode(id=diff.episode_id).paths() + dealer = self.streamer.dealer + dealer.queue(*playlist) + self.log(type=Log.Type.start, source=dealer.id, diffusion=diff, + comment=str(diff)) + + def sync(self): + """ Update sources' playlists. """ now = tz.now() + if self.sync_next is not None and now < self.sync_next: + return - # current and next diffs - current_diff, remaining_pl = self.__current_diff() - next_diff, next_pl = self.__next_diff(current_diff) + self.sync_next = now + tz.timedelta(minutes=self.sync_timeout) - # playlist - dealer.active = bool(remaining_pl) - playlist = remaining_pl + next_pl - - self.handle_pl_sync(dealer, playlist, next_diff, now) - self.handle_diff_start(dealer, next_diff, now) + for source in self.streamer.sources: + if isinstance(source, PlaylistSource): + source.sync() class Command (BaseCommand): @@ -390,32 +216,31 @@ class Command (BaseCommand): ) group.add_argument( '-t', '--timeout', type=int, - default=600, - help='time to wait in SECONDS before canceling a diffusion that ' - 'has not been ran but should have been. If 0, does not ' - 'check' + default=Monitor.cancel_timeout, + 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()[:] + stations = Station.objects.filter(name__in=station) if station else \ + Station.objects.all() + streamers = [Streamer(station) for station in stations] - for station in stations: - # station.prepare() - if config and not run: # no need to write it twice - station.streamer.push() + for streamer in streamers: + if config: + streamer.make_config() if run: - station.streamer.process_run() + streamer.run_process() if monitor: - monitors = [ - Monitor(station, cancel_timeout=timeout) - for station in stations - ] + monitors = [Monitor(streamer, cancel_timeout=timeout) + for streamer in streamers] + delay = delay / 1000 while True: for monitor in monitors: @@ -423,5 +248,5 @@ class Command (BaseCommand): time.sleep(delay) if run: - for station in stations: - station.controller.process_wait() + for streamer in streamers: + streamer.wait_process() diff --git a/aircox/models.py b/aircox/models.py deleted file mode 100755 index 94ccf59..0000000 --- a/aircox/models.py +++ /dev/null @@ -1,1520 +0,0 @@ -import calendar -import datetime -import logging -import os -import shutil -from enum import IntEnum - -import pytz -from django.conf import settings as main_settings -from django.contrib.contenttypes.fields import (GenericForeignKey, - GenericRelation) -from django.contrib.contenttypes.models import ContentType -from django.db import models -from django.db.models import F, Q -from django.db.models.functions import Concat, Substr -from django.db.transaction import atomic -from django.utils import timezone as tz -from django.utils.functional import cached_property -from django.utils.html import strip_tags -from django.utils.translation import ugettext_lazy as _ - -import aircox.settings as settings -import aircox.utils as utils -from taggit.managers import TaggableManager - -logger = logging.getLogger('aircox.core') - - -# -# Station related classes -# -class StationQuerySet(models.QuerySet): - def default(self, station=None): - """ - Return station model instance, using defaults or - given one. - """ - if station is None: - return self.order_by('-default', 'pk').first() - return self.filter(pk=station).first() - - -def default_station(): - """ Return default station (used by model fields) """ - return Station.objects.default() - - -class Station(models.Model): - """ - Represents a radio station, to which multiple programs are attached - and that is used as the top object for everything. - - A Station holds controllers for the audio stream generation too. - Theses are set up when needed (at the first access to these elements) - then cached. - """ - name = models.CharField(_('name'), max_length=64) - slug = models.SlugField(_('slug'), max_length=64, unique=True) - path = models.CharField( - _('path'), - help_text=_('path to the working directory'), - max_length=256, - blank=True, - ) - default = models.BooleanField( - _('default station'), - default=True, - help_text=_('if checked, this station is used as the main one') - ) - - objects = StationQuerySet.as_manager() - - # - # Controllers - # - __sources = None - __dealer = None - __streamer = None - - def __prepare_controls(self): - import aircox.controllers as controllers - if not self.__streamer: - self.__streamer = controllers.Streamer(station=self) - self.__dealer = controllers.Source(station=self) - self.__sources = [self.__dealer] + [ - controllers.Source(station=self, program=program) - - for program in Program.objects.filter(stream__isnull=False) - ] - - @property - def inputs(self): - """ - Return all active input ports of the station - """ - return self.port_set.filter( - direction=Port.Direction.input, - active=True - ) - - @property - def outputs(self): - """ Return all active output ports of the station """ - return self.port_set.filter( - direction=Port.Direction.output, - active=True, - ) - - @property - def sources(self): - """ Audio sources, dealer included """ - self.__prepare_controls() - return self.__sources - - @property - def dealer(self): - self.__prepare_controls() - return self.__dealer - - @property - def streamer(self): - """ - Audio controller for the station - """ - self.__prepare_controls() - return self.__streamer - - def on_air(self, date=None, count=0): - """ - Return a queryset of what happened on air, based on logs and - diffusions informations. The queryset is sorted by -date. - - * date: only for what happened on this date; - * count: number of items to retrieve if not zero; - - If date is not specified, count MUST be set to a non-zero value. - - It is different from Logs.on_air method since it filters - out elements that should have not been on air, such as a stream - that has been played when there was a live diffusion. - """ - # TODO argument to get sound instead of tracks - if not date and not count: - raise ValueError('at least one argument must be set') - - # FIXME can be a potential source of bug - if date: - date = utils.cast_date(date, datetime.date) - if date and date > datetime.date.today(): - return [] - - now = tz.now() - if date: - logs = Log.objects.at(date) - diffs = Diffusion.objects.station(self).at(date) \ - .filter(start__lte=now, type=Diffusion.Type.on_air) \ - .order_by('-start') - else: - logs = Log.objects - diffs = Diffusion.objects \ - .filter(type=Diffusion.Type.on_air, - start__lte=now) \ - .order_by('-start')[:count] - - q = Q(diffusion__isnull=False) | Q(track__isnull=False) - logs = logs.station(self).on_air().filter(q).order_by('-date') - - # filter out tracks played when there was a diffusion - n, q = 0, Q() - for diff in diffs: - if count and n >= count: - break - # FIXME: does not catch tracks started before diff end but - # that continued afterwards - q = q | Q(date__gte=diff.start, date__lte=diff.end) - n += 1 - logs = logs.exclude(q, diffusion__isnull=True) - if count: - logs = logs[:count] - return logs - - def __str__(self): - return self.name - - def save(self, make_sources=True, *args, **kwargs): - if not self.path: - self.path = os.path.join( - settings.AIRCOX_CONTROLLERS_WORKING_DIR, - self.slug - ) - - if self.default: - qs = Station.objects.filter(default=True) - - if self.pk: - qs = qs.exclude(pk=self.pk) - qs.update(default=False) - - super().save(*args, **kwargs) - - -class ProgramManager(models.Manager): - def station(self, station, qs=None, **kwargs): - qs = self if qs is None else qs - - return qs.filter(station=station, **kwargs) - - -class Program(models.Model): - """ - A Program can either be a Streamed or a Scheduled program. - - A Streamed program is used to generate non-stop random playlists when there - is not scheduled diffusion. In such a case, a Stream is used to describe - diffusion informations. - - A Scheduled program has a schedule and is the one with a normal use case. - - Renaming a Program rename the corresponding directory to matches the new - name if it does not exists. - """ - station = models.ForeignKey( - Station, - verbose_name=_('station'), - on_delete=models.CASCADE, - ) - name = models.CharField(_('name'), max_length=64) - slug = models.SlugField(_('slug'), max_length=64, unique=True) - active = models.BooleanField( - _('active'), - default=True, - help_text=_('if not checked this program is no longer active') - ) - sync = models.BooleanField( - _('syncronise'), - default=True, - help_text=_('update later diffusions according to schedule changes') - ) - - objects = ProgramManager() - - @property - def path(self): - """ Return program's directory path """ - return os.path.join(settings.AIRCOX_PROGRAMS_DIR, self.slug) - - def ensure_dir(self, subdir=None): - """ - Make sur the program's dir exists (and optionally subdir). Return True - if the dir (or subdir) exists. - """ - path = os.path.join(self.path, subdir) if subdir else \ - self.path - os.makedirs(path, exist_ok=True) - - return os.path.exists(path) - - @property - def archives_path(self): - return os.path.join( - self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - - @property - def excerpts_path(self): - return os.path.join( - self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR - ) - - def find_schedule(self, date): - """ - Return the first schedule that matches a given date. - """ - schedules = Schedule.objects.filter(program=self) - - for schedule in schedules: - if schedule.match(date, check_time=False): - return schedule - - def __init__(self, *kargs, **kwargs): - super().__init__(*kargs, **kwargs) - - if self.slug: - self.__initial_path = self.path - - @classmethod - def get_from_path(cl, path): - """ - Return a Program from the given path. We assume the path has been - given in a previous time by this model (Program.path getter). - """ - path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '') - - while path[0] == '/': - path = path[1:] - - while path[-1] == '/': - path = path[:-2] - - if '/' in path: - path = path[:path.index('/')] - - path = path.split('_') - path = path[-1] - qs = cl.objects.filter(id=int(path)) - - return qs[0] if qs else None - - def is_show(self): - return self.schedule_set.count() != 0 - - def __str__(self): - return self.name - - def save(self, *kargs, **kwargs): - super().save(*kargs, **kwargs) - - path_ = getattr(self, '__initial_path', None) - if path_ is not None and path_ != self.path and \ - os.path.exists(path_) and not os.path.exists(self.path): - logger.info('program #%s\'s dir changed to %s - update it.', - self.id, self.name) - - shutil.move(path_, self.path) - Sound.objects.filter(path__startswith=path_) \ - .update(path=Concat('path', Substr(F('path'), len(path_)))) - - -class Stream(models.Model): - """ - When there are no program scheduled, it is possible to play sounds - in order to avoid blanks. A Stream is a Program that plays this role, - and whose linked to a Stream. - - All sounds that are marked as good and that are under the related - program's archive dir are elligible for the sound's selection. - """ - program = models.ForeignKey( - Program, - verbose_name=_('related program'), - on_delete=models.CASCADE, - ) - delay = models.TimeField( - _('delay'), - blank=True, null=True, - help_text=_('minimal delay between two sound plays') - ) - begin = models.TimeField( - _('begin'), - blank=True, null=True, - help_text=_('used to define a time range this stream is' - 'played') - ) - end = models.TimeField( - _('end'), - blank=True, null=True, - help_text=_('used to define a time range this stream is' - 'played') - ) - - -# BIG FIXME: self.date is still used as datetime -class Schedule(models.Model): - """ - A Schedule defines time slots of programs' diffusions. It can be an initial - run or a rerun (in such case it is linked to the related schedule). - """ - # Frequency for schedules. Basically, it is a mask of bits where each bit is - # a week. Bits > rank 5 are used for special schedules. - # Important: the first week is always the first week where the weekday of - # the schedule is present. - # For ponctual programs, there is no need for a schedule, only a diffusion - class Frequency(IntEnum): - ponctual = 0b000000 - first = 0b000001 - second = 0b000010 - third = 0b000100 - fourth = 0b001000 - last = 0b010000 - first_and_third = 0b000101 - second_and_fourth = 0b001010 - every = 0b011111 - one_on_two = 0b100000 - - program = models.ForeignKey( - Program, models.CASCADE, - verbose_name=_('related program'), - ) - date = models.DateField( - _('date'), help_text=_('date of the first diffusion'), - ) - time = models.TimeField( - _('time'), help_text=_('start time'), - ) - timezone = models.CharField( - _('timezone'), - default=tz.get_current_timezone, max_length=100, - choices=[(x, x) for x in pytz.all_timezones], - help_text=_('timezone used for the date') - ) - duration = models.TimeField( - _('duration'), - help_text=_('regular duration'), - ) - frequency = models.SmallIntegerField( - _('frequency'), - choices=[(int(y), { - 'ponctual': _('ponctual'), - 'first': _('1st {day} of the month'), - 'second': _('2nd {day} of the month'), - 'third': _('3rd {day} of the month'), - 'fourth': _('4th {day} of the month'), - 'last': _('last {day} of the month'), - 'first_and_third': _('1st and 3rd {day}s of the month'), - 'second_and_fourth': _('2nd and 4th {day}s of the month'), - 'every': _('{day}'), - 'one_on_two': _('one {day} on two'), - }[x]) for x, y in Frequency.__members__.items()], - ) - initial = models.ForeignKey( - 'self', models.SET_NULL, - verbose_name=_('initial schedule'), - blank=True, null=True, - help_text=_('this schedule is a rerun of this one'), - ) - - @cached_property - def tz(self): - """ - Pytz timezone of the schedule. - """ - import pytz - - return pytz.timezone(self.timezone) - - @cached_property - def start(self): - """ Datetime of the start (timezone unaware) """ - return tz.datetime.combine(self.date, self.time) - - @cached_property - def end(self): - """ Datetime of the end """ - return self.start + utils.to_timedelta(self.duration) - - def get_frequency_verbose(self): - """ Return frequency formated for display """ - from django.template.defaultfilters import date - return self.get_frequency_display().format( - day=date(self.date, 'l') - ) - - # initial cached data - __initial = None - - def changed(self, fields=['date', 'duration', 'frequency', 'timezone']): - initial = self._Schedule__initial - - if not initial: - return - - this = self.__dict__ - - for field in fields: - if initial.get(field) != this.get(field): - return True - - return False - - def match(self, date=None, check_time=True): - """ - Return True if the given date(time) matches the schedule. - """ - date = utils.date_or_default( - date, tz.datetime if check_time else datetime.date) - - if self.date.weekday() != date.weekday() or \ - not self.match_week(date): - return False - - # we check against a normalized version (norm_date will have - # schedule's date. - return date == self.normalize(date) if check_time else True - - def match_week(self, date=None): - """ - Return True if the given week number matches the schedule, False - otherwise. - If the schedule is ponctual, return None. - """ - - if self.frequency == Schedule.Frequency.ponctual: - return False - - # since we care only about the week, go to the same day of the week - date = utils.date_or_default(date, datetime.date) - date += tz.timedelta(days=self.date.weekday() - date.weekday()) - - # FIXME this case - - if self.frequency == Schedule.Frequency.one_on_two: - # cf notes in date_of_month - diff = date - utils.cast_date(self.date, datetime.date) - - return not (diff.days % 14) - - first_of_month = date.replace(day=1) - week = date.isocalendar()[1] - first_of_month.isocalendar()[1] - - # weeks of month - - if week == 4: - # fifth week: return if for every week - - return self.frequency == self.Frequency.every - - return (self.frequency & (0b0001 << week) > 0) - - def normalize(self, date): - """ - Return a new datetime with schedule time. Timezone is handled - using `schedule.timezone`. - """ - date = tz.datetime.combine(date, self.time) - return self.tz.normalize(self.tz.localize(date)) - - def dates_of_month(self, date): - """ - Return a list with all matching dates of date.month (=today) - Ensure timezone awareness. - - :param datetime.date date: month and year - """ - - if self.frequency == Schedule.Frequency.ponctual: - return [] - - sched_wday, freq = self.date.weekday(), self.frequency - date = date.replace(day=1) - - # last of the month - if freq == Schedule.Frequency.last: - date = date.replace( - day=calendar.monthrange(date.year, date.month)[1]) - date_wday = date.weekday() - - # end of month before the wanted weekday: move one week back - - if date_wday < sched_wday: - date -= tz.timedelta(days=7) - date += tz.timedelta(days=sched_wday - date_wday) - - return [self.normalize(date)] - - # move to the first day of the month that matches the schedule's weekday - # check on SO#3284452 for the formula - date_wday, month = date.weekday(), date.month - date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) - - date_wday + sched_wday) - - if freq == Schedule.Frequency.one_on_two: - # - adjust date with modulo 14 (= 2 weeks in days) - # - there are max 3 "weeks on two" per month - if (date - self.date).days % 14: - date += tz.timedelta(days=7) - dates = (date + tz.timedelta(days=14*i) for i in range(0, 3)) - else: - dates = (date + tz.timedelta(days=7*week) for week in range(0, 5) - if freq & (0b1 << week)) - - return [self.normalize(date) for date in dates if date.month == month] - - def diffusions_of_month(self, date=None, exclude_saved=False): - """ - Return a list of Diffusion instances, from month of the given date, that - can be not in the database. - - If exclude_saved, exclude all diffusions that are yet in the database. - """ - - if self.frequency == Schedule.Frequency.ponctual: - return [] - - dates = self.dates_of_month(date) - diffusions = [] - - # existing diffusions - - for item in Diffusion.objects.filter( - program=self.program, start__in=dates): - - if item.start in dates: - dates.remove(item.start) - - if not exclude_saved: - diffusions.append(item) - - # new diffusions - duration = utils.to_timedelta(self.duration) - - delta = None - if self.initial: - delta = self.start - self.initial.start - - # FIXME: daylight saving bug: delta misses an hour when diffusion and - # rerun are not on the same daylight-saving timezone - # -> solution: add rerun=True param, and gen reruns from initial for each - diffusions += [ - Diffusion( - program=self.program, - type=Diffusion.Type.unconfirmed, - initial=Diffusion.objects.program(self.program).filter(start=date-delta).first() - if self.initial else None, - start=date, - end=date + duration, - ) for date in dates - ] - - return diffusions - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # initial only if it has been yet saved - - if self.pk: - self.__initial = self.__dict__.copy() - - def __str__(self): - return ' | '.join(['#' + str(self.id), self.program.name, - self.get_frequency_display(), - self.time.strftime('%a %H:%M')]) - - def save(self, *args, **kwargs): - if self.initial: - self.program = self.initial.program - self.duration = self.initial.duration - - if not self.frequency: - self.frequency = self.initial.frequency - super().save(*args, **kwargs) - - class Meta: - verbose_name = _('Schedule') - verbose_name_plural = _('Schedules') - - -class DiffusionQuerySet(models.QuerySet): - def station(self, station, **kwargs): - return self.filter(program__station=station, **kwargs) - - def program(self, program): - return self.filter(program=program) - - def on_air(self): - return self.filter(type=Diffusion.Type.on_air) - - def at(self, date=None): - """ - Return diffusions occuring at the given date, ordered by +start - - If date is a datetime instance, get diffusions that occurs at - the given moment. If date is not a datetime object, it uses - it as a date, and get diffusions that occurs this day. - - When date is None, uses tz.now(). - """ - # note: we work with localtime - date = utils.date_or_default(date) - - qs = self - filters = None - - if isinstance(date, datetime.datetime): - # use datetime: we want diffusion that occurs around this - # range - filters = {'start__lte': date, 'end__gte': date} - qs = qs.filter(**filters) - else: - # use date: we want diffusions that occurs this day - qs = qs.filter(Q(start__date=date) | Q(end__date=date)) - return qs.order_by('start').distinct() - - def after(self, date=None): - """ - Return a queryset of diffusions that happen after the given - date (default: today). - """ - date = utils.date_or_default(date) - if isinstance(date, tz.datetime): - qs = self.filter(start__gte=date) - else: - qs = self.filter(start__date__gte=date) - return qs.order_by('start') - - def before(self, date=None): - """ - Return a queryset of diffusions that finish before the given - date (default: today). - """ - date = utils.date_or_default(date) - if isinstance(date, tz.datetime): - qs = self.filter(start__lt=date) - else: - qs = self.filter(start__date__lt=date) - return qs.order_by('start') - - def range(self, start, end): - # FIXME can return dates that are out of range... - return self.after(start).before(end) - - -class Diffusion(models.Model): - """ - A Diffusion is an occurrence of a Program that is scheduled on the - station's timetable. It can be a rerun of a previous diffusion. In such - a case, use rerun's info instead of its own. - - A Diffusion without any rerun is named Episode (previously, a - Diffusion was different from an Episode, but in the end, an - episode only has a name, a linked program, and a list of sounds, so we - finally merge theme). - - A Diffusion can have different types: - - default: simple diffusion that is planified / did occurred - - unconfirmed: a generated diffusion that has not been confirmed and thus - is not yet planified - - cancel: the diffusion has been canceled - - stop: the diffusion has been manually stopped - """ - objects = DiffusionQuerySet.as_manager() - - class Type(IntEnum): - normal = 0x00 - unconfirmed = 0x01 - canceled = 0x02 - - # common - program = models.ForeignKey( - Program, - verbose_name=_('program'), - on_delete=models.CASCADE, - ) - # specific - type = models.SmallIntegerField( - verbose_name=_('type'), - choices=[(int(y), _(x)) for x, y in Type.__members__.items()], - ) - initial = models.ForeignKey( - 'self', on_delete=models.SET_NULL, - blank=True, null=True, - related_name='reruns', - verbose_name=_('initial diffusion'), - help_text=_('the diffusion is a rerun of this one') - ) - # port = models.ForeignKey( - # 'self', - # verbose_name = _('port'), - # blank = True, null = True, - # on_delete=models.SET_NULL, - # help_text = _('use this input port'), - # ) - conflicts = models.ManyToManyField( - 'self', - verbose_name=_('conflicts'), - blank=True, - help_text=_('conflicts'), - ) - - start = models.DateTimeField(_('start of the diffusion')) - end = models.DateTimeField(_('end of the diffusion')) - - @property - def duration(self): - return self.end - self.start - - @property - def date(self): - """ Return diffusion start as a date. """ - - return utils.cast_date(self.start) - - @cached_property - def local_start(self): - """ - Return a version of self.date that is localized to self.timezone; - This is needed since datetime are stored as UTC date and we want - to get it as local time. - """ - - return tz.localtime(self.start, tz.get_current_timezone()) - - @property - def local_end(self): - """ - Return a version of self.date that is localized to self.timezone; - This is needed since datetime are stored as UTC date and we want - to get it as local time. - """ - - return tz.localtime(self.end, tz.get_current_timezone()) - - @property - def original(self): - """ Return the original diffusion (self or initial) """ - - return self.initial if self.initial else self - - def is_live(self): - """ - True if Diffusion is live (False if there are sounds files) - """ - - return self.type == self.Type.on_air and \ - not self.get_sounds(archive=True).count() - - def get_playlist(self, **types): - """ - Returns sounds as a playlist (list of *local* archive file path). - The given arguments are passed to ``get_sounds``. - """ - - return list(self.get_sounds(**types) - .filter(path__isnull=False, - type=Sound.Type.archive) - .values_list('path', flat=True)) - - def get_sounds(self, **types): - """ - Return a queryset of sounds related to this diffusion, - ordered by type then path. - - **types: filter on the given sound types name, as `archive=True` - """ - sounds = (self.initial or self).sound_set.order_by('type', 'path') - _in = [getattr(Sound.Type, name) - for name, value in types.items() if value] - - return sounds.filter(type__in=_in) - - def is_date_in_range(self, date=None): - """ - Return true if the given date is in the diffusion's start-end - range. - """ - date = date or tz.now() - - return self.start < date < self.end - - def get_conflicts(self): - """ - Return a list of conflictual diffusions, based on the scheduled duration. - """ - - return Diffusion.objects.filter( - Q(start__lt=self.start, end__gt=self.start) | - Q(start__gt=self.start, start__lt=self.end) - ).exclude(pk=self.pk).distinct() - - def check_conflicts(self): - conflicts = self.get_conflicts() - self.conflicts.set(conflicts) - - __initial = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__initial = { - 'start': self.start, - 'end': self.end, - } - - def save(self, no_check=False, *args, **kwargs): - if no_check: - return super().save(*args, **kwargs) - - if self.initial: - # enforce link to the original diffusion - self.initial = self.initial.original - self.program = self.initial.program - - super().save(*args, **kwargs) - - if self.__initial: - if self.start != self.__initial['start'] or \ - self.end != self.__initial['end']: - self.check_conflicts() - - def __str__(self): - str_ = '{self.program.name} {date}'.format( - self=self, date=self.local_start.strftime('%Y/%m/%d %H:%M%z'), - ) - if self.initial: - str_ += ' ({})'.format(_('rerun')) - return str_ - - class Meta: - verbose_name = _('Diffusion') - verbose_name_plural = _('Diffusions') - permissions = ( - ('programming', _('edit the diffusion\'s planification')), - ) - - -class SoundQuerySet(models.QuerySet): - def podcasts(self): - """ Return sound available as podcasts """ - return self.filter(Q(embed__isnull=False) | Q(is_public=True)) - - def diffusion(self, diffusion): - return self.filter(diffusion=diffusion) - - -class Sound(models.Model): - """ - A Sound is the representation of a sound file that can be either an excerpt - or a complete archive of the related diffusion. - """ - class Type(IntEnum): - other = 0x00, - archive = 0x01, - excerpt = 0x02, - removed = 0x03, - - name = models.CharField(_('name'), max_length=64) - program = models.ForeignKey( - Program, - verbose_name=_('program'), - blank=True, null=True, - on_delete=models.SET_NULL, - help_text=_('program related to it'), - ) - diffusion = models.ForeignKey( - Diffusion, models.SET_NULL, - verbose_name=_('diffusion'), - blank=True, null=True, - limit_choices_to={'initial__isnull': True}, - help_text=_('initial diffusion related it') - ) - type = models.SmallIntegerField( - verbose_name=_('type'), - choices=[(int(y), _(x)) for x, y in Type.__members__.items()], - blank=True, null=True - ) - # FIXME: url() does not use the same directory than here - # should we use FileField for more reliability? - path = models.FilePathField( - _('file'), - path=settings.AIRCOX_PROGRAMS_DIR, - match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) - .replace('.', r'\.') + ')$', - recursive=True, - blank=True, null=True, - unique=True, - max_length=255 - ) - embed = models.TextField( - _('embed HTML code'), - blank=True, null=True, - help_text=_('HTML code used to embed a sound from external plateform'), - ) - duration = models.TimeField( - _('duration'), - blank=True, null=True, - help_text=_('duration of the sound'), - ) - mtime = models.DateTimeField( - _('modification time'), - blank=True, null=True, - help_text=_('last modification date and time'), - ) - is_good_quality = models.BooleanField( - _('good quality'), - help_text=_('sound meets quality requirements for diffusion'), - blank=True, null=True - ) - is_public = models.BooleanField( - _('public'), - default=False, - help_text=_('the sound is accessible to the public') - ) - - objects = SoundQuerySet.as_manager() - - def get_mtime(self): - """ - Get the last modification date from file - """ - mtime = os.stat(self.path).st_mtime - mtime = tz.datetime.fromtimestamp(mtime) - # db does not store microseconds - mtime = mtime.replace(microsecond=0) - - return tz.make_aware(mtime, tz.get_current_timezone()) - - def url(self): - """ - Return an url to the stream - """ - # path = self._meta.get_field('path').path - path = self.path.replace(main_settings.MEDIA_ROOT, '', 1) - #path = self.path.replace(path, '', 1) - - return main_settings.MEDIA_URL + '/' + path - - def file_exists(self): - """ - Return true if the file still exists - """ - - return os.path.exists(self.path) - - def file_metadata(self): - """ - Get metadata from sound file and return a Track object if succeed, - else None. - """ - if not self.file_exists(): - return None - - import mutagen - try: - meta = mutagen.File(self.path) - except: - meta = {} - - if meta is None: - meta = {} - - def get_meta(key, cast=str): - value = meta.get(key) - return cast(value[0]) if value else None - - info = '{} ({})'.format(get_meta('album'), get_meta('year')) \ - if meta and ('album' and 'year' in meta) else \ - get_meta('album') \ - if 'album' else \ - ('year' in meta) and get_meta('year') or '' - - return Track(sound=self, - position=get_meta('tracknumber', int) or 0, - title=get_meta('title') or self.name, - artist=get_meta('artist') or _('unknown'), - info=info) - - def check_on_file(self): - """ - Check sound file info again'st self, and update informations if - needed (do not save). Return True if there was changes. - """ - - if not self.file_exists(): - if self.type == self.Type.removed: - return - logger.info('sound %s: has been removed', self.path) - self.type = self.Type.removed - - return True - - # not anymore removed - changed = False - - if self.type == self.Type.removed and self.program: - changed = True - self.type = self.Type.archive \ - if self.path.startswith(self.program.archives_path) else \ - self.Type.excerpt - - # check mtime -> reset quality if changed (assume file changed) - mtime = self.get_mtime() - - if self.mtime != mtime: - self.mtime = mtime - self.is_good_quality = None - logger.info('sound %s: m_time has changed. Reset quality info', - self.path) - - return True - - return changed - - def check_perms(self): - """ - Check file permissions and update it if the sound is public - """ - - if not settings.AIRCOX_SOUND_AUTO_CHMOD or \ - self.removed or not os.path.exists(self.path): - - return - - flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public] - try: - os.chmod(self.path, flags) - except PermissionError as err: - logger.error( - 'cannot set permissions {} to file {}: {}'.format( - self.flags[self.public], - self.path, err - ) - ) - - def __check_name(self): - if not self.name and self.path: - # FIXME: later, remove date? - self.name = os.path.basename(self.path) - self.name = os.path.splitext(self.name)[0] - self.name = self.name.replace('_', ' ') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__check_name() - - def save(self, check=True, *args, **kwargs): - if check: - self.check_on_file() - self.__check_name() - super().save(*args, **kwargs) - - def __str__(self): - return '/'.join(self.path.split('/')[-3:]) - - class Meta: - verbose_name = _('Sound') - verbose_name_plural = _('Sounds') - - -class Track(models.Model): - """ - Track of a playlist of an object. The position can either be expressed - as the position in the playlist or as the moment in seconds it started. - """ - diffusion = models.ForeignKey( - Diffusion, models.CASCADE, blank=True, null=True, - verbose_name=_('diffusion'), - ) - sound = models.ForeignKey( - Sound, models.CASCADE, blank=True, null=True, - verbose_name=_('sound'), - ) - position = models.PositiveSmallIntegerField( - _('order'), - default=0, - help_text=_('position in the playlist'), - ) - timestamp = models.PositiveSmallIntegerField( - _('timestamp'), - blank=True, null=True, - help_text=_('position in seconds') - ) - title = models.CharField( - _('title'), - max_length=128, - ) - artist = models.CharField( - _('artist'), - max_length=128, - ) - tags = TaggableManager( - verbose_name=_('tags'), - blank=True, - ) - info = models.CharField( - _('information'), - max_length=128, - blank=True, null=True, - help_text=_('additional informations about this track, such as ' - 'the version, if is it a remix, features, etc.'), - ) - - class Meta: - verbose_name = _('Track') - verbose_name_plural = _('Tracks') - ordering = ('position',) - - def __str__(self): - return '{self.artist} -- {self.title} -- {self.position}'.format( - self=self) - - def save(self, *args, **kwargs): - if (self.sound is None and self.diffusion is None) or \ - (self.sound is not None and self.diffusion is not None): - raise ValueError('sound XOR diffusion is required') - super().save(*args, **kwargs) - -# -# Controls and audio input/output -# -class Port (models.Model): - """ - Represent an audio input/output for the audio stream - generation. - - You might want to take a look to LiquidSoap's documentation - for the options available for each kind of input/output. - - Some port types may be not available depending on the - direction of the port. - """ - class Direction(IntEnum): - input = 0x00 - output = 0x01 - - class Type(IntEnum): - jack = 0x00 - alsa = 0x01 - pulseaudio = 0x02 - icecast = 0x03 - http = 0x04 - https = 0x05 - file = 0x06 - - station = models.ForeignKey( - Station, - verbose_name=_('station'), - on_delete=models.CASCADE, - ) - direction = models.SmallIntegerField( - _('direction'), - choices=[(int(y), _(x)) for x, y in Direction.__members__.items()], - ) - type = models.SmallIntegerField( - _('type'), - # we don't translate the names since it is project names. - choices=[(int(y), x) for x, y in Type.__members__.items()], - ) - active = models.BooleanField( - _('active'), - default=True, - help_text=_('this port is active') - ) - settings = models.TextField( - _('port settings'), - help_text=_('list of comma separated params available; ' - 'this is put in the output config file as raw code; ' - 'plugin related'), - blank=True, null=True - ) - - def is_valid_type(self): - """ - Return True if the type is available for the given direction. - """ - - if self.direction == self.Direction.input: - return self.type not in ( - self.Type.icecast, self.Type.file - ) - - return self.type not in ( - self.Type.http, self.Type.https - ) - - def save(self, *args, **kwargs): - if not self.is_valid_type(): - raise ValueError( - "port type is not allowed with the given port direction" - ) - - return super().save(*args, **kwargs) - - def __str__(self): - return "{direction}: {type} #{id}".format( - direction=self.get_direction_display(), - type=self.get_type_display(), - id=self.pk or '' - ) - - -class LogQuerySet(models.QuerySet): - def station(self, station): - return self.filter(station=station) - - def at(self, date=None): - date = utils.date_or_default(date) - return self.filter(date__date=date) - - def on_air(self): - return self.filter(type=Log.Type.on_air) - - def start(self): - return self.filter(type=Log.Type.start) - - def with_diff(self, with_it=True): - return self.filter(diffusion__isnull=not with_it) - - def with_sound(self, with_it=True): - return self.filter(sound__isnull=not with_it) - - def with_track(self, with_it=True): - return self.filter(track__isnull=not with_it) - - @staticmethod - def _get_archive_path(station, date): - # note: station name is not included in order to avoid problems - # of retrieving archive when it changes - - return os.path.join( - settings.AIRCOX_LOGS_ARCHIVES_DIR, - '{}_{}.log.gz'.format(date.strftime("%Y%m%d"), station.pk) - ) - - @staticmethod - def _get_rel_objects(logs, type, attr): - """ - From a list of dict representing logs, retrieve related objects - of the given type. - - Example: _get_rel_objects([{..},..], Diffusion, 'diffusion') - """ - attr_id = attr + '_id' - - return { - rel.pk: rel - - for rel in type.objects.filter( - pk__in=( - log[attr_id] - - for log in logs if attr_id in log - ) - ) - } - - def load_archive(self, station, date): - """ - Return archived logs for a specific date as a list - """ - import yaml - import gzip - - path = self._get_archive_path(station, date) - - if not os.path.exists(path): - return [] - - with gzip.open(path, 'rb') as archive: - data = archive.read() - logs = yaml.load(data) - - # we need to preload diffusions, sounds and tracks - rels = { - 'diffusion': self._get_rel_objects(logs, Diffusion, 'diffusion'), - 'sound': self._get_rel_objects(logs, Sound, 'sound'), - 'track': self._get_rel_objects(logs, Track, 'track'), - } - - def rel_obj(log, attr): - attr_id = attr + '_id' - rel_id = log.get(attr + '_id') - - return rels[attr][rel_id] if rel_id else None - - # make logs - - return [ - Log(diffusion=rel_obj(log, 'diffusion'), - sound=rel_obj(log, 'sound'), - track=rel_obj(log, 'track'), - **log) - - for log in logs - ] - - def make_archive(self, station, date, force=False, keep=False): - """ - Archive logs of the given date. If the archive exists, it does - not overwrite it except if "force" is given. In this case, the - new elements will be appended to the existing archives. - - Return the number of archived logs, -1 if archive could not be - created. - """ - import yaml - import gzip - - os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True) - path = self._get_archive_path(station, date) - - if os.path.exists(path) and not force: - return -1 - - qs = self.station(station).at(date) - - if not qs.exists(): - return 0 - - fields = Log._meta.get_fields() - logs = [{i.attname: getattr(log, i.attname) - for i in fields} for log in qs] - - # Note: since we use Yaml, we can just append new logs when file - # exists yet <3 - with gzip.open(path, 'ab') as archive: - data = yaml.dump(logs).encode('utf8') - archive.write(data) - - if not keep: - qs.delete() - - return len(logs) - - -class Log(models.Model): - """ - Log sounds and diffusions that are played on the station. - - This only remember what has been played on the outputs, not on each - source; Source designate here which source is responsible of that. - """ - class Type(IntEnum): - stop = 0x00 - """ - Source has been stopped, e.g. manually - """ - start = 0x01 - """ - The diffusion or sound has been triggered by the streamer or - manually. - """ - load = 0x02 - """ - A playlist has updated, and loading started. A related Diffusion - does not means that the playlist is only for it (e.g. after a - crash, it can reload previous remaining sound files + thoses of - the next diffusion) - """ - on_air = 0x03 - """ - The sound or diffusion has been detected occurring on air. Can - also designate live diffusion, although Liquidsoap did not play - them since they don't have an attached sound archive. - """ - other = 0x04 - """ - Other log - """ - - type = models.SmallIntegerField( - choices=[(int(y), _(x.replace('_', ' '))) - for x, y in Type.__members__.items()], - blank=True, null=True, - verbose_name=_('type'), - ) - station = models.ForeignKey( - Station, on_delete=models.CASCADE, - verbose_name=_('station'), - help_text=_('related station'), - ) - source = models.CharField( - # we use a CharField to avoid loosing logs information if the - # source is removed - max_length=64, blank=True, null=True, - verbose_name=_('source'), - help_text=_('identifier of the source related to this log'), - ) - date = models.DateTimeField( - default=tz.now, db_index=True, - verbose_name=_('date'), - ) - comment = models.CharField( - max_length=512, blank=True, null=True, - verbose_name=_('comment'), - ) - - diffusion = models.ForeignKey( - Diffusion, on_delete=models.SET_NULL, - blank=True, null=True, db_index=True, - verbose_name=_('Diffusion'), - ) - sound = models.ForeignKey( - Sound, on_delete=models.SET_NULL, - blank=True, null=True, db_index=True, - verbose_name=_('Sound'), - ) - track = models.ForeignKey( - Track, on_delete=models.SET_NULL, - blank=True, null=True, db_index=True, - verbose_name=_('Track'), - ) - - collision = models.ForeignKey( - Diffusion, on_delete=models.SET_NULL, - blank=True, null=True, - verbose_name=_('Collision'), - related_name='+', - ) - - objects = LogQuerySet.as_manager() - - @property - def related(self): - return self.diffusion or self.sound or self.track - - @property - def local_date(self): - """ - Return a version of self.date that is localized to self.timezone; - This is needed since datetime are stored as UTC date and we want - to get it as local time. - """ - return tz.localtime(self.date, tz.get_current_timezone()) - - def print(self): - r = [] - if self.diffusion: - r.append('diff: ' + str(self.diffusion_id)) - if self.sound: - r.append('sound: ' + str(self.sound_id)) - if self.track: - r.append('track: ' + str(self.track_id)) - logger.info('log %s: %s%s', str(self), self.comment or '', - ' (' + ', '.join(r) + ')' if r else '') - - def __str__(self): - return '#{} ({}, {}, {})'.format( - self.pk, self.get_type_display(), - self.source, - self.local_date.strftime('%Y/%m/%d %H:%M%z'), - ) diff --git a/aircox/models/__pycache__/episode.cpython-37.pyc b/aircox/models/__pycache__/episode.cpython-37.pyc index d7e1cf0f1299b1c1918741b150bb088c2867885b..5daa7d116ce3a3cebd233c55cf5187953a1a554a 100644 GIT binary patch delta 4349 zcmbVPO>i7X6`r1--Ps?lR<{0D-jzrH8ruk)B>syd9K}g&9E&&+0X8w?(RQyq^8UGJ z*0DB zo1XXc_3QUue|G(E*D_0eeMtrWUc7nn#y=-N%8Zimqq!fvQynXg5rrmcx^kqNFXpR- zVgcrTG(-E>wBq>d3eD2KhrnU^2U_tc%m-*c%=_6G%q^H7q8XTHSPteBl)SCjhs7Yd zU(mz&o4mfp-D)^+rPdi#OUm6!_f2HE2VaDpg$Hud?7)o$@aur_XUDuf!@9$LVUf37^Byr2Pot5IlYc zh)Hx3BWcZ4GkRLwi;tAQ1ze^S2~~eP6s6m>dCL5FC9CSeq# z!!$+HFpAR=3=kNZbd>hfEQ}Hm7Kh-=qzr%@9|rNuB~ARWZ?J8_cvGe1w#f%Km33v4 zQ1vmyi5!Ds9NT)UU|sUc<)-h|Yra*kb1S&z`4)>}z*@FR81GDMQcQJ*j^j~y6Dxa_ zfN@*n)1Zu>1`?_smC&4t0Ft2K3xrSxg`y6UR&Gz}ZfGuaM2kCxVUbJ>PqYqOU$0rN z$1U155R~e{MuXWpKO?@B$hD>7F9rgC5mY^o>Qo?#+8TXYS-s|f{o{Ljs7Yn*{J9kJT*=INGMlPi$&iI&G`l2&o28YOP!^l=V`n$-UZ| zRja>m*=jNA2QCjB2LLOYtVSJYEnsjWkZT*E8ni+Umhv;8JT$522VSidgl3&n#+}s- z@L2k~i0=)_4b?kszZ-{90OI?z@c1zxikc>eNNeb606^6joX6GD`SUOU=hK0*PPWv@ z{VgrfrTawwLKrP6+XP`~^V5KXLA5;?^j7{r7=k)=Hx?`mq7D-BXhRH3FmS>zfNnbm z-oqsDs!X2s(2$=>V7LJ*_(fb6!O`ZIkn=K6cMnh`Y!vlk2 zFBlN?01yety81xnqnpZ>2Em|it9X>!TL4Tnu}SNc^%e`7yw)4BZeaNhR`SXoqZT@5 z!5Q0i(1M*$dcIc!NNOdB>&2Z-Bhjqk_MVBK~i& zu~i0%hkZl*HuK)8%P8t=Kz1ERJ)s6cvTtuggS)jg=8Cubr`ziCvaK$!$cL=h1oGCh zo#H(u0L5Y+lG&jPFMkbrdrJ8mIPD@VM>27!0!}2PN(QJp3bI{@cj7IKmu}U)67%Kx=2yi}hkm@W&nZ+DS%S?&n1VK;>iM!L z^(gB)Wx*-|Psb@}G*oCbJ|2D)6$H7_7{)Ebn*ZQK8)Aj=X3dWCRQ#ylh&%8dcbt!5g^wpq0cge6$h^2;NVF$m z9w-!w#{-pM(S`i6sW|YgtLqw!G^)TJtIMsVw9S%DkscZis2Tw;!sn5l*s;Jip&dO? zAnsujzj5T}KZALc3k3-#BW9iFCS!gTH4)N_e-?g1fodGv!pe=m!KEA z3&mJ&;n-DJ6;+9Y0Wm4`qye-tJA&u#Tj>A6(8s!XQvK8A_(ijG$ zwJ1IUeC!!Mh@;M6F zACGC21leK(*MEkJ<-Qtel7au;eck!*QJbWs4C!q}JU6j;5zn`+hH((%1_75CfZFS5 zijJ3#(2xd!%U$&{JFmc!zboQb6H6;K7)Gz<8Ct{XMIfQ!1*{smxZ6x|;1^?&??nwF zS9-K7UP^AjlD~*+lfPe*TfPX>P9B3%CqKw&+7Ct+Hb7Qhuja@~^>0NuQ=hc2pw4)w z1BAvf(P@X)0*ZXBAI5?8lxJQ96im z%yn?^<;jPeQTKg-vc402B@fgLGG>v{xim#OklO}giqB`}uN~W0w?oLZv|a+qScLgk zkVs%u9LZ?KmW`i8f)_A;3P~QxX;GaWY2!HxQ;t(*)m6rQkn}#j#Lx@-EP(;rD=?=Lk5zj|9<> zzVa7vB>hLS3y2Il+nWfxjTg&)Q_!0O7Yti()WD}!#&rpP+9C^Ror j>$F*6iv(sqLTi#V+%B^4pT>VJOGeeKmellYE_?QWabwE@ delta 4751 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 EKZx5Q_W%F@ delta 2532 zcma)8&2Jk;6!+||zwOj^5+`K&<20g)Qc7AVma1jznZ)bZUUznz zCZ!@psrZt@WrTVGsYqP7fg+9-AeBD=i3@w-j4CQs2nj_TcyHFJgUJC)``hRDHE-UV z_hx?_yFV5`7mr7L@cZNO__agd#NQ)@CHTjDMM8Z=g%YaV^A%N^r5g3$BSnn{Xb?Dl z8lqu%2WTIS(iljBG)LpK1E>(~7eAAHX@K_94E*n)>YOh>$ajI`l7frD58o<06Y$tE zfIGg5?+&44oq)13nOcnO&8owAUU4;>Rp)sE48HR5PVs>A+BBUNkCo!SBxvUqo`RRFQ3D)x0_i~)G8Mz=6BYF`Ns8~(Oa>L(RCpG6 zgW{<=d#V?xyvETf??A`^xPDlXJ9#2kX*cczjXmO)W*picO7NEHly%c-({{s>PtE!H zeIn?8``|Vkp2G#(qK`;@+qzk&BVs7fFTVB{{-=?{jnd))FnTnc-obNHUto@I@KV`a zVtT`H%A*_6piYir1hCir_)=VD^suXPR&5JxGcrcal2fv3jq!8hv(WMpX{PWzn7L}z zTEHS3j>C16dbU`N&G6U)tFz?>AD0 z=+^ZLD>?S5ji17AkmzyLIDv3d9F0z&837WOR$+5c$b%?2ih!Pq0kk(M$TgtsR$Ojy z%d>bG{rI(QPSKsp+ZaS14mftWTe%3t`V!Kzd~&Cev8ri%395_xv8m`V%7+kki{D}c ztH+QGBVd#AC_p~!9WiVHE{isX)XNCF0U)DZz2s3h_z@H|6EBy45g9UHv8rXzt@6$S z+s5PN(?TSq#FQA(Hr|gvQb?zmPGkpU(TE9~kO_OX34Q=fJ`rn)!mwP0$PIfv%c|7g zGQbu2X{q}pF?p&L^bqjeP|a9m)Z{h~px#~tnGD<#A4iZI?h%X09C<~2m>jt#+w@r) zT>reqORT6ZGS=WN_cABS4ckk^IrM}xXY2G_K-$X&hOm{5B@s_eiL0s8tOpga`#E+o z??)Iw7zA)*a?q4&%qd&ci&)mIfEsTi$V9b3rU54*`n9oskZo1`m6}w#iM%Ci5G^)zLDQkHAU zHd0-y*Kavx=JoTobjeKNZ2b}*c}0YXrfGgHs0B1t zPF*pWe*KVvE}%CXRinh9550rB?2?ssXL$O45D(Hh(kmXO`#Rj%664n`o9T6<#>A8E z-UP3pw{V$N8@j`m9r1ViLU$Q>+(bYrUqncYU(=a0_}|34%nibrtFbx`lsd}WY_Nwx z)Z#W!sUkk@d1drJgJXyLM-Csjx#;g3N^hEww8&&~Lpv!(s#eLU>UhmP6<0EUlH+1J zJD9-nR}9HZ0;lasaXXuP{ssoI2SaP#j%0fkWLNkb+^jpB;`{7Jf#*0Icf9u8psw36+;u;J0%BkLh(;phb`);v(WY!Rzn{0A5ip}xN!K}E?6S@rzZBf$ zT+OIfU#&ZAL4((uAoXVtNz1+~;Ed{rWif| diff --git a/aircox/models/__pycache__/program.cpython-37.pyc b/aircox/models/__pycache__/program.cpython-37.pyc index 442c9986555b73b9f72ec150850faf6b0b0168b6..1c9d7e2f5db3832c3c02c6200dcbe912e9d042e2 100644 GIT binary patch delta 4194 zcmbtX4Qv$06~4LKyW9Ko|G$k{jQz5pGHbl15P@Xp&NrsQGb8qmlrDBvp}`NRb+~8&z$kNG(#UR+XwssS@?QS=-=% zT2;N%eD~(f%$xV#y!U4A+BdEqCVAblSl9!fe_h_#Z*D%3S3y#*HZ_us%Z~VxEq8fn z6|J81&}u!lzZE!bz^S3dz$w<-px+MtMYIU|MYA(HDB0tQ*UlUwkNM+45WEGNRRv&s z_LI|I+qYjj?`sr)HvbBG!b^1fgqpfD)}WjpX}rx$abJbtmNB6 z+Y6Tic@H!z0N^2cWain>H%TRHgkGFDUW3ME)(Rb0(lWj-(n6YNh9c|90tH@m?L7^v;qe1GU(j-Yr zRHl9!0Lo7>K0;36_UvwE4l!+{Z&YVvdvtreH>|)%bS>Rhz){MGN%f2}tQ|D66GMuo zY%`f|3}t)tF=f!uGgPSqqftnmVw*~8R${7MNa{_aV)(&dY+ImN3cw*R^&F5{2?Va^ z5TRrmVqDN(>P0&4>y49S!K`pqwQOyRL!ls(r8=%5umO=wM`hk23nri@&i0os?OGBIgh$;&`>lMDaXzhe-5D0Qvmuct- zq2Xyc8PTJsV`n_cJgBJ=8ikq~J>pH~AM*?n8l!oWaG4e(I;L2nXq9#R$`G9|4YfL6<8)k zshQ0ZN7C4kB`V!*svAsxgYPZX{Re=0hhHr1_h*3mIakVdfg>T@RAkGd|)|6?GGpw@m~;O4x%?g0L3>?;eK9=E!@t?p1s5-MLfjDx!*g1xK-lvta}* z#|+J8XfeAVA&Y>9F*Jms4s3T4QQQb~g&G+(2pYf-pjo)HEJ+Z<0TT5>B*Sk2dQqS3 z&GSX0vQPFwVV7iyX){BWTYTg?f3ao}ndHByd7gaE%|)M+ah|RHJ?~#!w~HM{;SVDm z0f>9qdZfkG>SNzV`Vjz!jNQ=7Rp?P*2gz(?1R~(p!mIJ1xv!nd4O1&R%u_|7%WMD}O7l>8UNPP!j zt|*TnqZ5m=h$)57EIC0=;sq~x?ePY_ukK*&DVXEPb2;J!)8@#4k=5B0u01LODFmNC~S$Hw<_lnRvd*P+GHW75%}^ArqfXPQUrNjVyG zJqQ4iBDO}G*bBU`wY%qq8*I3dg!=47dlQ;>bjDU_yKv>Kf9s`qy)8!`Zng zlsx0hc%r@T@wWaizw)0&vLCzRQK%zf7~b>}dkvW9=7Lx9cnLcQYL@WJZEIT5qFJ?s zMnInjmV46k+A++Imw0{q+6j!K)0q&w1>4rd~E z5WYvR2#<^76pnQKVyb&)aAYte8Me~HP}v`(8vkv3wTM*q6I^;jS9GG2hml~Z=AbI} zJj$1JTp>CBNk>ne$bA$BPi#NFh3QQmLi1*LU!o`SCW`UF^9~-nXA_Hxeg=JpOfegd z?Gpbep^%qn{+X!ok~eulXHS!m>K-k_OB!a0L|N#*EBxWk2LrAeB7^W)-sR;hr_1pW zT<=S6jY5e`oWW}l062G>=cksiD7_bm>PqFqZYg=t)#5yAYrL)xmsPuN$5H-^*!#tqmI1JoA3D5 z0}13WM@S){d?$RLmKoKzGG;Qzn>)asL8g>8b7SoLKrXDT$hhTS3JUy$-@kf8L_89G z&}EbS%<7UxyffTgLVKZfZo}uis1QzQ%dp1W{we

Z+=fFx82H(?pRSH?v?%7?)4; z{5ACx*N_y>#%sj=OCzq&D0cA>#VIi?sX}k|EK)y0z*^-d$OWXXBK#6Tcp{0E2s7cZ zS?g+$i)Hn9(5x@bytZb<8_$7l`t8-IUY zLsf#k4lMW^XJ#^b8viX=ZU{ZWV_g;QAJ<-7bdo2!j*?USr(H|oa{ajLzLh(1VNg}k wfhsG-NhQdV=U&$0aW0-Bj(->a<6;_5txu3O{KWc1BvueAT3;~XneY_<8$Slt761SM delta 3378 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 delta 2803 zcma)8TWnNC7~big-96pyw%ZFWEzoXTpd2U#L!cl93#G+EDVG+hC3rmS%yv)N-Lsi< zmYa=5thW$C;@~Al)X0PSV2t5GA4yDnQH&-&m@$S#9}I@X_)4Pq|8v@HX`1M6_M6*( zoBuZZ#*;IBm3!myXaGK=_jjLqt?l#58)SPKEPLVSt_EO%aY6&*Wt7menZS6E2B`+4 zMng0VV~9pZ0*1~jfmGZGc2}-W9%EjS=aa5e%v14Oa7UvIr|qoAI1d4lL8RugZ2FS` zuN8%|Ct^}BI5cIsp4%w)YwZ-Z;|Iqe{QBVMRsc)|CIeH1lB)zXmJNfT2rJmGLzzK% zH4qqOyaondk5F5Jy1BhoFmaW5al5Q>`u{5$ubEY3n$%9Gi>~eDAJr+#O9zWJH-Oe; zNi(_FFoKRbTC8RK!U*%aN{qusSX>IP*|rRb{W~D*0&(g}>Ne=@MSj4sHPEsqri8ig67mN3O5bep@u9tFhh21+} zkG0e(o=7U=*eT}D$`V^*t*fP0u)K^6#AFirfWTIi~U);knpQY+k=1zKdOn zp}BMCw4ytXYZ;*|Z56Yg_^I+U(k))C8e5AA2I4(C#}-5ejg;jR%rn)=X%S9{iU(wq z5#rq_ctCuV=xkQWsn(BPPX>!Tn-hN}rndGXHIm3MI}cZOAop69M&sbtiDH?jKh+}U>Wt~+O*G*B zTd{^Il1L(9FM?8VRJ^sk`E&x-GL*{X94f1hFrKqrEaiUA9nP6L1hZhCQuB1yns6ak zRR9XO6@=r(>SMn80*G_J5)F;lm!Z=hkfagzeW(oR6}KCg8v}5%bVSE1Ow$*Xw{q-9 zL7Gl=%8*+Gu~IipJ8yfYDQisCJQkA9+9$47uB7aaqBDYNcb3<++YkNyyQ$|eF^|)#N4!nLZCKu!CQeCqr;^T_r(~ou@m4<$!sD)ZS?YPWD zH<^WOkz3gX5h>X!wL_4If1nl(0D2_hFT%tiqsA8gsCYDrIw{lS_#|MW?hSC{lI49F zix1c=OMMftCS-KL;mn@MA7mGJ6gTvd{!w%I_|OnnK8~S~deOXM16eN)tk^g`jYP}# z3?Az{JZ6p@K0dt992gwxH~Ys%Ic9|Kw(?PS-t%?sM27NF=rR7f2iNg^mdEDzuo7g& zUdPEYEANwKP!{I6dW~h&a?PBTx2B=iWW+f=$x@!{m(~#^Ql&|8rm}+LOr-`U564*Z z7ZA{GG7dSHwOlvFvRQCcsRZYeL_JG!pu|S&qEubeNFrPVy`PA)D+lWIm>$(5daJ%# zuht_KTBQz;*7qwvB@IhH3H+3}-rO194U)JazHVN-WkJivO>AiQE&+fEsC2fuK{s>Z zj{v?9e646{@e=BA0kgQ-a`NeC;C$GKEXsuWMB|r%Kp*$7UffN-JADd>)N?PRPg&U_^Scq2r;5Q`|M`!yY(hJ9 zU*^LkHkpHAG@j?5>L`mIju*5IzTS65z(}AIX E0sL%kC;$Ke diff --git a/aircox/models/__pycache__/station.cpython-37.pyc b/aircox/models/__pycache__/station.cpython-37.pyc index 52dd66428ef1973b4e4c1a47d3180c43644b1d5f..4774a61898d9618b5c002f397765aa57526d63ce 100644 GIT binary patch delta 937 zcmY+CO-vI}5XX16+wJ$Z-BK#VU==kiVC6#%CIqlb5rSw!L!^lzwY%c3mBqIUR#U@4 zB8iE3cn2>WFy6co?j~M5c=KlV>_rn36EDg|XSQ;%oB6%>X8v#IznQ)p{Bu$%CK6Er ze~I@Kw;rbUly4$D=fA}VU3zL$sHF7FmT(93w61POD(Njj5-RGvptbFP4*UTACN>Sj z{ClitBZRL=VmNmZO~DeJ6E0Z7uIL6V3D3b@SqEKwjZafpB1MQTxM3?|MfHF#y?`gA zim{odWW{Nx2mPF6C3KnUAh^m?!Ads8W?&mnx_~x9a|dw7gQap>1D3|lxI}O%X(|3H zK6ca#F=Mq}tr>oi$yoA2_HE0kxeoslUmohk%moZbMF=266jV_Lg#zN@5}3MiY?f{dK7451C-1r|0;WwbM8>n$4 zL>MGsBm8puCY1IcnD$2i#gw@u=ihNM*j(HLN@D+Sfo$Ak{Pz!isVZeii2agyH zvjzH^gXn18bljSKe|1dfA5?3!P@B)dwOCj5Vk&0x;Na72VGcybj@uN(VjrUKd| zT^J$I1lTO0NEjnrB8(Fzcs16^<*Wh?p3UwQuVd1SE>!Dl#w24lV;ZSwl3IWzXx~TZ zrvxy6AigbsDkOWxe`eQZzx^z?+ZTc~p>6)LeIRv~Zj#xSu_BM=Iw8+b delta 2486 zcmb7F&2Jk;6rb7kuGfy^uR2ZRer>=L`tMp2mSY>taPV((g_vU8oqSY=n4 zmG*&-7^;UjV@VT#SM5iisz3AgObsRSry$jEs5rbiB!Ua(um%1B<4h<*b(95lm2=T*Nl{2Df53(|fZxu87a`b+*pu6rl-y&1mt4(MX86FgE!V`ZtVLtfXLV zgqD~A!tfiB5)b3E;LbyJp~NALlBe*I58H5fMI`gA#ct+TIi8KyT8MU2`9!%LLzBFu z$jxDVscS6M%We~Dc1a19v2ifb0J80Yn!D(O>o_)eFo;6P5XmC>J9*Zrdw$JbSekep z=N?5!S60T#e$}tLi({6x1ol!DY^!6$8$0W`wW{MyAk*DCUE-Cr=Yw5cu&lbb*mRuI zKqX(TL(N@ok3D(*0X#q=gT2Uwd8>1hWkvgKs2F8w$p+X*OS1hX!mgqdVcfmduxq{t zDbnZqdQZCVVPvG6bOL1BNKqmWmSS?el7HT_Gbr~+$#@Dsou~2Bc}iI+{&zTfxuev^ zaJ+%TDV%9R@oqHEBH9N)NYn z$$A$FD%Bg)baZqj-E}ik|G#U`cy=6H?pcrArvIEvCp$iw=nItacsPorq`^zXQ&J^* zIEGxPM;0XRc*J{GP!R!FCGboact`&faw=(=6l%2=d%zxIvtx2NHPMP`UTBKCYj#TK zm>Ja=W}@aag)K}Bcd=7pLyONB4U7h#YdhPW?Qat>ssg4Oe5D7gqkREb1@n^acd>N( z2cf_TG!2K5;MLJ@+EXa9-2`>j!LoLl*Mo1g(KdyPE+p?=B-Aus(dP~JK=H9}aIQU6TFfGi4l{mS zUb56pe3i6Gm~6!w1H=hc%3_U_h>@N%fAAh_q+3c${TOG0VaBzYYZ$s59)*7i{8Oh( znP5eq$Vj1lLf{C>gCF$y;d971IAlXJ>=B*Ecv1~!oyRdcGISF;_*vZ8yMVuM&O&?35> zB*YP9JhEjNF`nmzBZ!O8Dcn$qQ+P@>v=~ZbijhhsQ$|Xs@6jpaePti)!JRYMaCx#v zuMNZQx8+lYzG2Sny}^2Hz9R475f*A`SHvY5E_OgI@*?XtJ+^ zTtv>5V=ZZmdIP!vc=%LbeeAkKc(+azJtOrdA;g1?TY{|z*$;F z2|?Vl%O*&0o&=plNK7&twCr*4RpyQ^kFNyd+3Ol*^xNRW?09yPcG49jqlv-!K3n8d z!LQjfrTb_ARzAwiQ!q9VCW(_TEfU?dK@?^grJP4iR1*SR-` ZDePe;dV begin seek(s, x) end) - s = store_metadata(id=id, size=1, s) - add_skip_command(s) - if disable_switch then - s - else - at(interactive.bool('#{id}_active', active), s) - end -end - -{% comment %} -A stream is a source that: -- is a playlist on random mode (playlist object accessible at {id}_playlist -- is interactive -{% endcomment %} -def stream (id, file) = - s = playlist(id = '#{id}_playlist', mode = "random", reload_mode='watch', file) - interactive_source(id, s) -end -{% endblock %} - -{% block functions_extras %} -{% endblock %} - - -{% block config %} -set("server.socket", true) -set("server.socket.path", "{{ station.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 %} -{% endblock %} - -{% block config_extras %} -{% endblock %} - - -{% block sources %} -live = fallback([ - {% with source=station.dealer %} - interactive_source('{{ source.id }}', - playlist.once(reload_mode='watch', "{{ source.path }}"), - active=false - ), - {% endwith %} -]) - - -stream = fallback([ - rotate([ - {% for source in station.sources %} - {% if source != station.dealer %} - {% with stream=source.stream %} - {% if stream.delay %} - delay({{ stream.delay }}., - stream("{{ source.id }}", "{{ source.path }}")), - {% elif stream.begin and stream.end %} - at({ {{stream.begin}}-{{stream.end}} }, - stream("{{ source.id }}", "{{ source.path }}")), - {% else %} - stream("{{ source.id }}", "{{ source.path }}"), - {% endif %} - {% endwith %} - {% endif %} - {% endfor %} - ]), - - blank(id="blank", duration=0.1), -]) - -{% endblock %} - -{% block sources_extras %} -{% endblock %} - -{% block station %} -{{ station.streamer.id }} = interactive_source ( - "{{ station.streamer.id }}", - fallback( - track_sensitive=false, - transitions=[to_live,to_stream], - [ live, stream ] - ), - disable_switch=true -) -{% endblock %} - - -{% block station_extras %} -{% endblock %} - - -{% block outputs %} -{% for output in station.outputs %} -output.{{ output.get_type_display }}( - {% if output.settings %} - {{ output.settings|safe }}, - {% endif %} - {{ station.streamer.id }} -) -{% endfor %} -{% endblock %} - -{% block output_extras %} -{% endblock %} - diff --git a/aircox/templates/aircox/scripts/station.liq b/aircox/templates/aircox/scripts/station.liq new file mode 100755 index 0000000..818f673 --- /dev/null +++ b/aircox/templates/aircox/scripts/station.liq @@ -0,0 +1,125 @@ +{% comment %} +Base liquidsoap station configuration. + + +[stream] +--> streams ---+---> station + | + dealer ---' + +{% endcomment %} + +{% block functions %} +{# Seek function #} +def seek(source, t) = + t = float_of_string(default=0.,t) + ret = source.seek(source,t) + log("seek #{ret} seconds.") + "#{ret}" +end + +{# Transition to live sources #} +def to_live(stream, live) + stream = fade.final(duration=2., type='log', stream) + live = fade.initial(duration=2., type='log', live) + add(normalize=false, [stream,live]) +end + +{# Transition to stream sources #} +def to_stream(live, stream) + source.skip(stream) + add(normalize=false, [live,stream]) +end + + +{% comment %} +An interactive source is a source that: +- is skippable through the given id on external interfaces +- is seekable through the given id and amount of seconds on e.i. +- store metadata +{% endcomment %} +def interactive (id, s) = + server.register(namespace=id, + description="Seek to a relative position", + usage="seek ", + "seek", fun (x) -> begin seek(s, x) end) + s = store_metadata(id=id, size=1, s) + add_skip_command(s) + s +end + +{% comment %} +A stream is an interactive playlist +{% endcomment %} +def stream (id, file) = + s = playlist(mode = "random", reload_mode='watch', file) + interactive(id, s) +end +{% endblock %} + + +{% 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 %} +{% endblock %} + +{% block config_extras %} +{% endblock %} + + +{% block sources %} +{% with source=streamer.dealer %} +live = interactive('{{ source.id }}', + request.queue(id="{{ source.id }}_queue") +) +{% endwith %} + + +streams = rotate(id="streams", [ + {% for source in streamer.sources %} + {% if source != streamer.dealer %} + {% with stream=source.stream %} + {% if stream.delay %} + delay({{ stream.delay }}., + stream("{{ source.id }}", "{{ source.path }}")), + {% elif stream.begin and stream.end %} + at({ {{stream.begin}}-{{stream.end}} }, + stream("{{ source.id }}", "{{ source.path }}")), + {% else %} + stream("{{ source.id }}", "{{ source.path }}"), + {% endif %} + {% endwith %} + {% endif %} + {% endfor %} +]) + +{% endblock %} + + +{% block station %} +{{ streamer.id }} = interactive ( + "{{ streamer.id }}", + fallback([ + live, + streams, + blank(id="blank", duration=0.1) + ], track_sensitive=false, transitions=[to_live,to_stream]) +) +{% endblock %} + + +{% block outputs %} +{% for output in streamer.outputs %} +output.{{ output.get_type_display }}( + {% if output.settings %} + {{ output.settings|safe }}, + {% endif %} + {{ streamer.id }} +) +{% endfor %} +{% endblock %} + + diff --git a/aircox/utils.py b/aircox/utils.py index dd5835b..bc40228 100755 --- a/aircox/utils.py +++ b/aircox/utils.py @@ -29,8 +29,7 @@ def date_or_default(date, into=None): type if any. """ date = date if date is not None else datetime.date.today() \ - if into is not None and issubclass(into, datetime.date) else \ - tz.datetime.now() + if into is not None and issubclass(into, datetime.date) else tz.now() if into is not None: date = cast_date(date, into)