diff --git a/docs/changelog.rst b/docs/changelog.rst index 70c2340a70..9237ebbfb9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -181,7 +181,9 @@ Tracklist controller Backend API ----------- -- (no changes yet) +- Add :meth:`mopidy.backend.PlaybackProvider.is_live` which can be + re-implemented by Playback providers, to define if the custom + URI scheme is a live stream. (PR: :issue:`1845`) Models ------ @@ -264,6 +266,10 @@ Audio - Remove the method :meth:`mopidy.audio.Audio.emit_end_of_stream`, which has been deprecated since 1.0. (Fixes: :issue:`1465`, PR: :issue:`1705`) +- Add live_stream option to the method :meth:`mopidy.audio.Audio.set_uri`, + which disables buffering, reducing latency for stream, + and discarding data when paused. (PR: :issue:`1845`) + Internals --------- diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index be2e27d212..a77a644710 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -435,6 +435,7 @@ def __init__(self, config, mixer): self._config = config self._target_state = Gst.State.NULL self._buffering = False + self._live_stream = False self._tags = {} self._pending_uri = None self._pending_tags = None @@ -570,9 +571,13 @@ def _on_source_setup(self, element, source): else: self._appsrc.reset() + if self._live_stream and hasattr(source.props, "is_live"): + gst_logger.debug("Enabling live stream mode") + source.set_live(True) + utils.setup_proxy(source, self._config["proxy"]) - def set_uri(self, uri): + def set_uri(self, uri, live_stream=False): """ Set URI of audio to be played. @@ -580,6 +585,9 @@ def set_uri(self, uri): :param uri: the URI to play :type uri: string + :param live_stream: disables buffering, reducing latency for stream, + and discarding data when paused + :type live_stream: bool """ # XXX: Hack to workaround issue on Mac OS X where volume level @@ -591,6 +599,7 @@ def set_uri(self, uri): self._pending_uri = uri self._pending_tags = {} + self._live_stream = live_stream self._playbin.set_property("uri", uri) if self.mixer is not None and current_volume is not None: diff --git a/mopidy/backend.py b/mopidy/backend.py index 187d46e270..c6ba6cbad2 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -213,6 +213,22 @@ def translate_uri(self, uri): """ return uri + def is_live(self, uri): + """ + Defines if the custom URI scheme should be read as a live stream. + + *MAY be reimplemented by subclass.* + + Playing a source as a live stream + disables buffering, which reduces latency before playback starts, + and discards data when paused. + + :param uri: the custom URI + :type uri: string + :rtype: bool + """ + return False + def change_track(self, track): """ Swith to provided track. @@ -235,7 +251,7 @@ def change_track(self, track): logger.debug("Backend translated URI from %s to %s", track.uri, uri) if not uri: return False - self.audio.set_uri(uri).get() + self.audio.set_uri(uri, live_stream=self.is_live(uri)).get() return True def resume(self): diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 42a4961c92..891fb7ec96 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -629,3 +629,55 @@ def test_change_to_stopped_while_buffering(self): self.audio.stop_playback() playbin.set_state.assert_called_with(Gst.State.NULL) assert not self.audio._buffering + + +class AudioLiveTest(unittest.TestCase): + config = { + "audio": { + "buffer_time": None, + "mixer": "fakemixer track_max_volume=65536", + "mixer_track": None, + "mixer_volume": None, + "output": "testoutput", + "visualizer": None, + } + } + + def setUp(self): # noqa: N802 + config = { + "audio": { + "buffer_time": None, + "mixer": "foomixer", + "mixer_volume": None, + "output": "testoutput", + "visualizer": None, + }, + "proxy": {"hostname": ""}, + } + self.audio = audio.Audio(config=config, mixer=None) + + def test_not_live_mode(self): + source = mock.MagicMock() + + # Avoid appsrc.configure() + source.get_factory.get_name = mock.MagicMock(return_value="not_appsrc") + + source.props = mock.MagicMock(spec=[]) + self.audio._live_stream = False + + self.audio._on_source_setup("dummy", source) + + source.set_live.assert_not_called() + + def test_live_mode(self): + source = mock.MagicMock() + + # Avoid appsrc.configure() + source.get_factory.get_name = mock.MagicMock(return_value="not_appsrc") + + source.props.is_live = mock.MagicMock(return_value=True) + self.audio._live_stream = True + + self.audio._on_source_setup("dummy", source) + + source.set_live.assert_called_with(True) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index a926b6ef15..807341ccd7 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -173,6 +173,9 @@ def test_play_tlid(self): current_tl_track = self.core.playback.get_current_tl_track() assert tl_tracks[1] == current_tl_track + def test_default_is_live_behaviour_is_not_live(self): + assert not self.backend.playback.is_live(self.tracks[0].uri).get() + class TestNextHandling(BaseTest): def test_get_current_tl_track_next(self): diff --git a/tests/dummy_audio.py b/tests/dummy_audio.py index c7f08b26e3..144f1a49cd 100644 --- a/tests/dummy_audio.py +++ b/tests/dummy_audio.py @@ -24,14 +24,16 @@ def __init__(self, config=None, mixer=None): self._callback = None self._uri = None self._stream_changed = False + self._live_stream = False self._tags = {} self._bad_uris = set() - def set_uri(self, uri): + def set_uri(self, uri, live_stream=False): assert self._uri is None, "prepare change not called before set" self._position = 0 self._uri = uri self._stream_changed = True + self._live_stream = live_stream self._tags = {} def set_appsrc(self, *args, **kwargs):