Merge branch 'develop' into feature/config-path
Conflicts: mopidy/backends/spotify/__init__.py
This commit is contained in:
commit
28d3b265c2
@ -45,6 +45,6 @@ Frontend implementations
|
||||
========================
|
||||
|
||||
* :mod:`mopidy.frontends.http`
|
||||
* :mod:`mopidy.frontends.lastfm`
|
||||
* :mod:`mopidy.frontends.mpd`
|
||||
* :mod:`mopidy.frontends.mpris`
|
||||
* :mod:`mopidy.frontends.scrobbler`
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
***************************************************
|
||||
:mod:`mopidy.frontends.lastfm` -- Last.fm Scrobbler
|
||||
***************************************************
|
||||
|
||||
.. automodule:: mopidy.frontends.lastfm
|
||||
:synopsis: Last.fm scrobbler frontend
|
||||
6
docs/modules/frontends/scrobbler.rst
Normal file
6
docs/modules/frontends/scrobbler.rst
Normal file
@ -0,0 +1,6 @@
|
||||
**********************************************
|
||||
:mod:`mopidy.frontends.scrobble` -- Scrobbler
|
||||
**********************************************
|
||||
|
||||
.. automodule:: mopidy.frontends.scrobbler
|
||||
:synopsis: Music scrobbler frontend
|
||||
@ -106,8 +106,8 @@ Scrobbling tracks to Last.fm
|
||||
|
||||
If you want to submit the tracks you are playing to your `Last.fm
|
||||
<http://www.last.fm/>`_ profile, make sure you've installed the dependencies
|
||||
found at :mod:`mopidy.frontends.lastfm` and add the following to your settings
|
||||
file::
|
||||
found at :mod:`mopidy.frontends.scrobbler` and add the following to your
|
||||
settings file::
|
||||
|
||||
LASTFM_USERNAME = u'myusername'
|
||||
LASTFM_PASSWORD = u'mysecret'
|
||||
|
||||
@ -26,11 +26,6 @@ timeout = 10
|
||||
|
||||
# Path to the Spotify data cache. Cannot be shared with other Spotify apps
|
||||
cache_path = $XDG_CACHE_DIR/mopidy/spotify
|
||||
|
||||
# Connect to Spotify through a proxy
|
||||
proxy_hostname =
|
||||
proxy_username =
|
||||
proxy_password =
|
||||
"""
|
||||
|
||||
__doc__ = """A backend for playing music from Spotify
|
||||
@ -81,9 +76,6 @@ class Extension(ext.Extension):
|
||||
schema['bitrate'] = config.Integer(choices=(96, 160, 320))
|
||||
schema['timeout'] = config.Integer(minimum=0)
|
||||
schema['cache_path'] = config.Path()
|
||||
schema['proxy_hostname'] = config.Hostname(optional=True)
|
||||
schema['proxy_username'] = config.String(optional=True)
|
||||
schema['proxy_password'] = config.String(optional=True, secret=True)
|
||||
return schema
|
||||
|
||||
def validate_environment(self):
|
||||
|
||||
@ -35,9 +35,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
||||
|
||||
PyspotifySessionManager.__init__(
|
||||
self, config['spotify']['username'], config['spotify']['password'],
|
||||
proxy=config['spotify']['proxy_hostname'],
|
||||
proxy_username=config['spotify']['proxy_username'],
|
||||
proxy_password=config['spotify']['proxy_password'])
|
||||
proxy=config['proxy']['hostname'],
|
||||
proxy_username=config['proxy']['username'],
|
||||
proxy_password=config['proxy']['password'])
|
||||
|
||||
process.BaseThread.__init__(self)
|
||||
self.name = 'SpotifyThread'
|
||||
|
||||
@ -16,6 +16,11 @@ pykka = info
|
||||
mixer = autoaudiomixer
|
||||
mixer_track =
|
||||
output = autoaudiosink
|
||||
|
||||
[proxy]
|
||||
hostname =
|
||||
username =
|
||||
password =
|
||||
"""
|
||||
|
||||
config_schemas = {} # TODO: use ordered dict?
|
||||
@ -31,6 +36,11 @@ config_schemas['audio']['mixer'] = config.String()
|
||||
config_schemas['audio']['mixer_track'] = config.String(optional=True)
|
||||
config_schemas['audio']['output'] = config.String()
|
||||
|
||||
config_schemas['proxy'] = config.ConfigSchema()
|
||||
config_schemas['proxy']['hostname'] = config.Hostname(optional=True)
|
||||
config_schemas['proxy']['username'] = config.String(optional=True)
|
||||
config_schemas['proxy']['password'] = config.String(optional=True, secret=True)
|
||||
|
||||
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
|
||||
#config_schemas['audio.outputs'] = config.AudioOutputConfigSchema()
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ from mopidy.utils import config, formatting
|
||||
|
||||
|
||||
default_config = """
|
||||
[lastfm]
|
||||
[scrobbler]
|
||||
|
||||
# If the Last.fm extension should be enabled or not
|
||||
enabled = true
|
||||
@ -28,7 +28,7 @@ Frontend which scrobbles the music you play to your `Last.fm
|
||||
|
||||
**Dependencies**
|
||||
|
||||
.. literalinclude:: ../../../requirements/lastfm.txt
|
||||
.. literalinclude:: ../../../requirements/scrobbler.txt
|
||||
|
||||
**Default config**
|
||||
|
||||
@ -44,8 +44,8 @@ The frontend is enabled by default if all dependencies are available.
|
||||
|
||||
class Extension(ext.Extension):
|
||||
|
||||
dist_name = 'Mopidy-Lastfm'
|
||||
ext_name = 'lastfm'
|
||||
dist_name = 'Mopidy-Scrobbler'
|
||||
ext_name = 'scrobbler'
|
||||
version = mopidy.__version__
|
||||
|
||||
def get_default_config(self):
|
||||
@ -64,5 +64,5 @@ class Extension(ext.Extension):
|
||||
raise exceptions.ExtensionError('pylast library not found', e)
|
||||
|
||||
def get_frontend_classes(self):
|
||||
from .actor import LastfmFrontend
|
||||
return [LastfmFrontend]
|
||||
from .actor import ScrobblerFrontend
|
||||
return [ScrobblerFrontend]
|
||||
@ -13,15 +13,15 @@ try:
|
||||
except ImportError as import_error:
|
||||
raise exceptions.OptionalDependencyError(import_error)
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.lastfm')
|
||||
logger = logging.getLogger('mopidy.frontends.scrobbler')
|
||||
|
||||
API_KEY = '2236babefa8ebb3d93ea467560d00d04'
|
||||
API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
|
||||
|
||||
|
||||
class LastfmFrontend(pykka.ThreadingActor, CoreListener):
|
||||
class ScrobblerFrontend(pykka.ThreadingActor, CoreListener):
|
||||
def __init__(self, config, core):
|
||||
super(LastfmFrontend, self).__init__()
|
||||
super(ScrobblerFrontend, self).__init__()
|
||||
self.lastfm = None
|
||||
self.last_start_time = None
|
||||
|
||||
@ -34,6 +34,39 @@ def validate_maximum(value, maximum):
|
||||
raise ValueError('%r must be smaller than %r.' % (value, maximum))
|
||||
|
||||
|
||||
# TODO: move this and levenshtein to a more appropriate class.
|
||||
def did_you_mean(name, choices):
|
||||
"""Suggest most likely setting based on levenshtein."""
|
||||
if not choices:
|
||||
return None
|
||||
|
||||
name = name.lower()
|
||||
candidates = [(levenshtein(name, c), c) for c in choices]
|
||||
candidates.sort()
|
||||
|
||||
if candidates[0][0] <= 3:
|
||||
return candidates[0][1]
|
||||
return None
|
||||
|
||||
|
||||
def levenshtein(a, b):
|
||||
"""Calculates the Levenshtein distance between a and b."""
|
||||
n, m = len(a), len(b)
|
||||
if n > m:
|
||||
return levenshtein(b, a)
|
||||
|
||||
current = xrange(n + 1)
|
||||
for i in xrange(1, m + 1):
|
||||
previous, current = current, [i] + [0] * n
|
||||
for j in xrange(1, n + 1):
|
||||
add, delete = previous[j] + 1, current[j - 1] + 1
|
||||
change = previous[j - 1]
|
||||
if a[j - 1] != b[i - 1]:
|
||||
change += 1
|
||||
current[j] = min(add, delete, change)
|
||||
return current[n]
|
||||
|
||||
|
||||
class ConfigValue(object):
|
||||
"""Represents a config key's value and how to handle it.
|
||||
|
||||
@ -279,6 +312,9 @@ class ConfigSchema(object):
|
||||
values[key] = self._schema[key].deserialize(value)
|
||||
except KeyError: # not in our schema
|
||||
errors[key] = 'unknown config key.'
|
||||
suggestion = did_you_mean(key, self._schema.keys())
|
||||
if suggestion:
|
||||
errors[key] += ' Did you mean %s?' % suggestion
|
||||
except ValueError as e: # deserialization failed
|
||||
errors[key] = str(e)
|
||||
|
||||
|
||||
@ -169,41 +169,5 @@ def validate_settings(defaults, settings):
|
||||
|
||||
elif setting not in defaults and not setting.startswith('CUSTOM_'):
|
||||
errors[setting] = 'Unknown setting.'
|
||||
suggestion = did_you_mean(setting, defaults)
|
||||
|
||||
if suggestion:
|
||||
errors[setting] += ' Did you mean %s?' % suggestion
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def did_you_mean(setting, defaults):
|
||||
"""Suggest most likely setting based on levenshtein."""
|
||||
if not defaults:
|
||||
return None
|
||||
|
||||
setting = setting.upper()
|
||||
candidates = [(levenshtein(setting, d), d) for d in defaults]
|
||||
candidates.sort()
|
||||
|
||||
if candidates[0][0] <= 3:
|
||||
return candidates[0][1]
|
||||
return None
|
||||
|
||||
|
||||
def levenshtein(a, b):
|
||||
"""Calculates the Levenshtein distance between a and b."""
|
||||
n, m = len(a), len(b)
|
||||
if n > m:
|
||||
return levenshtein(b, a)
|
||||
|
||||
current = xrange(n + 1)
|
||||
for i in xrange(1, m + 1):
|
||||
previous, current = current, [i] + [0] * n
|
||||
for j in xrange(1, n + 1):
|
||||
add, delete = previous[j] + 1, current[j - 1] + 1
|
||||
change = previous[j - 1]
|
||||
if a[j - 1] != b[i - 1]:
|
||||
change += 1
|
||||
current[j] = min(add, delete, change)
|
||||
return current[n]
|
||||
|
||||
4
setup.py
4
setup.py
@ -29,7 +29,7 @@ setup(
|
||||
],
|
||||
extras_require={
|
||||
b'spotify': ['pyspotify >= 1.9, < 1.11'],
|
||||
b'lastfm': ['pylast >= 0.5.7'],
|
||||
b'scrobbler': ['pylast >= 0.5.7'],
|
||||
b'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'],
|
||||
b'external_mixers': ['pyserial'],
|
||||
},
|
||||
@ -46,7 +46,7 @@ setup(
|
||||
],
|
||||
b'mopidy.ext': [
|
||||
'http = mopidy.frontends.http:Extension [http]',
|
||||
'lastfm = mopidy.frontends.lastfm:Extension [lastfm]',
|
||||
'scrobbler = mopidy.frontends.scrobbler:Extension [scrobbler]',
|
||||
'local = mopidy.backends.local:Extension',
|
||||
'mpd = mopidy.frontends.mpd:Extension',
|
||||
'mpris = mopidy.frontends.mpris:Extension',
|
||||
|
||||
@ -444,3 +444,22 @@ class LogLevelConfigSchemaTest(unittest.TestCase):
|
||||
result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO})
|
||||
self.assertEqual('\n'.join(expected), result)
|
||||
|
||||
|
||||
class DidYouMeanTest(unittest.TestCase):
|
||||
def testSuggestoins(self):
|
||||
choices = ('enabled', 'username', 'password', 'bitrate', 'timeout')
|
||||
|
||||
suggestion = config.did_you_mean('bitrate', choices)
|
||||
self.assertEqual(suggestion, 'bitrate')
|
||||
|
||||
suggestion = config.did_you_mean('bitrote', choices)
|
||||
self.assertEqual(suggestion, 'bitrate')
|
||||
|
||||
suggestion = config.did_you_mean('Bitrot', choices)
|
||||
self.assertEqual(suggestion, 'bitrate')
|
||||
|
||||
suggestion = config.did_you_mean('BTROT', choices)
|
||||
self.assertEqual(suggestion, 'bitrate')
|
||||
|
||||
suggestion = config.did_you_mean('btro', choices)
|
||||
self.assertEqual(suggestion, None)
|
||||
|
||||
@ -24,8 +24,7 @@ class ValidateSettingsTest(unittest.TestCase):
|
||||
result = setting_utils.validate_settings(
|
||||
self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'})
|
||||
self.assertEqual(
|
||||
result['MPD_SERVER_HOSTNMAE'],
|
||||
'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?')
|
||||
result['MPD_SERVER_HOSTNMAE'], 'Unknown setting.')
|
||||
|
||||
def test_custom_settings_does_not_return_errors(self):
|
||||
result = setting_utils.validate_settings(
|
||||
@ -149,27 +148,3 @@ class SettingsProxyTest(unittest.TestCase):
|
||||
def test_value_ending_in_path_can_be_none(self):
|
||||
self.settings.TEST_PATH = None
|
||||
self.assertEqual(self.settings.TEST_PATH, None)
|
||||
|
||||
|
||||
class DidYouMeanTest(unittest.TestCase):
|
||||
def testSuggestoins(self):
|
||||
defaults = {
|
||||
'MPD_SERVER_HOSTNAME': '::',
|
||||
'MPD_SERVER_PORT': 6600,
|
||||
'SPOTIFY_BITRATE': 160,
|
||||
}
|
||||
|
||||
suggestion = setting_utils.did_you_mean('spotify_bitrate', defaults)
|
||||
self.assertEqual(suggestion, 'SPOTIFY_BITRATE')
|
||||
|
||||
suggestion = setting_utils.did_you_mean('SPOTIFY_BITROTE', defaults)
|
||||
self.assertEqual(suggestion, 'SPOTIFY_BITRATE')
|
||||
|
||||
suggestion = setting_utils.did_you_mean('SPITIFY_BITROT', defaults)
|
||||
self.assertEqual(suggestion, 'SPOTIFY_BITRATE')
|
||||
|
||||
suggestion = setting_utils.did_you_mean('SPTIFY_BITROT', defaults)
|
||||
self.assertEqual(suggestion, 'SPOTIFY_BITRATE')
|
||||
|
||||
suggestion = setting_utils.did_you_mean('SPTIFY_BITRO', defaults)
|
||||
self.assertEqual(suggestion, None)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user