Merge pull request #295 from adamcik/feature/streaming-backend
Add basic streaming backend support.
This commit is contained in:
commit
f4251e63fa
23
mopidy/audio/utils.py
Normal file
23
mopidy/audio/utils.py
Normal file
@ -0,0 +1,23 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
|
||||
def supported_uri_schemes(uri_schemes):
|
||||
"""Determine which URIs we can actually support from provided whitelist.
|
||||
|
||||
:param uri_schemes: list/set of URIs to check support for.
|
||||
:type uri_schemes: list or set or URI schemes as strings.
|
||||
:rtype: set of URI schemes we can support via this GStreamer install.
|
||||
"""
|
||||
supported_schemes= set()
|
||||
registry = gst.registry_get_default()
|
||||
|
||||
for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY):
|
||||
for uri in factory.get_uri_protocols():
|
||||
if uri in uri_schemes:
|
||||
supported_schemes.add(uri)
|
||||
|
||||
return supported_schemes
|
||||
@ -57,9 +57,9 @@ class BaseLibraryProvider(object):
|
||||
"""
|
||||
See :meth:`mopidy.core.LibraryController.find_exact`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
@ -73,17 +73,17 @@ class BaseLibraryProvider(object):
|
||||
"""
|
||||
See :meth:`mopidy.core.LibraryController.refresh`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
def search(self, **query):
|
||||
"""
|
||||
See :meth:`mopidy.core.LibraryController.search`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
|
||||
class BasePlaybackProvider(object):
|
||||
|
||||
23
mopidy/backends/stream/__init__.py
Normal file
23
mopidy/backends/stream/__init__.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""A backend for playing music for streaming music.
|
||||
|
||||
This backend will handle streaming of URIs in
|
||||
:attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are
|
||||
installed.
|
||||
|
||||
**Issues:**
|
||||
|
||||
https://github.com/mopidy/mopidy/issues?labels=Stream+backend
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- None
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.STREAM_PROTOCOLS`
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
# flake8: noqa
|
||||
from .actor import StreamBackend
|
||||
38
mopidy/backends/stream/actor.py
Normal file
38
mopidy/backends/stream/actor.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.audio import utils
|
||||
from mopidy.backends import base
|
||||
from mopidy.models import SearchResult, Track
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.stream')
|
||||
|
||||
|
||||
class StreamBackend(pykka.ThreadingActor, base.Backend):
|
||||
def __init__(self, audio):
|
||||
super(StreamBackend, self).__init__()
|
||||
|
||||
self.library = StreamLibraryProvider(backend=self)
|
||||
self.playback = base.BasePlaybackProvider(audio=audio, backend=self)
|
||||
self.playlists = None
|
||||
|
||||
self.uri_schemes = utils.supported_uri_schemes(
|
||||
settings.STREAM_PROTOCOLS)
|
||||
|
||||
|
||||
# TODO: Should we consider letting lookup know how to expand common playlist
|
||||
# formats (m3u, pls, etc) for http(s) URIs?
|
||||
class StreamLibraryProvider(base.BaseLibraryProvider):
|
||||
def lookup(self, uri):
|
||||
if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes:
|
||||
return []
|
||||
# TODO: actually lookup the stream metadata by getting tags in same
|
||||
# way as we do for updating the local library with mopidy.scanner
|
||||
# Note that we would only want the stream metadata at this stage,
|
||||
# not the currently playing track's.
|
||||
return [Track(uri=uri, name=uri)]
|
||||
@ -41,7 +41,7 @@ class LibraryController(object):
|
||||
query = query or kwargs
|
||||
futures = [
|
||||
b.library.find_exact(**query) for b in self.backends.with_library]
|
||||
return pykka.get_all(futures)
|
||||
return [result for result in pykka.get_all(futures) if result]
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
@ -101,4 +101,4 @@ class LibraryController(object):
|
||||
query = query or kwargs
|
||||
futures = [
|
||||
b.library.search(**query) for b in self.backends.with_library]
|
||||
return pykka.get_all(futures)
|
||||
return [result for result in pykka.get_all(futures) if result]
|
||||
|
||||
@ -20,10 +20,12 @@ from __future__ import unicode_literals
|
||||
#: BACKENDS = (
|
||||
#: u'mopidy.backends.local.LocalBackend',
|
||||
#: u'mopidy.backends.spotify.SpotifyBackend',
|
||||
#: u'mopidy.backends.spotify.StreamBackend',
|
||||
#: )
|
||||
BACKENDS = (
|
||||
'mopidy.backends.local.LocalBackend',
|
||||
'mopidy.backends.spotify.SpotifyBackend',
|
||||
'mopidy.backends.stream.StreamBackend',
|
||||
)
|
||||
|
||||
#: The log format used for informational logging.
|
||||
@ -301,3 +303,26 @@ SPOTIFY_PROXY_PASSWORD = None
|
||||
#:
|
||||
#: SPOTIFY_TIMEOUT = 10
|
||||
SPOTIFY_TIMEOUT = 10
|
||||
|
||||
#: Whitelist of URIs to support streaming from.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.stream`
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: STREAM_PROTOCOLS = (
|
||||
#: u'http',
|
||||
#: u'https',
|
||||
#: u'mms',
|
||||
#: u'rtmp',
|
||||
#: u'rtmps',
|
||||
#: u'rtsp',
|
||||
#: )
|
||||
STREAM_PROTOCOLS = (
|
||||
'http',
|
||||
'https',
|
||||
'mms',
|
||||
'rtmp',
|
||||
'rtmps',
|
||||
'rtsp',
|
||||
)
|
||||
|
||||
@ -142,7 +142,13 @@ def validate_settings(defaults, settings):
|
||||
'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH',
|
||||
}
|
||||
|
||||
list_of_one_or_more = [
|
||||
must_be_iterable = [
|
||||
'BACKENDS',
|
||||
'FRONTENDS',
|
||||
'STREAM_PROTOCOLS',
|
||||
]
|
||||
|
||||
must_have_value_set = [
|
||||
'BACKENDS',
|
||||
'FRONTENDS',
|
||||
]
|
||||
@ -171,13 +177,13 @@ def validate_settings(defaults, settings):
|
||||
'Deprecated setting, please set the value via the GStreamer '
|
||||
'bin in OUTPUT.')
|
||||
|
||||
elif setting in list_of_one_or_more:
|
||||
if not hasattr(value, '__iter__'):
|
||||
errors[setting] = (
|
||||
'Must be a tuple. '
|
||||
"Remember the comma after single values: (u'value',)")
|
||||
if not value:
|
||||
errors[setting] = 'Must contain at least one value.'
|
||||
elif setting in must_be_iterable and not hasattr(value, '__iter__'):
|
||||
errors[setting] = (
|
||||
'Must be a tuple. '
|
||||
"Remember the comma after single values: (u'value',)")
|
||||
|
||||
elif setting in must_have_value_set and not value:
|
||||
errors[setting] = 'Must be set.'
|
||||
|
||||
elif setting not in defaults and not setting.startswith('CUSTOM_'):
|
||||
errors[setting] = 'Unknown setting.'
|
||||
|
||||
@ -90,6 +90,22 @@ class CoreLibraryTest(unittest.TestCase):
|
||||
self.library1.find_exact.assert_called_once_with(any=['a'])
|
||||
self.library2.find_exact.assert_called_once_with(any=['a'])
|
||||
|
||||
def test_find_exact_filters_out_none(self):
|
||||
track1 = Track(uri='dummy1:a')
|
||||
result1 = SearchResult(tracks=[track1])
|
||||
|
||||
self.library1.find_exact().get.return_value = result1
|
||||
self.library1.find_exact.reset_mock()
|
||||
self.library2.find_exact().get.return_value = None
|
||||
self.library2.find_exact.reset_mock()
|
||||
|
||||
result = self.core.library.find_exact(any=['a'])
|
||||
|
||||
self.assertIn(result1, result)
|
||||
self.assertNotIn(None, result)
|
||||
self.library1.find_exact.assert_called_once_with(any=['a'])
|
||||
self.library2.find_exact.assert_called_once_with(any=['a'])
|
||||
|
||||
def test_find_accepts_query_dict_instead_of_kwargs(self):
|
||||
track1 = Track(uri='dummy1:a')
|
||||
track2 = Track(uri='dummy2:a')
|
||||
@ -126,6 +142,22 @@ class CoreLibraryTest(unittest.TestCase):
|
||||
self.library1.search.assert_called_once_with(any=['a'])
|
||||
self.library2.search.assert_called_once_with(any=['a'])
|
||||
|
||||
def test_search_filters_out_none(self):
|
||||
track1 = Track(uri='dummy1:a')
|
||||
result1 = SearchResult(tracks=[track1])
|
||||
|
||||
self.library1.search().get.return_value = result1
|
||||
self.library1.search.reset_mock()
|
||||
self.library2.search().get.return_value = None
|
||||
self.library2.search.reset_mock()
|
||||
|
||||
result = self.core.library.search(any=['a'])
|
||||
|
||||
self.assertIn(result1, result)
|
||||
self.assertNotIn(None, result)
|
||||
self.library1.search.assert_called_once_with(any=['a'])
|
||||
self.library2.search.assert_called_once_with(any=['a'])
|
||||
|
||||
def test_search_accepts_query_dict_instead_of_kwargs(self):
|
||||
track1 = Track(uri='dummy1:a')
|
||||
track2 = Track(uri='dummy2:a')
|
||||
|
||||
@ -79,13 +79,13 @@ class ValidateSettingsTest(unittest.TestCase):
|
||||
result = setting_utils.validate_settings(
|
||||
self.defaults, {'FRONTENDS': []})
|
||||
self.assertEqual(
|
||||
result['FRONTENDS'], 'Must contain at least one value.')
|
||||
result['FRONTENDS'], 'Must be set.')
|
||||
|
||||
def test_empty_backends_list_returns_error(self):
|
||||
result = setting_utils.validate_settings(
|
||||
self.defaults, {'BACKENDS': []})
|
||||
self.assertEqual(
|
||||
result['BACKENDS'], 'Must contain at least one value.')
|
||||
result['BACKENDS'], 'Must be set.')
|
||||
|
||||
def test_noniterable_multivalue_setting_returns_error(self):
|
||||
result = setting_utils.validate_settings(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user