diff --git a/docs/changelog.rst b/docs/changelog.rst index 03016776..b9646ce8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,6 +53,11 @@ Bug fix release. services installed from packages that properly set :confval:`core/data_dir`, like the Debian and Arch pakages. (Fixes: :issue:`1259`, PR: :issue:`1266`) +- Stream: Expand nested playlists to find the stream URI. This used to work, + but regressed in 1.1.0 with the extraction of stream playlist parsing from + GStreamer to being handled by the Mopidy-Stream backend. (Fixes: + :issue:`1250`) + - Stream: If "file" is present in the :confval:`stream/protocols` config value and the :ref:`ext-file` extension is enabled, we exited with an error because two extensions claimed the same URI scheme. We now log a warning recommending diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 8066403b..095fcb2f 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals import fnmatch import logging import re +import time import urlparse import pykka @@ -80,22 +81,70 @@ class StreamPlaybackProvider(backend.PlaybackProvider): stream.Extension.dist_name, stream.Extension.version)) def translate_uri(self, uri): - try: - scan_result = self._scanner.scan(uri) - except exceptions.ScannerError as e: - logger.warning( - 'Problem scanning URI %s: %s', uri, e) - return None + return unwrap_stream( + uri, + timeout=self._config['stream']['timeout'], + scanner=self._scanner, + requests_session=self._session) - if not (scan_result.mime.startswith('text/') or + +def unwrap_stream(uri, timeout, scanner, requests_session): + """ + Get a stream URI from a playlist URI, ``uri``. + + Unwraps nested playlists until something that's not a playlist is found or + the ``timeout`` is reached. + """ + + original_uri = uri + deadline = time.time() + timeout + + while time.time() < deadline: + logger.debug('Unwrapping stream from URI: %s', uri) + + try: + scan_timeout = deadline - time.time() + if scan_timeout < 0: + logger.info( + 'Unwrapping stream from URI (%s) failed: ' + 'timed out in %sms', + uri, timeout) + return None + scan_result = scanner.scan(uri, timeout=scan_timeout) + except exceptions.ScannerError as exc: + logger.debug('GStreamer failed scanning URI (%s): %s', uri, exc) + scan_result = None + + if scan_result is not None and not ( + scan_result.mime.startswith('text/') or scan_result.mime.startswith('application/')): + logger.debug( + 'Unwrapped potential %s stream: %s', scan_result.mime, uri) return uri - content = http.download(self._session, uri) + download_timeout = deadline - time.time() + if download_timeout < 0: + logger.info( + 'Unwrapping stream from URI (%s) failed: timed out in %sms', + uri, timeout) + return None + content = http.download( + requests_session, uri, timeout=download_timeout) + if content is None: + logger.info( + 'Unwrapping stream from URI (%s) failed: ' + 'error downloading URI %s', original_uri, uri) return None - tracks = list(playlists.parse(content)) - if tracks: - # TODO Test streams and return first that seems to be playable - return tracks[0] + uris = playlists.parse(content) + if not uris: + logger.debug( + 'Failed parsing URI (%s) as playlist; found potential stream.', + uri) + return uri + + # TODO Test streams and return first that seems to be playable + logger.debug( + 'Parsed playlist (%s) and found new URI: %s', uri, uris[0]) + uri = uris[0] diff --git a/tests/stream/test_playback.py b/tests/stream/test_playback.py index c186ee88..ba7c2c92 100644 --- a/tests/stream/test_playback.py +++ b/tests/stream/test_playback.py @@ -12,7 +12,8 @@ from mopidy.stream import actor TIMEOUT = 1000 -URI = 'http://example.com/listen.m3u' +PLAYLIST_URI = 'http://example.com/listen.m3u' +STREAM_URI = 'http://example.com/stream.mp3' BODY = """ #EXTM3U http://example.com/stream.mp3 @@ -37,9 +38,7 @@ def audio(): @pytest.fixture def scanner(): - scanner = mock.Mock(spec=scan.Scanner) - scanner.scan.return_value.mime = 'text/foo' - return scanner + return mock.Mock(spec=scan.Scanner) @pytest.fixture @@ -55,58 +54,115 @@ def provider(audio, backend, config): return actor.StreamPlaybackProvider(audio, backend, config) -@responses.activate -def test_translate_uri_of_audio_stream_returns_same_uri( - scanner, provider): +class TestTranslateURI(object): - scanner.scan.return_value.mime = 'audio/ogg' + @responses.activate + def test_audio_stream_returns_same_uri(self, scanner, provider): + scanner.scan.return_value.mime = 'audio/mpeg' - result = provider.translate_uri(URI) + result = provider.translate_uri(STREAM_URI) - scanner.scan.assert_called_once_with(URI) - assert result == URI + scanner.scan.assert_called_once_with(STREAM_URI, timeout=mock.ANY) + assert result == STREAM_URI + @responses.activate + def test_text_playlist_with_mpeg_stream( + self, scanner, provider, caplog): -@responses.activate -def test_translate_uri_of_playlist_returns_first_uri_in_list( - scanner, provider): + scanner.scan.side_effect = [ + mock.Mock(mime='text/foo'), # scanning playlist + mock.Mock(mime='audio/mpeg'), # scanning stream + ] + responses.add( + responses.GET, PLAYLIST_URI, + body=BODY, content_type='audio/x-mpegurl') - responses.add( - responses.GET, URI, body=BODY, content_type='audio/x-mpegurl') + result = provider.translate_uri(PLAYLIST_URI) - result = provider.translate_uri(URI) + assert scanner.scan.mock_calls == [ + mock.call(PLAYLIST_URI, timeout=mock.ANY), + mock.call(STREAM_URI, timeout=mock.ANY), + ] + assert result == STREAM_URI - scanner.scan.assert_called_once_with(URI) - assert result == 'http://example.com/stream.mp3' - assert responses.calls[0].request.headers['User-Agent'].startswith( - 'Mopidy-Stream/') + # Check logging to ensure debuggability + assert 'Unwrapping stream from URI: %s' % PLAYLIST_URI + assert 'Parsed playlist (%s)' % PLAYLIST_URI in caplog.text() + assert 'Unwrapping stream from URI: %s' % STREAM_URI + assert ( + 'Unwrapped potential audio/mpeg stream: %s' % STREAM_URI + in caplog.text()) + # Check proper Requests session setup + assert responses.calls[0].request.headers['User-Agent'].startswith( + 'Mopidy-Stream/') -@responses.activate -def test_translate_uri_of_playlist_with_xml_mimetype(scanner, provider): - scanner.scan.return_value.mime = 'application/xspf+xml' - responses.add( - responses.GET, URI, body=BODY, content_type='application/xspf+xml') + @responses.activate + def test_xml_playlist_with_mpeg_stream(self, scanner, provider): + scanner.scan.side_effect = [ + mock.Mock(mime='application/xspf+xml'), # scanning playlist + mock.Mock(mime='audio/mpeg'), # scanning stream + ] + responses.add( + responses.GET, PLAYLIST_URI, + body=BODY, content_type='application/xspf+xml') - result = provider.translate_uri(URI) + result = provider.translate_uri(PLAYLIST_URI) - scanner.scan.assert_called_once_with(URI) - assert result == 'http://example.com/stream.mp3' + assert scanner.scan.mock_calls == [ + mock.call(PLAYLIST_URI, timeout=mock.ANY), + mock.call(STREAM_URI, timeout=mock.ANY), + ] + assert result == STREAM_URI + @responses.activate + def test_scan_fails_but_playlist_parsing_succeeds( + self, scanner, provider, caplog): -def test_translate_uri_when_scanner_fails(scanner, provider, caplog): - scanner.scan.side_effect = exceptions.ScannerError('foo failed') + scanner.scan.side_effect = [ + exceptions.ScannerError('some failure'), # scanning playlist + mock.Mock(mime='audio/mpeg'), # scanning stream + ] + responses.add( + responses.GET, PLAYLIST_URI, + body=BODY, content_type='audio/x-mpegurl') - result = provider.translate_uri('bar') + result = provider.translate_uri(PLAYLIST_URI) - assert result is None - assert 'Problem scanning URI bar: foo failed' in caplog.text() + assert 'Unwrapping stream from URI: %s' % PLAYLIST_URI + assert ( + 'GStreamer failed scanning URI (%s)' % PLAYLIST_URI + in caplog.text()) + assert 'Parsed playlist (%s)' % PLAYLIST_URI in caplog.text() + assert ( + 'Unwrapped potential audio/mpeg stream: %s' % STREAM_URI + in caplog.text()) + assert result == STREAM_URI + @responses.activate + def test_scan_fails_and_playlist_parsing_fails( + self, scanner, provider, caplog): -def test_translate_uri_when_playlist_download_fails(provider, caplog): - with mock.patch.object(actor, 'http') as http_mock: - http_mock.download.return_value = None + scanner.scan.side_effect = exceptions.ScannerError('some failure') + responses.add( + responses.GET, STREAM_URI, + body=b'some audio data', content_type='audio/mpeg') - result = provider.translate_uri(URI) + result = provider.translate_uri(STREAM_URI) - assert result is None + assert 'Unwrapping stream from URI: %s' % STREAM_URI + assert ( + 'GStreamer failed scanning URI (%s)' % STREAM_URI + in caplog.text()) + assert ( + 'Failed parsing URI (%s) as playlist; found potential stream.' + % STREAM_URI in caplog.text()) + assert result == STREAM_URI + + def test_failed_download_returns_none(self, provider, caplog): + with mock.patch.object(actor, 'http') as http_mock: + http_mock.download.return_value = None + + result = provider.translate_uri(PLAYLIST_URI) + + assert result is None