diff --git a/aircox/test.py b/aircox/test.py new file mode 100644 index 0000000..8400cab --- /dev/null +++ b/aircox/test.py @@ -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]]}``. + """ + for attr, value in funcs.items(): + interface_wrap(obj, attr, value) + + +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) diff --git a/aircox_streamer/controllers/monitor.py b/aircox_streamer/controllers/monitor.py index ed37e63..fc9c685 100644 --- a/aircox_streamer/controllers/monitor.py +++ b/aircox_streamer/controllers/monitor.py @@ -5,14 +5,10 @@ # x when liquidsoap fails to start/exists: exit # - handle restart after failure # - is stream restart after live ok? -import pytz from django.utils import timezone as tz from aircox.models import Diffusion, Log, Sound, Track -# force using UTC -tz.activate(pytz.UTC) - class Monitor: """Log and launch diffusions for the given station. @@ -33,9 +29,9 @@ class Monitor: """ Timedelta: minimal delay between two call of monitor. """ logs = None """Queryset to station's logs (ordered by -pk)""" - cancel_timeout = 20 + cancel_timeout = tz.timedelta(minutes=20) """Timeout in minutes before cancelling a diffusion.""" - sync_timeout = 5 + sync_timeout = tz.timedelta(minutes=5) """Timeout in minutes between two streamer's sync.""" sync_next = None """Datetime of the next sync.""" @@ -56,11 +52,10 @@ class Monitor: """Log of last triggered item (sound or diffusion).""" return self.logs.start().with_diff().first() - def __init__(self, streamer, delay, cancel_timeout, **kwargs): + def __init__(self, streamer, delay, **kwargs): self.streamer = streamer - # adding time ensure all calculation have a margin + # adding time ensures all calculations 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() @@ -117,18 +112,6 @@ class Monitor: 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 @@ -246,6 +229,18 @@ class Monitor: 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) @@ -272,7 +267,7 @@ class Monitor: if self.sync_next is not None and now < self.sync_next: return - self.sync_next = now + tz.timedelta(minutes=self.sync_timeout) + self.sync_next = now + self.sync_timeout for source in self.streamer.playlists: source.sync() diff --git a/aircox_streamer/management/commands/streamer.py b/aircox_streamer/management/commands/streamer.py index 9908fd8..9703f75 100755 --- a/aircox_streamer/management/commands/streamer.py +++ b/aircox_streamer/management/commands/streamer.py @@ -17,6 +17,7 @@ from django.utils import timezone as tz from aircox.models import Station from aircox_streamer.controllers import Monitor, Streamer + # force using UTC tz.activate(pytz.UTC) @@ -68,7 +69,7 @@ class Command(BaseCommand): "-t", "--timeout", type=float, - default=Monitor.cancel_timeout, + default=Monitor.cancel_timeout.total_seconds() / 60, help="time to wait in MINUTES before canceling a diffusion that " "should have ran but did not. ", ) @@ -106,7 +107,8 @@ class Command(BaseCommand): delay = tz.timedelta(milliseconds=delay) timeout = tz.timedelta(minutes=timeout) 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: diff --git a/aircox_streamer/tests/conftest.py b/aircox_streamer/tests/conftest.py index 791309e..7098d89 100644 --- a/aircox_streamer/tests/conftest.py +++ b/aircox_streamer/tests/conftest.py @@ -5,6 +5,7 @@ from datetime import datetime, time import tzlocal import pytest +from model_bakery import baker from aircox import models from aircox_streamer import controllers @@ -17,6 +18,33 @@ local_tz = tzlocal.get_localzone() working_dir = os.path.join(os.path.dirname(__file__), "working_dir") +def interface(self, 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)}``. + """ + if not isinstance(getattr(obj, "calls", None), dict): + obj.calls = {} + + for attr, value in funcs.items(): + + def func(*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 + + obj.calls[attr] = None + setattr(obj, attr, func) + + class FakeSocket: FAILING_ADDRESS = -1 """Connect with this address fails.""" @@ -142,6 +170,26 @@ def stream(program): return stream +@pytest.fixture +def episode(program): + episode = baker.make(models.Episode, title="test episode", program=program) + episode.playlist = lambda: ["/tmp/a", "/tmp/b"] + return episode + + +@pytest.fixture +def sound(program, episode): + return baker.make( + models.Sound, + program=program, + episode=episode, + name="sound", + type=models.Sound.TYPE_ARCHIVE, + position=0, + file="sound.mp3", + ) + + @pytest.fixture def sounds(program): items = [ @@ -219,6 +267,9 @@ def metadata_string(metadata_data): class FakeStreamer(controllers.Streamer): calls = {} + def __init__(self, **kwargs): + self.__dict__.update(**kwargs) + def fetch(self): self.calls["fetch"] = True @@ -236,7 +287,7 @@ class FakeSource(controllers.Source): def sync(self): self.calls["sync"] = True - def push(self, path): + def push(self, *path): self.calls["push"] = path return path @@ -250,6 +301,24 @@ class FakeSource(controllers.Source): 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) + 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) @@ -257,6 +326,9 @@ def streamers(stations, stations_ports): streamers.streamers = { station.pk: FakeStreamer(station) for station in stations } - for streamer in streamers.values(): - streamer.sources = [FakeSource(i) for i in range(0, 3)] + 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