diff --git a/aircox/admin/__pycache__/episode.cpython-37.pyc b/aircox/admin/__pycache__/episode.cpython-37.pyc index 5748b4a..5a51d54 100644 Binary files a/aircox/admin/__pycache__/episode.cpython-37.pyc and b/aircox/admin/__pycache__/episode.cpython-37.pyc differ diff --git a/aircox/admin/diffusion.py b/aircox/admin/diffusion.py deleted file mode 100644 index 168bbfb..0000000 --- a/aircox/admin/diffusion.py +++ /dev/null @@ -1,81 +0,0 @@ -from django.contrib import admin -from django.utils.translation import ugettext as _, ugettext_lazy - -from aircox.models import Diffusion, Sound, Track - -from .playlist import TracksInline - - -class SoundInline(admin.TabularInline): - model = Sound - fk_name = 'diffusion' - fields = ['type', 'path', 'duration', 'is_public'] - readonly_fields = ['type'] - extra = 0 - - -class RediffusionInline(admin.StackedInline): - model = Diffusion - fk_name = 'initial' - extra = 0 - fields = ['type', 'start', 'end'] - - -@admin.register(Diffusion) -class DiffusionAdmin(admin.ModelAdmin): - def archives(self, obj): - sounds = [str(s) for s in obj.get_sounds(archive=True)] - return ', '.join(sounds) if sounds else '' - - def conflicts_count(self, obj): - if obj.conflicts.count(): - return obj.conflicts.count() - return '' - conflicts_count.short_description = _('Conflicts') - - def start_date(self, obj): - return obj.local_start.strftime('%Y/%m/%d %H:%M') - start_date.short_description = _('start') - - def end_date(self, obj): - return obj.local_end.strftime('%H:%M') - end_date.short_description = _('end') - - def first(self, obj): - return obj.initial.start if obj.initial else '' - - list_display = ('id', 'program', 'start_date', 'end_date', 'type', 'first', 'archives', 'conflicts_count') - list_filter = ('type', 'start', 'program') - list_editable = ('type',) - ordering = ('-start', 'id') - - fields = ['type', 'start', 'end', 'initial', 'program', 'conflicts'] - readonly_fields = ('conflicts',) - inlines = [TracksInline, RediffusionInline, SoundInline] - - def get_playlist(self, request, obj=None): - return obj and getattr(obj, 'playlist', None) - - def get_form(self, request, obj=None, **kwargs): - if request.user.has_perm('aircox_program.programming'): - self.readonly_fields = [] - else: - self.readonly_fields = ['program', 'start', 'end'] - return super().get_form(request, obj, **kwargs) - - def get_object(self, *args, **kwargs): - """ - We want rerun to redirect to the given object. - """ - obj = super().get_object(*args, **kwargs) - if obj and obj.initial: - obj = obj.initial - return obj - - def get_queryset(self, request): - qs = super().get_queryset(request) - if request.GET and len(request.GET): - return qs - return qs.exclude(type=Diffusion.Type.unconfirmed) - - diff --git a/aircox/admin/episode.py b/aircox/admin/episode.py index 794221e..954de7e 100644 --- a/aircox/admin/episode.py +++ b/aircox/admin/episode.py @@ -36,21 +36,6 @@ class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin): fields = ['type', 'start', 'end', 'initial', 'program'] - def get_object(self, *args, **kwargs): - """ - We want rerun to redirect to the given object. - """ - obj = super().get_object(*args, **kwargs) - if obj and obj.initial: - obj = obj.initial - return obj - - def get_queryset(self, request): - qs = super().get_queryset(request) - if request.GET and len(request.GET): - return qs - return qs.exclude(type=Diffusion.Type.unconfirmed) - class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline): model = Diffusion diff --git a/aircox/connector.py b/aircox/connector.py index 047fc65..3a0499b 100755 --- a/aircox/connector.py +++ b/aircox/connector.py @@ -1,90 +1,84 @@ -import os import socket import re import json +response_re = re.compile(r'(.*)\s+END\s*$') +key_val_re = re.compile(r'(?P[^=]+)="?(?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 d7e1cf0..5daa7d1 100644 Binary files a/aircox/models/__pycache__/episode.cpython-37.pyc and b/aircox/models/__pycache__/episode.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/log.cpython-37.pyc b/aircox/models/__pycache__/log.cpython-37.pyc index 2edd22e..893589b 100644 Binary files a/aircox/models/__pycache__/log.cpython-37.pyc and b/aircox/models/__pycache__/log.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/program.cpython-37.pyc b/aircox/models/__pycache__/program.cpython-37.pyc index 442c998..1c9d7e2 100644 Binary files a/aircox/models/__pycache__/program.cpython-37.pyc and b/aircox/models/__pycache__/program.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/sound.cpython-37.pyc b/aircox/models/__pycache__/sound.cpython-37.pyc index 29f67d3..dc7b250 100644 Binary files a/aircox/models/__pycache__/sound.cpython-37.pyc and b/aircox/models/__pycache__/sound.cpython-37.pyc differ diff --git a/aircox/models/__pycache__/station.cpython-37.pyc b/aircox/models/__pycache__/station.cpython-37.pyc index 52dd664..4774a61 100644 Binary files a/aircox/models/__pycache__/station.cpython-37.pyc and b/aircox/models/__pycache__/station.cpython-37.pyc differ diff --git a/aircox/models/episode.py b/aircox/models/episode.py index be2174d..5d31e4c 100644 --- a/aircox/models/episode.py +++ b/aircox/models/episode.py @@ -10,20 +10,12 @@ from django.utils.functional import cached_property from aircox import settings, utils -from .program import Program, BaseRerun, BaseRerunQuerySet +from .program import Program, InProgramQuerySet, \ + BaseRerun, BaseRerunQuerySet from .page import Page, PageQuerySet -__all__ = ['Episode', 'EpisodeQuerySet', 'Diffusion', 'DiffusionQuerySet'] - - -class EpisodeQuerySet(PageQuerySet): - def station(self, station): - return self.filter(program__station=station) - - # FIXME: useful??? might use program.episode_set - def program(self, program): - return self.filter(program=program) +__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet'] class Episode(Page): @@ -32,7 +24,7 @@ class Episode(Page): verbose_name=_('program'), ) - objects = EpisodeQuerySet.as_manager() + objects = InProgramQuerySet.as_manager() class Meta: verbose_name = _('Episode') @@ -52,40 +44,31 @@ class Episode(Page): return cls(program=program, title=title) class DiffusionQuerySet(BaseRerunQuerySet): - def station(self, station): - return self.filter(episode__program__station=station) - - def program(self, program): - return self.filter(program=program) + def episode(self, episode=None, id=None): + """ Diffusions for this episode """ + return self.filter(episode=episode) if id is None else \ + self.filter(episode__id=id) def on_air(self): + """ On air diffusions """ return self.filter(type=Diffusion.Type.on_air) - def at(self, date=None): - """ - Return diffusions occuring at the given date, ordered by +start + def now(self, now=None, order=True): + """ Diffusions occuring now """ + now = now or tz.now() + qs = self.filter(start__lte=now, end__gte=now).distinct() + return qs.order_by('start') if order else qs - 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. + def today(self, today=None, order=True): + """ Diffusions occuring today. """ + today = today or datetime.date.today() + qs = self.filter(Q(start__date=today) | Q(end__date=today)) + return qs.order_by('start') if order else qs - 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 at(self, date, order=True): + """ Return diffusions at specified date or datetime """ + return self.now(date, order) if isinstance(date, tz.datetime) else \ + self.today(date, order) def after(self, date=None): """ @@ -183,10 +166,12 @@ class Diffusion(BaseRerun): # self.check_conflicts() def save_rerun(self): + print('rerun save', self) self.episode = self.initial.episode self.program = self.episode.program - def save_original(self): + def save_initial(self): + print('initial save', self) self.program = self.episode.program if self.episode != self._initial['episode']: self.rerun_set.update(episode=self.episode, program=self.program) @@ -221,20 +206,11 @@ class Diffusion(BaseRerun): return tz.localtime(self.end, tz.get_current_timezone()) - @property - def original(self): - """ Return the original diffusion (self or initial) """ - - return self.initial.original if self.initial else self - # TODO: property? def is_live(self): - """ - True if Diffusion is live (False if there are sounds files) - """ - + """ 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() + not self.episode.sound_set.archive().count() def get_playlist(self, **types): """ diff --git a/aircox/models/log.py b/aircox/models/log.py index 5e4d262..770297e 100644 --- a/aircox/models/log.py +++ b/aircox/models/log.py @@ -21,8 +21,9 @@ __all__ = ['Log', 'LogQuerySet'] class LogQuerySet(models.QuerySet): - def station(self, station): - return self.filter(station=station) + def station(self, station=None, id=None): + return self.filter(station=station) if id is None else \ + self.filter(station_id=id) def at(self, date=None): date = utils.date_or_default(date) @@ -189,16 +190,20 @@ class Log(models.Model): Other log """ + station = models.ForeignKey( + Station, models.CASCADE, + verbose_name=_('station'), + help_text=_('related station'), + ) 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, models.CASCADE, - verbose_name=_('station'), - help_text=_('related station'), + date = models.DateTimeField( + default=tz.now, db_index=True, + verbose_name=_('date'), ) source = models.CharField( # we use a CharField to avoid loosing logs information if the @@ -207,10 +212,6 @@ class Log(models.Model): 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'), diff --git a/aircox/models/program.py b/aircox/models/program.py index 5484eb2..5e48817 100644 --- a/aircox/models/program.py +++ b/aircox/models/program.py @@ -66,7 +66,8 @@ class Program(Page): @property def path(self): """ Return program's directory path """ - return os.path.join(settings.AIRCOX_PROGRAMS_DIR, self.slug) + return os.path.join(settings.AIRCOX_PROGRAMS_DIR, + self.slug.replace('-', '_')) @property def archives_path(self): @@ -80,7 +81,6 @@ class Program(Page): def __init__(self, *kargs, **kwargs): super().__init__(*kargs, **kwargs) - if self.slug: self.__initial_path = self.path @@ -137,7 +137,21 @@ class Program(Page): .update(path=Concat('path', Substr(F('path'), len(path_)))) -class BaseRerunQuerySet(models.QuerySet): +class InProgramQuerySet(models.QuerySet): + """ + Queryset for model having a ForeignKey field "program" to `Program`. + """ + def station(self, station=None, id=None): + return self.filter(program__station=station) if id is None else \ + self.filter(program__station__id=id) + + def program(self, program=None, id=None): + return self.filter(program=program) if id is None else \ + self.filter(program__id=id) + + +class BaseRerunQuerySet(InProgramQuerySet): + """ Queryset for BaseRerun (sub)classes. """ def rerun(self): return self.filter(initial__isnull=False) @@ -147,8 +161,8 @@ class BaseRerunQuerySet(models.QuerySet): class BaseRerun(models.Model): """ - Abstract model offering rerun facilities. - `start` datetime field or property must be implemented by sub-classes + Abstract model offering rerun facilities. Assume `start` is a + datetime field or attribute implemented by subclass. """ program = models.ForeignKey( Program, models.CASCADE, @@ -157,10 +171,13 @@ class BaseRerun(models.Model): initial = models.ForeignKey( 'self', models.SET_NULL, related_name='rerun_set', verbose_name=_('initial schedule'), + limit_choices_to={'initial__isnull': True}, blank=True, null=True, help_text=_('mark as rerun of this %(model_name)'), ) + objects = BaseRerunQuerySet.as_manager() + class Meta: abstract = True diff --git a/aircox/models/sound.py b/aircox/models/sound.py index 0238d26..f262dcb 100644 --- a/aircox/models/sound.py +++ b/aircox/models/sound.py @@ -22,15 +22,32 @@ __all__ = ['Sound', 'SoundQuerySet', 'Track'] class SoundQuerySet(models.QuerySet): + def episode(self, episode=None, id=None): + return self.filter(episode=episode) if id is None else \ + self.filter(episode__id=id) + + def diffusion(self, diffusion=None, id=None): + return self.filter(episode__diffusion=diffusion) if id is None else \ + self.filter(episode__diffusion__id=id) + def podcasts(self): - """ Return sound available as podcasts """ + """ Return sounds available as podcasts """ return self.filter(Q(embed__isnull=False) | Q(is_public=True)) - def episode(self, episode): - return self.filter(episode=episode) + def archive(self): + """ Return sounds that are archives """ + return self.filter(type=Sound.Type.archive) - def diffusion(self, diffusion): - return self.filter(episode__diffusion=diffusion) + def paths(self, archive=True, order_by=True): + """ + Return paths as a flat list (exclude sound without path). + If `order_by` is True, order by path. + """ + if archive: + self = self.archive() + if order_by: + self = self.order_by('path') + return self.filter(path__isnull=False).values_list('path', flat=True) class Sound(models.Model): @@ -46,6 +63,7 @@ class Sound(models.Model): name = models.CharField(_('name'), max_length=64) program = models.ForeignKey( + # FIXME: not nullable? Program, models.SET_NULL, blank=True, null=True, verbose_name=_('program'), help_text=_('program related to it'), @@ -95,6 +113,21 @@ class Sound(models.Model): objects = SoundQuerySet.as_manager() + class Meta: + verbose_name = _('Sound') + verbose_name_plural = _('Sounds') + + def __str__(self): + return '/'.join(self.path.split('/')[-3:]) + + def save(self, check=True, *args, **kwargs): + if self.episode is not None and self.program is None: + self.program = self.episode.program + if check: + self.check_on_file() + self.__check_name() + super().save(*args, **kwargs) + def get_mtime(self): """ Get the last modification date from file @@ -220,21 +253,6 @@ class Sound(models.Model): super().__init__(*args, **kwargs) self.__check_name() - def save(self, check=True, *args, **kwargs): - if self.episode is not None and self.program is None: - self.program = self.episode.program - 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): """ diff --git a/aircox/models/station.py b/aircox/models/station.py index 808ea62..ce3ff0e 100644 --- a/aircox/models/station.py +++ b/aircox/models/station.py @@ -33,6 +33,7 @@ class Station(models.Model): """ name = models.CharField(_('name'), max_length=64) slug = models.SlugField(_('slug'), max_length=64, unique=True) + # FIXME: remove - should be decided only by Streamer controller + settings path = models.CharField( _('path'), help_text=_('path to the working directory'), @@ -47,70 +48,13 @@ class Station(models.Model): objects = StationQuerySet.as_manager() - # - # Controllers - # - __sources = None - __dealer = None - __streamer = None - - def __prepare_controls(self): - import aircox.controllers as controllers - from .program import Program - 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): - """ Get dealer control """ - self.__prepare_controls() - return self.__dealer - - @property - def streamer(self): - """ Audio controller for the station """ - self.__prepare_controls() - return self.__streamer - 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 - ) + self.path = os.path.join(settings.AIRCOX_CONTROLLERS_WORKING_DIR, + self.slug.replace('-', '_')) if self.default: qs = Station.objects.filter(default=True) diff --git a/aircox/templates/aircox/config/liquidsoap.liq b/aircox/templates/aircox/config/liquidsoap.liq deleted file mode 100755 index d2765db..0000000 --- a/aircox/templates/aircox/config/liquidsoap.liq +++ /dev/null @@ -1,171 +0,0 @@ -{% comment %} -TODO: update doc -Base configuration file to configure a station on liquidsoap. - -# Interactive elements: -An interactive element is accessible to the people, in order to: -- get metadata -- seek -- skip the current sound -- enable/disable it - -# Element of the context -We use theses elements from the template's context: -- controller: controller describing the station itself -- settings: global settings - -# Overwrite the template -It is possible to overwrite the template, there are blocks at different -position in order to do it. Keep in mind that you might want to avoid to -put station specific configuration in the template itself. -{% endcomment %} - - -{% block functions %} - -{% comment %} -Seek function -{% endcomment %} -def seek(source, t) = - t = float_of_string(default=0.,t) - ret = source.seek(source,t) - log("seek #{ret} seconds.") - "#{ret}" -end - -{% comment %} -Transition to live sources -{% endcomment %} -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 - -{% comment %} -Transition to stream sources -{% endcomment %} -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. -- can be disabled -- store metadata -{% endcomment %} -def interactive_source (id, s, ~active=true, ~disable_switch=false) = - 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) - 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)