- Writes tests for aircox streamer application; - Add test utilities in aircox Co-authored-by: bkfox <thomas bkfox net> Reviewed-on: #110
This commit is contained in:
		
							
								
								
									
										33
									
								
								aircox/test.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								aircox/test.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
				
			|||||||
 | 
					"""This module provide test utilities."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ("interface",)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def interface(obj, funcs):
 | 
				
			||||||
 | 
					    """Override provided object's functions using dict of funcs, as
 | 
				
			||||||
 | 
					    ``{func_name: return_value}``.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Attribute ``obj.calls`` is a dict with all call done using those
 | 
				
			||||||
 | 
					    methods, as ``{func_name: (args, kwargs) | list[(args, kwargs]]}``.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if not isinstance(getattr(obj, "calls", None), dict):
 | 
				
			||||||
 | 
					        obj.calls = {}
 | 
				
			||||||
 | 
					    for attr, value in funcs.items():
 | 
				
			||||||
 | 
					        interface_wrap(obj, attr, value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def interface_wrap(obj, attr, value):
 | 
				
			||||||
 | 
					    obj.calls[attr] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def wrapper(*a, **kw):
 | 
				
			||||||
 | 
					        call = obj.calls.get(attr)
 | 
				
			||||||
 | 
					        if call is None:
 | 
				
			||||||
 | 
					            obj.calls[attr] = (a, kw)
 | 
				
			||||||
 | 
					        elif isinstance(call, tuple):
 | 
				
			||||||
 | 
					            obj.calls[attr] = [call, (a, kw)]
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            call.append((a, kw))
 | 
				
			||||||
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setattr(obj, attr, wrapper)
 | 
				
			||||||
										
											Binary file not shown.
										
									
								
							@ -1,130 +0,0 @@
 | 
				
			|||||||
# SOME DESCRIPTIVE TITLE.
 | 
					 | 
				
			||||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
 | 
					 | 
				
			||||||
# This file is distributed under the same license as the PACKAGE package.
 | 
					 | 
				
			||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
#, fuzzy
 | 
					 | 
				
			||||||
msgid ""
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
"Project-Id-Version: PACKAGE VERSION\n"
 | 
					 | 
				
			||||||
"Report-Msgid-Bugs-To: \n"
 | 
					 | 
				
			||||||
"POT-Creation-Date: 2020-01-06 14:14+0100\n"
 | 
					 | 
				
			||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
					 | 
				
			||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
					 | 
				
			||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
					 | 
				
			||||||
"Language: \n"
 | 
					 | 
				
			||||||
"MIME-Version: 1.0\n"
 | 
					 | 
				
			||||||
"Content-Type: text/plain; charset=UTF-8\n"
 | 
					 | 
				
			||||||
"Content-Transfer-Encoding: 8bit\n"
 | 
					 | 
				
			||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:37
 | 
					 | 
				
			||||||
msgid "input"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:38
 | 
					 | 
				
			||||||
msgid "output"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:56
 | 
					 | 
				
			||||||
msgid "station"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:58
 | 
					 | 
				
			||||||
msgid "direction"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:59
 | 
					 | 
				
			||||||
msgid "type"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:61
 | 
					 | 
				
			||||||
msgid "active"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:62
 | 
					 | 
				
			||||||
msgid "this port is active"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:65
 | 
					 | 
				
			||||||
msgid "port settings"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: models.py:66
 | 
					 | 
				
			||||||
msgid ""
 | 
					 | 
				
			||||||
"list of comma separated params available; this is put in the output config "
 | 
					 | 
				
			||||||
"file as raw code; plugin related"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:19
 | 
					 | 
				
			||||||
msgid "Synchronize source with Liquidsoap"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:23
 | 
					 | 
				
			||||||
msgid "Synchronise"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:26
 | 
					 | 
				
			||||||
msgid "Restart current track"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:30
 | 
					 | 
				
			||||||
msgid "Restart"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:33
 | 
					 | 
				
			||||||
msgid "Skip current file"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:34
 | 
					 | 
				
			||||||
msgid "Skip"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:43
 | 
					 | 
				
			||||||
msgid "Add sound"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:51
 | 
					 | 
				
			||||||
msgid "Select a sound"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:53
 | 
					 | 
				
			||||||
msgid "Add a sound to the queue (queue may start playing)"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:62
 | 
					 | 
				
			||||||
msgid "Add"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:68
 | 
					 | 
				
			||||||
msgid "Sounds in queue"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:86
 | 
					 | 
				
			||||||
msgid "Status"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:96
 | 
					 | 
				
			||||||
msgid "Air time"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:106
 | 
					 | 
				
			||||||
msgid "Time left"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/source_item.html:114
 | 
					 | 
				
			||||||
msgid "Data source"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/streamer.html:19
 | 
					 | 
				
			||||||
msgid "Reload"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/streamer.html:26
 | 
					 | 
				
			||||||
#: templates/aircox_streamer/streamer.html:27
 | 
					 | 
				
			||||||
msgid "Select a station"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#: urls.py:9 views.py:9
 | 
					 | 
				
			||||||
msgid "Streamer Monitor"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
@ -12,6 +12,8 @@ class Connector:
 | 
				
			|||||||
    Received data can be parsed from list of `key=value` or JSON.
 | 
					    Received data can be parsed from list of `key=value` or JSON.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    socket_class = socket.socket
 | 
				
			||||||
 | 
					    """Socket class to instanciate on open."""
 | 
				
			||||||
    socket = None
 | 
					    socket = None
 | 
				
			||||||
    """The socket."""
 | 
					    """The socket."""
 | 
				
			||||||
    address = None
 | 
					    address = None
 | 
				
			||||||
@ -26,27 +28,45 @@ class Connector:
 | 
				
			|||||||
        if address:
 | 
					        if address:
 | 
				
			||||||
            self.address = address
 | 
					            self.address = address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __enter__(self):
 | 
				
			||||||
 | 
					        r = self.open()
 | 
				
			||||||
 | 
					        if r == -1:
 | 
				
			||||||
 | 
					            raise RuntimeError("can not open socket.")
 | 
				
			||||||
 | 
					        return self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __exit__(self):
 | 
				
			||||||
 | 
					        self.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def open(self):
 | 
					    def open(self):
 | 
				
			||||||
 | 
					        """Open connection.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: 0 (success), 1 (already opened), -1 (failure)
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
        if self.is_open:
 | 
					        if self.is_open:
 | 
				
			||||||
            return
 | 
					            return 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        family = (
 | 
					        family = (
 | 
				
			||||||
            socket.AF_UNIX if isinstance(self.address, str) else socket.AF_INET
 | 
					            socket.AF_UNIX if isinstance(self.address, str) else socket.AF_INET
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            self.socket = socket.socket(family, socket.SOCK_STREAM)
 | 
					            self.socket = self.socket_class(family, socket.SOCK_STREAM)
 | 
				
			||||||
            self.socket.connect(self.address)
 | 
					            self.socket.connect(self.address)
 | 
				
			||||||
 | 
					            return 0
 | 
				
			||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            import traceback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            traceback.print_exc()
 | 
				
			||||||
            self.close()
 | 
					            self.close()
 | 
				
			||||||
            return -1
 | 
					            return -1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def close(self):
 | 
					    def close(self):
 | 
				
			||||||
 | 
					        if self.is_open:
 | 
				
			||||||
            self.socket.close()
 | 
					            self.socket.close()
 | 
				
			||||||
            self.socket = None
 | 
					            self.socket = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # FIXME: return None on failed
 | 
					    # FIXME: return None on failed
 | 
				
			||||||
    def send(self, *data, try_count=1, parse=False, parse_json=False):
 | 
					    def send(self, *data, try_count=1, parse=False, parse_json=False):
 | 
				
			||||||
        if self.open():
 | 
					        if self.open() == -1:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        data = bytes("".join([str(d) for d in data]) + "\n", encoding="utf-8")
 | 
					        data = bytes("".join([str(d) for d in data]) + "\n", encoding="utf-8")
 | 
				
			||||||
 | 
				
			|||||||
@ -1,404 +0,0 @@
 | 
				
			|||||||
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
 | 
					 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from aircox.conf import settings
 | 
					 | 
				
			||||||
from aircox.utils import to_seconds
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from .connector import Connector
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
__all__ = [
 | 
					 | 
				
			||||||
    "BaseMetadata",
 | 
					 | 
				
			||||||
    "Request",
 | 
					 | 
				
			||||||
    "Streamer",
 | 
					 | 
				
			||||||
    "Source",
 | 
					 | 
				
			||||||
    "PlaylistSource",
 | 
					 | 
				
			||||||
    "QueueSource",
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# TODO: for the moment, update in station and program names do not update the
 | 
					 | 
				
			||||||
#       related fields.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# FIXME liquidsoap does not manage timezones -- we have to convert
 | 
					 | 
				
			||||||
#       'on_air' metadata we get from it into utc one in order to work
 | 
					 | 
				
			||||||
#       correctly.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
local_tz = tzlocal.get_localzone()
 | 
					 | 
				
			||||||
logger = logging.getLogger("aircox")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class BaseMetadata:
 | 
					 | 
				
			||||||
    """Base class for handling request metadata."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    controller = None
 | 
					 | 
				
			||||||
    """Controller."""
 | 
					 | 
				
			||||||
    rid = None
 | 
					 | 
				
			||||||
    """Request id."""
 | 
					 | 
				
			||||||
    uri = None
 | 
					 | 
				
			||||||
    """Request uri."""
 | 
					 | 
				
			||||||
    status = None
 | 
					 | 
				
			||||||
    """Current playing status."""
 | 
					 | 
				
			||||||
    request_status = None
 | 
					 | 
				
			||||||
    """Requests' status."""
 | 
					 | 
				
			||||||
    air_time = None
 | 
					 | 
				
			||||||
    """Launch datetime."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, controller=None, rid=None, data=None):
 | 
					 | 
				
			||||||
        self.controller = controller
 | 
					 | 
				
			||||||
        self.rid = rid
 | 
					 | 
				
			||||||
        if data is not None:
 | 
					 | 
				
			||||||
            self.validate(data)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def is_playing(self):
 | 
					 | 
				
			||||||
        return self.status == "playing"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def status_verbose(self):
 | 
					 | 
				
			||||||
        return self.validate_status(self.status, True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def fetch(self):
 | 
					 | 
				
			||||||
        data = self.controller.send("request.metadata ", self.rid, parse=True)
 | 
					 | 
				
			||||||
        if data:
 | 
					 | 
				
			||||||
            self.validate(data)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def validate_status(self, status, i18n=False):
 | 
					 | 
				
			||||||
        on_air = self.controller.source
 | 
					 | 
				
			||||||
        if (
 | 
					 | 
				
			||||||
            on_air
 | 
					 | 
				
			||||||
            and status == "playing"
 | 
					 | 
				
			||||||
            and (on_air == self or on_air.rid == self.rid)
 | 
					 | 
				
			||||||
        ):
 | 
					 | 
				
			||||||
            return _("playing") if i18n else "playing"
 | 
					 | 
				
			||||||
        elif status == "playing":
 | 
					 | 
				
			||||||
            return _("paused") if i18n else "paused"
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            return _("stopped") if i18n else "stopped"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def validate_air_time(self, air_time):
 | 
					 | 
				
			||||||
        if air_time:
 | 
					 | 
				
			||||||
            air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S")
 | 
					 | 
				
			||||||
            return local_tz.localize(air_time)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def validate(self, data):
 | 
					 | 
				
			||||||
        """Validate provided data and set as attribute (must already be
 | 
					 | 
				
			||||||
        declared)"""
 | 
					 | 
				
			||||||
        for key, value in data.items():
 | 
					 | 
				
			||||||
            if hasattr(self, key) and not callable(getattr(self, key)):
 | 
					 | 
				
			||||||
                setattr(self, key, value)
 | 
					 | 
				
			||||||
        self.uri = data.get("initial_uri")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.air_time = self.validate_air_time(data.get("on_air"))
 | 
					 | 
				
			||||||
        self.status = self.validate_status(data.get("status"))
 | 
					 | 
				
			||||||
        self.request_status = data.get("status")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Request(BaseMetadata):
 | 
					 | 
				
			||||||
    title = None
 | 
					 | 
				
			||||||
    artist = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Streamer:
 | 
					 | 
				
			||||||
    connector = None
 | 
					 | 
				
			||||||
    process = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    station = None
 | 
					 | 
				
			||||||
    template_name = "aircox_streamer/scripts/station.liq"
 | 
					 | 
				
			||||||
    path = None
 | 
					 | 
				
			||||||
    """Config path."""
 | 
					 | 
				
			||||||
    sources = None
 | 
					 | 
				
			||||||
    """List of all monitored sources."""
 | 
					 | 
				
			||||||
    source = None
 | 
					 | 
				
			||||||
    """Current source being played on air."""
 | 
					 | 
				
			||||||
    # note: we disable on_air rids since we don't have use of it for the
 | 
					 | 
				
			||||||
    # moment
 | 
					 | 
				
			||||||
    # on_air = None
 | 
					 | 
				
			||||||
    # """ On-air request ids (rid) """
 | 
					 | 
				
			||||||
    inputs = None
 | 
					 | 
				
			||||||
    """Queryset to input ports."""
 | 
					 | 
				
			||||||
    outputs = None
 | 
					 | 
				
			||||||
    """Queryset to output ports."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, station, connector=None):
 | 
					 | 
				
			||||||
        self.station = station
 | 
					 | 
				
			||||||
        self.inputs = self.station.port_set.active().input()
 | 
					 | 
				
			||||||
        self.outputs = self.station.port_set.active().output()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.id = self.station.slug.replace("-", "_")
 | 
					 | 
				
			||||||
        self.path = os.path.join(station.path, "station.liq")
 | 
					 | 
				
			||||||
        self.connector = Connector(os.path.join(station.path, "station.sock"))
 | 
					 | 
				
			||||||
        self.init_sources()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def socket_path(self):
 | 
					 | 
				
			||||||
        """Path to Unix socket file."""
 | 
					 | 
				
			||||||
        return self.connector.address
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def is_ready(self):
 | 
					 | 
				
			||||||
        """If external program is ready to use, returns True."""
 | 
					 | 
				
			||||||
        return self.send("list") != ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def is_running(self):
 | 
					 | 
				
			||||||
        """True if holds a running process."""
 | 
					 | 
				
			||||||
        if self.process is None:
 | 
					 | 
				
			||||||
            return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        returncode = self.process.poll()
 | 
					 | 
				
			||||||
        if returncode is None:
 | 
					 | 
				
			||||||
            return True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.process = None
 | 
					 | 
				
			||||||
        logger.debug("process died with return code %s" % returncode)
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def playlists(self):
 | 
					 | 
				
			||||||
        return (s for s in self.sources if isinstance(s, PlaylistSource))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def queues(self):
 | 
					 | 
				
			||||||
        return (s for s in self.sources if isinstance(s, QueueSource))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Sources and config ###############################################
 | 
					 | 
				
			||||||
    def send(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        return self.connector.send(*args, **kwargs) or ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # request.on_air is not ordered: we need to do it manually
 | 
					 | 
				
			||||||
        self.source = next(
 | 
					 | 
				
			||||||
            iter(
 | 
					 | 
				
			||||||
                sorted(
 | 
					 | 
				
			||||||
                    (
 | 
					 | 
				
			||||||
                        source
 | 
					 | 
				
			||||||
                        for source in self.sources
 | 
					 | 
				
			||||||
                        if source.request_status == "playing"
 | 
					 | 
				
			||||||
                        and source.air_time
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                    key=lambda o: o.air_time,
 | 
					 | 
				
			||||||
                    reverse=True,
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            None,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Process ##########################################################
 | 
					 | 
				
			||||||
    def get_process_args(self):
 | 
					 | 
				
			||||||
        return ["liquidsoap", "-v", self.path]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def check_zombie_process(self):
 | 
					 | 
				
			||||||
        if not os.path.exists(self.socket_path):
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        conns = [
 | 
					 | 
				
			||||||
            conn
 | 
					 | 
				
			||||||
            for conn in psutil.net_connections(kind="unix")
 | 
					 | 
				
			||||||
            if conn.laddr == self.socket_path
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
        for conn in conns:
 | 
					 | 
				
			||||||
            if conn.pid is not None:
 | 
					 | 
				
			||||||
                os.kill(conn.pid, signal.SIGKILL)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def run_process(self):
 | 
					 | 
				
			||||||
        """Execute the external application with corresponding informations.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        This function must make sure that all needed files have been
 | 
					 | 
				
			||||||
        generated.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        if self.process:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        args = self.get_process_args()
 | 
					 | 
				
			||||||
        if not args:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.check_zombie_process()
 | 
					 | 
				
			||||||
        self.process = subprocess.Popen(args, stderr=subprocess.STDOUT)
 | 
					 | 
				
			||||||
        atexit.register(lambda: self.kill_process())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def kill_process(self):
 | 
					 | 
				
			||||||
        if self.process:
 | 
					 | 
				
			||||||
            logger.debug(
 | 
					 | 
				
			||||||
                "kill process %s: %s",
 | 
					 | 
				
			||||||
                self.process.pid,
 | 
					 | 
				
			||||||
                " ".join(self.get_process_args()),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            self.process.kill()
 | 
					 | 
				
			||||||
            self.process = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def wait_process(self):
 | 
					 | 
				
			||||||
        """Wait for the process to terminate if there is a process."""
 | 
					 | 
				
			||||||
        if self.process:
 | 
					 | 
				
			||||||
            self.process.wait()
 | 
					 | 
				
			||||||
            self.process = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Source(BaseMetadata):
 | 
					 | 
				
			||||||
    controller = None
 | 
					 | 
				
			||||||
    """Parent controller."""
 | 
					 | 
				
			||||||
    id = None
 | 
					 | 
				
			||||||
    """Source id."""
 | 
					 | 
				
			||||||
    remaining = 0.0
 | 
					 | 
				
			||||||
    """Remaining time."""
 | 
					 | 
				
			||||||
    status = "stopped"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def station(self):
 | 
					 | 
				
			||||||
        return self.controller.station
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, controller=None, id=None, *args, **kwargs):
 | 
					 | 
				
			||||||
        super().__init__(controller, *args, **kwargs)
 | 
					 | 
				
			||||||
        self.id = id
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def sync(self):
 | 
					 | 
				
			||||||
        """Synchronize what should be synchronized."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def fetch(self):
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            data = self.controller.send(self.id, ".remaining")
 | 
					 | 
				
			||||||
            if data:
 | 
					 | 
				
			||||||
                self.remaining = float(data)
 | 
					 | 
				
			||||||
        except ValueError:
 | 
					 | 
				
			||||||
            self.remaining = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        data = self.controller.send(self.id, ".get", parse=True)
 | 
					 | 
				
			||||||
        if data:
 | 
					 | 
				
			||||||
            self.validate(data if data and isinstance(data, dict) else {})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def skip(self):
 | 
					 | 
				
			||||||
        """Skip the current source sound."""
 | 
					 | 
				
			||||||
        self.controller.send(self.id, ".skip")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def restart(self):
 | 
					 | 
				
			||||||
        """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."""
 | 
					 | 
				
			||||||
        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 get_playlist(self):
 | 
					 | 
				
			||||||
        """Get playlist from db."""
 | 
					 | 
				
			||||||
        return self.get_sound_queryset().playlist()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def write_playlist(self, playlist=[]):
 | 
					 | 
				
			||||||
        """Write playlist to file."""
 | 
					 | 
				
			||||||
        os.makedirs(os.path.dirname(self.path), exist_ok=True)
 | 
					 | 
				
			||||||
        with open(self.path, "w") as file:
 | 
					 | 
				
			||||||
            file.write("\n".join(playlist or []))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def stream(self):
 | 
					 | 
				
			||||||
        """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):
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            "begin": stream.begin.strftime("%Hh%M") if stream.begin else None,
 | 
					 | 
				
			||||||
            "end": stream.end.strftime("%Hh%M") if stream.end else None,
 | 
					 | 
				
			||||||
            "delay": to_seconds(stream.delay) if stream.delay else 0,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def sync(self):
 | 
					 | 
				
			||||||
        playlist = self.get_playlist()
 | 
					 | 
				
			||||||
        self.write_playlist(playlist)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class QueueSource(Source):
 | 
					 | 
				
			||||||
    queue = None
 | 
					 | 
				
			||||||
    """Source's queue (excluded on_air request)"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        super().__init__(*args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def push(self, *paths):
 | 
					 | 
				
			||||||
        """Add the provided paths to source's play queue."""
 | 
					 | 
				
			||||||
        for path in paths:
 | 
					 | 
				
			||||||
            self.controller.send(self.id, "_queue.push ", path)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def fetch(self):
 | 
					 | 
				
			||||||
        super().fetch()
 | 
					 | 
				
			||||||
        queue = self.controller.send(self.id, "_queue.queue").strip()
 | 
					 | 
				
			||||||
        if not queue:
 | 
					 | 
				
			||||||
            self.queue = []
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.queue = queue.split(" ")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def requests(self):
 | 
					 | 
				
			||||||
        """Queue as requests metadata."""
 | 
					 | 
				
			||||||
        requests = [Request(self.controller, rid) for rid in self.queue]
 | 
					 | 
				
			||||||
        for request in requests:
 | 
					 | 
				
			||||||
            request.fetch()
 | 
					 | 
				
			||||||
        return requests
 | 
					 | 
				
			||||||
							
								
								
									
										25
									
								
								aircox_streamer/controllers/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								aircox_streamer/controllers/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					# TODO: for the moment, update in station and program names do not update the
 | 
				
			||||||
 | 
					#       related fields.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .metadata import Metadata, Request
 | 
				
			||||||
 | 
					from .streamer import Streamer
 | 
				
			||||||
 | 
					from .streamers import Streamers
 | 
				
			||||||
 | 
					from .sources import Source, PlaylistSource, QueueSource
 | 
				
			||||||
 | 
					from .monitor import Monitor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					streamers = Streamers()
 | 
				
			||||||
 | 
					"""Default controller used by views and viewsets."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = (
 | 
				
			||||||
 | 
					    "Metadata",
 | 
				
			||||||
 | 
					    "Request",
 | 
				
			||||||
 | 
					    "Streamer",
 | 
				
			||||||
 | 
					    "Streamers",
 | 
				
			||||||
 | 
					    "Source",
 | 
				
			||||||
 | 
					    "PlaylistSource",
 | 
				
			||||||
 | 
					    "QueueSource",
 | 
				
			||||||
 | 
					    "Monitor",
 | 
				
			||||||
 | 
					    "streamers",
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										95
									
								
								aircox_streamer/controllers/metadata.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										95
									
								
								aircox_streamer/controllers/metadata.py
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,95 @@
 | 
				
			|||||||
 | 
					import logging
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import tzlocal
 | 
				
			||||||
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = (
 | 
				
			||||||
 | 
					    "Metadata",
 | 
				
			||||||
 | 
					    "Request",
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					local_tz = tzlocal.get_localzone()
 | 
				
			||||||
 | 
					logger = logging.getLogger("aircox")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# FIXME liquidsoap does not manage timezones -- we have to convert
 | 
				
			||||||
 | 
					#       'on_air' metadata we get from it into utc one in order to work
 | 
				
			||||||
 | 
					#       correctly.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Metadata:
 | 
				
			||||||
 | 
					    """Base class for handling request metadata."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    controller = None
 | 
				
			||||||
 | 
					    """Controller."""
 | 
				
			||||||
 | 
					    rid = None
 | 
				
			||||||
 | 
					    """Request id."""
 | 
				
			||||||
 | 
					    uri = None
 | 
				
			||||||
 | 
					    """Request uri."""
 | 
				
			||||||
 | 
					    status = None
 | 
				
			||||||
 | 
					    """Current playing status."""
 | 
				
			||||||
 | 
					    request_status = None
 | 
				
			||||||
 | 
					    """Requests' status."""
 | 
				
			||||||
 | 
					    air_time = None
 | 
				
			||||||
 | 
					    """Launch datetime."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, controller=None, rid=None, data=None):
 | 
				
			||||||
 | 
					        self.controller = controller
 | 
				
			||||||
 | 
					        self.rid = rid
 | 
				
			||||||
 | 
					        if data is not None:
 | 
				
			||||||
 | 
					            self.validate(data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_playing(self):
 | 
				
			||||||
 | 
					        """True if the source is playing."""
 | 
				
			||||||
 | 
					        # FIXME: validate on controller's current source?
 | 
				
			||||||
 | 
					        return self.status == "playing"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def status_verbose(self):
 | 
				
			||||||
 | 
					        """Verbose version of self's status (translated string)."""
 | 
				
			||||||
 | 
					        status = self.validate_status(self.status)
 | 
				
			||||||
 | 
					        return _(status) if status else ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def fetch(self):
 | 
				
			||||||
 | 
					        data = self.controller.send("request.metadata ", self.rid, parse=True)
 | 
				
			||||||
 | 
					        if data:
 | 
				
			||||||
 | 
					            self.validate(data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate_status(self, status):
 | 
				
			||||||
 | 
					        """Return correct status for this metadata based on provided one and
 | 
				
			||||||
 | 
					        controller.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :returns: status string
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        on_air = self.controller.source
 | 
				
			||||||
 | 
					        if "playing" and on_air and (on_air == self or on_air.rid == self.rid):
 | 
				
			||||||
 | 
					            return "playing"
 | 
				
			||||||
 | 
					        elif status in ("paused", "playing"):
 | 
				
			||||||
 | 
					            return "paused"
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return "stopped"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate_air_time(self, air_time):
 | 
				
			||||||
 | 
					        if air_time:
 | 
				
			||||||
 | 
					            air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S")
 | 
				
			||||||
 | 
					            return local_tz.localize(air_time)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate(self, data):
 | 
				
			||||||
 | 
					        """Validate provided data and set as attribute (must already be
 | 
				
			||||||
 | 
					        declared)"""
 | 
				
			||||||
 | 
					        for key, value in data.items():
 | 
				
			||||||
 | 
					            if hasattr(self, key) and not callable(getattr(self, key)):
 | 
				
			||||||
 | 
					                setattr(self, key, value)
 | 
				
			||||||
 | 
					        self.uri = data.get("initial_uri")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.air_time = self.validate_air_time(data.get("on_air"))
 | 
				
			||||||
 | 
					        self.status = self.validate_status(data.get("status"))
 | 
				
			||||||
 | 
					        self.request_status = data.get("status")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Request(Metadata):
 | 
				
			||||||
 | 
					    title = None
 | 
				
			||||||
 | 
					    artist = None
 | 
				
			||||||
							
								
								
									
										272
									
								
								aircox_streamer/controllers/monitor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								aircox_streamer/controllers/monitor.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,272 @@
 | 
				
			|||||||
 | 
					# TODO:
 | 
				
			||||||
 | 
					# x controllers: remaining
 | 
				
			||||||
 | 
					# x diffusion conflicts
 | 
				
			||||||
 | 
					# x cancel
 | 
				
			||||||
 | 
					# x when liquidsoap fails to start/exists: exit
 | 
				
			||||||
 | 
					# - handle restart after failure
 | 
				
			||||||
 | 
					# - is stream restart after live ok?
 | 
				
			||||||
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox.models import Diffusion, Log, Sound, Track
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Monitor:
 | 
				
			||||||
 | 
					    """Log and launch diffusions for the given station.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Monitor should be able to be used after a crash a go back
 | 
				
			||||||
 | 
					    where it was playing, so we heavily use logs to be able to
 | 
				
			||||||
 | 
					    do that.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    We keep trace of played items on the generated stream:
 | 
				
			||||||
 | 
					    - sounds played on this stream;
 | 
				
			||||||
 | 
					    - scheduled diffusions
 | 
				
			||||||
 | 
					    - tracks for sounds of streamed programs
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    streamer = None
 | 
				
			||||||
 | 
					    """Streamer controller."""
 | 
				
			||||||
 | 
					    delay = None
 | 
				
			||||||
 | 
					    """ Timedelta: minimal delay between two call of monitor. """
 | 
				
			||||||
 | 
					    logs = None
 | 
				
			||||||
 | 
					    """Queryset to station's logs (ordered by -pk)"""
 | 
				
			||||||
 | 
					    cancel_timeout = tz.timedelta(minutes=20)
 | 
				
			||||||
 | 
					    """Timeout in minutes before cancelling a diffusion."""
 | 
				
			||||||
 | 
					    sync_timeout = tz.timedelta(minutes=5)
 | 
				
			||||||
 | 
					    """Timeout in minutes between two streamer's sync."""
 | 
				
			||||||
 | 
					    sync_next = None
 | 
				
			||||||
 | 
					    """Datetime of the next sync."""
 | 
				
			||||||
 | 
					    last_sound_logs = None
 | 
				
			||||||
 | 
					    """Last logged sounds, as ``{source_id: log}``."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def station(self):
 | 
				
			||||||
 | 
					        return self.streamer.station
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def last_log(self):
 | 
				
			||||||
 | 
					        """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.logs.start().with_diff().first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, streamer, delay, **kwargs):
 | 
				
			||||||
 | 
					        self.streamer = streamer
 | 
				
			||||||
 | 
					        # adding time ensures all calculations have a margin
 | 
				
			||||||
 | 
					        self.delay = delay + tz.timedelta(seconds=5)
 | 
				
			||||||
 | 
					        self.__dict__.update(kwargs)
 | 
				
			||||||
 | 
					        self.logs = self.get_logs_queryset()
 | 
				
			||||||
 | 
					        self.init_last_sound_logs()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_logs_queryset(self):
 | 
				
			||||||
 | 
					        """Return queryset to assign as `self.logs`"""
 | 
				
			||||||
 | 
					        return self.station.log_set.select_related(
 | 
				
			||||||
 | 
					            "diffusion", "sound", "track"
 | 
				
			||||||
 | 
					        ).order_by("-pk")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def init_last_sound_logs(self):
 | 
				
			||||||
 | 
					        """Retrieve last logs and initialize `last_sound_logs`"""
 | 
				
			||||||
 | 
					        logs = {}
 | 
				
			||||||
 | 
					        for source in self.streamer.sources:
 | 
				
			||||||
 | 
					            qs = self.logs.filter(source=source.id, sound__isnull=False)
 | 
				
			||||||
 | 
					            logs[source.id] = qs.first()
 | 
				
			||||||
 | 
					        self.last_sound_logs = logs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def monitor(self):
 | 
				
			||||||
 | 
					        """Run all monitoring functions once."""
 | 
				
			||||||
 | 
					        if not self.streamer.is_ready:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.streamer.fetch()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Skip tracing - analyzis:
 | 
				
			||||||
 | 
					        # Reason: multiple database request every x seconds, reducing it.
 | 
				
			||||||
 | 
					        # We could skip this part when remaining time is higher than a minimal
 | 
				
			||||||
 | 
					        # value (which should be derived from Command's delay). Problems:
 | 
				
			||||||
 | 
					        # - How to trace tracks? (+ Source can change: caching log might sucks)
 | 
				
			||||||
 | 
					        # - if liquidsoap's source/request changes: remaining time goes higher,
 | 
				
			||||||
 | 
					        #   thus avoiding fetch
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # Approach: something like having a mean time, such as:
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # ```
 | 
				
			||||||
 | 
					        # source = stream.source
 | 
				
			||||||
 | 
					        # mean_time = source.air_time
 | 
				
			||||||
 | 
					        #           + min(next_track.timestamp, source.remaining)
 | 
				
			||||||
 | 
					        #           - (command.delay + 1)
 | 
				
			||||||
 | 
					        # trace_required = \/ source' != source
 | 
				
			||||||
 | 
					        #                  \/ source.uri' != source.uri
 | 
				
			||||||
 | 
					        #                  \/ now < mean_time
 | 
				
			||||||
 | 
					        # ```
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        source = self.streamer.source
 | 
				
			||||||
 | 
					        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.handle_diffusions()
 | 
				
			||||||
 | 
					        self.sync()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def trace_sound(self, source):
 | 
				
			||||||
 | 
					        """Return on air sound log (create if not present)."""
 | 
				
			||||||
 | 
					        air_uri, air_time = source.uri, source.air_time
 | 
				
			||||||
 | 
					        last_log = self.last_sound_logs.get(source.id)
 | 
				
			||||||
 | 
					        if last_log and last_log.sound.file.path == source.uri:
 | 
				
			||||||
 | 
					            return last_log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # FIXME: can be a sound played when no Sound instance? If not, remove
 | 
				
			||||||
 | 
					        #        comment.
 | 
				
			||||||
 | 
					        # check if there is yet a log for this sound on the source
 | 
				
			||||||
 | 
					        # log = self.logs.on_air().filter(
 | 
				
			||||||
 | 
					        #     Q(sound__file=air_uri) |
 | 
				
			||||||
 | 
					        #     # sound can be null when arbitrary sound file is played
 | 
				
			||||||
 | 
					        #     Q(sound__isnull=True, track__isnull=True, comment=air_uri),
 | 
				
			||||||
 | 
					        #     source=source.id,
 | 
				
			||||||
 | 
					        #     date__range=date_range(air_time, self.delay),
 | 
				
			||||||
 | 
					        # ).first()
 | 
				
			||||||
 | 
					        # if log:
 | 
				
			||||||
 | 
					        #     return log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # get sound
 | 
				
			||||||
 | 
					        diff = None
 | 
				
			||||||
 | 
					        sound = Sound.objects.path(air_uri).first()
 | 
				
			||||||
 | 
					        if sound and sound.episode_id is not None:
 | 
				
			||||||
 | 
					            diff = (
 | 
				
			||||||
 | 
					                Diffusion.objects.episode(id=sound.episode_id)
 | 
				
			||||||
 | 
					                .on_air()
 | 
				
			||||||
 | 
					                .now(air_time)
 | 
				
			||||||
 | 
					                .first()
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # log sound on air
 | 
				
			||||||
 | 
					        return self.log(
 | 
				
			||||||
 | 
					            type=Log.TYPE_ON_AIR,
 | 
				
			||||||
 | 
					            date=source.air_time,
 | 
				
			||||||
 | 
					            source=source.id,
 | 
				
			||||||
 | 
					            sound=sound,
 | 
				
			||||||
 | 
					            diffusion=diff,
 | 
				
			||||||
 | 
					            comment=air_uri,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def trace_tracks(self, log):
 | 
				
			||||||
 | 
					        """Log tracks for the given sound log (for streamed programs only)."""
 | 
				
			||||||
 | 
					        if log.diffusion:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        tracks = Track.objects.filter(
 | 
				
			||||||
 | 
					            sound_id=log.sound_id, timestamp__isnull=False
 | 
				
			||||||
 | 
					        ).order_by("timestamp")
 | 
				
			||||||
 | 
					        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:
 | 
				
			||||||
 | 
					            pos = log.date + tz.timedelta(seconds=track.timestamp)
 | 
				
			||||||
 | 
					            if pos <= now:
 | 
				
			||||||
 | 
					                self.log(
 | 
				
			||||||
 | 
					                    type=Log.TYPE_ON_AIR,
 | 
				
			||||||
 | 
					                    date=pos,
 | 
				
			||||||
 | 
					                    source=log.source,
 | 
				
			||||||
 | 
					                    track=track,
 | 
				
			||||||
 | 
					                    comment=track,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_diffusions(self):
 | 
				
			||||||
 | 
					        """Handle scheduled diffusion, trigger if needed, preload playlists and
 | 
				
			||||||
 | 
					        so on."""
 | 
				
			||||||
 | 
					        # TODO: program restart
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Diffusion conflicts are handled by the way a diffusion is defined
 | 
				
			||||||
 | 
					        # as candidate for the next dealer's start.
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # ```
 | 
				
			||||||
 | 
					        # logged_diff: /\ \A diff in diffs: \E log: /\ log.type = START
 | 
				
			||||||
 | 
					        #                                           /\ log.diff = diff
 | 
				
			||||||
 | 
					        #                                           /\ log.date = diff.start
 | 
				
			||||||
 | 
					        # queue_empty: /\ dealer.queue is empty
 | 
				
			||||||
 | 
					        #              /\ \/ ~dealer.on_air
 | 
				
			||||||
 | 
					        #                 \/ dealer.remaining < delay
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # start_allowed: /\ diff not in logged_diff
 | 
				
			||||||
 | 
					        #                /\ queue_empty
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # start_canceled: /\ diff not in logged diff
 | 
				
			||||||
 | 
					        #                 /\ ~queue_empty
 | 
				
			||||||
 | 
					        #                 /\ diff.start < now + cancel_timeout
 | 
				
			||||||
 | 
					        # ```
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        now = tz.now()
 | 
				
			||||||
 | 
					        diff = (
 | 
				
			||||||
 | 
					            Diffusion.objects.station(self.station)
 | 
				
			||||||
 | 
					            .on_air()
 | 
				
			||||||
 | 
					            .now(now)
 | 
				
			||||||
 | 
					            .filter(episode__sound__type=Sound.TYPE_ARCHIVE)
 | 
				
			||||||
 | 
					            .first()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Can't use delay: diffusion may start later than its assigned start.
 | 
				
			||||||
 | 
					        log = None if not diff else self.logs.start().filter(diffusion=diff)
 | 
				
			||||||
 | 
					        if not diff or log:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        dealer = self.streamer.dealer
 | 
				
			||||||
 | 
					        # start
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            not dealer.queue
 | 
				
			||||||
 | 
					            and dealer.rid is None
 | 
				
			||||||
 | 
					            or dealer.remaining < self.delay.total_seconds()
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            self.start_diff(dealer, diff)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # cancel
 | 
				
			||||||
 | 
					        if diff.start < now - self.cancel_timeout:
 | 
				
			||||||
 | 
					            self.cancel_diff(dealer, diff)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def log(self, source, **kwargs):
 | 
				
			||||||
 | 
					        """Create a log using **kwargs, and print info."""
 | 
				
			||||||
 | 
					        kwargs.setdefault("station", self.station)
 | 
				
			||||||
 | 
					        kwargs.setdefault("date", tz.now())
 | 
				
			||||||
 | 
					        log = Log(source=source, **kwargs)
 | 
				
			||||||
 | 
					        log.save()
 | 
				
			||||||
 | 
					        log.print()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if log.sound:
 | 
				
			||||||
 | 
					            self.last_sound_logs[source] = log
 | 
				
			||||||
 | 
					        return log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def start_diff(self, source, diff):
 | 
				
			||||||
 | 
					        playlist = Sound.objects.episode(id=diff.episode_id).playlist()
 | 
				
			||||||
 | 
					        source.push(*playlist)
 | 
				
			||||||
 | 
					        self.log(
 | 
				
			||||||
 | 
					            type=Log.TYPE_START,
 | 
				
			||||||
 | 
					            source=source.id,
 | 
				
			||||||
 | 
					            diffusion=diff,
 | 
				
			||||||
 | 
					            comment=str(diff),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def cancel_diff(self, source, diff):
 | 
				
			||||||
 | 
					        diff.type = Diffusion.TYPE_CANCEL
 | 
				
			||||||
 | 
					        diff.save()
 | 
				
			||||||
 | 
					        self.log(
 | 
				
			||||||
 | 
					            type=Log.TYPE_CANCEL,
 | 
				
			||||||
 | 
					            source=source.id,
 | 
				
			||||||
 | 
					            diffusion=diff,
 | 
				
			||||||
 | 
					            comment=str(diff),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sync(self):
 | 
				
			||||||
 | 
					        """Update sources' playlists."""
 | 
				
			||||||
 | 
					        now = tz.now()
 | 
				
			||||||
 | 
					        if self.sync_next is not None and now < self.sync_next:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.sync_next = now + self.sync_timeout
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for source in self.streamer.playlists:
 | 
				
			||||||
 | 
					            source.sync()
 | 
				
			||||||
							
								
								
									
										141
									
								
								aircox_streamer/controllers/sources.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										141
									
								
								aircox_streamer/controllers/sources.py
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,141 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					import tzlocal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox.utils import to_seconds
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .metadata import Metadata, Request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = (
 | 
				
			||||||
 | 
					    "Source",
 | 
				
			||||||
 | 
					    "PlaylistSource",
 | 
				
			||||||
 | 
					    "QueueSource",
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					local_tz = tzlocal.get_localzone()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Source(Metadata):
 | 
				
			||||||
 | 
					    controller = None
 | 
				
			||||||
 | 
					    """Parent controller."""
 | 
				
			||||||
 | 
					    id = None
 | 
				
			||||||
 | 
					    """Source id."""
 | 
				
			||||||
 | 
					    remaining = 0.0
 | 
				
			||||||
 | 
					    """Remaining time."""
 | 
				
			||||||
 | 
					    status = "stopped"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def station(self):
 | 
				
			||||||
 | 
					        return self.controller.station
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, controller=None, id=None, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(controller, *args, **kwargs)
 | 
				
			||||||
 | 
					        self.id = id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sync(self):
 | 
				
			||||||
 | 
					        """Synchronize what should be synchronized."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def fetch(self):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            data = self.controller.send(self.id, ".remaining")
 | 
				
			||||||
 | 
					            if data:
 | 
				
			||||||
 | 
					                self.remaining = float(data)
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            self.remaining = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        data = self.controller.send(self.id, ".get", parse=True)
 | 
				
			||||||
 | 
					        if data:
 | 
				
			||||||
 | 
					            self.validate(data if data and isinstance(data, dict) else {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def skip(self):
 | 
				
			||||||
 | 
					        """Skip the current source sound."""
 | 
				
			||||||
 | 
					        self.controller.send(self.id, ".skip")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def restart(self):
 | 
				
			||||||
 | 
					        """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."""
 | 
				
			||||||
 | 
					        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, f"{self.id}.m3u")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_sound_queryset(self):
 | 
				
			||||||
 | 
					        """Get playlist's sounds queryset."""
 | 
				
			||||||
 | 
					        return self.program.sound_set.archive()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_playlist(self):
 | 
				
			||||||
 | 
					        """Get playlist from db."""
 | 
				
			||||||
 | 
					        return self.get_sound_queryset().playlist()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def write_playlist(self, playlist=[]):
 | 
				
			||||||
 | 
					        """Write playlist to file."""
 | 
				
			||||||
 | 
					        os.makedirs(os.path.dirname(self.path), exist_ok=True)
 | 
				
			||||||
 | 
					        with open(self.path, "w") as file:
 | 
				
			||||||
 | 
					            file.write("\n".join(playlist or []))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def stream(self):
 | 
				
			||||||
 | 
					        """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):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            "begin": stream.begin.strftime("%Hh%M") if stream.begin else None,
 | 
				
			||||||
 | 
					            "end": stream.end.strftime("%Hh%M") if stream.end else None,
 | 
				
			||||||
 | 
					            "delay": to_seconds(stream.delay) if stream.delay else 0,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sync(self):
 | 
				
			||||||
 | 
					        playlist = self.get_playlist()
 | 
				
			||||||
 | 
					        self.write_playlist(playlist)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class QueueSource(Source):
 | 
				
			||||||
 | 
					    queue = None
 | 
				
			||||||
 | 
					    """Source's queue (excluded on_air request)"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def requests(self):
 | 
				
			||||||
 | 
					        """Queue as requests metadata."""
 | 
				
			||||||
 | 
					        requests = [Request(self.controller, rid) for rid in self.queue]
 | 
				
			||||||
 | 
					        for request in requests:
 | 
				
			||||||
 | 
					            request.fetch()
 | 
				
			||||||
 | 
					        return requests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def push(self, *paths):
 | 
				
			||||||
 | 
					        """Add the provided paths to source's play queue."""
 | 
				
			||||||
 | 
					        for path in paths:
 | 
				
			||||||
 | 
					            self.controller.send(f"{self.id}_queue.push {path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def fetch(self):
 | 
				
			||||||
 | 
					        super().fetch()
 | 
				
			||||||
 | 
					        queue = self.controller.send(f"{self.id}_queue.queue").strip()
 | 
				
			||||||
 | 
					        if not queue:
 | 
				
			||||||
 | 
					            self.queue = []
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.queue = queue.split(" ")
 | 
				
			||||||
							
								
								
									
										193
									
								
								aircox_streamer/controllers/streamer.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										193
									
								
								aircox_streamer/controllers/streamer.py
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,193 @@
 | 
				
			|||||||
 | 
					import atexit
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					import signal
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import psutil
 | 
				
			||||||
 | 
					from django.template.loader import render_to_string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox.conf import settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ..connector import Connector
 | 
				
			||||||
 | 
					from .sources import PlaylistSource, QueueSource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ("Streamer",)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					logger = logging.getLogger("aircox")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Streamer:
 | 
				
			||||||
 | 
					    connector = None
 | 
				
			||||||
 | 
					    process = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    station = None
 | 
				
			||||||
 | 
					    template_name = "aircox_streamer/scripts/station.liq"
 | 
				
			||||||
 | 
					    path = None
 | 
				
			||||||
 | 
					    """Config path."""
 | 
				
			||||||
 | 
					    sources = None
 | 
				
			||||||
 | 
					    """List of all monitored sources."""
 | 
				
			||||||
 | 
					    source = None
 | 
				
			||||||
 | 
					    """Current source being played on air."""
 | 
				
			||||||
 | 
					    # note: we disable on_air rids since we don't have use of it for the
 | 
				
			||||||
 | 
					    # moment
 | 
				
			||||||
 | 
					    # on_air = None
 | 
				
			||||||
 | 
					    # """ On-air request ids (rid) """
 | 
				
			||||||
 | 
					    inputs = None
 | 
				
			||||||
 | 
					    """Queryset to input ports."""
 | 
				
			||||||
 | 
					    outputs = None
 | 
				
			||||||
 | 
					    """Queryset to output ports."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, station, connector=None):
 | 
				
			||||||
 | 
					        self.station = station
 | 
				
			||||||
 | 
					        self.inputs = self.station.port_set.active().input()
 | 
				
			||||||
 | 
					        self.outputs = self.station.port_set.active().output()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.id = self.station.slug.replace("-", "_")
 | 
				
			||||||
 | 
					        self.path = os.path.join(station.path, "station.liq")
 | 
				
			||||||
 | 
					        self.connector = connector or Connector(
 | 
				
			||||||
 | 
					            os.path.join(station.path, "station.sock")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.init_sources()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def socket_path(self):
 | 
				
			||||||
 | 
					        """Path to Unix socket file."""
 | 
				
			||||||
 | 
					        return self.connector.address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_ready(self):
 | 
				
			||||||
 | 
					        """If external program is ready to use, returns True."""
 | 
				
			||||||
 | 
					        return self.send("list") != ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_running(self):
 | 
				
			||||||
 | 
					        """True if holds a running process."""
 | 
				
			||||||
 | 
					        if self.process is None:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        returncode = self.process.poll()
 | 
				
			||||||
 | 
					        if returncode is None:
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.process = None
 | 
				
			||||||
 | 
					        logger.debug("process died with return code %s" % returncode)
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def playlists(self):
 | 
				
			||||||
 | 
					        return (s for s in self.sources if isinstance(s, PlaylistSource))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def queues(self):
 | 
				
			||||||
 | 
					        return (s for s in self.sources if isinstance(s, QueueSource))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Sources and config ###############################################
 | 
				
			||||||
 | 
					    def send(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        return self.connector.send(*args, **kwargs) or ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # request.on_air is not ordered: we need to do it manually
 | 
				
			||||||
 | 
					        self.source = next(
 | 
				
			||||||
 | 
					            iter(
 | 
				
			||||||
 | 
					                sorted(
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        source
 | 
				
			||||||
 | 
					                        for source in self.sources
 | 
				
			||||||
 | 
					                        if source.request_status == "playing"
 | 
				
			||||||
 | 
					                        and source.air_time
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    key=lambda o: o.air_time,
 | 
				
			||||||
 | 
					                    reverse=True,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            None,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Process ##########################################################
 | 
				
			||||||
 | 
					    def get_process_args(self):
 | 
				
			||||||
 | 
					        return ["liquidsoap", "-v", self.path]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def check_zombie_process(self):
 | 
				
			||||||
 | 
					        if not os.path.exists(self.socket_path):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conns = [
 | 
				
			||||||
 | 
					            conn
 | 
				
			||||||
 | 
					            for conn in psutil.net_connections(kind="unix")
 | 
				
			||||||
 | 
					            if conn.laddr == self.socket_path
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        for conn in conns:
 | 
				
			||||||
 | 
					            if conn.pid is not None:
 | 
				
			||||||
 | 
					                os.kill(conn.pid, signal.SIGKILL)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def run_process(self):
 | 
				
			||||||
 | 
					        """Execute the external application with corresponding informations.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        This function must make sure that all needed files have been
 | 
				
			||||||
 | 
					        generated.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.process:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        args = self.get_process_args()
 | 
				
			||||||
 | 
					        if not args:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.check_zombie_process()
 | 
				
			||||||
 | 
					        self.process = subprocess.Popen(args, stderr=subprocess.STDOUT)
 | 
				
			||||||
 | 
					        atexit.register(self.kill_process)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def kill_process(self):
 | 
				
			||||||
 | 
					        if self.process:
 | 
				
			||||||
 | 
					            logger.debug(
 | 
				
			||||||
 | 
					                "kill process %s: %s",
 | 
				
			||||||
 | 
					                self.process.pid,
 | 
				
			||||||
 | 
					                " ".join(self.get_process_args()),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.process.kill()
 | 
				
			||||||
 | 
					            self.process = None
 | 
				
			||||||
 | 
					            atexit.unregister(self.kill_process)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def wait_process(self):
 | 
				
			||||||
 | 
					        """Wait for the process to terminate if there is a process."""
 | 
				
			||||||
 | 
					        if self.process:
 | 
				
			||||||
 | 
					            self.process.wait()
 | 
				
			||||||
 | 
					            self.process = None
 | 
				
			||||||
							
								
								
									
										62
									
								
								aircox_streamer/controllers/streamers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								aircox_streamer/controllers/streamers.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox.models import Station
 | 
				
			||||||
 | 
					from .streamer import Streamer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ("Streamers",)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Streamers:
 | 
				
			||||||
 | 
					    """Keep multiple streamers in memory, allow fetching informations."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    streamers = None
 | 
				
			||||||
 | 
					    """Stations by station id."""
 | 
				
			||||||
 | 
					    streamer_class = Streamer
 | 
				
			||||||
 | 
					    timeout = None
 | 
				
			||||||
 | 
					    """Timedelta to next update."""
 | 
				
			||||||
 | 
					    next_date = None
 | 
				
			||||||
 | 
					    """Next update datetime."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, timeout=None, streamer_class=streamer_class):
 | 
				
			||||||
 | 
					        self.timeout = timeout or tz.timedelta(seconds=2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def reset(self, stations=Station.objects.active()):
 | 
				
			||||||
 | 
					        # FIXME: cf. TODO in aircox.controllers about model updates
 | 
				
			||||||
 | 
					        stations = stations.all()
 | 
				
			||||||
 | 
					        self.streamers = {
 | 
				
			||||||
 | 
					            station.pk: self.streamer_class(station) for station in stations
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def fetch(self):
 | 
				
			||||||
 | 
					        """Call streamers fetch if timed-out."""
 | 
				
			||||||
 | 
					        if self.streamers is None:
 | 
				
			||||||
 | 
					            self.reset()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        now = tz.now()
 | 
				
			||||||
 | 
					        if self.next_date is not None and now < self.next_date:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for streamer in self.streamers.values():
 | 
				
			||||||
 | 
					            streamer.fetch()
 | 
				
			||||||
 | 
					        self.next_date = now + self.timeout
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get(self, key, default=None):
 | 
				
			||||||
 | 
					        return self.streamers.get(key, default)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def values(self):
 | 
				
			||||||
 | 
					        return self.streamers.values()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __len__(self):
 | 
				
			||||||
 | 
					        return self.streamers and len(self.streamers) or 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __getitem__(self, key):
 | 
				
			||||||
 | 
					        return self.streamers[key]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __contains__(self, key):
 | 
				
			||||||
 | 
					        """Key can be a Station or a Station id."""
 | 
				
			||||||
 | 
					        if isinstance(key, Station):
 | 
				
			||||||
 | 
					            return key.pk in self.streamers
 | 
				
			||||||
 | 
					        return key in self.streamers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __iter__(self):
 | 
				
			||||||
 | 
					        return self.streamers.values() if self.streamers else iter(tuple())
 | 
				
			||||||
@ -8,290 +8,20 @@ to:
 | 
				
			|||||||
"""
 | 
					"""
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# TODO:
 | 
					 | 
				
			||||||
# x controllers: remaining
 | 
					 | 
				
			||||||
# x diffusion conflicts
 | 
					 | 
				
			||||||
# x cancel
 | 
					 | 
				
			||||||
# x when liquidsoap fails to start/exists: exit
 | 
					 | 
				
			||||||
# - handle restart after failure
 | 
					 | 
				
			||||||
# - is stream restart after live ok?
 | 
					 | 
				
			||||||
from argparse import RawTextHelpFormatter
 | 
					from argparse import RawTextHelpFormatter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pytz
 | 
					import pytz
 | 
				
			||||||
from django.core.management.base import BaseCommand
 | 
					from django.core.management.base import BaseCommand
 | 
				
			||||||
from django.utils import timezone as tz
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from aircox.models import Diffusion, Log, Sound, Station, Track
 | 
					from aircox.models import Station
 | 
				
			||||||
from aircox_streamer.controllers import Streamer
 | 
					from aircox_streamer.controllers import Monitor, Streamer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# force using UTC
 | 
					# force using UTC
 | 
				
			||||||
tz.activate(pytz.UTC)
 | 
					tz.activate(pytz.UTC)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Monitor:
 | 
					 | 
				
			||||||
    """Log and launch diffusions for the given station.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Monitor should be able to be used after a crash a go back
 | 
					 | 
				
			||||||
    where it was playing, so we heavily use logs to be able to
 | 
					 | 
				
			||||||
    do that.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    We keep trace of played items on the generated stream:
 | 
					 | 
				
			||||||
    - sounds played on this stream;
 | 
					 | 
				
			||||||
    - scheduled diffusions
 | 
					 | 
				
			||||||
    - tracks for sounds of streamed programs
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    streamer = None
 | 
					 | 
				
			||||||
    """Streamer controller."""
 | 
					 | 
				
			||||||
    delay = None
 | 
					 | 
				
			||||||
    """ Timedelta: minimal delay between two call of monitor. """
 | 
					 | 
				
			||||||
    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."""
 | 
					 | 
				
			||||||
    last_sound_logs = None
 | 
					 | 
				
			||||||
    """Last logged sounds, as ``{source_id: log}``."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def station(self):
 | 
					 | 
				
			||||||
        return self.streamer.station
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def last_log(self):
 | 
					 | 
				
			||||||
        """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.logs.start().with_diff().first()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, streamer, delay, cancel_timeout, **kwargs):
 | 
					 | 
				
			||||||
        self.streamer = streamer
 | 
					 | 
				
			||||||
        # adding time ensure all calculation have a margin
 | 
					 | 
				
			||||||
        self.delay = delay + tz.timedelta(seconds=5)
 | 
					 | 
				
			||||||
        self.cancel_timeout = cancel_timeout
 | 
					 | 
				
			||||||
        self.__dict__.update(kwargs)
 | 
					 | 
				
			||||||
        self.logs = self.get_logs_queryset()
 | 
					 | 
				
			||||||
        self.init_last_sound_logs()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_logs_queryset(self):
 | 
					 | 
				
			||||||
        """Return queryset to assign as `self.logs`"""
 | 
					 | 
				
			||||||
        return self.station.log_set.select_related(
 | 
					 | 
				
			||||||
            "diffusion", "sound", "track"
 | 
					 | 
				
			||||||
        ).order_by("-pk")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def init_last_sound_logs(self, key=None):
 | 
					 | 
				
			||||||
        """Retrieve last logs and initialize `last_sound_logs`"""
 | 
					 | 
				
			||||||
        logs = {}
 | 
					 | 
				
			||||||
        for source in self.streamer.sources:
 | 
					 | 
				
			||||||
            qs = self.logs.filter(source=source.id, sound__isnull=False)
 | 
					 | 
				
			||||||
            logs[source.id] = qs.first()
 | 
					 | 
				
			||||||
        self.last_sound_logs = logs
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def monitor(self):
 | 
					 | 
				
			||||||
        """Run all monitoring functions once."""
 | 
					 | 
				
			||||||
        if not self.streamer.is_ready:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.streamer.fetch()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Skip tracing - analyzis:
 | 
					 | 
				
			||||||
        # Reason: multiple database request every x seconds, reducing it.
 | 
					 | 
				
			||||||
        # We could skip this part when remaining time is higher than a minimal
 | 
					 | 
				
			||||||
        # value (which should be derived from Command's delay). Problems:
 | 
					 | 
				
			||||||
        # - How to trace tracks? (+ Source can change: caching log might sucks)
 | 
					 | 
				
			||||||
        # - if liquidsoap's source/request changes: remaining time goes higher,
 | 
					 | 
				
			||||||
        #   thus avoiding fetch
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        # Approach: something like having a mean time, such as:
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        # ```
 | 
					 | 
				
			||||||
        # source = stream.source
 | 
					 | 
				
			||||||
        # mean_time = source.air_time
 | 
					 | 
				
			||||||
        #           + min(next_track.timestamp, source.remaining)
 | 
					 | 
				
			||||||
        #           - (command.delay + 1)
 | 
					 | 
				
			||||||
        # trace_required = \/ source' != source
 | 
					 | 
				
			||||||
        #                  \/ source.uri' != source.uri
 | 
					 | 
				
			||||||
        #                  \/ now < mean_time
 | 
					 | 
				
			||||||
        # ```
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        source = self.streamer.source
 | 
					 | 
				
			||||||
        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.handle_diffusions()
 | 
					 | 
				
			||||||
        self.sync()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def log(self, source, **kwargs):
 | 
					 | 
				
			||||||
        """Create a log using **kwargs, and print info."""
 | 
					 | 
				
			||||||
        kwargs.setdefault("station", self.station)
 | 
					 | 
				
			||||||
        kwargs.setdefault("date", tz.now())
 | 
					 | 
				
			||||||
        log = Log(source=source, **kwargs)
 | 
					 | 
				
			||||||
        log.save()
 | 
					 | 
				
			||||||
        log.print()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if log.sound:
 | 
					 | 
				
			||||||
            self.last_sound_logs[source] = log
 | 
					 | 
				
			||||||
        return log
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def trace_sound(self, source):
 | 
					 | 
				
			||||||
        """Return on air sound log (create if not present)."""
 | 
					 | 
				
			||||||
        air_uri, air_time = source.uri, source.air_time
 | 
					 | 
				
			||||||
        last_log = self.last_sound_logs.get(source.id)
 | 
					 | 
				
			||||||
        if last_log and last_log.sound.file.path == source.uri:
 | 
					 | 
				
			||||||
            return last_log
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # FIXME: can be a sound played when no Sound instance? If not, remove
 | 
					 | 
				
			||||||
        #        comment.
 | 
					 | 
				
			||||||
        # check if there is yet a log for this sound on the source
 | 
					 | 
				
			||||||
        # log = self.logs.on_air().filter(
 | 
					 | 
				
			||||||
        #     Q(sound__file=air_uri) |
 | 
					 | 
				
			||||||
        #     # sound can be null when arbitrary sound file is played
 | 
					 | 
				
			||||||
        #     Q(sound__isnull=True, track__isnull=True, comment=air_uri),
 | 
					 | 
				
			||||||
        #     source=source.id,
 | 
					 | 
				
			||||||
        #     date__range=date_range(air_time, self.delay),
 | 
					 | 
				
			||||||
        # ).first()
 | 
					 | 
				
			||||||
        # if log:
 | 
					 | 
				
			||||||
        #     return log
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # get sound
 | 
					 | 
				
			||||||
        diff = None
 | 
					 | 
				
			||||||
        sound = Sound.objects.path(air_uri).first()
 | 
					 | 
				
			||||||
        if sound and sound.episode_id is not None:
 | 
					 | 
				
			||||||
            diff = (
 | 
					 | 
				
			||||||
                Diffusion.objects.episode(id=sound.episode_id)
 | 
					 | 
				
			||||||
                .on_air()
 | 
					 | 
				
			||||||
                .now(air_time)
 | 
					 | 
				
			||||||
                .first()
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # log sound on air
 | 
					 | 
				
			||||||
        return self.log(
 | 
					 | 
				
			||||||
            type=Log.TYPE_ON_AIR,
 | 
					 | 
				
			||||||
            date=source.air_time,
 | 
					 | 
				
			||||||
            source=source.id,
 | 
					 | 
				
			||||||
            sound=sound,
 | 
					 | 
				
			||||||
            diffusion=diff,
 | 
					 | 
				
			||||||
            comment=air_uri,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def trace_tracks(self, log):
 | 
					 | 
				
			||||||
        """Log tracks for the given sound log (for streamed programs only)."""
 | 
					 | 
				
			||||||
        if log.diffusion:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        tracks = Track.objects.filter(
 | 
					 | 
				
			||||||
            sound__id=log.sound_id, timestamp__isnull=False
 | 
					 | 
				
			||||||
        ).order_by("timestamp")
 | 
					 | 
				
			||||||
        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:
 | 
					 | 
				
			||||||
            pos = log.date + tz.timedelta(seconds=track.timestamp)
 | 
					 | 
				
			||||||
            if pos > now:
 | 
					 | 
				
			||||||
                break
 | 
					 | 
				
			||||||
            self.log(
 | 
					 | 
				
			||||||
                type=Log.TYPE_ON_AIR,
 | 
					 | 
				
			||||||
                date=pos,
 | 
					 | 
				
			||||||
                source=log.source,
 | 
					 | 
				
			||||||
                track=track,
 | 
					 | 
				
			||||||
                comment=track,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def handle_diffusions(self):
 | 
					 | 
				
			||||||
        """Handle scheduled diffusion, trigger if needed, preload playlists and
 | 
					 | 
				
			||||||
        so on."""
 | 
					 | 
				
			||||||
        # TODO: program restart
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Diffusion conflicts are handled by the way a diffusion is defined
 | 
					 | 
				
			||||||
        # as candidate for the next dealer's start.
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        # ```
 | 
					 | 
				
			||||||
        # logged_diff: /\ \A diff in diffs: \E log: /\ log.type = START
 | 
					 | 
				
			||||||
        #                                           /\ log.diff = diff
 | 
					 | 
				
			||||||
        #                                           /\ log.date = diff.start
 | 
					 | 
				
			||||||
        # queue_empty: /\ dealer.queue is empty
 | 
					 | 
				
			||||||
        #              /\ \/ ~dealer.on_air
 | 
					 | 
				
			||||||
        #                 \/ dealer.remaining < delay
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        # start_allowed: /\ diff not in logged_diff
 | 
					 | 
				
			||||||
        #                /\ queue_empty
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        # start_canceled: /\ diff not in logged diff
 | 
					 | 
				
			||||||
        #                 /\ ~queue_empty
 | 
					 | 
				
			||||||
        #                 /\ diff.start < now + cancel_timeout
 | 
					 | 
				
			||||||
        # ```
 | 
					 | 
				
			||||||
        #
 | 
					 | 
				
			||||||
        now = tz.now()
 | 
					 | 
				
			||||||
        diff = (
 | 
					 | 
				
			||||||
            Diffusion.objects.station(self.station)
 | 
					 | 
				
			||||||
            .on_air()
 | 
					 | 
				
			||||||
            .now(now)
 | 
					 | 
				
			||||||
            .filter(episode__sound__type=Sound.TYPE_ARCHIVE)
 | 
					 | 
				
			||||||
            .first()
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Can't use delay: diffusion may start later than its assigned start.
 | 
					 | 
				
			||||||
        log = None if not diff else self.logs.start().filter(diffusion=diff)
 | 
					 | 
				
			||||||
        if not diff or log:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        dealer = self.streamer.dealer
 | 
					 | 
				
			||||||
        # start
 | 
					 | 
				
			||||||
        if (
 | 
					 | 
				
			||||||
            not dealer.queue
 | 
					 | 
				
			||||||
            and dealer.rid is None
 | 
					 | 
				
			||||||
            or dealer.remaining < self.delay.total_seconds()
 | 
					 | 
				
			||||||
        ):
 | 
					 | 
				
			||||||
            self.start_diff(dealer, diff)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # cancel
 | 
					 | 
				
			||||||
        if diff.start < now - self.cancel_timeout:
 | 
					 | 
				
			||||||
            self.cancel_diff(dealer, diff)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def start_diff(self, source, diff):
 | 
					 | 
				
			||||||
        playlist = Sound.objects.episode(id=diff.episode_id).playlist()
 | 
					 | 
				
			||||||
        source.push(*playlist)
 | 
					 | 
				
			||||||
        self.log(
 | 
					 | 
				
			||||||
            type=Log.TYPE_START,
 | 
					 | 
				
			||||||
            source=source.id,
 | 
					 | 
				
			||||||
            diffusion=diff,
 | 
					 | 
				
			||||||
            comment=str(diff),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def cancel_diff(self, source, diff):
 | 
					 | 
				
			||||||
        diff.type = Diffusion.TYPE_CANCEL
 | 
					 | 
				
			||||||
        diff.save()
 | 
					 | 
				
			||||||
        self.log(
 | 
					 | 
				
			||||||
            type=Log.TYPE_CANCEL,
 | 
					 | 
				
			||||||
            source=source.id,
 | 
					 | 
				
			||||||
            diffusion=diff,
 | 
					 | 
				
			||||||
            comment=str(diff),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def sync(self):
 | 
					 | 
				
			||||||
        """Update sources' playlists."""
 | 
					 | 
				
			||||||
        now = tz.now()
 | 
					 | 
				
			||||||
        if self.sync_next is not None and now < self.sync_next:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.sync_next = now + tz.timedelta(minutes=self.sync_timeout)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for source in self.streamer.playlists:
 | 
					 | 
				
			||||||
            source.sync()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Command(BaseCommand):
 | 
					class Command(BaseCommand):
 | 
				
			||||||
    help = __doc__
 | 
					    help = __doc__
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -339,7 +69,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
            "-t",
 | 
					            "-t",
 | 
				
			||||||
            "--timeout",
 | 
					            "--timeout",
 | 
				
			||||||
            type=float,
 | 
					            type=float,
 | 
				
			||||||
            default=Monitor.cancel_timeout,
 | 
					            default=Monitor.cancel_timeout.total_seconds() / 60,
 | 
				
			||||||
            help="time to wait in MINUTES before canceling a diffusion that "
 | 
					            help="time to wait in MINUTES before canceling a diffusion that "
 | 
				
			||||||
            "should have ran but did not. ",
 | 
					            "should have ran but did not. ",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@ -377,7 +107,8 @@ class Command(BaseCommand):
 | 
				
			|||||||
            delay = tz.timedelta(milliseconds=delay)
 | 
					            delay = tz.timedelta(milliseconds=delay)
 | 
				
			||||||
            timeout = tz.timedelta(minutes=timeout)
 | 
					            timeout = tz.timedelta(minutes=timeout)
 | 
				
			||||||
            monitors = [
 | 
					            monitors = [
 | 
				
			||||||
                Monitor(streamer, delay, timeout) for streamer in streamers
 | 
					                Monitor(streamer, delay, cancel_timeout=timeout)
 | 
				
			||||||
 | 
					                for streamer in streamers
 | 
				
			||||||
            ]
 | 
					            ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            while not run or streamer.is_running:
 | 
					            while not run or streamer.is_running:
 | 
				
			||||||
 | 
				
			|||||||
@ -22,18 +22,18 @@ class BaseSerializer(serializers.Serializer):
 | 
				
			|||||||
        return reverse(self.url_name, kwargs=kwargs)
 | 
					        return reverse(self.url_name, kwargs=kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BaseMetadataSerializer(BaseSerializer):
 | 
					class MetadataSerializer(BaseSerializer):
 | 
				
			||||||
    rid = serializers.IntegerField()
 | 
					    rid = serializers.IntegerField()
 | 
				
			||||||
    air_time = serializers.DateTimeField()
 | 
					    air_time = serializers.DateTimeField()
 | 
				
			||||||
    uri = serializers.CharField()
 | 
					    uri = serializers.CharField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RequestSerializer(BaseMetadataSerializer):
 | 
					class RequestSerializer(MetadataSerializer):
 | 
				
			||||||
    title = serializers.CharField(required=False)
 | 
					    title = serializers.CharField(required=False)
 | 
				
			||||||
    artist = serializers.CharField(required=False)
 | 
					    artist = serializers.CharField(required=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SourceSerializer(BaseMetadataSerializer):
 | 
					class SourceSerializer(MetadataSerializer):
 | 
				
			||||||
    id = serializers.CharField()
 | 
					    id = serializers.CharField()
 | 
				
			||||||
    uri = serializers.CharField()
 | 
					    uri = serializers.CharField()
 | 
				
			||||||
    rid = serializers.IntegerField()
 | 
					    rid = serializers.IntegerField()
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										0
									
								
								aircox_streamer/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								aircox_streamer/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										337
									
								
								aircox_streamer/tests/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										337
									
								
								aircox_streamer/tests/conftest.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,337 @@
 | 
				
			|||||||
 | 
					import itertools
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from datetime import datetime, time
 | 
				
			||||||
 | 
					import tzlocal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					from model_bakery import baker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox import models
 | 
				
			||||||
 | 
					from aircox_streamer import controllers
 | 
				
			||||||
 | 
					from aircox_streamer.connector import Connector
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					local_tz = tzlocal.get_localzone()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					working_dir = os.path.join(os.path.dirname(__file__), "working_dir")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def interface_wrap(obj, attr, value):
 | 
				
			||||||
 | 
					    if not isinstance(getattr(obj, "calls", None), dict):
 | 
				
			||||||
 | 
					        obj.calls = {}
 | 
				
			||||||
 | 
					    obj.calls[attr] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def wrapper(*a, **kw):
 | 
				
			||||||
 | 
					        call = obj.calls.get(attr)
 | 
				
			||||||
 | 
					        if call is None:
 | 
				
			||||||
 | 
					            obj.calls[attr] = (a, kw)
 | 
				
			||||||
 | 
					        elif isinstance(call, tuple):
 | 
				
			||||||
 | 
					            obj.calls[attr] = [call, (a, kw)]
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            call.append((a, kw))
 | 
				
			||||||
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setattr(obj, attr, wrapper)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def interface(obj, funcs):
 | 
				
			||||||
 | 
					    """Override provided object's functions using dict of funcs, as ``{
 | 
				
			||||||
 | 
					    func_name: return_value}``.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Attribute ``obj.calls`` is a dict
 | 
				
			||||||
 | 
					    with all call done using those methods, as
 | 
				
			||||||
 | 
					    ``{func_name: (args, kwargs)}``.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    for attr, value in funcs.items():
 | 
				
			||||||
 | 
					        interface_wrap(obj, attr, value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeSocket:
 | 
				
			||||||
 | 
					    FAILING_ADDRESS = -1
 | 
				
			||||||
 | 
					    """Connect with this address fails."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    family, type, address = None, None, None
 | 
				
			||||||
 | 
					    sent_data = None
 | 
				
			||||||
 | 
					    """List of data that have been `send[all]`"""
 | 
				
			||||||
 | 
					    recv_data = None
 | 
				
			||||||
 | 
					    """Response data to return on recv."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, family, type):
 | 
				
			||||||
 | 
					        self.family = family
 | 
				
			||||||
 | 
					        self.type = type
 | 
				
			||||||
 | 
					        self.sent_data = []
 | 
				
			||||||
 | 
					        self.recv_data = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def connect(self, address):
 | 
				
			||||||
 | 
					        if address == self.FAILING_ADDRESS:
 | 
				
			||||||
 | 
					            raise RuntimeError("invalid connection")
 | 
				
			||||||
 | 
					        self.address = address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def close(self):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sendall(self, data):
 | 
				
			||||||
 | 
					        self.sent_data.append(data.decode())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def recv(self, count):
 | 
				
			||||||
 | 
					        if isinstance(self.recv_data, list):
 | 
				
			||||||
 | 
					            if len(self.recv_data):
 | 
				
			||||||
 | 
					                data, self.recv_data = self.recv_data[0], self.recv_data[1:]
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                data = ""
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            data = self.recv_data
 | 
				
			||||||
 | 
					            self.recv_data = self.recv_data[count:]
 | 
				
			||||||
 | 
					        data = data[:count]
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            data.encode("utf-8") if isinstance(data, str) else data
 | 
				
			||||||
 | 
					        ) or b"\nEND"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_sent(self, data):
 | 
				
			||||||
 | 
					        """Return True if provided data have been sent."""
 | 
				
			||||||
 | 
					        # use [:-1] because connector add "\n" at sent data
 | 
				
			||||||
 | 
					        return any(r for r in self.sent_data if r == data or r[:-1] == data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- models
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def station():
 | 
				
			||||||
 | 
					    station = models.Station(
 | 
				
			||||||
 | 
					        name="test", path=working_dir, default=True, active=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    station.save()
 | 
				
			||||||
 | 
					    return station
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def stations(station):
 | 
				
			||||||
 | 
					    objs = [
 | 
				
			||||||
 | 
					        models.Station(
 | 
				
			||||||
 | 
					            name=f"test-{i}",
 | 
				
			||||||
 | 
					            slug=f"test-{i}",
 | 
				
			||||||
 | 
					            path=working_dir,
 | 
				
			||||||
 | 
					            default=(i == 0),
 | 
				
			||||||
 | 
					            active=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        for i in range(0, 3)
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    models.Station.objects.bulk_create(objs)
 | 
				
			||||||
 | 
					    return [station] + objs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def station_ports(station):
 | 
				
			||||||
 | 
					    return _stations_ports(station)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def stations_ports(stations):
 | 
				
			||||||
 | 
					    return _stations_ports(*stations)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _stations_ports(*stations):
 | 
				
			||||||
 | 
					    items = list(
 | 
				
			||||||
 | 
					        itertools.chain(
 | 
				
			||||||
 | 
					            *[
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    models.Port(
 | 
				
			||||||
 | 
					                        station=station,
 | 
				
			||||||
 | 
					                        direction=models.Port.DIRECTION_INPUT,
 | 
				
			||||||
 | 
					                        type=models.Port.TYPE_HTTP,
 | 
				
			||||||
 | 
					                        active=True,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    models.Port(
 | 
				
			||||||
 | 
					                        station=station,
 | 
				
			||||||
 | 
					                        direction=models.Port.DIRECTION_OUTPUT,
 | 
				
			||||||
 | 
					                        type=models.Port.TYPE_FILE,
 | 
				
			||||||
 | 
					                        active=True,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                for station in stations
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    models.Port.objects.bulk_create(items)
 | 
				
			||||||
 | 
					    return items
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def program(station):
 | 
				
			||||||
 | 
					    program = models.Program(title="test", station=station)
 | 
				
			||||||
 | 
					    program.save()
 | 
				
			||||||
 | 
					    return program
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def stream(program):
 | 
				
			||||||
 | 
					    stream = models.Stream(
 | 
				
			||||||
 | 
					        program=program, begin=time(10, 12), end=time(12, 13)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    stream.save()
 | 
				
			||||||
 | 
					    return stream
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def episode(program):
 | 
				
			||||||
 | 
					    return baker.make(models.Episode, title="test episode", program=program)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def sound(program, episode):
 | 
				
			||||||
 | 
					    sound = models.Sound(
 | 
				
			||||||
 | 
					        program=program,
 | 
				
			||||||
 | 
					        episode=episode,
 | 
				
			||||||
 | 
					        name="sound",
 | 
				
			||||||
 | 
					        type=models.Sound.TYPE_ARCHIVE,
 | 
				
			||||||
 | 
					        position=0,
 | 
				
			||||||
 | 
					        file="sound.mp3",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    sound.save(check=False)
 | 
				
			||||||
 | 
					    return sound
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def sounds(program):
 | 
				
			||||||
 | 
					    items = [
 | 
				
			||||||
 | 
					        models.Sound(
 | 
				
			||||||
 | 
					            name=f"sound {i}",
 | 
				
			||||||
 | 
					            program=program,
 | 
				
			||||||
 | 
					            type=models.Sound.TYPE_ARCHIVE,
 | 
				
			||||||
 | 
					            position=i,
 | 
				
			||||||
 | 
					            file=f"sound-{i}.mp3",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        for i in range(0, 3)
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    models.Sound.objects.bulk_create(items)
 | 
				
			||||||
 | 
					    return items
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- connectors
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def connector():
 | 
				
			||||||
 | 
					    obj = Connector(os.path.join(working_dir, "test.sock"))
 | 
				
			||||||
 | 
					    obj.socket_class = FakeSocket
 | 
				
			||||||
 | 
					    yield obj
 | 
				
			||||||
 | 
					    obj.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def fail_connector():
 | 
				
			||||||
 | 
					    obj = Connector(FakeSocket.FAILING_ADDRESS)
 | 
				
			||||||
 | 
					    obj.socket_class = FakeSocket
 | 
				
			||||||
 | 
					    yield obj
 | 
				
			||||||
 | 
					    obj.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def controller(station, connector):
 | 
				
			||||||
 | 
					    connector.open()
 | 
				
			||||||
 | 
					    return controllers.Streamer(station, connector)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def socket(controller):
 | 
				
			||||||
 | 
					    return controller.connector.socket
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- metadata
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def metadata(controller):
 | 
				
			||||||
 | 
					    return controllers.Metadata(controller, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def metadata_data_air_time():
 | 
				
			||||||
 | 
					    return local_tz.localize(datetime(2023, 5, 1, 12, 10, 5))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def metadata_data(metadata_data_air_time):
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        "rid": 1,
 | 
				
			||||||
 | 
					        "initial_uri": "request_uri",
 | 
				
			||||||
 | 
					        "on_air": metadata_data_air_time.strftime("%Y/%m/%d %H:%M:%S"),
 | 
				
			||||||
 | 
					        "status": "playing",
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def metadata_string(metadata_data):
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        "\n".join(f"{key}={value}" for key, value in metadata_data.items())
 | 
				
			||||||
 | 
					        + "\nEND"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- streamers
 | 
				
			||||||
 | 
					class FakeStreamer(controllers.Streamer):
 | 
				
			||||||
 | 
					    calls = {}
 | 
				
			||||||
 | 
					    is_ready = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, **kwargs):
 | 
				
			||||||
 | 
					        self.__dict__.update(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def fetch(self):
 | 
				
			||||||
 | 
					        self.calls["fetch"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeSource(controllers.Source):
 | 
				
			||||||
 | 
					    def __init__(self, id, *args, **kwargs):
 | 
				
			||||||
 | 
					        self.id = id
 | 
				
			||||||
 | 
					        self.args = args
 | 
				
			||||||
 | 
					        self.kwargs = kwargs
 | 
				
			||||||
 | 
					        self.calls = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def fetch(self):
 | 
				
			||||||
 | 
					        self.calls["sync"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sync(self):
 | 
				
			||||||
 | 
					        self.calls["sync"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def push(self, *path):
 | 
				
			||||||
 | 
					        self.calls["push"] = path
 | 
				
			||||||
 | 
					        return path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def skip(self):
 | 
				
			||||||
 | 
					        self.calls["skip"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def restart(self):
 | 
				
			||||||
 | 
					        self.calls["restart"] = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def seek(self, c):
 | 
				
			||||||
 | 
					        self.calls["seek"] = c
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakePlaylist(FakeSource, controllers.PlaylistSource):
 | 
				
			||||||
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeQueueSource(FakeSource, controllers.QueueSource):
 | 
				
			||||||
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def streamer(station, station_ports):
 | 
				
			||||||
 | 
					    streamer = FakeStreamer(station=station)
 | 
				
			||||||
 | 
					    streamer.sources = [
 | 
				
			||||||
 | 
					        FakePlaylist(i, uri=f"source-{i}") for i in range(0, 3)
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    streamer.sources.append(FakeQueueSource(len(streamer.sources)))
 | 
				
			||||||
 | 
					    return streamer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def streamers(stations, stations_ports):
 | 
				
			||||||
 | 
					    streamers = controllers.Streamers(streamer_class=FakeStreamer)
 | 
				
			||||||
 | 
					    # avoid unecessary db calls
 | 
				
			||||||
 | 
					    streamers.streamers = {
 | 
				
			||||||
 | 
					        station.pk: FakeStreamer(station=station) for station in stations
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    for j, streamer in enumerate(streamers.values()):
 | 
				
			||||||
 | 
					        streamer.sources = [
 | 
				
			||||||
 | 
					            FakePlaylist(i, uri=f"source-{j}-{i}") for i in range(0, 3)
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        streamer.sources.append(FakeQueueSource(len(streamer.sources)))
 | 
				
			||||||
 | 
					    return streamers
 | 
				
			||||||
							
								
								
									
										39
									
								
								aircox_streamer/tests/fake_modules/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								aircox_streamer/tests/fake_modules/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					import atexit as o_atexit
 | 
				
			||||||
 | 
					import subprocess as o_subprocess
 | 
				
			||||||
 | 
					import psutil as o_psutil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from . import atexit, subprocess, psutil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					modules = [
 | 
				
			||||||
 | 
					    (o_atexit, atexit, {}),
 | 
				
			||||||
 | 
					    (o_subprocess, subprocess, {}),
 | 
				
			||||||
 | 
					    (o_psutil, psutil, {}),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def init_mappings():
 | 
				
			||||||
 | 
					    for original, spoof, mapping in modules:
 | 
				
			||||||
 | 
					        if mapping:
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        mapping.update(
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                attr: (getattr(original, attr, None), spoofed)
 | 
				
			||||||
 | 
					                for attr, spoofed in vars(spoof).items()
 | 
				
			||||||
 | 
					                if not attr.startswith("_") and hasattr(original, attr)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def setup():
 | 
				
			||||||
 | 
					    for original, spoof, mappings in modules:
 | 
				
			||||||
 | 
					        for attr, (orig, spoofed) in mappings.items():
 | 
				
			||||||
 | 
					            setattr(original, attr, spoofed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def setdown():
 | 
				
			||||||
 | 
					    for original, spoof, mappings in modules:
 | 
				
			||||||
 | 
					        for attr, (orig, spoofed) in mappings.items():
 | 
				
			||||||
 | 
					            setattr(original, attr, orig)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					init_mappings()
 | 
				
			||||||
							
								
								
									
										10
									
								
								aircox_streamer/tests/fake_modules/atexit.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								aircox_streamer/tests/fake_modules/atexit.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					registered = []
 | 
				
			||||||
 | 
					"""Items registered by register()"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def register(func, *args, **kwargs):
 | 
				
			||||||
 | 
					    registered.append(func)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def unregister(func):
 | 
				
			||||||
 | 
					    registered.remove(func)
 | 
				
			||||||
							
								
								
									
										15
									
								
								aircox_streamer/tests/fake_modules/psutil.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								aircox_streamer/tests/fake_modules/psutil.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					"""Spoof psutil module in order to run and check tests."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeNetConnection:
 | 
				
			||||||
 | 
					    def __init__(self, laddr, pid=None):
 | 
				
			||||||
 | 
					        self.laddr = laddr
 | 
				
			||||||
 | 
					        self.pid = pid
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def net_connections(*args, **kwargs):
 | 
				
			||||||
 | 
					    return net_connections.result
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					net_connections.result = []
 | 
				
			||||||
 | 
					"""Result value of net_connections call."""
 | 
				
			||||||
							
								
								
									
										39
									
								
								aircox_streamer/tests/fake_modules/subprocess.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								aircox_streamer/tests/fake_modules/subprocess.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					"""Spoof psutil module in order to run and check tests Resulting values of
 | 
				
			||||||
 | 
					method calls are set inside `fixtures` module."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					STDOUT = 1
 | 
				
			||||||
 | 
					STDERR = 2
 | 
				
			||||||
 | 
					STDIN = 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeProcess:
 | 
				
			||||||
 | 
					    args = None
 | 
				
			||||||
 | 
					    kwargs = None
 | 
				
			||||||
 | 
					    """Kwargs passed to Popen."""
 | 
				
			||||||
 | 
					    killed = False
 | 
				
			||||||
 | 
					    """kill() have been called."""
 | 
				
			||||||
 | 
					    waited = False
 | 
				
			||||||
 | 
					    """wait() have been called."""
 | 
				
			||||||
 | 
					    polled = False
 | 
				
			||||||
 | 
					    """poll() have been called."""
 | 
				
			||||||
 | 
					    poll_result = None
 | 
				
			||||||
 | 
					    """Result of poll() method."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, args=[], kwargs={}):
 | 
				
			||||||
 | 
					        self.pid = -13
 | 
				
			||||||
 | 
					        self.args = args
 | 
				
			||||||
 | 
					        self.kwargs = kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def kill(self):
 | 
				
			||||||
 | 
					        self.killed = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def wait(self):
 | 
				
			||||||
 | 
					        self.waited = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def poll(self):
 | 
				
			||||||
 | 
					        self.polled = True
 | 
				
			||||||
 | 
					        return self.poll_result
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def Popen(args, **kwargs):
 | 
				
			||||||
 | 
					    return FakeProcess(args, kwargs)
 | 
				
			||||||
							
								
								
									
										70
									
								
								aircox_streamer/tests/test_connector.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								aircox_streamer/tests/test_connector.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,70 @@
 | 
				
			|||||||
 | 
					import json
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import socket
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .conftest import working_dir
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestConnector:
 | 
				
			||||||
 | 
					    payload = "non_value_info\n" 'a="value_1"\n' 'b="value_b"\n' "END"
 | 
				
			||||||
 | 
					    """Test payload."""
 | 
				
			||||||
 | 
					    payload_data = {"a": "value_1", "b": "value_b"}
 | 
				
			||||||
 | 
					    """Resulting data of payload."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_open(self, connector):
 | 
				
			||||||
 | 
					        assert connector.open() == 0
 | 
				
			||||||
 | 
					        assert connector.is_open
 | 
				
			||||||
 | 
					        assert connector.socket.family == socket.AF_UNIX
 | 
				
			||||||
 | 
					        assert connector.socket.type == socket.SOCK_STREAM
 | 
				
			||||||
 | 
					        assert connector.socket.address == os.path.join(
 | 
				
			||||||
 | 
					            working_dir, "test.sock"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        connector.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_open_af_inet(self, connector):
 | 
				
			||||||
 | 
					        address = ("test", 30)
 | 
				
			||||||
 | 
					        connector.address = address
 | 
				
			||||||
 | 
					        assert connector.open() == 0
 | 
				
			||||||
 | 
					        assert connector.is_open
 | 
				
			||||||
 | 
					        assert connector.socket.family == socket.AF_INET
 | 
				
			||||||
 | 
					        assert connector.socket.type == socket.SOCK_STREAM
 | 
				
			||||||
 | 
					        assert connector.socket.address == address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_open_is_already_open(self, connector):
 | 
				
			||||||
 | 
					        connector.open()
 | 
				
			||||||
 | 
					        assert connector.open() == 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_open_failure(self, fail_connector):
 | 
				
			||||||
 | 
					        assert fail_connector.open() == -1
 | 
				
			||||||
 | 
					        assert fail_connector.socket is None  # close() called
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_close(self, connector):
 | 
				
			||||||
 | 
					        connector.open()
 | 
				
			||||||
 | 
					        assert connector.socket is not None
 | 
				
			||||||
 | 
					        connector.close()
 | 
				
			||||||
 | 
					        assert connector.socket is None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_send(self, connector):
 | 
				
			||||||
 | 
					        connector.open()
 | 
				
			||||||
 | 
					        connector.socket.recv_data = self.payload
 | 
				
			||||||
 | 
					        result = connector.send("fake_action", parse=True)
 | 
				
			||||||
 | 
					        assert result == self.payload_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_send_open_failure(self, fail_connector):
 | 
				
			||||||
 | 
					        assert fail_connector.send("fake_action", parse=True) is None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_parse(self, connector):
 | 
				
			||||||
 | 
					        result = connector.parse(self.payload)
 | 
				
			||||||
 | 
					        assert result == self.payload_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_parse_json(self, connector):
 | 
				
			||||||
 | 
					        # include case where json string is surrounded by '"'
 | 
				
			||||||
 | 
					        dumps = '"' + json.dumps(self.payload_data) + '"'
 | 
				
			||||||
 | 
					        result = connector.parse_json(dumps)
 | 
				
			||||||
 | 
					        assert result == self.payload_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_parse_json_empty_value(self, connector):
 | 
				
			||||||
 | 
					        assert connector.parse_json('""') is None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_parse_json_failure(self, connector):
 | 
				
			||||||
 | 
					        assert connector.parse_json("-- invalid json string --") is None
 | 
				
			||||||
							
								
								
									
										59
									
								
								aircox_streamer/tests/test_controllers_metadata.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								aircox_streamer/tests/test_controllers_metadata.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,59 @@
 | 
				
			|||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox_streamer.controllers import Metadata
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestBaseMetaData:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_playing(self, metadata):
 | 
				
			||||||
 | 
					        metadata.status = "playing"
 | 
				
			||||||
 | 
					        assert metadata.is_playing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_playing_false(self, metadata):
 | 
				
			||||||
 | 
					        metadata.status = "other"
 | 
				
			||||||
 | 
					        assert not metadata.is_playing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_fetch(self, controller, metadata, metadata_data, metadata_string):
 | 
				
			||||||
 | 
					        controller.connector.socket.recv_data = metadata_string
 | 
				
			||||||
 | 
					        metadata.fetch()
 | 
				
			||||||
 | 
					        assert metadata.uri == metadata_data["initial_uri"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_validate_status_playing(self, controller, metadata):
 | 
				
			||||||
 | 
					        controller.source = metadata
 | 
				
			||||||
 | 
					        assert metadata.validate_status("playing") == "playing"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_validate_status_paused(self, controller, metadata):
 | 
				
			||||||
 | 
					        controller.source = Metadata(controller, metadata.rid + 1)
 | 
				
			||||||
 | 
					        assert metadata.validate_status("playing") == "paused"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_validate_status_stopped(self, controller, metadata):
 | 
				
			||||||
 | 
					        controller.source = Metadata(controller, 2)
 | 
				
			||||||
 | 
					        assert metadata.validate_status("") == "stopped"
 | 
				
			||||||
 | 
					        assert metadata.validate_status("any") == "stopped"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_validate_air_time(
 | 
				
			||||||
 | 
					        self, metadata, metadata_data, metadata_data_air_time
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        air_time = metadata_data["on_air"]
 | 
				
			||||||
 | 
					        result = metadata.validate_air_time(air_time)
 | 
				
			||||||
 | 
					        assert result == metadata_data_air_time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_validate_air_time_none(self, metadata):
 | 
				
			||||||
 | 
					        assert metadata.validate_air_time("") is None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_validate(self, metadata, metadata_data, metadata_data_air_time):
 | 
				
			||||||
 | 
					        metadata.validate(metadata_data)
 | 
				
			||||||
 | 
					        assert metadata.uri == metadata_data["initial_uri"]
 | 
				
			||||||
 | 
					        assert metadata.air_time == metadata_data_air_time
 | 
				
			||||||
 | 
					        # controller.source != metadata + status = "playing"
 | 
				
			||||||
 | 
					        #       => status == "paused"
 | 
				
			||||||
 | 
					        assert metadata.status == "paused"
 | 
				
			||||||
 | 
					        assert metadata.request_status == "playing"
 | 
				
			||||||
							
								
								
									
										251
									
								
								aircox_streamer/tests/test_controllers_monitor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										251
									
								
								aircox_streamer/tests/test_controllers_monitor.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,251 @@
 | 
				
			|||||||
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					from model_bakery import baker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox import models
 | 
				
			||||||
 | 
					from aircox.test import interface
 | 
				
			||||||
 | 
					from aircox_streamer import controllers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def monitor(streamer):
 | 
				
			||||||
 | 
					    streamer.calls = {}
 | 
				
			||||||
 | 
					    return controllers.Monitor(
 | 
				
			||||||
 | 
					        streamer,
 | 
				
			||||||
 | 
					        tz.timedelta(seconds=10),
 | 
				
			||||||
 | 
					        cancel_timeout=tz.timedelta(minutes=10),
 | 
				
			||||||
 | 
					        sync_timeout=tz.timedelta(minutes=5),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def diffusion(program, episode):
 | 
				
			||||||
 | 
					    return baker.make(
 | 
				
			||||||
 | 
					        models.Diffusion,
 | 
				
			||||||
 | 
					        program=program,
 | 
				
			||||||
 | 
					        episode=episode,
 | 
				
			||||||
 | 
					        start=tz.now() - tz.timedelta(minutes=10),
 | 
				
			||||||
 | 
					        end=tz.now() + tz.timedelta(minutes=30),
 | 
				
			||||||
 | 
					        schedule=None,
 | 
				
			||||||
 | 
					        type=models.Diffusion.TYPE_ON_AIR,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def source(monitor, streamer, sound, diffusion):
 | 
				
			||||||
 | 
					    source = next(monitor.streamer.playlists)
 | 
				
			||||||
 | 
					    source.uri = sound.file.path
 | 
				
			||||||
 | 
					    source.episode_id = sound.episode_id
 | 
				
			||||||
 | 
					    source.air_time = diffusion.start + tz.timedelta(seconds=10)
 | 
				
			||||||
 | 
					    return source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def tracks(sound):
 | 
				
			||||||
 | 
					    items = [
 | 
				
			||||||
 | 
					        baker.prepare(models.Track, sound=sound, position=i, timestamp=i * 60)
 | 
				
			||||||
 | 
					        for i in range(0, 4)
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    models.Track.objects.bulk_create(items)
 | 
				
			||||||
 | 
					    return items
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def log(station, source, sound):
 | 
				
			||||||
 | 
					    return baker.make(
 | 
				
			||||||
 | 
					        models.Log,
 | 
				
			||||||
 | 
					        station=station,
 | 
				
			||||||
 | 
					        type=models.Log.TYPE_START,
 | 
				
			||||||
 | 
					        sound=sound,
 | 
				
			||||||
 | 
					        source=source.id,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestMonitor:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_last_diff_start(self, monitor):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test___init__(self, monitor):
 | 
				
			||||||
 | 
					        assert isinstance(monitor.logs, models.LogQuerySet)
 | 
				
			||||||
 | 
					        assert isinstance(monitor.last_sound_logs, dict)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_get_logs_queryset(self, monitor, station, sounds):
 | 
				
			||||||
 | 
					        query = monitor.get_logs_queryset()
 | 
				
			||||||
 | 
					        assert all(log.station_id == station.pk for log in query)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_init_last_sound_logs(self, monitor, source, log):
 | 
				
			||||||
 | 
					        monitor.init_last_sound_logs()
 | 
				
			||||||
 | 
					        assert monitor.last_sound_logs[source.id] == log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_monitor(self, monitor, source, log, sound):
 | 
				
			||||||
 | 
					        monitor.streamer.is_ready = True
 | 
				
			||||||
 | 
					        monitor.streamer.source = source
 | 
				
			||||||
 | 
					        interface(
 | 
				
			||||||
 | 
					            monitor,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "trace_sound": log,
 | 
				
			||||||
 | 
					                "trace_tracks": None,
 | 
				
			||||||
 | 
					                "handle_diffusions": None,
 | 
				
			||||||
 | 
					                "sync": None,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        monitor.monitor()
 | 
				
			||||||
 | 
					        assert monitor.streamer.calls.get("fetch")
 | 
				
			||||||
 | 
					        assert monitor.calls["trace_sound"] == ((source,), {})
 | 
				
			||||||
 | 
					        assert monitor.calls["trace_tracks"] == ((log,), {})
 | 
				
			||||||
 | 
					        assert monitor.calls["handle_diffusions"]
 | 
				
			||||||
 | 
					        assert monitor.calls["sync"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_monitor_streamer_not_ready(self, monitor):
 | 
				
			||||||
 | 
					        monitor.streamer.is_ready = False
 | 
				
			||||||
 | 
					        interface(
 | 
				
			||||||
 | 
					            monitor,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "trace_sound": log,
 | 
				
			||||||
 | 
					                "trace_tracks": None,
 | 
				
			||||||
 | 
					                "handle_diffusions": None,
 | 
				
			||||||
 | 
					                "sync": None,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        monitor.monitor()
 | 
				
			||||||
 | 
					        assert not monitor.streamer.calls.get("fetch")
 | 
				
			||||||
 | 
					        assert monitor.calls["trace_sound"] is None
 | 
				
			||||||
 | 
					        assert monitor.calls["trace_tracks"] is None
 | 
				
			||||||
 | 
					        assert not monitor.calls["handle_diffusions"]
 | 
				
			||||||
 | 
					        assert not monitor.calls["sync"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_monitor_no_source_uri(self, monitor, log):
 | 
				
			||||||
 | 
					        source.uri = None
 | 
				
			||||||
 | 
					        monitor.streamer.is_ready = True
 | 
				
			||||||
 | 
					        monitor.streamer.source = source
 | 
				
			||||||
 | 
					        interface(
 | 
				
			||||||
 | 
					            monitor,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "trace_sound": log,
 | 
				
			||||||
 | 
					                "trace_tracks": None,
 | 
				
			||||||
 | 
					                "handle_diffusions": None,
 | 
				
			||||||
 | 
					                "sync": None,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        monitor.monitor()
 | 
				
			||||||
 | 
					        assert monitor.streamer.calls.get("fetch")
 | 
				
			||||||
 | 
					        assert monitor.calls["trace_sound"] is None
 | 
				
			||||||
 | 
					        assert monitor.calls["trace_tracks"] is None
 | 
				
			||||||
 | 
					        assert monitor.calls["handle_diffusions"]
 | 
				
			||||||
 | 
					        assert monitor.calls["sync"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_trace_sound(self, monitor, diffusion, source, sound):
 | 
				
			||||||
 | 
					        monitor.last_sound_logs[source.id] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = monitor.trace_sound(source)
 | 
				
			||||||
 | 
					        assert result.type == models.Log.TYPE_ON_AIR
 | 
				
			||||||
 | 
					        assert result.source == source.id
 | 
				
			||||||
 | 
					        assert result.sound == sound
 | 
				
			||||||
 | 
					        assert result.diffusion == diffusion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_trace_sound_returns_last_log(self, monitor, source, sound, log):
 | 
				
			||||||
 | 
					        log.sound = sound
 | 
				
			||||||
 | 
					        monitor.last_sound_logs[source.id] = log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = monitor.trace_sound(source)
 | 
				
			||||||
 | 
					        assert result == log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_trace_tracks(self, monitor, log, tracks):
 | 
				
			||||||
 | 
					        interface(monitor, {"log": None})
 | 
				
			||||||
 | 
					        for track in tracks:
 | 
				
			||||||
 | 
					            log.date = tz.now() - tz.timedelta(seconds=track.timestamp + 5)
 | 
				
			||||||
 | 
					            monitor.trace_tracks(log)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert monitor.calls["log"]
 | 
				
			||||||
 | 
					        log_by_track = [call[1].get("track") for call in monitor.calls["log"]]
 | 
				
			||||||
 | 
					        # only one call of log
 | 
				
			||||||
 | 
					        assert all(log_by_track.count(track) for track in tracks)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_trace_tracks_returns_on_log_diffusion(
 | 
				
			||||||
 | 
					        self, monitor, log, diffusion, tracks
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        log.diffusion = None
 | 
				
			||||||
 | 
					        monitor.trace_tracks(log)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_trace_tracks_returns_on_no_tracks_exists(self, monitor, log):
 | 
				
			||||||
 | 
					        log.diffusion = None
 | 
				
			||||||
 | 
					        monitor.trace_tracks(log)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_handle_diffusions(self, monitor):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_log(self, monitor, source):
 | 
				
			||||||
 | 
					        log = monitor.log("source", type=models.Log.TYPE_START, comment="test")
 | 
				
			||||||
 | 
					        assert log.source == "source"
 | 
				
			||||||
 | 
					        assert log.type == models.Log.TYPE_START
 | 
				
			||||||
 | 
					        assert log.comment == "test"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_start_diff(
 | 
				
			||||||
 | 
					        self, monitor, diffusion, source, episode, sound, tracks
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        result = {}
 | 
				
			||||||
 | 
					        monitor.log = lambda **kw: result.update(kw)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        monitor.start_diff(source, diffusion)
 | 
				
			||||||
 | 
					        assert source.calls["push"] == (sound.file.path,)
 | 
				
			||||||
 | 
					        assert result == {
 | 
				
			||||||
 | 
					            "type": models.Log.TYPE_START,
 | 
				
			||||||
 | 
					            "source": source.id,
 | 
				
			||||||
 | 
					            "diffusion": diffusion,
 | 
				
			||||||
 | 
					            "comment": str(diffusion),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_cancel_diff(self, monitor, source, diffusion):
 | 
				
			||||||
 | 
					        result = {}
 | 
				
			||||||
 | 
					        monitor.log = lambda **kw: result.update(kw)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        monitor.cancel_diff(source, diffusion)
 | 
				
			||||||
 | 
					        assert diffusion.type == models.Log.TYPE_CANCEL
 | 
				
			||||||
 | 
					        assert result == {
 | 
				
			||||||
 | 
					            "type": models.Log.TYPE_CANCEL,
 | 
				
			||||||
 | 
					            "source": source.id,
 | 
				
			||||||
 | 
					            "diffusion": diffusion,
 | 
				
			||||||
 | 
					            "comment": str(diffusion),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_sync(self, monitor):
 | 
				
			||||||
 | 
					        now = tz.now()
 | 
				
			||||||
 | 
					        monitor.sync_next = now - tz.timedelta(minutes=1)
 | 
				
			||||||
 | 
					        monitor.sync()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert monitor.sync_next >= now + monitor.sync_timeout
 | 
				
			||||||
 | 
					        assert all(
 | 
				
			||||||
 | 
					            source.calls.get("sync") for source in monitor.streamer.playlists
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db(transaction=True)
 | 
				
			||||||
 | 
					    def test_sync_timeout_not_reached_skip_sync(self, monitor):
 | 
				
			||||||
 | 
					        monitor.sync_next = tz.now() + tz.timedelta(
 | 
				
			||||||
 | 
					            seconds=monitor.sync_timeout.total_seconds() + 20
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        monitor.sync()
 | 
				
			||||||
 | 
					        assert all(
 | 
				
			||||||
 | 
					            not source.calls.get("sync")
 | 
				
			||||||
 | 
					            for source in monitor.streamer.playlists
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
							
								
								
									
										146
									
								
								aircox_streamer/tests/test_controllers_sources.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								aircox_streamer/tests/test_controllers_sources.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,146 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox_streamer.controllers import (
 | 
				
			||||||
 | 
					    Source,
 | 
				
			||||||
 | 
					    PlaylistSource,
 | 
				
			||||||
 | 
					    QueueSource,
 | 
				
			||||||
 | 
					    Request,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def source(controller):
 | 
				
			||||||
 | 
					    return Source(controller, 13)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def playlist_source(controller, program):
 | 
				
			||||||
 | 
					    return PlaylistSource(controller, 14, program)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def queue_source(controller):
 | 
				
			||||||
 | 
					    return QueueSource(controller, 15)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestSource:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_station(self, source, station):
 | 
				
			||||||
 | 
					        assert source.station == station
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_fetch(self, socket, source, metadata_string):
 | 
				
			||||||
 | 
					        remaining = 3.12
 | 
				
			||||||
 | 
					        socket.recv_data = [
 | 
				
			||||||
 | 
					            f"{remaining} END",
 | 
				
			||||||
 | 
					            metadata_string,
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        source.fetch()
 | 
				
			||||||
 | 
					        assert socket.is_sent(f"{source.id}.remaining")
 | 
				
			||||||
 | 
					        assert socket.is_sent(f"{source.id}.get")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert source.remaining == remaining
 | 
				
			||||||
 | 
					        assert source.request_status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_skip(self, socket, source):
 | 
				
			||||||
 | 
					        socket.recv_data = "\nEND"
 | 
				
			||||||
 | 
					        source.skip()
 | 
				
			||||||
 | 
					        assert socket.is_sent(f"{source.id}.skip\n")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_restart(self, socket, source):
 | 
				
			||||||
 | 
					        source.restart()
 | 
				
			||||||
 | 
					        prefix = f"{source.id}.seek"
 | 
				
			||||||
 | 
					        assert any(r for r in socket.sent_data if r.startswith(prefix))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_seek(self, socket, source):
 | 
				
			||||||
 | 
					        source.seek(10)
 | 
				
			||||||
 | 
					        assert socket.is_sent(f"{source.id}.seek 10")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestPlaylistSource:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_sound_queryset(self, playlist_source, sounds):
 | 
				
			||||||
 | 
					        query = playlist_source.get_sound_queryset()
 | 
				
			||||||
 | 
					        assert all(
 | 
				
			||||||
 | 
					            r.program_id == playlist_source.program.pk
 | 
				
			||||||
 | 
					            and r.type == r.TYPE_ARCHIVE
 | 
				
			||||||
 | 
					            for r in query
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_playlist(self, playlist_source, sounds):
 | 
				
			||||||
 | 
					        expected = {r.file.path for r in sounds}
 | 
				
			||||||
 | 
					        query = playlist_source.get_playlist()
 | 
				
			||||||
 | 
					        assert all(r in expected for r in query)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_write_playlist(self, playlist_source):
 | 
				
			||||||
 | 
					        playlist = ["/tmp/a", "/tmp/b"]
 | 
				
			||||||
 | 
					        playlist_source.write_playlist(playlist)
 | 
				
			||||||
 | 
					        with open(playlist_source.path, "r") as file:
 | 
				
			||||||
 | 
					            result = file.read()
 | 
				
			||||||
 | 
					        os.remove(playlist_source.path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert result == "\n".join(playlist)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_stream(self, playlist_source, stream):
 | 
				
			||||||
 | 
					        result = playlist_source.stream()
 | 
				
			||||||
 | 
					        assert result == {
 | 
				
			||||||
 | 
					            "begin": stream.begin.strftime("%Hh%M"),
 | 
				
			||||||
 | 
					            "end": stream.end.strftime("%Hh%M"),
 | 
				
			||||||
 | 
					            "delay": 0,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_sync(self, playlist_source):
 | 
				
			||||||
 | 
					        # spoof method
 | 
				
			||||||
 | 
					        playlist = ["/tmp/a", "/tmp/b"]
 | 
				
			||||||
 | 
					        written_playlist = []
 | 
				
			||||||
 | 
					        playlist_source.get_playlist = lambda: playlist
 | 
				
			||||||
 | 
					        playlist_source.write_playlist = lambda p: written_playlist.extend(p)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        playlist_source.sync()
 | 
				
			||||||
 | 
					        assert written_playlist == playlist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestQueueSource:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_requests(self, queue_source, socket, metadata_string):
 | 
				
			||||||
 | 
					        queue_source.queue = [13, 14, 15]
 | 
				
			||||||
 | 
					        socket.recv_data = [
 | 
				
			||||||
 | 
					            f"{metadata_string}\nEND" for _ in queue_source.queue
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        requests = queue_source.requests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert all(isinstance(r, Request) for r in requests)
 | 
				
			||||||
 | 
					        assert all(r.uri for r in requests)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_push(self, queue_source, socket):
 | 
				
			||||||
 | 
					        paths = ["/tmp/a", "/tmp/b"]
 | 
				
			||||||
 | 
					        queue_source.push(*paths)
 | 
				
			||||||
 | 
					        assert all(
 | 
				
			||||||
 | 
					            socket.is_sent(f"{queue_source.id}_queue.push {path}")
 | 
				
			||||||
 | 
					            for path in paths
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_fetch(self, queue_source, socket, metadata_string):
 | 
				
			||||||
 | 
					        queue = ["13", "14", "15"]
 | 
				
			||||||
 | 
					        socket.recv_data = [
 | 
				
			||||||
 | 
					            # Source fetch remaining & metadata
 | 
				
			||||||
 | 
					            "13 END",
 | 
				
			||||||
 | 
					            metadata_string,
 | 
				
			||||||
 | 
					            " ".join(queue) + "\nEND",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        queue_source.fetch()
 | 
				
			||||||
 | 
					        assert queue_source.uri
 | 
				
			||||||
 | 
					        assert queue_source.queue == queue
 | 
				
			||||||
							
								
								
									
										150
									
								
								aircox_streamer/tests/test_controllers_streamer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								aircox_streamer/tests/test_controllers_streamer.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,150 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from aircox_streamer import controllers
 | 
				
			||||||
 | 
					from . import fake_modules
 | 
				
			||||||
 | 
					from .fake_modules import atexit, subprocess, psutil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeSource:
 | 
				
			||||||
 | 
					    synced = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sync(self):
 | 
				
			||||||
 | 
					        self.synced = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def streamer(station, connector, station_ports, stream):
 | 
				
			||||||
 | 
					    fake_modules.setup()
 | 
				
			||||||
 | 
					    streamer = controllers.Streamer(station, connector)
 | 
				
			||||||
 | 
					    psutil.net_connections.result = [
 | 
				
			||||||
 | 
					        psutil.FakeNetConnection(streamer.socket_path, None),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    yield streamer
 | 
				
			||||||
 | 
					    fake_modules.setdown()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestStreamer:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_socket_path(self, streamer):
 | 
				
			||||||
 | 
					        assert streamer.socket_path == streamer.connector.address
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_ready(self, streamer, socket):
 | 
				
			||||||
 | 
					        socket.recv_data = "item 1\nEND"
 | 
				
			||||||
 | 
					        assert streamer.is_ready
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_ready_false(self, streamer, socket):
 | 
				
			||||||
 | 
					        socket.recv_data = ""
 | 
				
			||||||
 | 
					        assert not streamer.is_ready
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_running(self, streamer):
 | 
				
			||||||
 | 
					        streamer.process = subprocess.FakeProcess()
 | 
				
			||||||
 | 
					        streamer.process.poll_result = None
 | 
				
			||||||
 | 
					        assert streamer.is_running
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_running_no_process(self, streamer):
 | 
				
			||||||
 | 
					        streamer.process = None
 | 
				
			||||||
 | 
					        assert not streamer.is_running
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_is_running_process_died(self, streamer):
 | 
				
			||||||
 | 
					        process = subprocess.FakeProcess()
 | 
				
			||||||
 | 
					        process.poll_result = 1
 | 
				
			||||||
 | 
					        streamer.process = process
 | 
				
			||||||
 | 
					        assert not streamer.is_running
 | 
				
			||||||
 | 
					        assert streamer.process is None
 | 
				
			||||||
 | 
					        assert process.polled
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_playlists(self, streamer, program):
 | 
				
			||||||
 | 
					        result = list(streamer.playlists)
 | 
				
			||||||
 | 
					        assert len(result) == 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = result[0]
 | 
				
			||||||
 | 
					        assert isinstance(result, controllers.PlaylistSource)
 | 
				
			||||||
 | 
					        assert result.program == program
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_queues(self, streamer):
 | 
				
			||||||
 | 
					        result = list(streamer.queues)
 | 
				
			||||||
 | 
					        assert len(result) == 1
 | 
				
			||||||
 | 
					        assert result[0] == streamer.dealer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_init_sources(self, streamer, program):
 | 
				
			||||||
 | 
					        streamer.init_sources()
 | 
				
			||||||
 | 
					        assert isinstance(streamer.dealer, controllers.QueueSource)
 | 
				
			||||||
 | 
					        # one for dealer, one for program
 | 
				
			||||||
 | 
					        assert len(streamer.sources) == 2
 | 
				
			||||||
 | 
					        assert streamer.sources[1].program == program
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_make_config(self, streamer):
 | 
				
			||||||
 | 
					        streamer.make_config()
 | 
				
			||||||
 | 
					        assert os.path.exists(streamer.path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_sync(self, streamer):
 | 
				
			||||||
 | 
					        streamer.sources = [FakeSource(), FakeSource()]
 | 
				
			||||||
 | 
					        streamer.sync()
 | 
				
			||||||
 | 
					        assert all(source.synced for source in streamer.sources)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_fetch(self, streamer):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_process_args(self, streamer):
 | 
				
			||||||
 | 
					        assert streamer.get_process_args() == [
 | 
				
			||||||
 | 
					            "liquidsoap",
 | 
				
			||||||
 | 
					            "-v",
 | 
				
			||||||
 | 
					            streamer.path,
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_check_zombie_process(self, streamer):
 | 
				
			||||||
 | 
					        with open(streamer.socket_path, "w+") as file:
 | 
				
			||||||
 | 
					            file.write("data")
 | 
				
			||||||
 | 
					        # This test is incomplete, but we can not go further because os module
 | 
				
			||||||
 | 
					        # is not spoofed (too much work) to check if os.kill is called.
 | 
				
			||||||
 | 
					        streamer.check_zombie_process()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_check_zombie_process_no_socket(self, streamer):
 | 
				
			||||||
 | 
					        if os.path.exists(streamer.socket_path):
 | 
				
			||||||
 | 
					            os.remove(streamer.socket_path)
 | 
				
			||||||
 | 
					        streamer.check_zombie_process()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_run_process(self, streamer):
 | 
				
			||||||
 | 
					        if os.path.exists(streamer.socket_path):
 | 
				
			||||||
 | 
					            os.remove(streamer.socket_path)
 | 
				
			||||||
 | 
					        streamer.run_process()
 | 
				
			||||||
 | 
					        process = streamer.process
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert process.args == streamer.get_process_args()
 | 
				
			||||||
 | 
					        assert streamer.kill_process in atexit.registered
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_kill_process(self, streamer):
 | 
				
			||||||
 | 
					        streamer.run_process()
 | 
				
			||||||
 | 
					        process = streamer.process
 | 
				
			||||||
 | 
					        streamer.kill_process()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert process.killed
 | 
				
			||||||
 | 
					        assert streamer.process is None
 | 
				
			||||||
 | 
					        assert streamer.kill_process not in atexit.registered
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_wait_process(self, streamer):
 | 
				
			||||||
 | 
					        process = subprocess.FakeProcess()
 | 
				
			||||||
 | 
					        streamer.process = process
 | 
				
			||||||
 | 
					        streamer.wait_process()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert process.waited
 | 
				
			||||||
 | 
					        assert streamer.process is None
 | 
				
			||||||
							
								
								
									
										37
									
								
								aircox_streamer/tests/test_controllers_streamers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								aircox_streamer/tests/test_controllers_streamers.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.utils import timezone as tz
 | 
				
			||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestStreamers:
 | 
				
			||||||
 | 
					    @pytest.fixture
 | 
				
			||||||
 | 
					    def test___init__(self, streamers):
 | 
				
			||||||
 | 
					        assert isinstance(streamers.timeout, timedelta)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.fixture
 | 
				
			||||||
 | 
					    def test_reset(self, streamers, stations):
 | 
				
			||||||
 | 
					        streamers.reset()
 | 
				
			||||||
 | 
					        assert all(
 | 
				
			||||||
 | 
					            streamers.streamers[station.pk] == station for station in stations
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.fixture
 | 
				
			||||||
 | 
					    def test_fetch(self, streamers):
 | 
				
			||||||
 | 
					        streamers.next_date = tz.now() - tz.timedelta(seconds=30)
 | 
				
			||||||
 | 
					        streamers.streamers = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        now = tz.now()
 | 
				
			||||||
 | 
					        streamers.fetch()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert all(streamer.calls.get("fetch") for streamer in streamers)
 | 
				
			||||||
 | 
					        assert streamers.next_date > now
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.fixture
 | 
				
			||||||
 | 
					    def test_fetch_timeout_not_reached(self, streamers):
 | 
				
			||||||
 | 
					        next_date = tz.now() + tz.timedelta(seconds=30)
 | 
				
			||||||
 | 
					        streamers.next_date = next_date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        streamers.fetch()
 | 
				
			||||||
 | 
					        assert all(not streamer.calls.get("fetch") for streamer in streamers)
 | 
				
			||||||
 | 
					        assert streamers.next_date == next_date
 | 
				
			||||||
							
								
								
									
										185
									
								
								aircox_streamer/tests/test_viewsets.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								aircox_streamer/tests/test_viewsets.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,185 @@
 | 
				
			|||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.http import Http404
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from rest_framework.exceptions import ValidationError
 | 
				
			||||||
 | 
					from aircox_streamer.viewsets import (
 | 
				
			||||||
 | 
					    ControllerViewSet,
 | 
				
			||||||
 | 
					    SourceViewSet,
 | 
				
			||||||
 | 
					    StreamerViewSet,
 | 
				
			||||||
 | 
					    QueueSourceViewSet,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeSerializer:
 | 
				
			||||||
 | 
					    def __init__(self, instance, *args, **kwargs):
 | 
				
			||||||
 | 
					        self.instance = instance
 | 
				
			||||||
 | 
					        self.data = {"instance": self.instance}
 | 
				
			||||||
 | 
					        self.args = args
 | 
				
			||||||
 | 
					        self.kwargs = kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FakeRequest:
 | 
				
			||||||
 | 
					    def __init__(self, **kwargs):
 | 
				
			||||||
 | 
					        self.__dict__.update(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def controller_viewset(streamers, station):
 | 
				
			||||||
 | 
					    return ControllerViewSet(
 | 
				
			||||||
 | 
					        streamers=streamers,
 | 
				
			||||||
 | 
					        streamer=streamers[station.pk],
 | 
				
			||||||
 | 
					        serializer_class=FakeSerializer,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def streamer_viewset(streamers, station):
 | 
				
			||||||
 | 
					    return StreamerViewSet(
 | 
				
			||||||
 | 
					        streamers=streamers,
 | 
				
			||||||
 | 
					        streamer=streamers[station.pk],
 | 
				
			||||||
 | 
					        serializer_class=FakeSerializer,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def source_viewset(streamers, station):
 | 
				
			||||||
 | 
					    return SourceViewSet(
 | 
				
			||||||
 | 
					        streamers=streamers,
 | 
				
			||||||
 | 
					        streamer=streamers[station.pk],
 | 
				
			||||||
 | 
					        serializer_class=FakeSerializer,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.fixture
 | 
				
			||||||
 | 
					def queue_source_viewset(streamers, station):
 | 
				
			||||||
 | 
					    return QueueSourceViewSet(
 | 
				
			||||||
 | 
					        streamers=streamers,
 | 
				
			||||||
 | 
					        streamer=streamers[station.pk],
 | 
				
			||||||
 | 
					        serializer_class=FakeSerializer,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestControllerViewSet:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_streamer(self, controller_viewset, stations):
 | 
				
			||||||
 | 
					        station = stations[0]
 | 
				
			||||||
 | 
					        streamer = controller_viewset.get_streamer(station.pk)
 | 
				
			||||||
 | 
					        assert streamer.station.pk == station.pk
 | 
				
			||||||
 | 
					        assert streamer.calls.get("fetch")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_streamer_station_not_found(self, controller_viewset):
 | 
				
			||||||
 | 
					        controller_viewset.streamers.streamers = {}
 | 
				
			||||||
 | 
					        with pytest.raises(Http404):
 | 
				
			||||||
 | 
					            controller_viewset.get_streamer(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_serializer(self, controller_viewset):
 | 
				
			||||||
 | 
					        controller_viewset.object = {"object": "value"}
 | 
				
			||||||
 | 
					        serializer = controller_viewset.get_serializer(test=True)
 | 
				
			||||||
 | 
					        assert serializer.kwargs["test"]
 | 
				
			||||||
 | 
					        assert serializer.instance == controller_viewset.object
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_serialize(self, controller_viewset):
 | 
				
			||||||
 | 
					        instance = {}
 | 
				
			||||||
 | 
					        data = controller_viewset.serialize(instance, test=True)
 | 
				
			||||||
 | 
					        assert data == {"instance": instance}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestStreamerViewSet:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_retrieve(self, streamer_viewset):
 | 
				
			||||||
 | 
					        streamer_viewset.streamer = {"streamer": "test"}
 | 
				
			||||||
 | 
					        resp = streamer_viewset.retrieve(None, None)
 | 
				
			||||||
 | 
					        assert resp.data == {"instance": streamer_viewset.streamer}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_list(self, streamer_viewset):
 | 
				
			||||||
 | 
					        streamers = {"a": 1, "b": 2}
 | 
				
			||||||
 | 
					        streamer_viewset.streamers.streamers = streamers
 | 
				
			||||||
 | 
					        resp = streamer_viewset.list(None)
 | 
				
			||||||
 | 
					        assert set(resp.data["results"]["instance"]) == set(streamers.values())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestSourceViewSet:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_sources(self, source_viewset, streamers):
 | 
				
			||||||
 | 
					        source_viewset.streamer.sources.append(45)
 | 
				
			||||||
 | 
					        sources = source_viewset.get_sources()
 | 
				
			||||||
 | 
					        assert 45 not in set(sources)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_source(self, source_viewset):
 | 
				
			||||||
 | 
					        source = source_viewset.get_source(1)
 | 
				
			||||||
 | 
					        assert source.id == 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_source_not_found(self, source_viewset):
 | 
				
			||||||
 | 
					        with pytest.raises(Http404):
 | 
				
			||||||
 | 
					            source_viewset.get_source(1000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_retrieve(self, source_viewset, station):
 | 
				
			||||||
 | 
					        resp = source_viewset.retrieve(None, 0)
 | 
				
			||||||
 | 
					        source = source_viewset.streamers[station.pk].sources[0]
 | 
				
			||||||
 | 
					        # this is FakeSerializer being used which provides us the proof
 | 
				
			||||||
 | 
					        assert resp.data["instance"] == source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_list(self, source_viewset, station):
 | 
				
			||||||
 | 
					        sources = source_viewset.streamers[station.pk].sources
 | 
				
			||||||
 | 
					        resp = source_viewset.list(None)
 | 
				
			||||||
 | 
					        assert list(resp.data["results"]["instance"]) == sources
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test__run(self, source_viewset):
 | 
				
			||||||
 | 
					        calls = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def action(x):
 | 
				
			||||||
 | 
					            return calls.setdefault("action", True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        source_viewset._run(0, action)
 | 
				
			||||||
 | 
					        assert calls.get("action")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_all_api_source_actions(self, source_viewset, station):
 | 
				
			||||||
 | 
					        source = source_viewset.streamers[station.pk].sources[0]
 | 
				
			||||||
 | 
					        request = FakeRequest(POST={"seek": 1})
 | 
				
			||||||
 | 
					        source_viewset.get_source = lambda x: source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for action in ("sync", "skip", "restart", "seek"):
 | 
				
			||||||
 | 
					            func = getattr(source_viewset, action)
 | 
				
			||||||
 | 
					            func(request, 1)
 | 
				
			||||||
 | 
					            assert source.calls.get(action)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestQueueSourceViewSet:
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_get_sound_queryset(self, queue_source_viewset, station, sounds):
 | 
				
			||||||
 | 
					        ids = {sound.pk for sound in sounds}
 | 
				
			||||||
 | 
					        request = FakeRequest(station=station)
 | 
				
			||||||
 | 
					        query = queue_source_viewset.get_sound_queryset(request)
 | 
				
			||||||
 | 
					        assert set(query.values_list("pk", flat=True)) == ids
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_push(self, queue_source_viewset, station, sounds):
 | 
				
			||||||
 | 
					        calls = {}
 | 
				
			||||||
 | 
					        sound = sounds[0]
 | 
				
			||||||
 | 
					        request = FakeRequest(station=station, data={"sound_id": sound.pk})
 | 
				
			||||||
 | 
					        queue_source_viewset._run = lambda pk, func: calls.setdefault(
 | 
				
			||||||
 | 
					            "_run", (pk, func)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        result = queue_source_viewset.push(request, 13)
 | 
				
			||||||
 | 
					        assert "_run" in calls
 | 
				
			||||||
 | 
					        assert result[0] == 13
 | 
				
			||||||
 | 
					        assert callable(result[1])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.django_db
 | 
				
			||||||
 | 
					    def test_push_missing_sound_in_request_post(
 | 
				
			||||||
 | 
					        self, queue_source_viewset, station
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        request = FakeRequest(station=station, data={})
 | 
				
			||||||
 | 
					        with pytest.raises(ValidationError):
 | 
				
			||||||
 | 
					            queue_source_viewset.push(request, 0)
 | 
				
			||||||
							
								
								
									
										0
									
								
								aircox_streamer/tests/working_dir/keepme.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								aircox_streamer/tests/working_dir/keepme.txt
									
									
									
									
									
										Normal file
									
								
							@ -4,11 +4,11 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
from aircox.viewsets import SoundViewSet
 | 
					from aircox.viewsets import SoundViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from . import viewsets
 | 
					from . import viewsets
 | 
				
			||||||
from .views import StreamerAdminMixin
 | 
					from .views import StreamerAdminView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
admin.site.route_view(
 | 
					admin.site.route_view(
 | 
				
			||||||
    "tools/streamer",
 | 
					    "tools/streamer",
 | 
				
			||||||
    StreamerAdminMixin.as_view(),
 | 
					    StreamerAdminView.as_view(),
 | 
				
			||||||
    "tools-streamer",
 | 
					    "tools-streamer",
 | 
				
			||||||
    label=_("Streamer Monitor"),
 | 
					    label=_("Streamer Monitor"),
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
				
			|||||||
@ -2,8 +2,17 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
from django.views.generic import TemplateView
 | 
					from django.views.generic import TemplateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from aircox.views.admin import AdminMixin
 | 
					from aircox.views.admin import AdminMixin
 | 
				
			||||||
 | 
					from .controllers import streamers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StreamerAdminMixin(AdminMixin, TemplateView):
 | 
					class StreamerAdminView(AdminMixin, TemplateView):
 | 
				
			||||||
    template_name = "aircox_streamer/streamer.html"
 | 
					    template_name = "aircox_streamer/streamer.html"
 | 
				
			||||||
    title = _("Streamer Monitor")
 | 
					    title = _("Streamer Monitor")
 | 
				
			||||||
 | 
					    streamers = streamers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dispatch(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        # Note: this might raise concurrency racing problem with viewsets,
 | 
				
			||||||
 | 
					        # since streamers.streamers is reset to a new dict. Still am i not
 | 
				
			||||||
 | 
					        # sure, and needs analysis.
 | 
				
			||||||
 | 
					        self.streamers.reset()
 | 
				
			||||||
 | 
					        return super().dispatch(*args, **kwargs)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,15 @@
 | 
				
			|||||||
from django.http import Http404
 | 
					from django.http import Http404
 | 
				
			||||||
from django.shortcuts import get_object_or_404
 | 
					from django.shortcuts import get_object_or_404
 | 
				
			||||||
from django.utils import timezone as tz
 | 
					 | 
				
			||||||
from rest_framework import viewsets
 | 
					from rest_framework import viewsets
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.exceptions import ValidationError
 | 
					from rest_framework.exceptions import ValidationError
 | 
				
			||||||
from rest_framework.permissions import IsAdminUser
 | 
					from rest_framework.permissions import IsAdminUser
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from aircox.models import Sound, Station
 | 
					from aircox.models import Sound
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from . import controllers
 | 
					from . import controllers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from .serializers import (
 | 
					from .serializers import (
 | 
				
			||||||
    PlaylistSerializer,
 | 
					    PlaylistSerializer,
 | 
				
			||||||
    QueueSourceSerializer,
 | 
					    QueueSourceSerializer,
 | 
				
			||||||
@ -19,8 +19,7 @@ from .serializers import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = [
 | 
					__all__ = [
 | 
				
			||||||
    "Streamers",
 | 
					    "ControllerViewSet",
 | 
				
			||||||
    "BaseControllerAPIView",
 | 
					 | 
				
			||||||
    "RequestViewSet",
 | 
					    "RequestViewSet",
 | 
				
			||||||
    "StreamerViewSet",
 | 
					    "StreamerViewSet",
 | 
				
			||||||
    "SourceViewSet",
 | 
					    "SourceViewSet",
 | 
				
			||||||
@ -29,94 +28,45 @@ __all__ = [
 | 
				
			|||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Streamers:
 | 
					class ControllerViewSet(viewsets.ViewSet):
 | 
				
			||||||
    date = None
 | 
					 | 
				
			||||||
    """Next update datetime."""
 | 
					 | 
				
			||||||
    streamers = None
 | 
					 | 
				
			||||||
    """Stations by station id."""
 | 
					 | 
				
			||||||
    timeout = None
 | 
					 | 
				
			||||||
    """Timedelta to next update."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, timeout=None):
 | 
					 | 
				
			||||||
        self.timeout = timeout or tz.timedelta(seconds=2)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def load(self, force=False):
 | 
					 | 
				
			||||||
        # FIXME: cf. TODO in aircox.controllers about model updates
 | 
					 | 
				
			||||||
        stations = Station.objects.active()
 | 
					 | 
				
			||||||
        if self.streamers is None or force:
 | 
					 | 
				
			||||||
            self.streamers = {
 | 
					 | 
				
			||||||
                station.pk: controllers.Streamer(station)
 | 
					 | 
				
			||||||
                for station in stations
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        streamers = self.streamers
 | 
					 | 
				
			||||||
        self.streamers = {
 | 
					 | 
				
			||||||
            station.pk: controllers.Streamer(station)
 | 
					 | 
				
			||||||
            if station.pk in streamers
 | 
					 | 
				
			||||||
            else streamers[station.pk]
 | 
					 | 
				
			||||||
            for station in stations
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def fetch(self):
 | 
					 | 
				
			||||||
        if self.streamers is None:
 | 
					 | 
				
			||||||
            self.load()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        now = tz.now()
 | 
					 | 
				
			||||||
        if self.date is not None and now < self.date:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for streamer in self.streamers.values():
 | 
					 | 
				
			||||||
            streamer.fetch()
 | 
					 | 
				
			||||||
        self.date = now + self.timeout
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get(self, key, default=None):
 | 
					 | 
				
			||||||
        return self.streamers.get(key, default)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def values(self):
 | 
					 | 
				
			||||||
        return self.streamers.values()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __getitem__(self, key):
 | 
					 | 
				
			||||||
        return self.streamers[key]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __contains__(self, key):
 | 
					 | 
				
			||||||
        return key in self.streamers
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
streamers = Streamers()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class BaseControllerAPIView(viewsets.ViewSet):
 | 
					 | 
				
			||||||
    permission_classes = (IsAdminUser,)
 | 
					    permission_classes = (IsAdminUser,)
 | 
				
			||||||
    serializer_class = None
 | 
					    serializer_class = None
 | 
				
			||||||
 | 
					    streamers = controllers.streamers
 | 
				
			||||||
 | 
					    """Streamers controller instance."""
 | 
				
			||||||
    streamer = None
 | 
					    streamer = None
 | 
				
			||||||
 | 
					    """User's Streamer instance."""
 | 
				
			||||||
    object = None
 | 
					    object = None
 | 
				
			||||||
 | 
					    """Object to serialize."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_streamer(self, request, station_pk=None, **kwargs):
 | 
					    def get_streamer(self, station_pk=None):
 | 
				
			||||||
        streamers.fetch()
 | 
					        """Get user's streamer."""
 | 
				
			||||||
        id = int(request.station.pk if station_pk is None else station_pk)
 | 
					        if station_pk is None:
 | 
				
			||||||
        if id not in streamers:
 | 
					            station_pk = self.request.station.pk
 | 
				
			||||||
 | 
					        self.streamers.fetch()
 | 
				
			||||||
 | 
					        if station_pk not in self.streamers:
 | 
				
			||||||
            raise Http404("station not found")
 | 
					            raise Http404("station not found")
 | 
				
			||||||
        return streamers[id]
 | 
					        return self.streamers[station_pk]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_serializer(self, **kwargs):
 | 
					    def get_serializer(self, **kwargs):
 | 
				
			||||||
 | 
					        """Get serializer instance."""
 | 
				
			||||||
        return self.serializer_class(self.object, **kwargs)
 | 
					        return self.serializer_class(self.object, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def serialize(self, obj, **kwargs):
 | 
					    def serialize(self, obj, **kwargs):
 | 
				
			||||||
 | 
					        """Serializer controller data."""
 | 
				
			||||||
        self.object = obj
 | 
					        self.object = obj
 | 
				
			||||||
        serializer = self.get_serializer(**kwargs)
 | 
					        serializer = self.get_serializer(**kwargs)
 | 
				
			||||||
        return serializer.data
 | 
					        return serializer.data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def dispatch(self, request, *args, station_pk=None, **kwargs):
 | 
					    def dispatch(self, request, *args, station_pk=None, **kwargs):
 | 
				
			||||||
        self.streamer = self.get_streamer(request, station_pk, **kwargs)
 | 
					        self.streamer = self.get_streamer(station_pk)
 | 
				
			||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					        return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RequestViewSet(BaseControllerAPIView):
 | 
					class RequestViewSet(ControllerViewSet):
 | 
				
			||||||
    serializer_class = RequestSerializer
 | 
					    serializer_class = RequestSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StreamerViewSet(BaseControllerAPIView):
 | 
					class StreamerViewSet(ControllerViewSet):
 | 
				
			||||||
    serializer_class = StreamerSerializer
 | 
					    serializer_class = StreamerSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def retrieve(self, request, pk=None):
 | 
					    def retrieve(self, request, pk=None):
 | 
				
			||||||
@ -124,7 +74,7 @@ class StreamerViewSet(BaseControllerAPIView):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def list(self, request, pk=None):
 | 
					    def list(self, request, pk=None):
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
            {"results": self.serialize(streamers.values(), many=True)}
 | 
					            {"results": self.serialize(self.streamers.values(), many=True)}
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def dispatch(self, request, *args, pk=None, **kwargs):
 | 
					    def dispatch(self, request, *args, pk=None, **kwargs):
 | 
				
			||||||
@ -135,7 +85,7 @@ class StreamerViewSet(BaseControllerAPIView):
 | 
				
			|||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					        return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SourceViewSet(BaseControllerAPIView):
 | 
					class SourceViewSet(ControllerViewSet):
 | 
				
			||||||
    serializer_class = SourceSerializer
 | 
					    serializer_class = SourceSerializer
 | 
				
			||||||
    model = controllers.Source
 | 
					    model = controllers.Source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -151,8 +101,8 @@ class SourceViewSet(BaseControllerAPIView):
 | 
				
			|||||||
        return source
 | 
					        return source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def retrieve(self, request, pk=None):
 | 
					    def retrieve(self, request, pk=None):
 | 
				
			||||||
        self.object = self.get_source(pk)
 | 
					        source = self.get_source(pk)
 | 
				
			||||||
        return Response(self.serialize())
 | 
					        return Response(self.serialize(source))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def list(self, request):
 | 
					    def list(self, request):
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
@ -192,8 +142,8 @@ class QueueSourceViewSet(SourceViewSet):
 | 
				
			|||||||
    serializer_class = QueueSourceSerializer
 | 
					    serializer_class = QueueSourceSerializer
 | 
				
			||||||
    model = controllers.QueueSource
 | 
					    model = controllers.QueueSource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_sound_queryset(self):
 | 
					    def get_sound_queryset(self, request):
 | 
				
			||||||
        return Sound.objects.station(self.request.station).archive()
 | 
					        return Sound.objects.station(request.station).archive()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @action(detail=True, methods=["POST"])
 | 
					    @action(detail=True, methods=["POST"])
 | 
				
			||||||
    def push(self, request, pk):
 | 
					    def push(self, request, pk):
 | 
				
			||||||
@ -201,7 +151,7 @@ class QueueSourceViewSet(SourceViewSet):
 | 
				
			|||||||
            raise ValidationError('missing "sound_id" POST data')
 | 
					            raise ValidationError('missing "sound_id" POST data')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        sound = get_object_or_404(
 | 
					        sound = get_object_or_404(
 | 
				
			||||||
            self.get_sound_queryset(), pk=request.data["sound_id"]
 | 
					            self.get_sound_queryset(request), pk=request.data["sound_id"]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return self._run(
 | 
					        return self._run(
 | 
				
			||||||
            pk, lambda s: s.push(sound.file.path) if sound.file.path else None
 | 
					            pk, lambda s: s.push(sound.file.path) if sound.file.path else None
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user