From b5d9dc10a70a660184757760fb55223ef2d164ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 15:03:46 +0100 Subject: [PATCH 001/144] utils: Handle paths with non-UTF-8 encodings - path_to_uri() encodes unicode input as UTF-8 and leaves bytestring input unchanged before it is converted to file:// URIs. - uri_to_path() will now always return bytestrings, since we don't know if there is any non-UTF-8 encoded chars in the file path, and converting it to unicode would make such paths no longer match the dir or file it was referring to. - split_path() will now assume it gets a bytestring in. --- mopidy/utils/path.py | 31 ++++++++++++++++++++++++++----- tests/utils/path_test.py | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 73063183..eea13fb1 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -51,19 +51,40 @@ def get_or_create_file(filename): def path_to_uri(*paths): + """ + Convert OS specific path to file:// URI. + + Accepts either unicode strings or bytestrings. The encoding of any + bytestring will be maintained so that :func:`uri_to_path` can return the + same bytestring. + + Returns a file:// URI as an unicode string. + """ path = os.path.join(*paths) - path = path.encode('utf-8') + if isinstance(path, unicode): + path = path.encode('utf-8') if sys.platform == 'win32': return 'file:' + urllib.pathname2url(path) return 'file://' + urllib.pathname2url(path) def uri_to_path(uri): + """ + Convert the file:// to a OS specific path. + + Returns a bytestring, since the file path can contain chars with other + encoding than UTF-8. + + If we had returned these paths as unicode strings, you wouldn't be able to + look up the matching dir or file on your file system because the exact path + would be lost by ignoring its encoding. + """ + if isinstance(uri, unicode): + uri = uri.encode('utf-8') if sys.platform == 'win32': - path = urllib.url2pathname(re.sub('^file:', '', uri)) + return urllib.url2pathname(re.sub(b'^file:', b'', uri)) else: - path = urllib.url2pathname(re.sub('^file://', '', uri)) - return path.encode('latin1').decode('utf-8') # Undo double encoding + return urllib.url2pathname(re.sub(b'^file://', b'', uri)) def split_path(path): @@ -72,7 +93,7 @@ def split_path(path): path, part = os.path.split(path) if part: parts.insert(0, part) - if not path or path == '/': + if not path or path == b'/': break return parts diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 512a3ba1..cfe58e0a 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -90,31 +90,55 @@ class PathToFileURITest(unittest.TestCase): result = path.path_to_uri('/tmp/æøå') self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + def test_utf8_in_path(self): + if sys.platform == 'win32': + result = path.path_to_uri('C:/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') + else: + result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + + def test_latin1_in_path(self): + if sys.platform == 'win32': + result = path.path_to_uri('C:/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///C://%E6%F8%E5') + else: + result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///tmp/%E6%F8%E5') + class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://WINDOWS/clock.avi') - self.assertEqual(result, 'C:/WINDOWS/clock.avi') + self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8')) else: result = path.uri_to_path('file:///etc/fstab') - self.assertEqual(result, '/etc/fstab') + self.assertEqual(result, '/etc/fstab'.encode('utf-8')) def test_space_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://test%20this') - self.assertEqual(result, 'C:/test this') + self.assertEqual(result, 'C:/test this'.encode('utf-8')) else: result = path.uri_to_path('file:///tmp/test%20this') - self.assertEqual(result, '/tmp/test this') + self.assertEqual(result, '/tmp/test this'.encode('utf-8')) def test_unicode_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, 'C:/æøå') + self.assertEqual(result, 'C:/æøå'.encode('utf-8')) else: result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, '/tmp/æøå') + self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) + + def test_latin1_in_uri(self): + if sys.platform == 'win32': + result = path.uri_to_path('file:///C://%E6%F8%E5') + self.assertEqual(result, 'C:/æøå'.encode('latin-1')) + else: + result = path.uri_to_path('file:///tmp/%E6%F8%E5') + self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) class SplitPathTest(unittest.TestCase): From 905ceeb72a8ee5bb0aa8911f7a6fa06a40458417 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 15:05:18 +0100 Subject: [PATCH 002/144] utils: find_files() returns bytestrings --- mopidy/utils/path.py | 30 ++++++++++++++++-------------- tests/utils/path_test.py | 6 +++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index eea13fb1..8ef5e187 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -106,30 +106,32 @@ def expand_path(path): def find_files(path): + """ + Finds all files within a path. + + Directories and files with names starting with ``.`` is ignored. + + :returns: yields the full path to files as bytestrings + """ + if isinstance(path, unicode): + path = path.encode('utf-8') + if os.path.isfile(path): - if not isinstance(path, unicode): - path = path.decode('utf-8') - if not os.path.basename(path).startswith('.'): + if not os.path.basename(path).startswith(b'.'): yield path else: for dirpath, dirnames, filenames in os.walk(path): - # Filter out hidden folders by modifying dirnames in place. for dirname in dirnames: - if dirname.startswith('.'): + if dirname.startswith(b'.'): + # Skip hidden folders by modifying dirnames inplace dirnames.remove(dirname) for filename in filenames: - # Skip hidden files. - if filename.startswith('.'): + if filename.startswith(b'.'): + # Skip hidden files continue - filename = os.path.join(dirpath, filename) - if not isinstance(filename, unicode): - try: - filename = filename.decode('utf-8') - except UnicodeDecodeError: - filename = filename.decode('latin1') - yield filename + yield os.path.join(dirpath, filename) def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index cfe58e0a..629819f8 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -201,11 +201,11 @@ class FindFilesTest(unittest.TestCase): self.assertEqual(len(files), 1) self.assert_(files[0], path_to_data_dir('blank.mp3')) - def test_names_are_unicode(self): - is_unicode = lambda f: isinstance(f, unicode) + def test_names_are_bytestrings(self): + is_bytes = lambda f: isinstance(f, bytes) for name in self.find(''): self.assert_( - is_unicode(name), '%s is not unicode object' % repr(name)) + is_bytes(name), '%s is not unicode object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) From a006918453b9b7e2cf89635967687ff8730a037b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 7 Dec 2012 12:11:48 +0100 Subject: [PATCH 003/144] mpd: Use bytestrings in dir tree building --- mopidy/frontends/mpd/translator.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 3b77f929..b16264a1 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -177,16 +177,17 @@ def _add_to_tag_cache(result, folders, files): def tracks_to_directory_tree(tracks): directories = ({}, []) + for track in tracks: - path = '' + path = b'' current = directories - local_folder = settings.LOCAL_MUSIC_PATH - track_path = uri_to_path(track.uri) - track_path = re.sub('^' + re.escape(local_folder), '', track_path) - track_dir = os.path.dirname(track_path) + absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) + relative_track_dir_path = re.sub( + '^' + re.escape(settings.LOCAL_MUSIC_PATH), b'', + absolute_track_dir_path) - for part in split_path(track_dir): + for part in split_path(relative_track_dir_path): path = os.path.join(path, part) if path not in current[0]: current[0][path] = ({}, []) From 6311e13cecc1c1cd8868d0e53890b2b42f7149d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Dec 2012 12:03:26 +0100 Subject: [PATCH 004/144] mpd: urlencode any non-UTF-8 path so it can be displayed as UTF-8 --- mopidy/frontends/mpd/translator.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index b16264a1..1d7b52aa 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import os import re +import urllib from mopidy import settings from mopidy.frontends.mpd import protocol @@ -153,13 +154,16 @@ def tracks_to_tag_cache_format(tracks): def _add_to_tag_cache(result, folders, files): - music_folder = settings.LOCAL_MUSIC_PATH + base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') for path, entry in folders.items(): - name = os.path.split(path)[1] - mtime = get_mtime(os.path.join(music_folder, path)) - result.append(('directory', path)) - result.append(('mtime', mtime)) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.pathname2url(path).decode('utf-8') + name = os.path.split(text_path)[1] + result.append(('directory', text_path)) + result.append(('mtime', get_mtime(os.path.join(base_path, path)))) result.append(('begin', name)) _add_to_tag_cache(result, *entry) result.append(('end', name)) @@ -167,9 +171,13 @@ def _add_to_tag_cache(result, folders, files): result.append(('songList begin',)) for track in files: track_result = dict(track_to_mpd_format(track)) - track_result['mtime'] = get_mtime(uri_to_path(track_result['file'])) - track_result['file'] = track_result['file'] - track_result['key'] = os.path.basename(track_result['file']) + path = uri_to_path(track_result['file']) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.pathname2url(path).decode('utf-8') + track_result['mtime'] = get_mtime(path) + track_result['key'] = os.path.basename(text_path) track_result = order_mpd_track_info(track_result.items()) result.extend(track_result) result.append(('songList end',)) From e9eac16284153b44eed556dba9753f5789ddf99a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:07:00 +0100 Subject: [PATCH 005/144] mpd: Use relative urlencoded paths in tag cache This partly reverts "beac2e8 mpd: Use file:// URIs in tag_cache" by removing the "file://" URI scheme and the music dir base path from the "file:" fields in the tag cache. The advantage is that the tag cache becomes independent of the music dir location and the tag cache loader can be made compatible with both old and new tag caches. --- mopidy/frontends/mpd/translator.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 1d7b52aa..b2113dda 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -169,17 +169,25 @@ def _add_to_tag_cache(result, folders, files): result.append(('end', name)) result.append(('songList begin',)) + for track in files: track_result = dict(track_to_mpd_format(track)) + path = uri_to_path(track_result['file']) try: text_path = path.decode('utf-8') except UnicodeDecodeError: text_path = urllib.pathname2url(path).decode('utf-8') + relative_path = os.path.relpath(path, base_path) + relative_uri = urllib.pathname2url(relative_path) + + track_result['file'] = relative_uri track_result['mtime'] = get_mtime(path) track_result['key'] = os.path.basename(text_path) track_result = order_mpd_track_info(track_result.items()) + result.extend(track_result) + result.append(('songList end',)) From c8a068b02ca6665e4bc335fb808071894e4c3ef4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:08:51 +0100 Subject: [PATCH 006/144] local: Support tag caches with urlencoded paths This adds support for loading tag caches where the "file:" field has urlencoded paths. For old tag caches without the urlencoding, this is a noop. Thus, old tag caches continues to work. --- mopidy/backends/local/translator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 59e2957a..5f2a9bc5 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import logging +import urllib from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode @@ -139,6 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] + path = urllib.uri2pathname(path) if artist_kwargs: artist = Artist(**artist_kwargs) From b397162bd0020332c45d9ab1fa0a3555066cdb20 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 14:45:36 +0100 Subject: [PATCH 007/144] docs: Update changelog --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index aa69536c..acade010 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -29,6 +29,10 @@ v0.10.0 (in development) :option:`-v`/:option:`--verbose` options to control the amount of logging output when scanning. +- The scanner can now handle files with other encodings than UTF-8. Rebuild + your tag cache with ``mopidy-scan`` to include tracks that may have been + ignored previously. + **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through From 1707d6ae6e708b47586bfb5416f649e052c9c823 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:43:16 +0100 Subject: [PATCH 008/144] local: Fix typo --- mopidy/backends/local/translator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 5f2a9bc5..00500ef7 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -140,7 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] - path = urllib.uri2pathname(path) + path = urllib.url2pathname(path) if artist_kwargs: artist = Artist(**artist_kwargs) From b76e27a62bda316c5a673845929efec04b26cfe8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:54:04 +0100 Subject: [PATCH 009/144] mpd: Revert full URI in tag cache test as well --- tests/frontends/mpd/serializer_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index 211db600..aa3b77bb 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -4,7 +4,7 @@ import datetime import os from mopidy import settings -from mopidy.utils.path import mtime +from mopidy.utils.path import mtime, uri_to_path from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, TlTrack, Playlist, Track @@ -131,7 +131,9 @@ class TracksToTagCacheFormatTest(unittest.TestCase): mtime.undo_fake() def translate(self, track): + base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') result = dict(translator.track_to_mpd_format(track)) + result['file'] = uri_to_path(result['file'])[len(base_path) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) From a221036e5a4f831690fe56fe71d9f506d3489c4e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 23:05:12 +0100 Subject: [PATCH 010/144] tests: Fix error message --- tests/utils/path_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 629819f8..461f0809 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -205,7 +205,7 @@ class FindFilesTest(unittest.TestCase): is_bytes = lambda f: isinstance(f, bytes) for name in self.find(''): self.assert_( - is_bytes(name), '%s is not unicode object' % repr(name)) + is_bytes(name), '%s is not bytes object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) From 0f603c3eded58cef063f1f033f3c7a29a0c8e7b3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 23:13:52 +0100 Subject: [PATCH 011/144] Use urllib.{quote,unquote} instead of {pathname2url,url2pathname} --- mopidy/backends/local/translator.py | 2 +- mopidy/frontends/mpd/translator.py | 6 +++--- mopidy/utils/path.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 00500ef7..ff58a16e 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -140,7 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] - path = urllib.url2pathname(path) + path = urllib.unquote(path) if artist_kwargs: artist = Artist(**artist_kwargs) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index b2113dda..0c95f044 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -160,7 +160,7 @@ def _add_to_tag_cache(result, folders, files): try: text_path = path.decode('utf-8') except UnicodeDecodeError: - text_path = urllib.pathname2url(path).decode('utf-8') + text_path = urllib.quote(path).decode('utf-8') name = os.path.split(text_path)[1] result.append(('directory', text_path)) result.append(('mtime', get_mtime(os.path.join(base_path, path)))) @@ -177,9 +177,9 @@ def _add_to_tag_cache(result, folders, files): try: text_path = path.decode('utf-8') except UnicodeDecodeError: - text_path = urllib.pathname2url(path).decode('utf-8') + text_path = urllib.quote(path).decode('utf-8') relative_path = os.path.relpath(path, base_path) - relative_uri = urllib.pathname2url(relative_path) + relative_uri = urllib.quote(relative_path) track_result['file'] = relative_uri track_result['mtime'] = get_mtime(path) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 8ef5e187..c4fa0ce2 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -64,8 +64,8 @@ def path_to_uri(*paths): if isinstance(path, unicode): path = path.encode('utf-8') if sys.platform == 'win32': - return 'file:' + urllib.pathname2url(path) - return 'file://' + urllib.pathname2url(path) + return 'file:' + urllib.quote(path) + return 'file://' + urllib.quote(path) def uri_to_path(uri): @@ -82,9 +82,9 @@ def uri_to_path(uri): if isinstance(uri, unicode): uri = uri.encode('utf-8') if sys.platform == 'win32': - return urllib.url2pathname(re.sub(b'^file:', b'', uri)) + return urllib.unquote(re.sub(b'^file:', b'', uri)) else: - return urllib.url2pathname(re.sub(b'^file://', b'', uri)) + return urllib.unquote(re.sub(b'^file://', b'', uri)) def split_path(path): From 2b54837c64e8e7818ca10e29e473e221fc5f65d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 00:16:33 +0100 Subject: [PATCH 012/144] Bump version number to 0.10.0 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 918e1459..049db682 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.9.0' +__version__ = '0.10.0' from mopidy import settings as default_settings_module diff --git a/tests/version_test.py b/tests/version_test.py index 966b8b94..271f004a 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -31,5 +31,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.2'), SV('0.7.3')) self.assertLess(SV('0.7.3'), SV('0.8.0')) self.assertLess(SV('0.8.0'), SV('0.8.1')) - self.assertLess(SV('0.8.1'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.9.1')) + self.assertLess(SV('0.8.1'), SV('0.9.0')) + self.assertLess(SV('0.9.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.10.1')) From 24ace415a00458200020c78d6b230ef6b2b15417 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 00:19:43 +0100 Subject: [PATCH 013/144] spotify: pyspotify 1.9 and 1.10 are both supported --- docs/changes.rst | 5 +++++ mopidy/backends/spotify/__init__.py | 2 +- requirements/spotify.txt | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index acade010..d3c32d3f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,11 @@ This change log is used to track all major changes to Mopidy. v0.10.0 (in development) ======================== +**Dependencies** + +- pyspotify >= 1.9, < 1.11 is now required for Spotify support. In other words, + you're free to upgrade to pyspotify 1.10, but it isn't a requirement. + **Documentation** - Added installation instructions for Fedora. diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 141656cc..a8e9ffda 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -21,7 +21,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** - libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) -- pyspotify >= 1.9, < 1.10 (python-spotify package from apt.mopidy.com) +- pyspotify >= 1.9, < 1.11 (python-spotify package from apt.mopidy.com) **Settings:** diff --git a/requirements/spotify.txt b/requirements/spotify.txt index c37d4674..b501e63e 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1 +1 @@ -pyspotify >= 1.9, < 1.10 +pyspotify >= 1.9, < 1.11 From 005b751efb62b584906f25e06e484217c9e6fe90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 00:20:03 +0100 Subject: [PATCH 014/144] docs: Update changelog for v0.10.0 --- docs/changes.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index d3c32d3f..b6e433d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,8 +4,10 @@ Changes This change log is used to track all major changes to Mopidy. -v0.10.0 (in development) -======================== +v0.10.0 (2012-12-12) +==================== + +We've added an HTTP frontend for those wanting to build web clients for Mopidy! **Dependencies** From 6fdb0579f618d49b111c6033062cf8d5c211a395 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 01:03:19 +0100 Subject: [PATCH 015/144] travis: mopidy package now Recommends cherrypy and ws4py --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7acda2bd..df08679b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ install: - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" - - "pip install -r requirements/http.txt" # Until ws4py is packaged as a .deb before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" From 4122bd663965300dc01e927cefa4f8b5bb443f57 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 01:59:24 +0100 Subject: [PATCH 016/144] docs: Add HTTP frontends to frontend impls list --- docs/api/frontends.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 2237b4e7..8488b408 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -44,6 +44,7 @@ The following requirements applies to any frontend implementation: Frontend implementations ======================== +* :mod:`mopidy.frontends.http` * :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpris` From fe2adfae73d3d8a37dfda42863b322001b3b8573 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:02:16 +0100 Subject: [PATCH 017/144] docs: Fix syntax error in example --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 59c867d8..cfe8c46a 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -229,7 +229,7 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the .. code-block:: js - mopidy.on("state:online", function () [ + mopidy.on("state:online", function () { mopidy.playback.next(); }); From daa56e656710a5bbbc6c0db2ce245c806eadd93f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:06:05 +0100 Subject: [PATCH 018/144] docs: Fix typo --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index cfe8c46a..3c96c213 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -324,7 +324,7 @@ event listeners, and delete the object like this: .. code-block:: js // Close the WebSocket without reconnecting. Letting the object be garbage - // collected will have the same effect, so this isn't striclty necessary. + // collected will have the same effect, so this isn't strictly necessary. mopidy.close(); // Unregister all event listeners. If you don't do this, you may have From 0c6de005a0e74d0cd8a42dff49f3374b8ecd50ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:07:24 +0100 Subject: [PATCH 019/144] docs: Fix typo --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 3c96c213..32edde0f 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -452,7 +452,7 @@ Example to get started with 9. The web page should now queue and play your first playlist every time your load it. See the browser's console for output from the function, any errors, - and a all events that are emitted. + and all events that are emitted. """ # flake8: noqa From eb717693f921305038703a0785de0ae0b112fb6c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:17:29 +0100 Subject: [PATCH 020/144] Add fabfile.py with autotest task --- fabfile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 fabfile.py diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 00000000..e2d77a06 --- /dev/null +++ b/fabfile.py @@ -0,0 +1,14 @@ +from fabric.api import local + + +def test(): + local('nosetests tests/') + + +def autotest(): + while True: + local('clear') + test() + local( + 'inotifywait -q -e create -e modify -e delete ' + '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') From 1919d7f8c21f888cab96b4a52b15b6e4094c2b4e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:21:21 +0100 Subject: [PATCH 021/144] docs: Include full list of authors --- .mailmap | 2 ++ AUTHORS | 13 +++++++++++++ docs/authors.rst | 8 +------- fabfile.py | 6 ++++++ 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 AUTHORS diff --git a/.mailmap b/.mailmap index 15d8f359..93a4aed1 100644 --- a/.mailmap +++ b/.mailmap @@ -1,3 +1,5 @@ +Thomas Adamcik +Thomas Adamcik Kristian Klette Johannes Knutsen Johannes Knutsen diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..9c9951f8 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,13 @@ +- Stein Magnus Jodal +- Johannes Knutsen +- Thomas Adamcik +- Kristian Klette +- Martins Grunskis +- Henrik Olsson +- Antoine Pierlot-Garcin +- John Bäckstrand +- Fred Hatfull +- Erling Børresen +- David C +- Christian Johansen +- Matt Bray diff --git a/docs/authors.rst b/docs/authors.rst index 822abc15..97a2dd2b 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,13 +4,7 @@ Authors Contributors to Mopidy in the order of appearance: -- Stein Magnus Jodal -- Johannes Knutsen -- Thomas Adamcik -- Kristian Klette - -A complete list of persons with commits accepted into the Mopidy repo can be -found at `GitHub `_. +.. include:: ../AUTHORS Showing your appreciation diff --git a/fabfile.py b/fabfile.py index e2d77a06..267bdc23 100644 --- a/fabfile.py +++ b/fabfile.py @@ -12,3 +12,9 @@ def autotest(): local( 'inotifywait -q -e create -e modify -e delete ' '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') + + +def update_authors(): + # Keep authors in the order of appearance and use awk to filter out dupes + local( + "git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") From 165a0e4aef6d0f886707ef0ae046418381a2df08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 13:48:39 +0100 Subject: [PATCH 022/144] Update PyPI short package description --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6135df31..5840ca53 100644 --- a/setup.py +++ b/setup.py @@ -94,7 +94,7 @@ setup( scripts=['bin/mopidy', 'bin/mopidy-scan'], url='http://www.mopidy.com/', license='Apache License, Version 2.0', - description='MPD server with Spotify support', + description='Music server with MPD and Spotify support', long_description=open('README.rst').read(), classifiers=[ 'Development Status :: 4 - Beta', From bac240501befc5a73c86c060e6cd16f8daa63b5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 11:52:55 +0100 Subject: [PATCH 023/144] docs: Add empty changelog entry for v0.11 --- docs/changes.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index b6e433d3..3720ccf4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,13 @@ Changes This change log is used to track all major changes to Mopidy. + +v0.11.0 (in development) +======================== + +- No changes yet. + + v0.10.0 (2012-12-12) ==================== From ac537a63c75145ee3bf60d35c74b6d3e3dda1895 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 11:51:55 +0100 Subject: [PATCH 024/144] mpd: Add 'seekcur' command --- docs/changes.rst | 4 ++- mopidy/frontends/mpd/protocol/playback.py | 20 ++++++++++++ tests/frontends/mpd/protocol/playback_test.py | 31 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3720ccf4..8de66e45 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,7 +8,9 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== -- No changes yet. +**MPD frontend** + +- Add support for ``seekcur`` command added in MPD 0.17. v0.10.0 (2012-12-12) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 5a4569e1..68c49ca0 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -349,6 +349,26 @@ def seekid(context, tlid, seconds): context.core.playback.seek(int(seconds) * 1000).get() +@handle_request(r'^seekcur "(?P\d+)"$') +@handle_request(r'^seekcur "(?P[-+]\d+)"$') +def seekcur(context, position=None, diff=None): + """ + *musicpd.org, playback section:* + + ``seekcur {TIME}`` + + Seeks to the position ``TIME`` within the current song. If prefixed by + '+' or '-', then the time is relative to the current playing position. + """ + if position is not None: + position = int(position) * 1000 + context.core.playback.seek(position).get() + elif diff is not None: + position = context.core.playback.time_position.get() + position += int(diff) * 1000 + context.core.playback.seek(position).get() + + @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') def setvol(context, volume): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 14168a35..063493ec 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -414,6 +414,37 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse('OK') + def test_seekcur_absolute_value(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + + self.sendRequest('seekcur "30"') + + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + self.assertInResponse('OK') + + def test_seekcur_positive_diff(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(10000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) + + self.sendRequest('seekcur "+20"') + + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + self.assertInResponse('OK') + + def test_seekcur_negative_diff(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + + self.sendRequest('seekcur "-20"') + + self.assertLessEqual(self.core.playback.time_position.get(), 15000) + self.assertInResponse('OK') + def test_stop(self): self.sendRequest('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) From 49d585a97c7e13d3e39c315a13bddbfe58206141 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 12:08:17 +0100 Subject: [PATCH 025/144] mpd: Add 'config' command --- docs/changes.rst | 2 ++ mopidy/frontends/mpd/protocol/reflection.py | 22 ++++++++++++++++--- .../frontends/mpd/protocol/reflection_test.py | 11 ++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8de66e45..6f15ff20 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,8 @@ v0.11.0 (in development) - Add support for ``seekcur`` command added in MPD 0.17. +- Add support for ``config`` command added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index d9c35743..cc1c7222 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,8 +1,23 @@ from __future__ import unicode_literals +from mopidy.frontends.mpd.exceptions import MpdPermissionError from mopidy.frontends.mpd.protocol import handle_request, mpd_commands +@handle_request(r'^config$', auth_required=False) +def config(context): + """ + *musicpd.org, reflection section:* + + ``config`` + + Dumps configuration values that may be interesting for the client. This + command is only permitted to "local" clients (connected via UNIX domain + socket). + """ + raise MpdPermissionError(command='config') + + @handle_request(r'^commands$', auth_required=False) def commands(context): """ @@ -19,10 +34,10 @@ def commands(context): command.name for command in mpd_commands if not command.auth_required]) - # No one is permited to use kill, rest of commands are not listed by MPD, - # so we shouldn't either. + # No one is permited to use 'config' or 'kill', rest of commands are not + # listed by MPD, so we shouldn't either. command_names = command_names - set([ - 'kill', 'command_list_begin', 'command_list_ok_begin', + 'config', 'kill', 'command_list_begin', 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', 'sticker']) @@ -73,6 +88,7 @@ def notcommands(context): command.name for command in mpd_commands if command.auth_required] # No permission to use + command_names.append('config') command_names.append('kill') return [ diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py index 9c07f104..f2720473 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -6,6 +6,11 @@ from tests.frontends.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): + def test_config_is_not_allowed_across_the_network(self): + self.sendRequest('config') + self.assertEqualResponse( + 'ACK [4@0] {config} you don\'t have permission for "config"') + def test_commands_returns_list_of_all_commands(self): self.sendRequest('commands') # Check if some random commands are included @@ -13,6 +18,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertInResponse('command: play') self.assertInResponse('command: status') # Check if commands you do not have access to are not present + self.assertNotInResponse('command: config') self.assertNotInResponse('command: kill') # Check if the blacklisted commands are not present self.assertNotInResponse('command: command_list_begin') @@ -40,9 +46,10 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.sendRequest('decoders') self.assertInResponse('OK') - def test_notcommands_returns_only_kill_and_ok(self): + def test_notcommands_returns_only_config_and_kill_and_ok(self): response = self.sendRequest('notcommands') - self.assertEqual(2, len(response)) + self.assertEqual(3, len(response)) + self.assertInResponse('command: config') self.assertInResponse('command: kill') self.assertInResponse('OK') From 50cbe5f3841e2801d573483f41ed1b8b174a2a58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 22:12:24 +0100 Subject: [PATCH 026/144] mpd: Add range support to 'load' command --- docs/changes.rst | 3 ++ .../mpd/protocol/stored_playlists.py | 23 +++++++++--- .../mpd/protocol/stored_playlists_test.py | 36 ++++++++++++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6f15ff20..300af3d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,9 @@ v0.11.0 (in development) - Add support for ``config`` command added in MPD 0.17. +- Add support for loading a range of tracks from a playlist to the ``load`` + command, as added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index eef1f3d1..034403ec 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -92,23 +92,36 @@ def listplaylists(context): return result -@handle_request(r'^load "(?P[^"]+)"$') -def load(context, name): +@handle_request(r'^load "(?P[^"]+)"( "(?P\d+):(?P\d+)*")*$') +def load(context, name, start=None, end=None): """ *musicpd.org, stored playlists section:* - ``load {NAME}`` + ``load {NAME} [START:END]`` - Loads the playlist ``NAME.m3u`` from the playlist directory. + Loads the playlist into the current queue. Playlist plugins are + supported. A range may be specified to load only a part of the + playlist. *Clarifications:* - ``load`` appends the given playlist to the current playlist. + + - MPD 0.17.1 does not support open-ended ranges, i.e. without end + specified, for the ``load`` command, even though MPD's general range docs + allows open-ended ranges. + + - MPD 0.17.1 does not fail if the specified range is outside the playlist, + in either or both ends. """ playlists = context.core.playlists.filter(name=name).get() if not playlists: raise MpdNoExistError('No such playlist', command='load') - context.core.tracklist.add(playlists[0].tracks) + if start is not None: + start = int(start) + if end is not None: + end = int(end) + context.core.tracklist.add(playlists[0].tracks[start:end]) @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index be2afd4c..49da5d0b 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -73,7 +73,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('playlist: ') self.assertInResponse('OK') - def test_load_known_playlist_appends_to_tracklist(self): + def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.playlists = [ @@ -81,6 +81,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list"') + tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) @@ -90,6 +91,39 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqual('e', tracks[4].uri) self.assertInResponse('OK') + def test_load_with_range_loads_part_of_playlist(self): + self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.backend.playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest('load "A-list" "1:2"') + + tracks = self.core.tracklist.tracks.get() + self.assertEqual(3, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('d', tracks[2].uri) + self.assertInResponse('OK') + + def test_load_with_range_without_end_loads_rest_of_playlist(self): + self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.backend.playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest('load "A-list" "1:"') + + tracks = self.core.tracklist.tracks.get() + self.assertEqual(4, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('d', tracks[2].uri) + self.assertEqual('e', tracks[3].uri) + self.assertInResponse('OK') + def test_load_unknown_playlist_acks(self): self.sendRequest('load "unknown playlist"') self.assertEqual(0, len(self.core.tracklist.tracks.get())) From 6ac2c249b52d1a076aca3308f9caf52601bc2221 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 00:33:11 +0100 Subject: [PATCH 027/144] mpd: Add 'findadd' command --- docs/changes.rst | 2 ++ mopidy/frontends/mpd/protocol/music_db.py | 30 +++++++++++-------- tests/frontends/mpd/protocol/music_db_test.py | 10 ++++++- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 300af3d3..4224c5d2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -17,6 +17,8 @@ v0.11.0 (in development) - Add support for loading a range of tracks from a playlist to the ``load`` command, as added in MPD 0.17. +- Add support for the ``findadd`` command. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 00b9ec00..91f0dcf4 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -51,7 +51,8 @@ def count(context, tag, needle): @handle_request( - r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'^find ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def find(context, mpd_query): """ @@ -59,8 +60,10 @@ def find(context, mpd_query): ``find {TYPE} {WHAT}`` - Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be - ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any + tag supported by MPD, or one of the two special parameters - ``file`` + to search by full path (relative to database root), and ``any`` to + match against all available tags. ``WHAT`` is what to find. *GMPC:* @@ -82,26 +85,29 @@ def find(context, mpd_query): query = _build_query(mpd_query) except ValueError: return - return tracks_to_mpd_format( - context.core.library.find_exact(**query).get()) + result = context.core.library.find_exact(**query).get() + return tracks_to_mpd_format(result) @handle_request( r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' - r'"[^"]+"\s?)+)$') -def findadd(context, query): + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def findadd(context, mpd_query): """ *musicpd.org, music database section:* ``findadd {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT`` and adds them to - current playlist. ``TYPE`` can be any tag supported by MPD. - ``WHAT`` is what to find. + current playlist. Parameters have the same meaning as for ``find``. """ - # TODO Add result to current playlist - #result = context.find(query) + try: + query = _build_query(mpd_query) + except ValueError: + return + result = context.core.library.find_exact(**query).get() + context.core.tracklist.add(result) @handle_request( diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 4539eb4c..7f50d169 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -13,7 +13,15 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_findadd(self): - self.sendRequest('findadd "album" "what"') + self.backend.library.dummy_find_exact_result = [ + Track(uri='dummy:a', name='A'), + ] + self.assertEqual(self.core.tracklist.length.get(), 0) + + self.sendRequest('findadd "title" "A"') + + self.assertEqual(self.core.tracklist.length.get(), 1) + self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') def test_listall(self): From 9b1dfa69784412d84695dbab34c828c7b542ef8b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 00:40:33 +0100 Subject: [PATCH 028/144] mpd: Add 'searchadd' command --- docs/changes.rst | 2 + mopidy/frontends/mpd/protocol/music_db.py | 38 +++++++++++++++---- tests/frontends/mpd/protocol/music_db_test.py | 12 ++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4224c5d2..83252a9c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -19,6 +19,8 @@ v0.11.0 (in development) - Add support for the ``findadd`` command. +- Add support for ``searchadd`` command added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 91f0dcf4..2c0a2c32 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -340,17 +340,17 @@ def rescan(context, uri=None): @handle_request( - r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'^search ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def search(context, mpd_query): """ *musicpd.org, music database section:* - ``search {TYPE} {WHAT}`` + ``search {TYPE} {WHAT} [...]`` - Searches for any song that contains ``WHAT``. ``TYPE`` can be - ``title``, ``artist``, ``album`` or ``filename``. Search is not - case sensitive. + Searches for any song that contains ``WHAT``. Parameters have the same + meaning as for ``find``, except that search is not case sensitive. *GMPC:* @@ -374,8 +374,32 @@ def search(context, mpd_query): query = _build_query(mpd_query) except ValueError: return - return tracks_to_mpd_format( - context.core.library.search(**query).get()) + result = context.core.library.search(**query).get() + return tracks_to_mpd_format(result) + + +@handle_request( + r'^searchadd ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def searchadd(context, mpd_query): + """ + *musicpd.org, music database section:* + + ``searchadd {TYPE} {WHAT} [...]`` + + Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds + them to current playlist. + + Parameters have the same meaning as for ``find``, except that search is + not case sensitive. + """ + try: + query = _build_query(mpd_query) + except ValueError: + return + result = context.core.library.search(**query).get() + context.core.tracklist.add(result) @handle_request(r'^update( "(?P[^"]+)")*$') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 7f50d169..13f0759b 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -24,6 +24,18 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') + def test_searchadd(self): + self.backend.library.dummy_search_result = [ + Track(uri='dummy:a', name='A'), + ] + self.assertEqual(self.core.tracklist.length.get(), 0) + + self.sendRequest('searchadd "title" "a"') + + self.assertEqual(self.core.tracklist.length.get(), 1) + self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') + self.assertInResponse('OK') + def test_listall(self): self.sendRequest('listall "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') From b95c8032de14cdb7a92620eb45bf7a7259532497 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 01:18:13 +0100 Subject: [PATCH 029/144] mpd: Add 'searchaddpl' command --- docs/changes.rst | 2 + mopidy/frontends/mpd/protocol/music_db.py | 35 +++++++++++++++++ tests/frontends/mpd/protocol/music_db_test.py | 38 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 83252a9c..8add66e1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -21,6 +21,8 @@ v0.11.0 (in development) - Add support for ``searchadd`` command added in MPD 0.17. +- Add support for ``searchaddpl`` command added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 2c0a2c32..66735538 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -402,6 +402,41 @@ def searchadd(context, mpd_query): context.core.tracklist.add(result) +@handle_request( + r'^searchaddpl ' + r'"(?P[^"]+)" ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def searchaddpl(context, playlist_name, mpd_query): + """ + *musicpd.org, music database section:* + + ``searchaddpl {NAME} {TYPE} {WHAT} [...]`` + + Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds + them to the playlist named ``NAME``. + + If a playlist by that name doesn't exist it is created. + + Parameters have the same meaning as for ``find``, except that search is + not case sensitive. + """ + try: + query = _build_query(mpd_query) + except ValueError: + return + result = context.core.library.search(**query).get() + + playlists = context.core.playlists.filter(name=playlist_name).get() + if playlists: + playlist = playlists[0] + else: + playlist = context.core.playlists.create(playlist_name).get() + tracks = list(playlist.tracks) + result + playlist = playlist.copy(tracks=tracks) + context.core.playlists.save(playlist) + + @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): """ diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 13f0759b..5c887958 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -36,6 +36,44 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') + def test_searchaddpl_appends_to_existing_playlist(self): + playlist = self.core.playlists.create('my favs').get() + playlist = playlist.copy(tracks=[ + Track(uri='dummy:x', name='X'), + Track(uri='dummy:y', name='y'), + ]) + self.core.playlists.save(playlist) + self.backend.library.dummy_search_result = [ + Track(uri='dummy:a', name='A'), + ] + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(len(playlists[0].tracks), 2) + + self.sendRequest('searchaddpl "my favs" "title" "a"') + + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(len(playlists[0].tracks), 3) + self.assertEqual(playlists[0].tracks[0].uri, 'dummy:x') + self.assertEqual(playlists[0].tracks[1].uri, 'dummy:y') + self.assertEqual(playlists[0].tracks[2].uri, 'dummy:a') + self.assertInResponse('OK') + + def test_searchaddpl_creates_missing_playlist(self): + self.backend.library.dummy_search_result = [ + Track(uri='dummy:a', name='A'), + ] + self.assertEqual( + len(self.core.playlists.filter(name='my favs').get()), 0) + + self.sendRequest('searchaddpl "my favs" "title" "a"') + + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a') + self.assertInResponse('OK') + def test_listall(self): self.sendRequest('listall "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') From b43fc2ebe98cf0e2712c4d0e50748b8f9a79e386 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 01:44:20 +0100 Subject: [PATCH 030/144] mpd: Stub channel commands --- docs/changes.rst | 3 + docs/modules/frontends/mpd.rst | 8 +++ mopidy/frontends/mpd/protocol/__init__.py | 5 +- mopidy/frontends/mpd/protocol/channels.py | 69 +++++++++++++++++++ tests/frontends/mpd/protocol/channels_test.py | 25 +++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 mopidy/frontends/mpd/protocol/channels.py create mode 100644 tests/frontends/mpd/protocol/channels_test.py diff --git a/docs/changes.rst b/docs/changes.rst index 8add66e1..e313c540 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,6 +23,9 @@ v0.11.0 (in development) - Add support for ``searchaddpl`` command added in MPD 0.17. +- Add empty stubs for channel commands for client to client communication, + which was added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 090ca5cd..f25b90f2 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -30,6 +30,14 @@ Audio output :members: +Channels +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.channels + :synopsis: MPD protocol: channels -- client to client communication + :members: + + Command list ------------ diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index a8bdc2c7..6afde4b9 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -74,6 +74,7 @@ def load_protocol_modules(): """ # pylint: disable = W0612 from . import ( # noqa - audio_output, command_list, connection, current_playlist, empty, - music_db, playback, reflection, status, stickers, stored_playlists) + audio_output, channels, command_list, connection, current_playlist, + empty, music_db, playback, reflection, status, stickers, + stored_playlists) # pylint: enable = W0612 diff --git a/mopidy/frontends/mpd/protocol/channels.py b/mopidy/frontends/mpd/protocol/channels.py new file mode 100644 index 00000000..11ac6fda --- /dev/null +++ b/mopidy/frontends/mpd/protocol/channels.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals + +from mopidy.frontends.mpd.protocol import handle_request +from mopidy.frontends.mpd.exceptions import MpdNotImplemented + + +@handle_request(r'^subscribe "(?P[A-Za-z0-9:._-]+)"$') +def subscribe(context, channel): + """ + *musicpd.org, client to client section:* + + ``subscribe {NAME}`` + + Subscribe to a channel. The channel is created if it does not exist + already. The name may consist of alphanumeric ASCII characters plus + underscore, dash, dot and colon. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^unsubscribe "(?P[A-Za-z0-9:._-]+)"$') +def unsubscribe(context, channel): + """ + *musicpd.org, client to client section:* + + ``unsubscribe {NAME}`` + + Unsubscribe from a channel. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^channels$') +def channels(context): + """ + *musicpd.org, client to client section:* + + ``channels`` + + Obtain a list of all channels. The response is a list of "channel:" + lines. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^readmessages$') +def readmessages(context): + """ + *musicpd.org, client to client section:* + + ``readmessages`` + + Reads messages for this client. The response is a list of "channel:" + and "message:" lines. + """ + raise MpdNotImplemented # TODO + + +@handle_request( + r'^sendmessage "(?P[A-Za-z0-9:._-]+)" "(?P[^"]*)"$') +def sendmessage(context, channel, text): + """ + *musicpd.org, client to client section:* + + ``sendmessage {CHANNEL} {TEXT}`` + + Send a message to the specified channel. + """ + raise MpdNotImplemented # TODO diff --git a/tests/frontends/mpd/protocol/channels_test.py b/tests/frontends/mpd/protocol/channels_test.py new file mode 100644 index 00000000..86cf8197 --- /dev/null +++ b/tests/frontends/mpd/protocol/channels_test.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +from tests.frontends.mpd import protocol + + +class ChannelsHandlerTest(protocol.BaseTestCase): + def test_subscribe(self): + self.sendRequest('subscribe "topic"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_unsubscribe(self): + self.sendRequest('unsubscribe "topic"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_channels(self): + self.sendRequest('channels') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_readmessages(self): + self.sendRequest('readmessages') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_sendmessage(self): + self.sendRequest('sendmessage "topic" "a message"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') From 0b6673e7f59a193e77b9647b9a78ecc6c6dfefef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 01:46:59 +0100 Subject: [PATCH 031/144] mpd: Bump protocol version to 0.17.0 --- docs/changes.rst | 23 ++++++++++++----------- mopidy/frontends/mpd/protocol/__init__.py | 4 ++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index e313c540..6a5aa572 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,21 +10,22 @@ v0.11.0 (in development) **MPD frontend** -- Add support for ``seekcur`` command added in MPD 0.17. - -- Add support for ``config`` command added in MPD 0.17. - -- Add support for loading a range of tracks from a playlist to the ``load`` - command, as added in MPD 0.17. - - Add support for the ``findadd`` command. -- Add support for ``searchadd`` command added in MPD 0.17. +- Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`): -- Add support for ``searchaddpl`` command added in MPD 0.17. + - Add support for ``seekcur`` command. -- Add empty stubs for channel commands for client to client communication, - which was added in MPD 0.17. + - Add support for ``config`` command. + + - Add support for loading a range of tracks from a playlist to the ``load`` + command. + + - Add support for ``searchadd`` command. + + - Add support for ``searchaddpl`` command. + + - Add empty stubs for channel commands for client to client communication. v0.10.0 (2012-12-12) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 6afde4b9..1827624b 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -21,8 +21,8 @@ ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = '\n' -#: The MPD protocol version is 0.16.0. -VERSION = '0.16.0' +#: The MPD protocol version is 0.17.0. +VERSION = '0.17.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) From a084105898f6be5c87f0e6716df8300f4edb3d17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:07:29 +0100 Subject: [PATCH 032/144] docs: Use requirement files as single source of dependencies --- mopidy/audio/mixers/nad.py | 8 ++++---- mopidy/backends/spotify/__init__.py | 3 +-- mopidy/frontends/http/__init__.py | 4 +--- mopidy/frontends/lastfm.py | 2 +- requirements/README.rst | 4 ++-- requirements/core.txt | 1 + requirements/external_mixers.txt | 1 + requirements/http.txt | 3 +++ requirements/lastfm.txt | 2 ++ requirements/spotify.txt | 7 +++++++ 10 files changed, 23 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index b5cb522d..52ab4757 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -1,11 +1,11 @@ """Mixer that controls volume using a NAD amplifier. +The NAD amplifier must be connected to the machine running Mopidy using a +serial cable. + **Dependencies:** -- pyserial (python-serial in Debian/Ubuntu) - -- The NAD amplifier must be connected to the machine running Mopidy using a - serial cable. +.. literalinclude:: ../../../../requirements/external_mixers.txt **Settings:** diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index a8e9ffda..507511f4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -20,8 +20,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** -- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) -- pyspotify >= 1.9, < 1.11 (python-spotify package from apt.mopidy.com) +.. literalinclude:: ../../../requirements/spotify.txt **Settings:** diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 32edde0f..94b8e58e 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -4,9 +4,7 @@ from a web based client. **Dependencies** -- ``cherrypy`` - -- ``ws4py`` +.. literalinclude:: ../../../requirements/http.txt **Settings** diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 7f367262..565e5041 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -8,7 +8,7 @@ Frontend which scrobbles the music you play to your `Last.fm **Dependencies:** -- `pylast `_ >= 0.5.7 +.. literalinclude:: ../../../requirements/lastfm.txt **Settings:** diff --git a/requirements/README.rst b/requirements/README.rst index cc061a7b..e1a6d757 100644 --- a/requirements/README.rst +++ b/requirements/README.rst @@ -3,8 +3,8 @@ pip requirement files ********************* The files found here are `requirement files -`_ that may be used with `pip -`_. +`_ that may be used +with `pip `_. To install the dependencies found in one of these files, simply run e.g.:: diff --git a/requirements/core.txt b/requirements/core.txt index 7f83e251..7a28564f 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1,2 @@ Pykka >= 1.0 +# Available as python-pykka from apt.mopidy.com diff --git a/requirements/external_mixers.txt b/requirements/external_mixers.txt index f6c1a1f5..20cb7864 100644 --- a/requirements/external_mixers.txt +++ b/requirements/external_mixers.txt @@ -1 +1,2 @@ pyserial +# Available as python-serial in Debian/Ubuntu diff --git a/requirements/http.txt b/requirements/http.txt index d8757e29..aea7c1a8 100644 --- a/requirements/http.txt +++ b/requirements/http.txt @@ -1,2 +1,5 @@ cherrypy >= 3.2.2 +# Available as python-cherrypy3 in Debian/Ubuntu + ws4py >= 0.2.3 +# Available as python-ws4py from apt.mopidy.com diff --git a/requirements/lastfm.txt b/requirements/lastfm.txt index 314c4223..c52256c3 100644 --- a/requirements/lastfm.txt +++ b/requirements/lastfm.txt @@ -1 +1,3 @@ pylast >= 0.5.7 +# Available as python-pylast in newer Debian/Ubuntu and from apt.mopidy.com for +# older releases of Debian/Ubuntu diff --git a/requirements/spotify.txt b/requirements/spotify.txt index b501e63e..333e55c8 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1 +1,8 @@ pyspotify >= 1.9, < 1.11 +# The libspotify Python wrapper +# Available as the python-spotify package from apt.mopidy.com + +# libspotify >= 12, < 13 +# The libspotify C library from +# https://developer.spotify.com/technologies/libspotify/ +# Available as the libspotify12 package from apt.mopidy.com From 8fdd7fdf585b562260ae209494fadac6c0b6cc88 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:13:58 +0100 Subject: [PATCH 033/144] docs: The Mopidy resources was moved to /mopidy --- mopidy/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 259bc645..0a71ccfa 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -103,10 +103,10 @@ HTTP_SERVER_HOSTNAME = u'127.0.0.1' #: Default: 6680 HTTP_SERVER_PORT = 6680 -#: Which directory Mopidy's HTTP server should serve at /. +#: Which directory Mopidy's HTTP server should serve at ``/``. #: #: Change this to have Mopidy serve e.g. files for your JavaScript client. -#: /api and /ws will continue to work as usual even if you change this setting. +#: ``/mopidy`` will continue to work as usual even if you change this setting. #: #: Used by :mod:`mopidy.frontends.http`. #: From b1f0a67dd42e8fdf041250514611003f5130f642 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:43:45 +0100 Subject: [PATCH 034/144] mpd: Reuse query regexp. Fix 'filename' expression --- mopidy/frontends/mpd/protocol/music_db.py | 31 ++++++++--------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 66735538..7cdfc5e0 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -8,6 +8,11 @@ from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.translator import tracks_to_mpd_format +QUERY_RE = ( + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') + + def _build_query(mpd_query): """ Parses a MPD query string and converts it to the Mopidy query format. @@ -50,10 +55,7 @@ def count(context, tag, needle): return [('songs', 0), ('playtime', 0)] # TODO -@handle_request( - r'^find ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^find ' + QUERY_RE) def find(context, mpd_query): """ *musicpd.org, music database section:* @@ -89,10 +91,7 @@ def find(context, mpd_query): return tracks_to_mpd_format(result) -@handle_request( - r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^findadd ' + QUERY_RE) def findadd(context, mpd_query): """ *musicpd.org, music database section:* @@ -339,10 +338,7 @@ def rescan(context, uri=None): return update(context, uri, rescan_unmodified_files=True) -@handle_request( - r'^search ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^search ' + QUERY_RE) def search(context, mpd_query): """ *musicpd.org, music database section:* @@ -378,10 +374,7 @@ def search(context, mpd_query): return tracks_to_mpd_format(result) -@handle_request( - r'^searchadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^searchadd ' + QUERY_RE) def searchadd(context, mpd_query): """ *musicpd.org, music database section:* @@ -402,11 +395,7 @@ def searchadd(context, mpd_query): context.core.tracklist.add(result) -@handle_request( - r'^searchaddpl ' - r'"(?P[^"]+)" ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^searchaddpl "(?P[^"]+)" ' + QUERY_RE) def searchaddpl(context, playlist_name, mpd_query): """ *musicpd.org, music database section:* From a5993628631a122624765fcdb263dd5138c864f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:46:52 +0100 Subject: [PATCH 035/144] Remove unused yappi profiler --- requirements/tests.txt | 1 - tests/__main__.py | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/requirements/tests.txt b/requirements/tests.txt index 20aff929..74fe7595 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -5,4 +5,3 @@ nose pylint tox unittest2 -yappi diff --git a/tests/__main__.py b/tests/__main__.py index 11757cbb..164f1e66 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,10 +1,5 @@ from __future__ import unicode_literals import nose -import yappi -try: - yappi.start() - nose.main() -finally: - yappi.print_stats() +nose.main() From 5122a254aac132a0ea4e6dc146cc1cec784e123c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Dec 2012 20:22:42 +0100 Subject: [PATCH 036/144] models: Specify time zone of Playlist.last_modified as UTC --- docs/changes.rst | 6 ++++++ mopidy/frontends/mpd/protocol/stored_playlists.py | 3 +-- mopidy/models.py | 4 ++-- tests/models_test.py | 10 +++++----- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6a5aa572..18c1f0ad 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -27,6 +27,12 @@ v0.11.0 (in development) - Add empty stubs for channel commands for client to client communication. +**Internal changes** + +*Models:* + +- Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 034403ec..b1fe87de 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -82,11 +82,10 @@ def listplaylists(context): continue result.append(('playlist', playlist.name)) last_modified = ( - playlist.last_modified or dt.datetime.now()).isoformat() + playlist.last_modified or dt.datetime.utcnow()).isoformat() # Remove microseconds last_modified = last_modified.split('.')[0] # Add time zone information - # TODO Convert to UTC before adding Z last_modified = last_modified + 'Z' result.append(('Last-Modified', last_modified)) return result diff --git a/mopidy/models.py b/mopidy/models.py index a4ed1b4f..e47ed3be 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -290,7 +290,7 @@ class Playlist(ImmutableObject): :type name: string :param tracks: playlist's tracks :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time + :param last_modified: playlist's modification time in UTC :type last_modified: :class:`datetime.datetime` """ @@ -303,7 +303,7 @@ class Playlist(ImmutableObject): #: The playlist's tracks. Read-only. tracks = tuple() - #: The playlist modification time. Read-only. + #: The playlist modification time in UTC. Read-only. #: #: :class:`datetime.datetime`, or :class:`None` if unknown. last_modified = None diff --git a/tests/models_test.py b/tests/models_test.py index 9a3062fc..1a4d869a 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -707,7 +707,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist.length, 3) def test_last_modified(self): - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) self.assertRaises( @@ -715,7 +715,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -727,7 +727,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -739,7 +739,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -752,7 +752,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_last_modified(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() new_last_modified = last_modified + datetime.timedelta(1) playlist = Playlist( uri='an uri', name='a name', tracks=tracks, From cc2510bd5641a6b3d3b0fee5711b3fde1cad7287 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Dec 2012 15:19:52 +0100 Subject: [PATCH 037/144] tests: Rename core.playlist tests so it is included in test runs --- tests/core/{playlists.py => playlists_test.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/core/{playlists.py => playlists_test.py} (100%) diff --git a/tests/core/playlists.py b/tests/core/playlists_test.py similarity index 100% rename from tests/core/playlists.py rename to tests/core/playlists_test.py From 59ccc207ea0eb8ca4d60e1f9b0d8ce0a7a38625d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Dec 2012 15:38:32 +0100 Subject: [PATCH 038/144] core: Make all methods callable without kwargs, e.g. from Mopidy.js --- docs/changes.rst | 12 +++++++++ mopidy/core/library.py | 16 ++++++++++-- mopidy/core/playlists.py | 18 +++++++++---- mopidy/core/tracklist.py | 26 +++++++++++++------ tests/core/library_test.py | 30 ++++++++++++++++++++++ tests/core/playlists_test.py | 18 ++++++++++--- tests/core/tracklist_test.py | 49 ++++++++++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 tests/core/tracklist_test.py diff --git a/docs/changes.rst b/docs/changes.rst index 18c1f0ad..30e0c056 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -33,6 +33,18 @@ v0.11.0 (in development) - Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. +*Core API:* + +- Change the following methods to accept either a dict with filters or kwargs. + Previously they only accepted kwargs, which made them impossible to use from + the Mopidy.js through JSON-RPC, which doesn't support kwargs. + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + - :meth:`mopidy.core.PlaylistsController.filter` + - :meth:`mopidy.core.TracklistController.filter` + - :meth:`mopidy.core.TracklistController.remove` + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/core/library.py b/mopidy/core/library.py index c1a89222..3c596a3a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -17,23 +17,29 @@ class LibraryController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) - def find_exact(self, **query): + def find_exact(self, query=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. Examples:: # Returns results matching 'a' + find_exact({'any': ['a']}) find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact({'artist': ['xyz']}) find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact({'any': ['a', 'b'], 'artist': ['xyz']}) find_exact(any=['a', 'b'], artist=['xyz']) :param query: one or more queries to search for :type query: dict :rtype: list of :class:`mopidy.models.Track` """ + query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] results = pykka.get_all(futures) @@ -72,23 +78,29 @@ class LibraryController(object): b.library.refresh(uri) for b in self.backends.with_library] pykka.get_all(futures) - def search(self, **query): + def search(self, query=None, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. Examples:: # Returns results matching 'a' + search({'any': ['a']}) search(any=['a']) + # Returns results matching artist 'xyz' + search({'artist': ['xyz']}) search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search({'any': ['a', 'b'], 'artist': ['xyz']}) search(any=['a', 'b'], artist=['xyz']) :param query: one or more queries to search for :type query: dict :rtype: list of :class:`mopidy.models.Track` """ + query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] results = pykka.get_all(futures) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 6a368ac6..62098c7f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -70,21 +70,29 @@ class PlaylistsController(object): if backend: backend.playlists.delete(uri).get() - def filter(self, **criteria): + def filter(self, criteria=None, **kwargs): """ Filter playlists by the given criterias. Examples:: - filter(name='a') # Returns track with name 'a' - filter(uri='xyz') # Returns track with URI 'xyz' - filter(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' + # Returns track with name 'a' + filter({'name': 'a'}) + filter(name='a') + + # Returns track with URI 'xyz' + filter({'uri': 'xyz'}) + filter(uri='xyz') + + # Returns track with name 'a' and URI 'xyz' + filter({'name': 'a', 'uri': 'xyz'}) + filter(name='a', uri='xyz') :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` """ + criteria = criteria or kwargs matches = self.playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 656e15b1..402e6c09 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -103,21 +103,33 @@ class TracklistController(object): self._tl_tracks = [] self._increase_version() - def filter(self, **criteria): + def filter(self, criteria=None, **kwargs): """ Filter the tracklist by the given criterias. Examples:: - filter(tlid=7) # Returns track with TLID 7 (tracklist ID) - filter(id=1) # Returns track with ID 1 - filter(uri='xyz') # Returns track with URI 'xyz' - filter(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' + # Returns track with TLID 7 (tracklist ID) + filter({'tlid': 7}) + filter(tlid=7) + + # Returns track with ID 1 + filter({'id': 1}) + filter(id=1) + + # Returns track with URI 'xyz' + filter({'uri': 'xyz'}) + filter(uri='xyz') + + # Returns track with ID 1 and URI 'xyz' + filter({'id': 1, 'uri': 'xyz'}) + filter(id=1, uri='xyz') :param criteria: on or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` """ + criteria = criteria or kwargs matches = self._tl_tracks for (key, value) in criteria.iteritems(): if key == 'tlid': @@ -172,7 +184,7 @@ class TracklistController(object): self._tl_tracks = new_tl_tracks self._increase_version() - def remove(self, **criteria): + def remove(self, criteria=None, **kwargs): """ Remove the matching tracks from the tracklist. @@ -184,7 +196,7 @@ class TracklistController(object): :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed """ - tl_tracks = self.filter(**criteria) + tl_tracks = self.filter(criteria, **kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 1bd481de..a2c358d7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -87,6 +87,21 @@ 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_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.find_exact().get.return_value = [track1] + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(dict(any=['a'])) + + self.assertIn(track1, result) + self.assertIn(track2, result) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') @@ -101,3 +116,18 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(track2, 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') + self.library1.search().get.return_value = [track1] + self.library1.search.reset_mock() + self.library2.search().get.return_value = [track2] + self.library2.search.reset_mock() + + result = self.core.library.search(dict(any=['a'])) + + self.assertIn(track1, result) + self.assertIn(track2, result) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/core/playlists_test.py b/tests/core/playlists_test.py index 949625fe..cea93c5b 100644 --- a/tests/core/playlists_test.py +++ b/tests/core/playlists_test.py @@ -27,12 +27,12 @@ class PlaylistsTest(unittest.TestCase): self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None - self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) - self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) + self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:b')]) self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] - self.pl2a = Playlist(tracks=[Track(uri='dummy2:a')]) - self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) + self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:a')]) + self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] self.core = Core(audio=None, backends=[ @@ -103,6 +103,16 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) + def test_filter_returns_matching_playlists(self): + result = self.core.playlists.filter(name='A') + + self.assertEqual(2, len(result)) + + def test_filter_accepts_dict_instead_of_kwargs(self): + result = self.core.playlists.filter({'name': 'A'}) + + self.assertEqual(2, len(result)) + def test_lookup_selects_the_dummy1_backend(self): self.core.playlists.lookup('dummy1:a') diff --git a/tests/core/tracklist_test.py b/tests/core/tracklist_test.py new file mode 100644 index 00000000..550cfe63 --- /dev/null +++ b/tests/core/tracklist_test.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals + +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class TracklistTest(unittest.TestCase): + def setUp(self): + self.tracks = [ + Track(uri='a', name='foo'), + Track(uri='b', name='foo'), + Track(uri='c', name='bar') + ] + self.core = Core(audio=None, backends=[]) + self.tl_tracks = self.core.tracklist.add(self.tracks) + + def test_remove_removes_tl_tracks_matching_query(self): + tl_tracks = self.core.tracklist.remove(name='foo') + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + self.assertEqual(1, self.core.tracklist.length) + self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) + + def test_remove_works_with_dict_instead_of_kwargs(self): + tl_tracks = self.core.tracklist.remove({'name': 'foo'}) + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + self.assertEqual(1, self.core.tracklist.length) + self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) + + def test_filter_returns_tl_tracks_matching_query(self): + tl_tracks = self.core.tracklist.filter(name='foo') + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + def test_filter_works_with_dict_instead_of_kwargs(self): + tl_tracks = self.core.tracklist.filter({'name': 'foo'}) + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + # TODO Extract tracklist tests from the base backend tests From 6008a53027dcae841a084e6f89bfcc3aac008925 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Dec 2012 15:42:12 +0100 Subject: [PATCH 039/144] tests: Update JSON-RPC introspection test to match changes core API --- tests/utils/jsonrpc_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 7c8a0a9b..59cb89b5 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -609,4 +609,6 @@ class JsonRpcInspectorTest(JsonRpcTestBase): self.assertEquals( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') self.assertEquals( - methods['core.tracklist.filter']['params'][0]['kwargs'], True) + methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') + self.assertEquals( + methods['core.tracklist.filter']['params'][1]['kwargs'], True) From 15d56b84cb62f8e2bf08bdd23e9d9ff9f9b9366a Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 17 Dec 2012 00:06:20 +0100 Subject: [PATCH 040/144] core: Trigger volume changed event in set_volume --- mopidy/core/playback.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4941ef0f..901976d6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -283,6 +283,8 @@ class PlaybackController(object): return self._volume def set_volume(self, volume): + self._trigger_volume_changed() + if self.audio: self.audio.set_volume(volume) else: @@ -515,6 +517,10 @@ class PlaybackController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') + def _trigger_volume_changed(self): + logger.debug('Triggering volume changed event') + listener.CoreListener.send('volume_changed') + def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) From ee448d4dd3947d9578c9b535887cc060bbc69696 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 10:27:59 +0100 Subject: [PATCH 041/144] audio: Make get_volume able to return all levels This fixes an issue where applications which changes volume by 1 level at a time could fail to change volume if track.max_volume is less than 100. E.g. get volume could return 44 after volume was set to 43, then the application would just try to set it to 43 again. --- mopidy/audio/actor.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 7de98075..ca115ba1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -43,6 +43,7 @@ class Audio(pykka.ThreadingActor): self._mixer_track = None self._software_mixing = False self._appsrc = None + self._volume_set = -1 self._notify_source_signal_id = None self._about_to_finish_id = None @@ -388,10 +389,18 @@ class Audio(pykka.ThreadingActor): volumes = self._mixer.get_volume(self._mixer_track) avg_volume = float(sum(volumes)) / len(volumes) - new_scale = (0, 100) - old_scale = ( + internal_scale = (0, 100) + mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - return self._rescale(avg_volume, old=old_scale, new=new_scale) + + volume_set_scaled = self._rescale( + self._volume_set, old=internal_scale, new=mixer_scale) + + if self._volume_set > 0 and volume_set_scaled == avg_volume: + return self._volume_set + else: + return self._rescale( + avg_volume, old=mixer_scale, new=internal_scale) def set_volume(self, volume): """ @@ -408,6 +417,8 @@ class Audio(pykka.ThreadingActor): if self._mixer is None: return False + self._volume_set = volume + old_scale = (0, 100) new_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) From d3e3bef2c0d4040ee865b024284cdcb3a2880fad Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 10:29:41 +0100 Subject: [PATCH 042/144] audio: Rename scales in set_volume for consistency --- mopidy/audio/actor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ca115ba1..d91022ca 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -419,11 +419,11 @@ class Audio(pykka.ThreadingActor): self._volume_set = volume - old_scale = (0, 100) - new_scale = ( + internal_scale = (0, 100) + mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - volume = self._rescale(volume, old=old_scale, new=new_scale) + volume = self._rescale(volume, old=internal_scale, new=mixer_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) From 58389f31de6a3cd04f185a50aa05596c33e9a216 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 18:08:00 +0100 Subject: [PATCH 043/144] core: Trigger volume changed after actual change --- mopidy/core/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 901976d6..ec51b7ec 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -283,14 +283,14 @@ class PlaybackController(object): return self._volume def set_volume(self, volume): - self._trigger_volume_changed() - if self.audio: self.audio.set_volume(volume) else: # For testing self._volume = volume + self._trigger_volume_changed() + volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" From 88b3bd49345965cefaaedab730c717a84d97de46 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 18:09:00 +0100 Subject: [PATCH 044/144] tests: Test that set volume triggers volume changed --- tests/core/events_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 88f07de6..008d23dd 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -117,3 +117,9 @@ class BackendEventsTest(unittest.TestCase): playlist = playlist.copy(name='bar') self.core.playlists.save(playlist).get() self.assertEqual(send.call_args[0][0], 'playlist_changed') + + def test_set_volume_sends_volume_changed_event(self, send): + self.core.playback.set_volume(10).get() + send.reset_mock() + self.core.playback.set_volume(20).get() + self.assertEqual(send.call_args[0][0], 'volume_changed') From fcda4696bc2b7d13c3e0867de99a8dea068c185f Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 18:18:29 +0100 Subject: [PATCH 045/144] audio: Use None instead of -1 for undefined volume --- mopidy/audio/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index d91022ca..0df8fb2b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -43,7 +43,7 @@ class Audio(pykka.ThreadingActor): self._mixer_track = None self._software_mixing = False self._appsrc = None - self._volume_set = -1 + self._volume_set = None self._notify_source_signal_id = None self._about_to_finish_id = None @@ -396,7 +396,7 @@ class Audio(pykka.ThreadingActor): volume_set_scaled = self._rescale( self._volume_set, old=internal_scale, new=mixer_scale) - if self._volume_set > 0 and volume_set_scaled == avg_volume: + if self._volume_set is not None and volume_set_scaled == avg_volume: return self._volume_set else: return self._rescale( From 200e4d2536484b1063a82aa6a4505f7f33cc0960 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 19:00:46 +0100 Subject: [PATCH 046/144] audio: Don't try to rescale volume_set if it's None --- mopidy/audio/actor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0df8fb2b..3910ee80 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -393,10 +393,8 @@ class Audio(pykka.ThreadingActor): mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - volume_set_scaled = self._rescale( - self._volume_set, old=internal_scale, new=mixer_scale) - - if self._volume_set is not None and volume_set_scaled == avg_volume: + if self._volume_set is not None and self._rescale(self._volume_set, + old=internal_scale, new=mixer_scale) == avg_volume: return self._volume_set else: return self._rescale( From 12942631bf4ca38ce847286c85d23f9705978a2c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 18 Dec 2012 23:18:51 +0100 Subject: [PATCH 047/144] mpd: Change name of output from 'None' to 'Default' --- mopidy/frontends/mpd/protocol/audio_output.py | 2 +- tests/frontends/mpd/protocol/audio_output_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index b4d491e5..01982a71 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -39,6 +39,6 @@ def outputs(context): """ return [ ('outputid', 0), - ('outputname', None), + ('outputname', 'Default'), ('outputenabled', 1), ] diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 11cd249e..560e935f 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -15,6 +15,6 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_outputs(self): self.sendRequest('outputs') self.assertInResponse('outputid: 0') - self.assertInResponse('outputname: None') + self.assertInResponse('outputname: Default') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') From ba84af2586e5d0cc70ffd95f8899d28659c36d9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 18 Dec 2012 23:29:57 +0100 Subject: [PATCH 048/144] mpd: Add list of unsupported MPD features --- mopidy/frontends/mpd/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 572192ef..e1edc89d 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -19,6 +19,29 @@ original MPD server. Make sure :attr:`mopidy.settings.FRONTENDS` includes ``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD frontend. + +**Limitations:** + +This is a non exhaustive list of MPD features that Mopidy doesn't support. +Items on this list will probably not be supported in the near future. + +- Toggling of audio outputs is not supported +- Channels for client-to-client communication is not supported +- Stickers is not supported +- Crossfade is not supported +- Replay gain is not supported +- ``count`` does not provide any statistics +- ``stats`` does not provide any statistics +- ``list`` does not support listing tracks by genre +- ``decoders`` does not provide information about available decoders + +The following items are currently not supported, but should be added in the +near future: + +- Modifying stored playlists is not supported +- ``tagtypes`` is not supported +- Browsing the file system is not supported +- Live update of the music database is not supported """ from __future__ import unicode_literals From dd1fad249b565ee6321ff68e0b3c675bc6842f83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 00:05:13 +0100 Subject: [PATCH 049/144] mpd: Fix grammar --- mopidy/frontends/mpd/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e1edc89d..6b4eacc8 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -26,8 +26,8 @@ This is a non exhaustive list of MPD features that Mopidy doesn't support. Items on this list will probably not be supported in the near future. - Toggling of audio outputs is not supported -- Channels for client-to-client communication is not supported -- Stickers is not supported +- Channels for client-to-client communication are not supported +- Stickers are not supported - Crossfade is not supported - Replay gain is not supported - ``count`` does not provide any statistics From 524f22eff44638d00c2e8930b3bd2eeb79a15ee0 Mon Sep 17 00:00:00 2001 From: Wouter van Wijk Date: Wed, 19 Dec 2012 12:48:33 +0100 Subject: [PATCH 050/144] Added lookup for artists, albums --- mopidy/backends/spotify/library.py | 86 ++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index df04058b..1179341f 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -2,11 +2,16 @@ from __future__ import unicode_literals import logging import Queue +import time + +TIME_OUT = 10 from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track +from mopidy.backends.base import BaseLibraryProvider +from mopidy.models import Playlist from . import translator @@ -56,11 +61,82 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return self.search(**query) def lookup(self, uri): - try: - return [SpotifyTrack(uri)] - except SpotifyError as e: - logger.debug('Failed to lookup "%s": %s', uri, e) - return [] + link = Link.from_string(uri) + #uri is an album + if link.type() == Link.LINK_ALBUM: + try: + spotify_album = Link.from_string(uri).as_album() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + browser = self.backend.spotify.session.browse_album(spotify_album) + + #wait 5 seconds + start = time.time() + while not browser.is_loaded(): + time.sleep(0.1) + if time.time() > (start + TIME_OUT): + break + album = translator.to_mopidy_album(spotify_album) + + #for track in browser: + # track = translator.to_mopidy_track(track) + + #from translator + tracks=[translator.to_mopidy_track(t) for t in browser + if str(Link.from_track(t, 0))] + + playlist = Playlist(tracks=tracks, uri=uri, name=album.name) + return playlist + + except SpotifyError as e: + logger.debug(u'Failed to lookup album "%s": %s', uri, e) + return None + + #uri is an album + if link.type() == Link.LINK_ARTIST: + try: + spotify_artist = Link.from_string(uri).as_artist() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + browser = self.backend.spotify.session.browse_artist(spotify_artist) + #wait 5 seconds + start = time.time() + while not browser.is_loaded(): + time.sleep(0.1) + if time.time() > (start + TIME_OUT): + break + artist = translator.to_mopidy_artist(spotify_artist) + + #for track in browser: + # track = translator.to_mopidy_track(track) + + #from translator + tracks=[translator.to_mopidy_track(t) for t in browser + if str(Link.from_track(t, 0))] + + playlist = Playlist(tracks=tracks, uri=uri, name=artist.name) + return playlist + + except SpotifyError as e: + logger.debug(u'Failed to lookup album "%s": %s', uri, e) + return None + + #uri is a playlist of another user + # if l.type() == Link.LINK_PLAYLIST: + # if l.type() == Link.LINK_USER: + + #uri is a track + try: + spotify_track = Link.from_string(uri).as_track() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + return translator.to_mopidy_track(spotify_track) + except SpotifyError as e: + logger.debug(u'Failed to lookup track "%s": %s', uri, e) + return None def refresh(self, uri=None): pass # TODO From 9b9af4b1997e91039c7d4484423be77b2e5d5dea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 14:40:01 +0100 Subject: [PATCH 051/144] Update author list --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 9c9951f8..91a9f6cf 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,3 +11,4 @@ - David C - Christian Johansen - Matt Bray +- Trygve Aaberge From dfda1cb0643dfa0cc82401ac34135e5b0ee99d5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 14:40:48 +0100 Subject: [PATCH 052/144] docs: Update changelog --- docs/changes.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 30e0c056..0247aa9d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -45,6 +45,8 @@ v0.11.0 (in development) - :meth:`mopidy.core.TracklistController.filter` - :meth:`mopidy.core.TracklistController.remove` +- Actually trigger the :meth:`mopidy.core.CoreListener.volume_changed` event. + v0.10.0 (2012-12-12) ==================== From 0d7b22b745d003948aeb9671566f85298cb058a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 14:59:05 +0100 Subject: [PATCH 053/144] tests: Don't need audio actor for code event tests --- tests/core/events_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 008d23dd..9c10306a 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock import pykka -from mopidy import audio, core +from mopidy import core from mopidy.backends import dummy from mopidy.models import Track @@ -13,8 +13,7 @@ from tests import unittest @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.audio = mock.Mock(spec=audio.Audio) - self.backend = dummy.DummyBackend.start(audio=audio).proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): From c5ac7aeb77db59885acf0b5115a65e3664f930c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 16:31:58 +0100 Subject: [PATCH 054/144] core: Include new volume level in volume_changed event --- docs/changes.rst | 3 +++ mopidy/core/playback.py | 6 +++--- mopidy/frontends/mpd/actor.py | 2 +- mopidy/frontends/mpris/actor.py | 2 +- tests/core/events_test.py | 1 + tests/frontends/mpris/events_test.py | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 0247aa9d..8a7b2d91 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -47,6 +47,9 @@ v0.11.0 (in development) - Actually trigger the :meth:`mopidy.core.CoreListener.volume_changed` event. +- Include the new volume level in the + :meth:`mopidy.core.CoreListener.volume_changed` event. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ec51b7ec..141c2e70 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -289,7 +289,7 @@ class PlaybackController(object): # For testing self._volume = volume - self._trigger_volume_changed() + self._trigger_volume_changed(volume) volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" @@ -517,9 +517,9 @@ class PlaybackController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') - def _trigger_volume_changed(self): + def _trigger_volume_changed(self, volume): logger.debug('Triggering volume changed event') - listener.CoreListener.send('volume_changed') + listener.CoreListener.send('volume_changed', volume=volume) def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 925b15b7..d3c718c4 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -49,5 +49,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def options_changed(self): self.send_idle('options') - def volume_changed(self): + def volume_changed(self, volume): self.send_idle('mixer') diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 795b2694..57bbd790 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -84,7 +84,7 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - def volume_changed(self): + def volume_changed(self, volume): logger.debug('Received volume_changed event') self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 9c10306a..d5f9fc14 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -122,3 +122,4 @@ class BackendEventsTest(unittest.TestCase): send.reset_mock() self.core.playback.set_volume(20).get() self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 20) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 18a9de6f..60c9a783 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -65,7 +65,7 @@ class BackendEventsTest(unittest.TestCase): def test_volume_changed_event_changes_volume(self): self.mpris_object.Get.return_value = 1.0 - self.mpris_frontend.volume_changed() + self.mpris_frontend.volume_changed(volume=100) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'Volume'), {}), ]) From 9602fff8bbf6740ddd40f653d0833ab73b3c2c73 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 16:33:59 +0100 Subject: [PATCH 055/144] tests: Formatting --- tests/core/events_test.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index d5f9fc14..62b142ee 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -20,15 +20,17 @@ class BackendEventsTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_backends_playlists_loaded_forwards_event_to_frontends(self, send): - send.reset_mock() self.core.playlists_loaded().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_pause_sends_track_playback_paused_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play().get() send.reset_mock() + self.core.playback.pause().get() + self.assertEqual(send.call_args[0][0], 'track_playback_paused') def test_resume_sends_track_playback_resumed(self, send): @@ -36,73 +38,97 @@ class BackendEventsTest(unittest.TestCase): self.core.playback.play() self.core.playback.pause().get() send.reset_mock() + self.core.playback.resume().get() + self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]) send.reset_mock() + self.core.playback.play().get() + self.assertEqual(send.call_args[0][0], 'track_playback_started') def test_stop_sends_track_playback_ended_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play().get() send.reset_mock() + self.core.playback.stop().get() + self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') def test_seek_sends_seeked_event(self, send): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play().get() send.reset_mock() + self.core.playback.seek(1000).get() + self.assertEqual(send.call_args[0][0], 'seeked') def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() + self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_clear_sends_tracklist_changed_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.tracklist.clear().get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_move_sends_tracklist_changed_event(self, send): self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() + self.core.tracklist.move(0, 1, 1).get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_remove_sends_tracklist_changed_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.tracklist.remove(uri='dummy:a').get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_shuffle_sends_tracklist_changed_event(self, send): self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() + self.core.tracklist.shuffle().get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_playlists_refresh_sends_playlists_loaded_event(self, send): send.reset_mock() + self.core.playlists.refresh().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send): send.reset_mock() + self.core.playlists.refresh(uri_scheme='dummy').get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_create_sends_playlist_changed_event(self, send): send.reset_mock() + self.core.playlists.create('foo').get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') @unittest.SkipTest @@ -112,14 +138,18 @@ class BackendEventsTest(unittest.TestCase): def test_playlists_save_sends_playlist_changed_event(self, send): playlist = self.core.playlists.create('foo').get() - send.reset_mock() playlist = playlist.copy(name='bar') + send.reset_mock() + self.core.playlists.save(playlist).get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') def test_set_volume_sends_volume_changed_event(self, send): self.core.playback.set_volume(10).get() send.reset_mock() + self.core.playback.set_volume(20).get() + self.assertEqual(send.call_args[0][0], 'volume_changed') self.assertEqual(send.call_args[1]['volume'], 20) From e176425b916d3dbdf1e2cdc02c8467d3571fdeb8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 16:35:52 +0100 Subject: [PATCH 056/144] core: Test that 'seeked' event includes 'time_position' --- tests/core/events_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 62b142ee..bc3cf2e2 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -68,6 +68,7 @@ class BackendEventsTest(unittest.TestCase): self.core.playback.seek(1000).get() self.assertEqual(send.call_args[0][0], 'seeked') + self.assertEqual(send.call_args[1]['time_position'], 1000) def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() From ea37cf1a447acedc1df05c8c79867ab04c18d5c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 17:06:04 +0100 Subject: [PATCH 057/144] core: Include the TlTrack in track_playback_* events --- docs/changes.rst | 3 +++ mopidy/core/listener.py | 24 ++++++++++++------------ mopidy/core/playback.py | 13 +++++++------ mopidy/frontends/lastfm.py | 6 ++++-- mopidy/frontends/mpris/actor.py | 8 ++++---- tests/core/events_test.py | 15 +++++++++++---- tests/core/listener_test.py | 14 +++++++------- tests/frontends/mpris/events_test.py | 10 +++++----- 8 files changed, 53 insertions(+), 40 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8a7b2d91..96056c18 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -50,6 +50,9 @@ v0.11.0 (in development) - Include the new volume level in the :meth:`mopidy.core.CoreListener.volume_changed` event. +- The ``track_playback_{paused,resumed,started,ended}`` events now include a + :class:`mopidy.models.TlTrack` instead of a :class:`mopidy.models.Track`. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 7c4ab093..c93fc39e 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -34,51 +34,51 @@ class CoreListener(object): """ getattr(self, event)(**kwargs) - def track_playback_paused(self, track, time_position): + def track_playback_paused(self, tl_track, time_position): """ Called whenever track playback is paused. *MAY* be implemented by actor. - :param track: the track that was playing when playback paused - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was playing when playback paused + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass - def track_playback_resumed(self, track, time_position): + def track_playback_resumed(self, tl_track, time_position): """ Called whenever track playback is resumed. *MAY* be implemented by actor. - :param track: the track that was playing when playback resumed - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was playing when playback resumed + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass - def track_playback_started(self, track): + def track_playback_started(self, tl_track): """ Called whenever a new track starts playing. *MAY* be implemented by actor. - :param track: the track that just started playing - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that just started playing + :type tl_track: :class:`mopidy.models.TlTrack` """ pass - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): """ Called whenever playback of a track ends. *MAY* be implemented by actor. - :param track: the track that was played before playback stopped - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was played before playback stopped + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 141c2e70..21f09ad2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -482,7 +482,7 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_paused', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') @@ -490,22 +490,23 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_resumed', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_track_playback_started(self): logger.debug('Triggering track playback started event') - if self.current_track is None: + if self.current_tl_track is None: return listener.CoreListener.send( - 'track_playback_started', track=self.current_track) + 'track_playback_started', + tl_track=self.current_tl_track) def _trigger_track_playback_ended(self): logger.debug('Triggering track playback ended event') - if self.current_track is None: + if self.current_tl_track is None: return listener.CoreListener.send( 'track_playback_ended', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug('Triggering playback state change event') diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 565e5041..61dc306c 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -66,7 +66,8 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): logger.error('Error during Last.fm setup: %s', e) self.stop() - def track_playback_started(self, track): + def track_playback_started(self, tl_track): + track = tl_track.track artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 self.last_start_time = int(time.time()) @@ -83,7 +84,8 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): pylast.MalformedResponseError, pylast.WSError) as e: logger.warning('Error submitting playing track to Last.fm: %s', e) - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): + track = tl_track.track artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 time_position = time_position // 1000 diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 57bbd790..5e171826 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -66,20 +66,20 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.mpris_object.PropertiesChanged( interface, dict(props_with_new_values), []) - def track_playback_paused(self, track, time_position): + def track_playback_paused(self, tl_track, time_position): logger.debug('Received track_playback_paused event') self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - def track_playback_resumed(self, track, time_position): + def track_playback_resumed(self, tl_track, time_position): logger.debug('Received track_playback_resumed event') self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - def track_playback_started(self, track): + def track_playback_started(self, tl_track): logger.debug('Received track_playback_started event') self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): logger.debug('Received track_playback_ended event') self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index bc3cf2e2..11881db7 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -25,16 +25,18 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_pause_sends_track_playback_paused_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play().get() send.reset_mock() self.core.playback.pause().get() self.assertEqual(send.call_args[0][0], 'track_playback_paused') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args[1]['time_position'], 0) def test_resume_sends_track_playback_resumed(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play() self.core.playback.pause().get() send.reset_mock() @@ -42,23 +44,28 @@ class BackendEventsTest(unittest.TestCase): self.core.playback.resume().get() self.assertEqual(send.call_args[0][0], 'track_playback_resumed') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args[1]['time_position'], 0) def test_play_sends_track_playback_started_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() self.core.playback.play().get() self.assertEqual(send.call_args[0][0], 'track_playback_started') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) def test_stop_sends_track_playback_ended_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play().get() send.reset_mock() self.core.playback.stop().get() self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') + self.assertEqual(send.call_args_list[0][1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args_list[0][1]['time_position'], 0) def test_seek_sends_seeked_event(self, send): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 8aaf1234..2d7182d9 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock from mopidy.core import CoreListener, PlaybackState -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack from tests import unittest @@ -16,22 +16,22 @@ class CoreListenerTest(unittest.TestCase): self.listener.track_playback_paused = mock.Mock() self.listener.on_event( - 'track_playback_paused', track=Track(), position=0) + 'track_playback_paused', track=TlTrack(), position=0) self.listener.track_playback_paused.assert_called_with( - track=Track(), position=0) + track=TlTrack(), position=0) def test_listener_has_default_impl_for_track_playback_paused(self): - self.listener.track_playback_paused(Track(), 0) + self.listener.track_playback_paused(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_resumed(self): - self.listener.track_playback_resumed(Track(), 0) + self.listener.track_playback_resumed(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_started(self): - self.listener.track_playback_started(Track()) + self.listener.track_playback_started(TlTrack()) def test_listener_has_default_impl_for_track_playback_ended(self): - self.listener.track_playback_ended(Track(), 0) + self.listener.track_playback_ended(TlTrack(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): self.listener.playback_state_changed( diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 60c9a783..f1add1b3 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -5,7 +5,7 @@ import sys import mock from mopidy.exceptions import OptionalDependencyError -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack try: from mopidy.frontends.mpris import MprisFrontend, objects @@ -25,7 +25,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_paused_event_changes_playback_status(self): self.mpris_object.Get.return_value = 'Paused' - self.mpris_frontend.track_playback_paused(Track(), 0) + self.mpris_frontend.track_playback_paused(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ]) @@ -34,7 +34,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_resumed_event_changes_playback_status(self): self.mpris_object.Get.return_value = 'Playing' - self.mpris_frontend.track_playback_resumed(Track(), 0) + self.mpris_frontend.track_playback_resumed(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ]) @@ -43,7 +43,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_started_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_started(Track()) + self.mpris_frontend.track_playback_started(TlTrack()) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ((objects.PLAYER_IFACE, 'Metadata'), {}), @@ -54,7 +54,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_ended_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_ended(Track(), 0) + self.mpris_frontend.track_playback_ended(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ((objects.PLAYER_IFACE, 'Metadata'), {}), From 6a5ceeec50534193fe54700c41caeeb58da4dba8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 19:49:23 +0100 Subject: [PATCH 058/144] audio: Test setting of volume with mixer volume max below 100 --- tests/audio/actor_test.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 64666d9d..73c8c165 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -4,6 +4,8 @@ import pygst pygst.require('0.10') import gst +import pykka + from mopidy import audio, settings from mopidy.utils.path import path_to_uri @@ -18,7 +20,7 @@ class AudioTest(unittest.TestCase): self.audio = audio.Audio.start().proxy() def tearDown(self): - self.audio.stop() + pykka.ActorRegistry.stop_all() settings.runtime.clear() def prepare_uri(self, uri): @@ -56,6 +58,14 @@ class AudioTest(unittest.TestCase): self.assertTrue(self.audio.set_volume(value).get()) self.assertEqual(value, self.audio.get_volume().get()) + def test_set_volume_with_mixer_max_below_100(self): + settings.MIXER = 'fakemixer track_max_volume=40' + self.audio = audio.Audio.start().proxy() + + for value in range(0, 101): + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO From 6d8a8a7902d464948f9302280a79748e654ecc5d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 19:55:15 +0100 Subject: [PATCH 059/144] docs: Update changelog --- docs/changes.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 96056c18..8d614f1d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -53,6 +53,14 @@ v0.11.0 (in development) - The ``track_playback_{paused,resumed,started,ended}`` events now include a :class:`mopidy.models.TlTrack` instead of a :class:`mopidy.models.Track`. +*Audio:* + +- Mixers with fewer than 100 volume levels could report another volume level + than what you just set due to the conversion between Mopidy's 0-100 range and + the mixer's range. Now Mopidy returns the recently set volume if the mixer + reports a volume level that matches the recently set volume, otherwise the + mixer's volume level is rescaled to the 1-100 range and returned. + v0.10.0 (2012-12-12) ==================== From d5c401bd07bbd349c528d171d976ecf3fb0fddd9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 20:29:45 +0100 Subject: [PATCH 060/144] spotify: Fix flake8 warnings in lookup method --- mopidy/backends/spotify/library.py | 59 ++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 1179341f..884e9ac6 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -10,7 +10,6 @@ from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track -from mopidy.backends.base import BaseLibraryProvider from mopidy.models import Playlist from . import translator @@ -66,10 +65,11 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if link.type() == Link.LINK_ALBUM: try: spotify_album = Link.from_string(uri).as_album() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - browser = self.backend.spotify.session.browse_album(spotify_album) + # TODO Block until metadata_updated callback is called. + # Before that the track will be unloaded, unless it's + # already in the stored playlists. + browser = self.backend.spotify.session.browse_album( + spotify_album) #wait 5 seconds start = time.time() @@ -81,26 +81,29 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): #for track in browser: # track = translator.to_mopidy_track(track) - + #from translator - tracks=[translator.to_mopidy_track(t) for t in browser - if str(Link.from_track(t, 0))] - - playlist = Playlist(tracks=tracks, uri=uri, name=album.name) + tracks = [ + translator.to_mopidy_track(t) + for t in browser if str(Link.from_track(t, 0))] + + playlist = Playlist( + tracks=tracks, uri=uri, name=album.name) return playlist - + except SpotifyError as e: logger.debug(u'Failed to lookup album "%s": %s', uri, e) return None - + #uri is an album if link.type() == Link.LINK_ARTIST: try: spotify_artist = Link.from_string(uri).as_artist() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - browser = self.backend.spotify.session.browse_artist(spotify_artist) + # TODO Block until metadata_updated callback is called. + # Before that the track will be unloaded, unless it's + # already in the stored playlists. + browser = self.backend.spotify.session.browse_artist( + spotify_artist) #wait 5 seconds start = time.time() while not browser.is_loaded(): @@ -111,28 +114,30 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): #for track in browser: # track = translator.to_mopidy_track(track) - + #from translator - tracks=[translator.to_mopidy_track(t) for t in browser - if str(Link.from_track(t, 0))] - - playlist = Playlist(tracks=tracks, uri=uri, name=artist.name) + tracks = [ + translator.to_mopidy_track(t) + for t in browser if str(Link.from_track(t, 0))] + + playlist = Playlist( + tracks=tracks, uri=uri, name=artist.name) return playlist - + except SpotifyError as e: logger.debug(u'Failed to lookup album "%s": %s', uri, e) return None - + #uri is a playlist of another user # if l.type() == Link.LINK_PLAYLIST: # if l.type() == Link.LINK_USER: - + #uri is a track try: spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. + # TODO Block until metadata_updated callback is called. Before + # that the track will be unloaded, unless it's already in the + # stored playlists. return translator.to_mopidy_track(spotify_track) except SpotifyError as e: logger.debug(u'Failed to lookup track "%s": %s', uri, e) From 699588b52530601b4d51ca5f35e558cea74c790a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 21:54:09 +0100 Subject: [PATCH 061/144] spotify: Refactor lookup code, add playlist support --- mopidy/backends/spotify/library.py | 126 +++++++++++------------------ 1 file changed, 46 insertions(+), 80 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 884e9ac6..25c58a17 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -4,13 +4,10 @@ import logging import Queue import time -TIME_OUT = 10 - from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track -from mopidy.models import Playlist from . import translator @@ -19,9 +16,14 @@ logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" - def __init__(self, uri): + def __init__(self, uri=None, track=None): super(SpotifyTrack, self).__init__() - self._spotify_track = Link.from_string(uri).as_track() + if uri: + self._spotify_track = Link.from_string(uri).as_track() + elif track: + self._spotify_track = track + else: + raise AttributeError('uri or track must be provided') self._unloaded_track = Track(uri=uri, name='[loading...]') self._track = None @@ -60,88 +62,52 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return self.search(**query) def lookup(self, uri): + try: link = Link.from_string(uri) - #uri is an album + if link.type() == Link.LINK_TRACK: + return self._lookup_track(uri) if link.type() == Link.LINK_ALBUM: - try: - spotify_album = Link.from_string(uri).as_album() - # TODO Block until metadata_updated callback is called. - # Before that the track will be unloaded, unless it's - # already in the stored playlists. - browser = self.backend.spotify.session.browse_album( - spotify_album) + return self._lookup_album(uri) + elif link.type() == Link.LINK_ARTIST: + return self._lookup_artist(uri) + elif link.type() == Link.LINK_PLAYLIST: + return self._lookup_playlist(uri) + else: + return [] + except SpotifyError as error: + logger.debug(u'Failed to lookup "%s": %s', uri, error) + return [] - #wait 5 seconds - start = time.time() - while not browser.is_loaded(): - time.sleep(0.1) - if time.time() > (start + TIME_OUT): - break - album = translator.to_mopidy_album(spotify_album) + def _lookup_track(self, uri): + return [SpotifyTrack(uri)] - #for track in browser: - # track = translator.to_mopidy_track(track) + def _lookup_album(self, uri): + album = Link.from_string(uri).as_album() + album_browser = self.backend.spotify.session.browse_album(album) + self._wait_for_object_to_load(album_browser) + return [SpotifyTrack(track=t) for t in album_browser] - #from translator - tracks = [ - translator.to_mopidy_track(t) - for t in browser if str(Link.from_track(t, 0))] + def _lookup_artist(self, uri): + artist = Link.from_string(uri).as_artist() + artist_browser = self.backend.spotify.session.browse_artist(artist) + self._wait_for_object_to_load(artist_browser) + return [SpotifyTrack(track=t) for t in artist_browser] - playlist = Playlist( - tracks=tracks, uri=uri, name=album.name) - return playlist + def _lookup_playlist(self, uri): + playlist = Link.from_string(uri).as_playlist() + self._wait_for_object_to_load(playlist) + return [SpotifyTrack(track=t) for t in playlist] - except SpotifyError as e: - logger.debug(u'Failed to lookup album "%s": %s', uri, e) - return None - - #uri is an album - if link.type() == Link.LINK_ARTIST: - try: - spotify_artist = Link.from_string(uri).as_artist() - # TODO Block until metadata_updated callback is called. - # Before that the track will be unloaded, unless it's - # already in the stored playlists. - browser = self.backend.spotify.session.browse_artist( - spotify_artist) - #wait 5 seconds - start = time.time() - while not browser.is_loaded(): - time.sleep(0.1) - if time.time() > (start + TIME_OUT): - break - artist = translator.to_mopidy_artist(spotify_artist) - - #for track in browser: - # track = translator.to_mopidy_track(track) - - #from translator - tracks = [ - translator.to_mopidy_track(t) - for t in browser if str(Link.from_track(t, 0))] - - playlist = Playlist( - tracks=tracks, uri=uri, name=artist.name) - return playlist - - except SpotifyError as e: - logger.debug(u'Failed to lookup album "%s": %s', uri, e) - return None - - #uri is a playlist of another user - # if l.type() == Link.LINK_PLAYLIST: - # if l.type() == Link.LINK_USER: - - #uri is a track - try: - spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before - # that the track will be unloaded, unless it's already in the - # stored playlists. - return translator.to_mopidy_track(spotify_track) - except SpotifyError as e: - logger.debug(u'Failed to lookup track "%s": %s', uri, e) - return None + def _wait_for_object_to_load(self, spotify_obj, timeout=10): + # XXX Sleeping to wait for the Spotify object to load is an ugly hack, + # but it works. We should look into other solutions for this. + start = time.time() + while not spotify_obj.is_loaded(): + time.sleep(0.1) + if time.time() > (start + timeout): + logger.debug( + 'Timeout: Spotify object did not load in %ds', timeout) + return def refresh(self, uri=None): pass # TODO From e39d15399b989bd843a173c449a5825474ac3c1d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 21:58:26 +0100 Subject: [PATCH 062/144] Update author list --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 91a9f6cf..d536c059 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,3 +12,4 @@ - Christian Johansen - Matt Bray - Trygve Aaberge +- Wouter van Wijk From e63e6f7bbb2adf1e89961731de417cae62a802f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 21:58:36 +0100 Subject: [PATCH 063/144] docs: Update changelog --- docs/changes.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 8d614f1d..97199291 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,19 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== +**Spotify backend** + +- Add support for looking up albums, artists, and playlists by URI in addition + to tracks. (Fixes: :issue:`67`) + + As an example of how this can be used, you can try the the following MPD + commands which now all adds one or more tracks to your tracklist:: + + add "spotify:track:1mwt9hzaH7idmC5UCoOUkz" + add "spotify:album:3gpHG5MGwnipnap32lFYvI" + add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" + add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" + **MPD frontend** - Add support for the ``findadd`` command. From 81a76bfd92992ebc8a9d6f102c9f7f0e8d107055 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Wed, 19 Dec 2012 21:56:02 +0100 Subject: [PATCH 064/144] audio: Define mixer_scale in _setup_mixer --- mopidy/audio/actor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 3910ee80..78fbd056 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -41,6 +41,7 @@ class Audio(pykka.ThreadingActor): self._playbin = None self._mixer = None self._mixer_track = None + self._mixer_scale = None self._software_mixing = False self._appsrc = None self._volume_set = None @@ -150,6 +151,8 @@ class Audio(pykka.ThreadingActor): self._mixer = mixer self._mixer_track = track + self._mixer_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) logger.info( 'Audio mixer set to "%s" using track "%s"', mixer.get_factory().get_name(), track.label) @@ -390,15 +393,13 @@ class Audio(pykka.ThreadingActor): avg_volume = float(sum(volumes)) / len(volumes) internal_scale = (0, 100) - mixer_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) if self._volume_set is not None and self._rescale(self._volume_set, - old=internal_scale, new=mixer_scale) == avg_volume: + old=internal_scale, new=self._mixer_scale) == avg_volume: return self._volume_set else: return self._rescale( - avg_volume, old=mixer_scale, new=internal_scale) + avg_volume, old=self._mixer_scale, new=internal_scale) def set_volume(self, volume): """ @@ -418,10 +419,9 @@ class Audio(pykka.ThreadingActor): self._volume_set = volume internal_scale = (0, 100) - mixer_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - volume = self._rescale(volume, old=internal_scale, new=mixer_scale) + volume = self._rescale( + volume, old=internal_scale, new=self._mixer_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) From 8be84a1ea409a38f4898bb40ee4d6654a786d5a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 22:40:32 +0100 Subject: [PATCH 065/144] audio: Fix flake8 warning --- mopidy/audio/actor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 78fbd056..1b6c79b3 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -394,8 +394,13 @@ class Audio(pykka.ThreadingActor): internal_scale = (0, 100) - if self._volume_set is not None and self._rescale(self._volume_set, - old=internal_scale, new=self._mixer_scale) == avg_volume: + if self._volume_set is not None: + volume_set_on_mixer_scale = self._rescale( + self._volume_set, old=internal_scale, new=self._mixer_scale) + else: + volume_set_on_mixer_scale = None + + if volume_set_on_mixer_scale == avg_volume: return self._volume_set else: return self._rescale( From d1b2641b863ccee4877eb068df93848d03313a55 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:16:45 +0100 Subject: [PATCH 066/144] spotify: Gather the search functionality in one place --- mopidy/backends/spotify/library.py | 56 ++++++++++++++++------ mopidy/backends/spotify/session_manager.py | 13 ----- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index df04058b..f451a93a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import logging -import Queue +import pykka from spotify import Link, SpotifyError from mopidy.backends import base @@ -67,14 +67,46 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def search(self, **query): if not query: - # Since we can't search for the entire Spotify library, we return - # all tracks in the playlists when the query is empty. - tracks = [] - for playlist in self.backend.playlists.playlists: - tracks += playlist.tracks - return tracks + return self._get_all_tracks() + + spotify_query = self._translate_search_query(query) + logger.debug('Spotify search query: %s' % spotify_query) + + future = pykka.ThreadingFuture() + + def callback(results, userdata=None): + # TODO Include results from results.albums(), etc. too + # TODO Consider launching a second search if results.total_tracks() + # is larger than len(results.tracks()) + tracks = [ + translator.to_mopidy_track(t) for t in results.tracks()] + future.set(tracks) + + self.backend.spotify.connected.wait() + + self.backend.spotify.session.search( + spotify_query, callback, + track_count=100, album_count=0, artist_count=0) + + timeout = 10 # TODO Make this a setting + try: + return future.get(timeout=timeout) + except pykka.Timeout: + logger.debug( + 'Timeout: Spotify search did not return in %ds', timeout) + return [] + + def _get_all_tracks(self): + # Since we can't search for the entire Spotify library, we return + # all tracks in the playlists when the query is empty. + tracks = [] + for playlist in self.backend.playlists.playlists: + tracks += playlist.tracks + return tracks + + def _translate_search_query(self, mopidy_query): spotify_query = [] - for (field, values) in query.iteritems(): + for (field, values) in mopidy_query.iteritems(): if field == 'uri': tracks = [] for value in values: @@ -97,10 +129,4 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): else: spotify_query.append('%s:"%s"' % (field, value)) spotify_query = ' '.join(spotify_query) - logger.debug('Spotify search query: %s' % spotify_query) - queue = Queue.Queue() - self.backend.spotify.search(spotify_query, queue) - try: - return queue.get(timeout=3) # XXX What is an reasonable timeout? - except Queue.Empty: - return [] + return spotify_query diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 288c61f2..f2631406 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -165,19 +165,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.info('Loaded %d Spotify playlist(s)', len(playlists)) BackendListener.send('playlists_loaded') - def search(self, query, queue): - """Search method used by Mopidy backend""" - def callback(results, userdata=None): - # TODO Include results from results.albums(), etc. too - # TODO Consider launching a second search if results.total_tracks() - # is larger than len(results.tracks()) - tracks = [ - translator.to_mopidy_track(t) for t in results.tracks()] - queue.put(tracks) - self.connected.wait() - self.session.search( - query, callback, track_count=100, album_count=0, artist_count=0) - def logout(self): """Log out from spotify""" logger.debug('Logging out from Spotify') From 3cdc9e4e99c3284cc72ccce8a472610237790e95 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:25:20 +0100 Subject: [PATCH 067/144] spotify: Add SPOTIFY_TIMEOUT setting --- docs/changes.rst | 5 +++++ mopidy/settings.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 8d614f1d..b5217200 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,11 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== +**Spotify backend** + +- Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to + control how long we should wait before giving up on Spotify searches, etc. + **MPD frontend** - Add support for the ``findadd`` command. diff --git a/mopidy/settings.py b/mopidy/settings.py index 0a71ccfa..0a272035 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -282,3 +282,12 @@ SPOTIFY_PROXY_USERNAME = None #: #: SPOTIFY_PROXY_PASSWORD = None SPOTIFY_PROXY_PASSWORD = None + +#: Max number of seconds to wait for Spotify operations to complete. +#: +#: Used by :mod:`mopidy.backends.spotify` +#: +#: Default:: +#: +#: SPOTIFY_TIMEOUT = 10 +SPOTIFY_TIMEOUT = 10 From 8baf813fb6dd8858d85a20c016dffb184686cea1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:31:33 +0100 Subject: [PATCH 068/144] spotify: Use SPOTIFY_TIMEOUT in search --- mopidy/backends/spotify/library.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index f451a93a..ca6ee92a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -5,6 +5,7 @@ import logging import pykka from spotify import Link, SpotifyError +from mopidy import settings from mopidy.backends import base from mopidy.models import Track @@ -82,18 +83,20 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): translator.to_mopidy_track(t) for t in results.tracks()] future.set(tracks) - self.backend.spotify.connected.wait() + if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): + logger.debug('Not connected: Spotify search cancelled') + return [] self.backend.spotify.session.search( spotify_query, callback, track_count=100, album_count=0, artist_count=0) - timeout = 10 # TODO Make this a setting try: - return future.get(timeout=timeout) + return future.get(timeout=settings.SPOTIFY_TIMEOUT) except pykka.Timeout: logger.debug( - 'Timeout: Spotify search did not return in %ds', timeout) + 'Timeout: Spotify search did not return in %ds', + settings.SPOTIFY_TIMEOUT) return [] def _get_all_tracks(self): From 4d67dd1353dc56b4dcfae4ba22c2e9440d5c7236 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:37:43 +0100 Subject: [PATCH 069/144] spotify: Use SPOTIFY_TIMEOUT when waiting for objects to load --- mopidy/backends/spotify/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index d171ecae..dec13ced 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -99,7 +99,8 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self._wait_for_object_to_load(playlist) return [SpotifyTrack(track=t) for t in playlist] - def _wait_for_object_to_load(self, spotify_obj, timeout=10): + def _wait_for_object_to_load( + self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): # XXX Sleeping to wait for the Spotify object to load is an ugly hack, # but it works. We should look into other solutions for this. start = time.time() From 79cbdb4fbbd99536501051a3abf4fd3d0121d241 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:43:48 +0100 Subject: [PATCH 070/144] mpd: Add MPD_SERVER_CONNECTION_TIMEOUT setting --- docs/changes.rst | 4 ++++ mopidy/frontends/mpd/actor.py | 3 ++- mopidy/settings.py | 10 ++++++++++ mopidy/utils/network.py | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index b5217200..e72abc02 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -15,6 +15,10 @@ v0.11.0 (in development) **MPD frontend** +- Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which + controls how long an MPD client can stay inactive before the connection is + closed by the server. + - Add support for the ``findadd`` command. - Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`): diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index d3c718c4..11e07aa7 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -23,7 +23,8 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): network.Server( hostname, port, protocol=session.MpdSession, protocol_kwargs={'core': core}, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS, + timeout=settings.MPD_SERVER_CONNECTION_TIMEOUT) except IOError as error: logger.error( 'MPD server startup failed: %s', diff --git a/mopidy/settings.py b/mopidy/settings.py index 0a272035..c2081e27 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -174,6 +174,16 @@ MIXER = 'autoaudiomixer' #: MIXER_TRACK = None MIXER_TRACK = None +#: Number of seconds an MPD client can stay inactive before the connection is +#: closed by the server. +#: +#: Used by :mod:`mopidy.frontends.mpd`. +#: +#: Default:: +#: +#: MPD_SERVER_CONNECTION_TIMEOUT = 60 +MPD_SERVER_CONNECTION_TIMEOUT = 60 + #: Which address Mopidy's MPD server should bind to. #: #: Used by :mod:`mopidy.frontends.mpd`. diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 604350d1..1ffb12d6 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -291,7 +291,7 @@ class Connection(object): return True def timeout_callback(self): - self.stop('Client timeout out after %s seconds' % self.timeout) + self.stop('Client inactive for %ds; closing connection' % self.timeout) return False From 30edba0a3e5bc9cab85f7f8313f9e2a336bb6d76 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:25:47 +0100 Subject: [PATCH 071/144] spotify: Unbreak search by URI --- mopidy/backends/spotify/library.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index ca6ee92a..bfdcb4f5 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -70,6 +70,13 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not query: return self._get_all_tracks() + if 'uri' in query.keys(): + result = [] + for uri in query['uri']: + tracks = self.lookup(uri) + result += tracks + return result + spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -110,14 +117,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def _translate_search_query(self, mopidy_query): spotify_query = [] for (field, values) in mopidy_query.iteritems(): - if field == 'uri': - tracks = [] - for value in values: - track = self.lookup(value) - if track: - tracks.append(track) - return tracks - elif field == 'track': + if field == 'track': field = 'title' elif field == 'date': field = 'year' From cb78dc634180d72a6b0ce96b974bb614b5ad62ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:26:29 +0100 Subject: [PATCH 072/144] spotify: Spotify wants 'track', not 'title' (#272) --- mopidy/backends/spotify/library.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index bfdcb4f5..cd6db63d 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -117,9 +117,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def _translate_search_query(self, mopidy_query): spotify_query = [] for (field, values) in mopidy_query.iteritems(): - if field == 'track': - field = 'title' - elif field == 'date': + if field == 'date': field = 'year' if not hasattr(values, '__iter__'): values = [values] From 08f017842560907b15b4e580f70bd25825db3b4c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:46:35 +0100 Subject: [PATCH 073/144] mpd: Extract query translators for direct testing --- mopidy/frontends/mpd/protocol/music_db.py | 93 +++-------------------- mopidy/frontends/mpd/translator.py | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 83 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 7cdfc5e0..393561de 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,11 +1,8 @@ from __future__ import unicode_literals -import re -import shlex - -from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented +from mopidy.frontends.mpd import translator +from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists -from mopidy.frontends.mpd.translator import tracks_to_mpd_format QUERY_RE = ( @@ -13,35 +10,6 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') -def _build_query(mpd_query): - """ - Parses a MPD query string and converts it to the Mopidy query format. - """ - query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') - query_parts = re.findall(query_pattern, mpd_query) - query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' - r'"(?P[^"]+)"') - query = {} - for query_part in query_parts: - m = re.match(query_part_pattern, query_part) - field = m.groupdict()['field'].lower() - if field == 'title': - field = 'track' - elif field in ('file', 'filename'): - field = 'uri' - field = str(field) # Needed for kwargs keys on OS X and Windows - what = m.groupdict()['what'] - if not what: - raise ValueError - if field in query: - query[field].append(what) - else: - query[field] = [what] - return query - - @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -84,11 +52,11 @@ def find(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.find_exact(**query).get() - return tracks_to_mpd_format(result) + return translator.tracks_to_mpd_format(result) @handle_request(r'^findadd ' + QUERY_RE) @@ -102,7 +70,7 @@ def findadd(context, mpd_query): current playlist. Parameters have the same meaning as for ``find``. """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.find_exact(**query).get() @@ -196,7 +164,7 @@ def list_(context, field, mpd_query=None): """ field = field.lower() try: - query = _list_build_query(field, mpd_query) + query = translator.query_from_mpd_list_format(field, mpd_query) except ValueError: return if field == 'artist': @@ -209,47 +177,6 @@ def list_(context, field, mpd_query=None): pass # TODO We don't have genre in our internal data structures yet -def _list_build_query(field, mpd_query): - """Converts a ``list`` query to a Mopidy query.""" - if mpd_query is None: - return {} - try: - # shlex does not seem to be friends with unicode objects - tokens = shlex.split(mpd_query.encode('utf-8')) - except ValueError as error: - if str(error) == 'No closing quotation': - raise MpdArgError('Invalid unquoted character', command='list') - else: - raise - tokens = [t.decode('utf-8') for t in tokens] - if len(tokens) == 1: - if field == 'album': - if not tokens[0]: - raise ValueError - return {'artist': [tokens[0]]} - else: - raise MpdArgError( - 'should be "Album" for 3 arguments', command='list') - elif len(tokens) % 2 == 0: - query = {} - while tokens: - key = tokens[0].lower() - key = str(key) # Needed for kwargs keys on OS X and Windows - value = tokens[1] - tokens = tokens[2:] - if key not in ('artist', 'album', 'date', 'genre'): - raise MpdArgError('not able to parse args', command='list') - if not value: - raise ValueError - if key in query: - query[key].append(value) - else: - query[key] = [value] - return query - else: - raise MpdArgError('not able to parse args', command='list') - - def _list_artist(context, query): artists = set() tracks = context.core.library.find_exact(**query).get() @@ -367,11 +294,11 @@ def search(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.search(**query).get() - return tracks_to_mpd_format(result) + return translator.tracks_to_mpd_format(result) @handle_request(r'^searchadd ' + QUERY_RE) @@ -388,7 +315,7 @@ def searchadd(context, mpd_query): not case sensitive. """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.search(**query).get() @@ -411,7 +338,7 @@ def searchaddpl(context, playlist_name, mpd_query): not case sensitive. """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.search(**query).get() diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 0c95f044..ef7c8a1c 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -2,10 +2,12 @@ from __future__ import unicode_literals import os import re +import shlex import urllib from mopidy import settings from mopidy.frontends.mpd import protocol +from mopidy.frontends.mpd.exceptions import MpdArgError from mopidy.models import TlTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path @@ -134,6 +136,82 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) +def query_from_mpd_list_format(field, mpd_query): + """ + Converts an MPD ``list`` query to a Mopidy query. + """ + if mpd_query is None: + return {} + try: + # shlex does not seem to be friends with unicode objects + tokens = shlex.split(mpd_query.encode('utf-8')) + except ValueError as error: + if str(error) == 'No closing quotation': + raise MpdArgError('Invalid unquoted character', command='list') + else: + raise + tokens = [t.decode('utf-8') for t in tokens] + if len(tokens) == 1: + if field == 'album': + if not tokens[0]: + raise ValueError + return {'artist': [tokens[0]]} + else: + raise MpdArgError( + 'should be "Album" for 3 arguments', command='list') + elif len(tokens) % 2 == 0: + query = {} + while tokens: + key = tokens[0].lower() + key = str(key) # Needed for kwargs keys on OS X and Windows + value = tokens[1] + tokens = tokens[2:] + if key not in ('artist', 'album', 'date', 'genre'): + raise MpdArgError('not able to parse args', command='list') + if not value: + raise ValueError + if key in query: + query[key].append(value) + else: + query[key] = [value] + return query + else: + raise MpdArgError('not able to parse args', command='list') + + +def query_from_mpd_search_format(mpd_query): + """ + Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy + query format. + + :param mpd_query: the MPD search query + :type mpd_query: string + """ + query_pattern = ( + r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') + query_parts = re.findall(query_pattern, mpd_query) + query_part_pattern = ( + r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' + r'"(?P[^"]+)"') + query = {} + for query_part in query_parts: + m = re.match(query_part_pattern, query_part) + field = m.groupdict()['field'].lower() + if field == 'title': + field = 'track' + elif field in ('file', 'filename'): + field = 'uri' + field = str(field) # Needed for kwargs keys on OS X and Windows + what = m.groupdict()['what'] + if not what: + raise ValueError + if field in query: + query[field].append(what) + else: + query[field] = [what] + return query + + def tracks_to_tag_cache_format(tracks): """ Format list of tracks for output to MPD tag cache From f9dc3e3d81bb6b578fe055f22cf6210f96b18097 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:48:04 +0100 Subject: [PATCH 074/144] mpd: Rename test file to match src file --- tests/frontends/mpd/{serializer_test.py => translator_test.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/frontends/mpd/{serializer_test.py => translator_test.py} (100%) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/translator_test.py similarity index 100% rename from tests/frontends/mpd/serializer_test.py rename to tests/frontends/mpd/translator_test.py From 42faec8a3c8c2faaecdf90fddbf8bf79fcc8f357 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 18:59:01 +0100 Subject: [PATCH 075/144] spotify: SpotifyTrack fails when both uri and track is provided --- mopidy/backends/spotify/library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index dec13ced..28e9c61f 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -19,12 +19,12 @@ class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" def __init__(self, uri=None, track=None): super(SpotifyTrack, self).__init__() - if uri: + if (uri and track) or (not uri and not track): + raise AttributeError('uri or track must be provided') + elif uri: self._spotify_track = Link.from_string(uri).as_track() elif track: self._spotify_track = track - else: - raise AttributeError('uri or track must be provided') self._unloaded_track = Track(uri=uri, name='[loading...]') self._track = None From e118c73aa36506e1f66eda027aedfdda98758821 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 19:01:04 +0100 Subject: [PATCH 076/144] spotify: Refactor loading timeout logic --- mopidy/backends/spotify/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 28e9c61f..bde1e3fb 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -103,10 +103,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): # XXX Sleeping to wait for the Spotify object to load is an ugly hack, # but it works. We should look into other solutions for this. - start = time.time() + wait_until = time.time() + timeout while not spotify_obj.is_loaded(): time.sleep(0.1) - if time.time() > (start + timeout): + if time.time() > wait_until: logger.debug( 'Timeout: Spotify object did not load in %ds', timeout) return From a3ab9567331f1072a79e3d1869a38e338ff8d2f9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 19:08:01 +0100 Subject: [PATCH 077/144] spotify: Block track lookups until we get data This makes track lookup behave consistently with lookup of artists, albums and playlists. I consider this "safe", since track lookup is only used for lookup of single tracks by URI. If you're e.g. loading a playlist full of unloaded tracks, you should still use SpotifyTrack to avoid blocking on track loading. --- mopidy/backends/spotify/library.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index bde1e3fb..af25fab2 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -80,7 +80,9 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return [] def _lookup_track(self, uri): - return [SpotifyTrack(uri)] + track = Link.from_string(uri).as_track() + self._wait_for_object_to_load(track) + return [SpotifyTrack(track=track)] def _lookup_album(self, uri): album = Link.from_string(uri).as_album() From 147bb5e983d059862dce55bd807c4662376fdac7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:09:11 +0100 Subject: [PATCH 078/144] local: Read track date from tag cache --- docs/changes.rst | 4 ++++ mopidy/backends/local/translator.py | 3 +++ tests/backends/local/translator_test.py | 12 ++++++------ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3caa02df..2a55e73e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -24,6 +24,10 @@ v0.11.0 (in development) add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" +**Local backend** + +- Load track dates from tag cache. + **MPD frontend** - Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index ff58a16e..390fd92a 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -123,6 +123,9 @@ def _convert_mpd_data(data, tracks, music_dir): if 'title' in data: track_kwargs['name'] = data['title'] + if 'date' in data: + track_kwargs['date'] = data['date'] + if 'musicbrainz_trackid' in data: track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 90ee849d..61a86672 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -99,8 +99,8 @@ expected_tracks = [] def generate_track(path, ident): uri = path_to_uri(path_to_data_dir(path)) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, + album=expected_albums[0], track_no=1, date='2006', length=4000) expected_tracks.append(track) @@ -126,8 +126,8 @@ class MPDTagCacheToTracksTest(unittest.TestCase): path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, track_no=1, + album=expected_albums[0], date='2006', length=4000) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): @@ -182,6 +182,6 @@ class MPDTagCacheToTracksTest(unittest.TestCase): artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=album, length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, track_no=1, + album=album, date='2006', length=4000) self.assertEqual(track, list(tracks)[0]) From 53f3ef488c0176712670f92f61c0e9bb731a0e92 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:26:55 +0100 Subject: [PATCH 079/144] local: Reorder search filters and tests --- mopidy/backends/local/library.py | 16 ++++++------ tests/backends/base/library.py | 44 ++++++++++++++++---------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index e0e6f423..65c45376 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -46,23 +46,23 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip() + uri_filter = lambda t: q == t.uri track_filter = lambda t: q == t.name album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) - uri_filter = lambda t: q == t.uri any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) - if field == 'track': + if field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'track': result_tracks = filter(track_filter, result_tracks) elif field == 'album': result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -80,23 +80,23 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip().lower() + uri_filter = lambda t: q in t.uri.lower() track_filter = lambda t: q in t.name.lower() album_filter = lambda t: q in getattr( t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) - uri_filter = lambda t: q in t.uri.lower() any_filter = lambda t: track_filter(t) or album_filter(t) or \ artist_filter(t) or uri_filter(t) - if field == 'track': + if field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'track': result_tracks = filter(track_filter, result_tracks) elif field == 'album': result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4e9232e5..c9db7767 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -60,11 +60,13 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['unknown artist']) self.assertEqual(result, []) - def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) + def test_find_exact_uri(self): + track_1_uri = 'file://' + path_to_data_dir('uri1') + result = self.library.find_exact(uri=track_1_uri) self.assertEqual(result, self.tracks[:1]) - result = self.library.find_exact(artist=['artist2']) + track_2_uri = 'file://' + path_to_data_dir('uri2') + result = self.library.find_exact(uri=track_2_uri) self.assertEqual(result, self.tracks[1:2]) def test_find_exact_track(self): @@ -74,6 +76,13 @@ class LibraryControllerTest(object): result = self.library.find_exact(track=['track2']) self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_artist(self): + result = self.library.find_exact(artist=['artist1']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.find_exact(artist=['artist2']) + self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_album(self): result = self.library.find_exact(album=['album1']) self.assertEqual(result, self.tracks[:1]) @@ -81,15 +90,6 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, self.tracks[1:2]) - def test_find_exact_uri(self): - track_1_uri = 'file://' + path_to_data_dir('uri1') - result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(result, self.tracks[:1]) - - track_2_uri = 'file://' + path_to_data_dir('uri2') - result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(result, self.tracks[1:2]) - def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -120,11 +120,11 @@ class LibraryControllerTest(object): result = self.library.search(any=['unknown']) self.assertEqual(result, []) - def test_search_artist(self): - result = self.library.search(artist=['Tist1']) + def test_search_uri(self): + result = self.library.search(uri=['RI1']) self.assertEqual(result, self.tracks[:1]) - result = self.library.search(artist=['Tist2']) + result = self.library.search(uri=['RI2']) self.assertEqual(result, self.tracks[1:2]) def test_search_track(self): @@ -134,6 +134,13 @@ class LibraryControllerTest(object): result = self.library.search(track=['Rack2']) self.assertEqual(result, self.tracks[1:2]) + def test_search_artist(self): + result = self.library.search(artist=['Tist1']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.search(artist=['Tist2']) + self.assertEqual(result, self.tracks[1:2]) + def test_search_album(self): result = self.library.search(album=['Bum1']) self.assertEqual(result, self.tracks[:1]) @@ -141,13 +148,6 @@ class LibraryControllerTest(object): result = self.library.search(album=['Bum2']) self.assertEqual(result, self.tracks[1:2]) - def test_search_uri(self): - result = self.library.search(uri=['RI1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.search(uri=['RI2']) - self.assertEqual(result, self.tracks[1:2]) - def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, self.tracks[:1]) From 02c8ea53d7eb54c25deec60425d1e4703b890c7e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:28:36 +0100 Subject: [PATCH 080/144] local: Add search-by-date support --- docs/changes.rst | 2 ++ mopidy/backends/local/library.py | 6 ++++++ tests/backends/base/library.py | 32 ++++++++++++++++++++++++++++---- tests/data/library_tag_cache | 2 ++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2a55e73e..bc709be2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -28,6 +28,8 @@ v0.11.0 (in development) - Load track dates from tag cache. +- Add support for searching by track date. + **MPD frontend** - Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 65c45376..143c6d84 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -51,6 +51,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) + date_filter = lambda t: q == t.date any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) @@ -63,6 +64,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -86,6 +89,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) + date_filter = lambda t: t.date and t.date.startswith(q) any_filter = lambda t: track_filter(t) or album_filter(t) or \ artist_filter(t) or uri_filter(t) @@ -97,6 +101,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index c9db7767..57aec3c6 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -16,11 +16,12 @@ class LibraryControllerTest(object): Album()] tracks = [ Track( - name='track1', length=4000, artists=artists[:1], - album=albums[0], uri='file://' + path_to_data_dir('uri1')), + uri='file://' + path_to_data_dir('uri1'), name='track1', + artists=artists[:1], album=albums[0], date='2001-02-03', + length=4000), Track( - name='track2', length=4000, artists=artists[1:2], - album=albums[1], uri='file://' + path_to_data_dir('uri2')), + uri='file://' + path_to_data_dir('uri2'), name='track2', + artists=artists[1:2], album=albums[1], date='2002', length=4000), Track()] def setUp(self): @@ -90,6 +91,16 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_date(self): + result = self.library.find_exact(date=['2001']) + self.assertEqual(result, []) + + result = self.library.find_exact(date=['2001-02-03']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.find_exact(date=['2002']) + self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -148,6 +159,19 @@ class LibraryControllerTest(object): result = self.library.search(album=['Bum2']) self.assertEqual(result, self.tracks[1:2]) + def test_search_date(self): + result = self.library.search(date=['2001']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.search(date=['2001-02-03']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.search(date=['2001-02-04']) + self.assertEqual(result, []) + + result = self.library.search(date=['2002']) + self.assertEqual(result, self.tracks[1:2]) + def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, self.tracks[:1]) diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index e090fcbd..50771a0a 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -8,12 +8,14 @@ file: /uri1 Artist: artist1 Title: track1 Album: album1 +Date: 2001-02-03 Time: 4 key: uri2 file: /uri2 Artist: artist2 Title: track2 Album: album2 +Date: 2002 Time: 4 key: uri3 file: /uri3 From 242df281149c4743fd8d202649b3216d96b0e611 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:34:06 +0100 Subject: [PATCH 081/144] mpd: Support search by date (#272) --- docs/changes.rst | 2 ++ mopidy/frontends/mpd/translator.py | 9 ++++++--- tests/frontends/mpd/translator_test.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index bc709be2..8c2087e5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -53,6 +53,8 @@ v0.11.0 (in development) - Add empty stubs for channel commands for client to client communication. +- Add support for search by date. + **Internal changes** *Models:* diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index ef7c8a1c..e26d7dce 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -187,12 +187,15 @@ def query_from_mpd_search_format(mpd_query): :param mpd_query: the MPD search query :type mpd_query: string """ + # XXX The regexps below should be refactored to reuse common patterns here + # and in mopidy.frontends.mpd.protocol.music_db. query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') + r'"?(?:[Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"') query_parts = re.findall(query_pattern, mpd_query) query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' - r'"(?P[^"]+)"') + r'"?(?P([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny))"? "(?P[^"]+)"') query = {} for query_part in query_parts: m = re.match(query_part_pattern, query_part) diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index aa3b77bb..088ae137 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -121,6 +121,20 @@ class PlaylistMpdFormatTest(unittest.TestCase): self.assertEqual(dict(result[0])['Track'], 2) +class QueryFromMpdSearchFormatTest(unittest.TestCase): + def test_dates_are_extracted(self): + result = translator.query_from_mpd_search_format( + 'Date "1974-01-02" Date "1975"') + self.assertEqual(result['date'][0], '1974-01-02') + self.assertEqual(result['date'][1], '1975') + + # TODO Test more mappings + + +class QueryFromMpdListFormatTest(unittest.TestCase): + pass # TODO + + class TracksToTagCacheFormatTest(unittest.TestCase): def setUp(self): settings.LOCAL_MUSIC_PATH = '/dir/subdir' From 4b94a5a8efccf0ee0661a2dea06cbe6b0d90c613 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 22:15:46 +0100 Subject: [PATCH 082/144] spotify: Increase max search results from 100 to 200 --- docs/changes.rst | 3 +++ mopidy/backends/spotify/library.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8c2087e5..266f73f2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -24,6 +24,9 @@ v0.11.0 (in development) add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" +- Increase max number of tracks returned by searches from 100 to 200, which + seems to be Spotify's current max limit. + **Local backend** - Load track dates from tag cache. diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 7fcf286f..81587e00 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -146,7 +146,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self.backend.spotify.session.search( spotify_query, callback, - track_count=100, album_count=0, artist_count=0) + track_count=200, album_count=0, artist_count=0) try: return future.get(timeout=settings.SPOTIFY_TIMEOUT) From 52b20b3297557d6bebd395419fe34da74fd18fe8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 23:36:45 +0100 Subject: [PATCH 083/144] models: Add SearchResult model --- docs/changes.rst | 3 +++ mopidy/models.py | 31 +++++++++++++++++++++++++++ tests/models_test.py | 50 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 266f73f2..8df821f1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -64,6 +64,9 @@ v0.11.0 (in development) - Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. +- Added :class:`mopidy.models.SearchResult` model to encapsulate search results + consisting of more than just tracks. + *Core API:* - Change the following methods to accept either a dict with filters or kwargs. diff --git a/mopidy/models.py b/mopidy/models.py index e47ed3be..73209b6e 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -318,3 +318,34 @@ class Playlist(ImmutableObject): def length(self): """The number of tracks in the playlist. Read-only.""" return len(self.tracks) + + +class SearchResult(ImmutableObject): + """ + :param uri: search result URI + :type uri: string + :param tracks: matching tracks + :type tracks: list of :class:`Track` elements + :param artists: matching artists + :type artists: list of :class:`Artist` elements + :param albums: matching albums + :type albums: list of :class:`Album` elements + """ + + # The search result URI. Read-only. + uri = None + + # The tracks matching the search query. Read-only. + tracks = tuple() + + # The artists matching the search query. Read-only. + artists = tuple() + + # The albums matching the search query. Read-only. + albums = tuple() + + def __init__(self, *args, **kwargs): + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) + self.__dict__['artists'] = tuple(kwargs.pop('artists', [])) + self.__dict__['albums'] = tuple(kwargs.pop('albums', [])) + super(SearchResult, self).__init__(*args, **kwargs) diff --git a/tests/models_test.py b/tests/models_test.py index 1a4d869a..89d0b132 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -4,7 +4,7 @@ import datetime import json from mopidy.models import ( - Artist, Album, TlTrack, Track, Playlist, + Artist, Album, TlTrack, Track, Playlist, SearchResult, ModelJSONEncoder, model_json_decoder) from tests import unittest @@ -862,10 +862,56 @@ class PlaylistTest(unittest.TestCase): def test_ne(self): playlist1 = Playlist( - uri='uri1', name='name2', tracks=[Track(uri='uri1')], + uri='uri1', name='name1', tracks=[Track(uri='uri1')], last_modified=1) playlist2 = Playlist( uri='uri2', name='name2', tracks=[Track(uri='uri2')], last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) + + +class SearchResultTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + result = SearchResult(uri=uri) + self.assertEqual(result.uri, uri) + self.assertRaises(AttributeError, setattr, result, 'uri', None) + + def test_tracks(self): + tracks = [Track(), Track(), Track()] + result = SearchResult(tracks=tracks) + self.assertEqual(list(result.tracks), tracks) + self.assertRaises(AttributeError, setattr, result, 'tracks', None) + + def test_artists(self): + artists = [Artist(), Artist(), Artist()] + result = SearchResult(artists=artists) + self.assertEqual(list(result.artists), artists) + self.assertRaises(AttributeError, setattr, result, 'artists', None) + + def test_albums(self): + albums = [Album(), Album(), Album()] + result = SearchResult(albums=albums) + self.assertEqual(list(result.albums), albums) + self.assertRaises(AttributeError, setattr, result, 'albums', None) + + def test_invalid_kwarg(self): + test = lambda: SearchResult(foo='baz') + self.assertRaises(TypeError, test) + + def test_repr_without_results(self): + self.assertEquals( + "SearchResult(albums=[], artists=[], tracks=[], uri=u'uri')", + repr(SearchResult(uri='uri'))) + + def test_serialize_without_results(self): + self.assertDictEqual( + {'__model__': 'SearchResult', 'uri': 'uri'}, + SearchResult(uri='uri').serialize()) + + def test_to_json_and_back(self): + result1 = SearchResult(uri='uri') + serialized = json.dumps(result1, cls=ModelJSONEncoder) + result2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result1, result2) From b0ba2040dfc387ec55163a5462d732d14ca00380 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 00:28:24 +0100 Subject: [PATCH 084/144] Return SearchResult objects from find_exact() and search() This applies to both backends and core. --- docs/changes.rst | 6 ++ mopidy/backends/dummy.py | 6 +- mopidy/backends/local/library.py | 6 +- mopidy/backends/spotify/library.py | 27 +++---- mopidy/core/library.py | 11 ++- mopidy/frontends/mpd/protocol/music_db.py | 38 +++++----- tests/backends/base/library.py | 70 +++++++++---------- tests/core/library_test.py | 46 +++++++----- tests/frontends/mpd/protocol/music_db_test.py | 33 ++++----- 9 files changed, 131 insertions(+), 112 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8df821f1..8f887ed5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -69,6 +69,12 @@ v0.11.0 (in development) *Core API:* +- Change the following methods to return :class:`mopidy.models.SearchResult` + objects which can include both track results and other results: + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + - Change the following methods to accept either a dict with filters or kwargs. Previously they only accepted kwargs, which made them impossible to use from the Mopidy.js through JSON-RPC, which doesn't support kwargs. diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 39180bbb..c6997b12 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -19,7 +19,7 @@ from __future__ import unicode_literals import pykka from mopidy.backends import base -from mopidy.models import Playlist +from mopidy.models import Playlist, SearchResult class DummyBackend(pykka.ThreadingActor, base.Backend): @@ -37,8 +37,8 @@ class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_find_exact_result = [] - self.dummy_search_result = [] + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 143c6d84..ad81efea 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -4,7 +4,7 @@ import logging from mopidy import settings from mopidy.backends import base -from mopidy.models import Album +from mopidy.models import Album, SearchResult from .translator import parse_mpd_tag_cache @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 81587e00..0e009fd9 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -8,7 +8,7 @@ from spotify import Link, SpotifyError from mopidy import settings from mopidy.backends import base -from mopidy.models import Track +from mopidy.models import Track, SearchResult from . import translator @@ -121,11 +121,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return self._get_all_tracks() if 'uri' in query.keys(): - result = [] + tracks = [] for uri in query['uri']: - tracks = self.lookup(uri) - result += tracks - return result + tracks += self.lookup(uri) + return SearchResult(tracks=tracks) spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -133,12 +132,14 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): future = pykka.ThreadingFuture() def callback(results, userdata=None): - # TODO Include results from results.albums(), etc. too - # TODO Consider launching a second search if results.total_tracks() - # is larger than len(results.tracks()) - tracks = [ - translator.to_mopidy_track(t) for t in results.tracks()] - future.set(tracks) + search_result = SearchResult( + albums=[ + translator.to_mopidy_album(a) for a in results.albums()], + artists=[ + translator.to_mopidy_artist(a) for a in results.artists()], + tracks=[ + translator.to_mopidy_track(t) for t in results.tracks()]) + future.set(search_result) if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): logger.debug('Not connected: Spotify search cancelled') @@ -146,7 +147,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self.backend.spotify.session.search( spotify_query, callback, - track_count=200, album_count=0, artist_count=0) + album_count=200, artist_count=200, track_count=200) try: return future.get(timeout=settings.SPOTIFY_TIMEOUT) @@ -154,7 +155,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): logger.debug( 'Timeout: Spotify search did not return in %ds', settings.SPOTIFY_TIMEOUT) - return [] + return SearchResult(uri='spotify:search') def _get_all_tracks(self): # Since we can't search for the entire Spotify library, we return diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 3c596a3a..39a1e99c 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import itertools import urlparse import pykka @@ -37,13 +36,12 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return pykka.get_all(futures) def lookup(self, uri): """ @@ -98,10 +96,9 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return pykka.get_all(futures) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 393561de..f9149a50 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import itertools + from mopidy.frontends.mpd import translator from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists @@ -10,6 +12,10 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def _get_tracks(search_results): + return list(itertools.chain(*[r.tracks for r in search_results])) + + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -55,8 +61,8 @@ def find(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(result) + results = context.core.library.find_exact(**query).get() + return translator.tracks_to_mpd_format(_get_tracks(results)) @handle_request(r'^findadd ' + QUERY_RE) @@ -73,8 +79,8 @@ def findadd(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.find_exact(**query).get() - context.core.tracklist.add(result) + results = context.core.library.find_exact(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request( @@ -179,8 +185,8 @@ def list_(context, field, mpd_query=None): def _list_artist(context, query): artists = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): for artist in track.artists: if artist.name: artists.add(('Artist', artist.name)) @@ -189,8 +195,8 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.album and track.album.name: albums.add(('Album', track.album.name)) return albums @@ -198,8 +204,8 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.date: dates.add(('Date', track.date)) return dates @@ -297,8 +303,8 @@ def search(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(result) + results = context.core.library.search(**query).get() + return translator.tracks_to_mpd_format(_get_tracks(results)) @handle_request(r'^searchadd ' + QUERY_RE) @@ -318,8 +324,8 @@ def searchadd(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() - context.core.tracklist.add(result) + results = context.core.library.search(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request(r'^searchaddpl "(?P[^"]+)" ' + QUERY_RE) @@ -341,14 +347,14 @@ def searchaddpl(context, playlist_name, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() + results = context.core.library.search(**query).get() playlists = context.core.playlists.filter(name=playlist_name).get() if playlists: playlist = playlists[0] else: playlist = context.core.playlists.create(playlist_name).get() - tracks = list(playlist.tracks) + result + tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.copy(tracks=tracks) context.core.playlists.save(playlist) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 57aec3c6..c75bec74 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -53,53 +53,53 @@ class LibraryControllerTest(object): def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): track_1_uri = 'file://' + path_to_data_dir('uri1') result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) track_2_uri = 'file://' + path_to_data_dir('uri2') result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_track(self): result = self.library.find_exact(track=['track1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(track=['track2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_artist(self): result = self.library.find_exact(artist=['artist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(artist=['artist2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_album(self): result = self.library.find_exact(album=['album1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(album=['album2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_date(self): result = self.library.find_exact(date=['2001']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(date=['2001-02-03']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(date=['2002']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) @@ -117,70 +117,70 @@ class LibraryControllerTest(object): def test_search_no_hits(self): result = self.library.search(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(uri=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(any=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): result = self.library.search(uri=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(uri=['RI2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track(self): result = self.library.search(track=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(track=['Rack2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_artist(self): result = self.library.search(artist=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(artist=['Tist2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_album(self): result = self.library.search(album=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(album=['Bum2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_date(self): result = self.library.search(date=['2001']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(date=['2001-02-03']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(date=['2001-02-04']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(date=['2002']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_any(self): result = self.library.search(any=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): test = lambda: self.library.search(wrong=['test']) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index a2c358d7..32e618d2 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -4,7 +4,7 @@ import mock from mopidy.backends import base from mopidy.core import Core -from mopidy.models import Track +from mopidy.models import SearchResult, Track from tests import unittest @@ -75,59 +75,71 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, 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') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(dict(any=['a'])) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, 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') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(dict(any=['a'])) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 5c887958..86fd8ad7 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, SearchResult, Track from tests.frontends.mpd import protocol @@ -13,9 +13,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_findadd(self): - self.backend.library.dummy_find_exact_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.sendRequest('findadd "title" "A"') @@ -25,9 +24,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_searchadd(self): - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.sendRequest('searchadd "title" "a"') @@ -43,9 +41,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Track(uri='dummy:y', name='y'), ]) self.core.playlists.save(playlist) - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) self.assertEqual(len(playlists[0].tracks), 2) @@ -61,9 +58,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_searchaddpl_creates_missing_playlist(self): - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual( len(self.core.playlists.filter(name='my favs').get()), 0) @@ -242,8 +238,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(artists=[Artist(name='')])] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(artists=[Artist(name='')])]) self.sendRequest('list "artist"') self.assertNotInResponse('Artist: ') @@ -301,8 +297,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(album=Album(name=''))] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(album=Album(name=''))]) self.sendRequest('list "album"') self.assertNotInResponse('Album: ') @@ -356,7 +352,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): - self.backend.library.dummy_find_exact_result = [Track(date='')] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(date='')]) self.sendRequest('list "date"') self.assertNotInResponse('Date: ') From 71f27d5625328205e4d1158c20d56a22fce89fb3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 11:13:25 +0100 Subject: [PATCH 085/144] local: Add uri to SearchResults --- mopidy/backends/local/actor.py | 2 +- mopidy/backends/local/library.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 75baeab2..c664fb99 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -20,4 +20,4 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - self.uri_schemes = ['file'] + self.uri_schemes = ['file', 'local'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index ad81efea..2295dfb5 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(tracks=result_tracks) + return SearchResult(uri='local:search', tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(tracks=result_tracks) + return SearchResult(uri='local:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): From e804333897b53379ac7f6cbd6f62fbf78fe4f92a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 21:57:20 +0100 Subject: [PATCH 086/144] spotify: Add uri to SearchResult --- mopidy/backends/spotify/library.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 0e009fd9..55f704f7 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging import time +import urllib import pykka from spotify import Link, SpotifyError @@ -124,7 +125,11 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): tracks = [] for uri in query['uri']: tracks += self.lookup(uri) - return SearchResult(tracks=tracks) + if len(query['uri']) == 1: + uri = query['uri'] + else: + uri = 'spotify:search' + return SearchResult(uri=uri, tracks=tracks) spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -133,6 +138,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def callback(results, userdata=None): search_result = SearchResult( + uri='spotify:search:' + urllib.quote(results.query()), albums=[ translator.to_mopidy_album(a) for a in results.albums()], artists=[ From a8c0f6baa808dea2d13a5387dc887ef1082f806c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 22:25:21 +0100 Subject: [PATCH 087/144] spotify: Make query a bytestring before urlencoding it --- mopidy/backends/spotify/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 55f704f7..5dccc25e 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -138,7 +138,8 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def callback(results, userdata=None): search_result = SearchResult( - uri='spotify:search:' + urllib.quote(results.query()), + uri='spotify:search:%s' % ( + urllib.quote(results.query().encode('utf-8'))), albums=[ translator.to_mopidy_album(a) for a in results.albums()], artists=[ From 455f0145e71e77121502556b05c2c98bc33b179c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 23:08:18 +0100 Subject: [PATCH 088/144] mpd: Include artists and albums in search results --- docs/changes.rst | 5 +++ mopidy/frontends/mpd/protocol/music_db.py | 36 ++++++++++++++++--- tests/frontends/mpd/protocol/music_db_test.py | 34 ++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8f887ed5..3bc68948 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -58,6 +58,11 @@ v0.11.0 (in development) - Add support for search by date. +- Include fake tracks representing albums and artists in the search results. + When these are added to the tracklist, they expand to either all tracks in + the album or all tracks by the artist. This makes it easy to play full albums + in proper order, which is a feature that have been frequently requested. + **Internal changes** *Models:* diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index f9149a50..ca5ef730 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals +import functools import itertools +from mopidy.models import Track from mopidy.frontends.mpd import translator from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists @@ -12,8 +14,28 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') -def _get_tracks(search_results): - return list(itertools.chain(*[r.tracks for r in search_results])) +def _get_field(field, search_results): + return list(itertools.chain(*[getattr(r, field) for r in search_results])) + + +_get_albums = functools.partial(_get_field, 'albums') +_get_artists = functools.partial(_get_field, 'artists') +_get_tracks = functools.partial(_get_field, 'tracks') + + +def _album_as_track(album): + return Track( + uri=album.uri, + name='Album: ' + album.name, + album=album, + artists=album.artists) + + +def _artist_as_track(artist): + return Track( + uri=artist.uri, + name='Artist: ' + artist.name, + artists=[artist]) @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') @@ -62,7 +84,10 @@ def find(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(_get_tracks(results)) + albums = [_album_as_track(a) for a in _get_albums(results)] + artists = [_artist_as_track(a) for a in _get_artists(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^findadd ' + QUERY_RE) @@ -304,7 +329,10 @@ def search(context, mpd_query): except ValueError: return results = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(_get_tracks(results)) + albums = [_album_as_track(a) for a in _get_albums(results)] + artists = [_artist_as_track(a) for a in _get_artists(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^searchadd ' + QUERY_RE) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 86fd8ad7..fedc34a1 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -115,6 +115,23 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "any" "foo"') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_find_album(self): self.sendRequest('find "album" "what"') self.assertInResponse('OK') @@ -409,6 +426,23 @@ class MusicDatabaseListTest(protocol.BaseTestCase): class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search(self): + self.backend.library.dummy_search_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('search "any" "foo"') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_search_album(self): self.sendRequest('search "album" "analbum"') self.assertInResponse('OK') From e5c0bcd110781c0d81bf178d5d5eb8fc066a8749 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 23:28:59 +0100 Subject: [PATCH 089/144] docs: Add issue references --- docs/changes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changes.rst b/docs/changes.rst index 3bc68948..f373832d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -62,6 +62,7 @@ v0.11.0 (in development) When these are added to the tracklist, they expand to either all tracks in the album or all tracks by the artist. This makes it easy to play full albums in proper order, which is a feature that have been frequently requested. + (Fixes: :issue:`67`, :issue:`148`) **Internal changes** From 5060db48f2ccf04f3c7c5f9908840f2891faa7e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 23:51:40 +0100 Subject: [PATCH 090/144] mpd: Refactor search result to (fake) tracks functionality --- mopidy/frontends/mpd/protocol/music_db.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index ca5ef730..b346d714 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -38,6 +38,13 @@ def _artist_as_track(artist): artists=[artist]) +def _search_results_as_tracks(results): + albums = [_album_as_track(a) for a in _get_albums(results)] + artists = [_artist_as_track(a) for a in _get_artists(results)] + tracks = _get_tracks(results) + return artists + albums + tracks + + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -84,10 +91,7 @@ def find(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - albums = [_album_as_track(a) for a in _get_albums(results)] - artists = [_artist_as_track(a) for a in _get_artists(results)] - tracks = _get_tracks(results) - return translator.tracks_to_mpd_format(artists + albums + tracks) + return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) @handle_request(r'^findadd ' + QUERY_RE) @@ -329,10 +333,7 @@ def search(context, mpd_query): except ValueError: return results = context.core.library.search(**query).get() - albums = [_album_as_track(a) for a in _get_albums(results)] - artists = [_artist_as_track(a) for a in _get_artists(results)] - tracks = _get_tracks(results) - return translator.tracks_to_mpd_format(artists + albums + tracks) + return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) @handle_request(r'^searchadd ' + QUERY_RE) From 357a26d7f9c39fe83c418a634b2f86fe858bb8b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 01:40:19 +0100 Subject: [PATCH 091/144] spotify: Fix improper search() return value --- mopidy/backends/spotify/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 5dccc25e..0af76e4b 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -170,7 +170,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): tracks = [] for playlist in self.backend.playlists.playlists: tracks += playlist.tracks - return tracks + return SearchResult(uri='spotify:search', tracks=tracks) def _translate_search_query(self, mopidy_query): spotify_query = [] From 4f4754c57396d183ab8d94c36d57f279121d6c6e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 01:40:51 +0100 Subject: [PATCH 092/144] mpd: Test 'list' response content --- tests/frontends/mpd/protocol/music_db_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 86fd8ad7..58bb33e8 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -181,6 +181,17 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:a', name='A', artists=[ + Artist(name='A Artist')])]) + + self.sendRequest('list "artist" "artist" "foo"') + + self.assertInResponse('Artist: A Artist') + self.assertInResponse('OK') + def test_list_foo_returns_ack(self): self.sendRequest('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') From 04be75ed97ed74cdb70d0aec165a547bf5f4a355 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 02:12:07 +0100 Subject: [PATCH 093/144] mpd: Add album date to 'fake' tracks --- mopidy/frontends/mpd/protocol/music_db.py | 3 ++- tests/frontends/mpd/protocol/music_db_test.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index b346d714..bbacaacd 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -27,8 +27,9 @@ def _album_as_track(album): return Track( uri=album.uri, name='Album: ' + album.name, + artists=album.artists, album=album, - artists=album.artists) + date=album.date) def _artist_as_track(artist): diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index fedc34a1..a641cb27 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -117,7 +117,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): def test_find(self): self.backend.library.dummy_find_exact_result = SearchResult( - albums=[Album(uri='dummy:album:a', name='A')], + albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) @@ -125,8 +125,11 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + self.assertInResponse('file: dummy:artist:b') self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') From 54662479ef4dbd2dbbd9765c839a42316ec1d37b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 12:49:27 +0100 Subject: [PATCH 094/144] mpd: Limit use of fake tracks in 'find` responses If searching for exact artist, don't include fake artist tracks. If searching for exact album, don't include fake album tracks. This makes sure that ncmpcpp's media library doesn't include the magic artist-track in an artist's album listing, and that it doesn't include the magic album-track in an album's track listing. --- mopidy/frontends/mpd/protocol/music_db.py | 20 ++++---- tests/frontends/mpd/protocol/music_db_test.py | 46 +++++++++++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index bbacaacd..c457ee02 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -39,13 +39,6 @@ def _artist_as_track(artist): artists=[artist]) -def _search_results_as_tracks(results): - albums = [_album_as_track(a) for a in _get_albums(results)] - artists = [_artist_as_track(a) for a in _get_artists(results)] - tracks = _get_tracks(results) - return artists + albums + tracks - - @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -92,7 +85,13 @@ def find(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) + result_tracks = [] + if 'artist' not in query: + result_tracks += [_artist_as_track(a) for a in _get_artists(results)] + if 'album' not in query: + result_tracks += [_album_as_track(a) for a in _get_albums(results)] + result_tracks += _get_tracks(results) + return translator.tracks_to_mpd_format(result_tracks) @handle_request(r'^findadd ' + QUERY_RE) @@ -334,7 +333,10 @@ def search(context, mpd_query): except ValueError: return results = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) + artists = [_artist_as_track(a) for a in _get_artists(results)] + albums = [_album_as_track(a) for a in _get_albums(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^searchadd ' + QUERY_RE) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index a641cb27..0a69b7cf 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -115,7 +115,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): - def test_find(self): + def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], @@ -123,12 +123,52 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.sendRequest('find "any" "foo"') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') self.assertInResponse('Date: 2001') - self.assertInResponse('file: dummy:artist:b') - self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_does_not_include_fake_artist_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_and_album_does_not_include_fake_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo" "album" "bar"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertNotInResponse('file: dummy:album:a') + self.assertNotInResponse('Title: Album: A') + self.assertNotInResponse('Date: 2001') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') From ce318316a3d74a83a0aa9f40e9de21849afd3dc5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 12:20:30 +0100 Subject: [PATCH 095/144] mpd: Don't restart current track before seek --- docs/changes.rst | 3 ++ mopidy/frontends/mpd/protocol/playback.py | 2 ++ tests/frontends/mpd/protocol/playback_test.py | 29 ++++++++++++++----- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 266f73f2..2dd6d940 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -58,6 +58,9 @@ v0.11.0 (in development) - Add support for search by date. +- Make ``seek`` and ``seekid`` not restart the current track before seeking in + it. + **Internal changes** *Models:* diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 68c49ca0..b8153dc9 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,6 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ + songpos = int(songpos) if context.core.playback.tracklist_position.get() != songpos: playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -343,6 +344,7 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ + tlid = int(tlid) tl_track = context.core.playback.current_tl_track.get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid) diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 063493ec..cc49a8cd 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -371,45 +371,58 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('previous') self.assertInResponse('OK') - def test_seek(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + def test_seek_in_current_track(self): + seek_track = Track(uri='dummy:a', length=40000) + self.core.tracklist.add([seek_track]) + self.core.playback.play() - self.sendRequest('seek "0"') self.sendRequest('seek "0" "30"') + + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse('OK') - def test_seek_with_songpos(self): + def test_seek_in_another_track(self): seek_track = Track(uri='dummy:b', length=40000) self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) + self.core.playback.play() + self.assertNotEqual(self.core.playback.current_track.get(), seek_track) self.sendRequest('seek "1" "30"') + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse('OK') def test_seek_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - self.sendRequest('seek 0') self.sendRequest('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + def test_seekid_in_current_track(self): + seek_track = Track(uri='dummy:a', length=40000) + self.core.tracklist.add([seek_track]) + self.core.playback.play() + self.sendRequest('seekid "0" "30"') + + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid_with_tlid(self): + def test_seekid_in_another_track(self): seek_track = Track(uri='dummy:b', length=40000) self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) + self.core.playback.play() self.sendRequest('seekid "1" "30"') + self.assertEqual(1, self.core.playback.current_tl_track.get().tlid) self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse('OK') From 8fcc7966b2222a44a22053801ed9f50112da64fb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 12:25:20 +0100 Subject: [PATCH 096/144] spotify: Create SpotifyTrack with uri if lookup track isn't loaded --- mopidy/backends/spotify/library.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 81587e00..db4c5d7e 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -82,7 +82,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def _lookup_track(self, uri): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) - return [SpotifyTrack(track=track)] + if track.is_loaded(): + return [SpotifyTrack(track=track)] + else: + return [SpotifyTrack(uri=uri)] def _lookup_album(self, uri): album = Link.from_string(uri).as_album() From 167932278b3751d2bc138953bf6fb2a364dda0c8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 18:57:36 +0100 Subject: [PATCH 097/144] audio: Remove blocking get_state() calls in get_position() and seek() --- mopidy/audio/actor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 1b6c79b3..714ab0a6 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -274,13 +274,11 @@ class Audio(pykka.ThreadingActor): :rtype: int """ - if self._playbin.get_state()[1] == gst.STATE_NULL: - return 0 try: position = self._playbin.query_position(gst.FORMAT_TIME)[0] return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) + except gst.QueryError: + logger.debug('Position query failed') return 0 def set_position(self, position): @@ -291,12 +289,9 @@ class Audio(pykka.ThreadingActor): :type position: int :rtype: :class:`True` if successful, else :class:`False` """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple( + return self._playbin.seek_simple( gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._playbin.get_state() # block until seek is done - return handeled def start_playback(self): """ From 9fa0f5213ee59e62d889d67ea439993cad70edc1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 18:57:03 +0100 Subject: [PATCH 098/144] audio: Setup appsrc with seek-data callback --- mopidy/audio/actor.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 714ab0a6..bd974bed 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -39,13 +39,17 @@ class Audio(pykka.ThreadingActor): super(Audio, self).__init__() self._playbin = None + self._mixer = None self._mixer_track = None self._mixer_scale = None self._software_mixing = False - self._appsrc = None self._volume_set = None + self._appsrc = None + self._appsrc_seek_data_callback = None + self._appsrc_seek_data_id = None + self._notify_source_signal_id = None self._about_to_finish_id = None self._message_signal_id = None @@ -77,7 +81,12 @@ class Audio(pykka.ThreadingActor): 'notify::source', self._on_new_source) def _on_about_to_finish(self, element): - self._appsrc = None + source, self._appsrc = self._appsrc, None + if source is None: + return + if self._appsrc_seek_data_id is not None: + source.disconnect(self._appsrc_seek_data_id) + self._appsrc_seek_data_id = None def _on_new_source(self, element, pad): uri = element.get_property('uri') @@ -93,9 +102,19 @@ class Audio(pykka.ThreadingActor): source.set_property('caps', default_caps) # GStreamer does not like unicode source.set_property('format', b'time') + source.set_property('stream-type', b'seekable') + + self._appsrc_seek_data_id = source.connect( + 'seek-data', self._appsrc_on_seek_data) self._appsrc = source + def _appsrc_on_seek_data(self, appsrc, time_in_ns): + time_in_ms = time_in_ns // gst.MSECOND + if self._appsrc_seek_data_callback is not None: + self._appsrc_seek_data_callback(time_in_ms) + return True + def _teardown_playbin(self): if self._about_to_finish_id: self._playbin.disconnect(self._about_to_finish_id) @@ -242,6 +261,19 @@ class Audio(pykka.ThreadingActor): """ self._playbin.set_property('uri', uri) + def set_appsrc(self, seek_data=None): + """ + Switch to using appsrc for getting audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param seek_data: callback for when data from a new position is needed + to continue playback + :type seek_data: callable which takes time position in ms + """ + self._appsrc_seek_data_callback = seek_data + self._playbin.set_property('uri', 'appsrc://') + def emit_data(self, buffer_): """ Call this to deliver raw audio data to be played. From c7656cdc15e71320fb910c6eaf2ffb4a340b9de2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 20:24:58 +0100 Subject: [PATCH 099/144] spotify: Replace wall clock timer with GStreamer timer --- mopidy/backends/spotify/playback.py | 93 ++++------------------ mopidy/backends/spotify/session_manager.py | 4 + 2 files changed, 19 insertions(+), 78 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index e4534172..d80ef543 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,113 +1,50 @@ from __future__ import unicode_literals import logging -import time +import functools from spotify import Link, SpotifyError from mopidy.backends import base -from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify') +def seek_data_callback(spotify_backend, time_position): + logger.debug('seek_data_callback(%d) called', time_position) + spotify_backend.playback.on_seek_data(time_position) + + class SpotifyPlaybackProvider(base.BasePlaybackProvider): - def __init__(self, *args, **kwargs): - super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - - self._timer = TrackPositionTimer() - - def pause(self): - self._timer.pause() - - return super(SpotifyPlaybackProvider, self).pause() - def play(self, track): if track.uri is None: return False + spotify_backend = self.backend.actor_ref.proxy() + seek_data_callback_bound = functools.partial( + seek_data_callback, spotify_backend) + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) self.audio.prepare_change() - self.audio.set_uri('appsrc://') + self.audio.set_appsrc(seek_data=seek_data_callback_bound) self.audio.start_playback() self.audio.set_metadata(track) - self._timer.play() - return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) return False - def resume(self): - time_position = self.get_time_position() - self._timer.resume() - self.audio.prepare_change() - result = self.seek(time_position) - self.audio.start_playback() - return result - - def seek(self, time_position): - self.backend.spotify.session.seek(time_position) - self._timer.seek(time_position) - return True - def stop(self): self.backend.spotify.session.play(0) - return super(SpotifyPlaybackProvider, self).stop() - def get_time_position(self): - # XXX: The default implementation of get_time_position hangs/times out - # when used with the Spotify backend and GStreamer appsrc. If this can - # be resolved, we no longer need to use a wall clock based time - # position for Spotify playback. - return self._timer.get_time_position() - - -class TrackPositionTimer(object): - """ - Keeps track of time position in a track using the wall clock and playback - events. - - To not introduce a reverse dependency on the playback controller, this - class keeps track of playback state itself. - """ - - def __init__(self): - self._state = PlaybackState.STOPPED - self._accumulated = 0 - self._started = 0 - - def play(self): - self._state = PlaybackState.PLAYING - self._accumulated = 0 - self._started = self._wall_time() - - def pause(self): - self._state = PlaybackState.PAUSED - self._accumulated += self._wall_time() - self._started - - def resume(self): - self._state = PlaybackState.PLAYING - - def seek(self, time_position): - self._started = self._wall_time() - self._accumulated = time_position - - def get_time_position(self): - if self._state == PlaybackState.PLAYING: - time_since_started = self._wall_time() - self._started - return self._accumulated + time_since_started - elif self._state == PlaybackState.PAUSED: - return self._accumulated - elif self._state == PlaybackState.STOPPED: - return 0 - - def _wall_time(self): - return int(time.time() * 1000) + def on_seek_data(self, time_position): + logger.debug('playback.on_seek_data(%d) called', time_position) + self.backend.spotify.next_buffer_timestamp = time_position + self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index f2631406..0eed9939 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,6 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() + self.next_buffer_timestamp = None self.container_manager = None self.playlist_manager = None @@ -121,6 +122,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): } buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) + if self.next_buffer_timestamp is not None: + buffer_.timestamp = self.next_buffer_timestamp * gst.MSECOND + self.next_buffer_timestamp = None if self.audio.emit_data(buffer_).get(): return num_frames From 5d707e39186d60ee1db1468ecc4ba40ea45feded Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 15:42:49 +0100 Subject: [PATCH 100/144] settings: Fail if BACKENDS/FRONTENDS setting isn't iterable (fixes #278) --- docs/changes.rst | 9 +++++++++ mopidy/utils/settings.py | 4 ++++ tests/utils/settings_test.py | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 2dd6d940..1da3dacc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,15 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== +**Settings** + +- The settings validator now complains if a setting which expects a tuple of + values (e.g. :attr:`mopidy.settings.BACKENDS`, + :attr:`mopidy.settings.FRONTENDS`) has a non-iterable value. This typically + happens because the setting value contains a single value and one has + forgotten to add a comma after the string, making the value a tuple. (Fixes: + :issue:`278`) + **Spotify backend** - Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index fee5252d..6eb462ce 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -172,6 +172,10 @@ def validate_settings(defaults, settings): '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.' diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 0ecbb90f..1dcac1bb 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -87,6 +87,14 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual( result['BACKENDS'], 'Must contain at least one value.') + def test_noniterable_multivalue_setting_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': ('this is not a tuple')}) + self.assertEqual( + result['FRONTENDS'], + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") + class SettingsProxyTest(unittest.TestCase): def setUp(self): From c81d1d77bff2cb660d59c8d937b54307c8f49146 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 16:30:59 +0100 Subject: [PATCH 101/144] fab: Make 'test' and 'autotest' able to run a subset of the tests --- fabfile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fabfile.py b/fabfile.py index 267bdc23..370c81be 100644 --- a/fabfile.py +++ b/fabfile.py @@ -1,14 +1,15 @@ from fabric.api import local -def test(): - local('nosetests tests/') +def test(path=None): + path = path or 'tests/' + local('nosetests ' + path) -def autotest(): +def autotest(path=None): while True: local('clear') - test() + test(path) local( 'inotifywait -q -e create -e modify -e delete ' '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') From 524bfc931797c35e5371e37f4e699ee5ffbca51d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 18:32:52 +0100 Subject: [PATCH 102/144] local: Use 'file:search' as uri for search results for now --- mopidy/backends/local/actor.py | 2 +- mopidy/backends/local/library.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index c664fb99..75baeab2 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -20,4 +20,4 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - self.uri_schemes = ['file', 'local'] + self.uri_schemes = ['file'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 2295dfb5..eb328ce2 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='local:search', tracks=result_tracks) + return SearchResult(uri='file:search', tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='local:search', tracks=result_tracks) + return SearchResult(uri='file:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): From eec6c271c2b2c9ab3ff46c3952e66cbe1b68f234 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 18:41:07 +0100 Subject: [PATCH 103/144] spotify: Refactor URI lookup --- mopidy/backends/spotify/library.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 0af76e4b..45ec0563 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -121,12 +121,13 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not query: return self._get_all_tracks() - if 'uri' in query.keys(): + uris = query.get('uri', []) + if uris: tracks = [] - for uri in query['uri']: + for uri in uris: tracks += self.lookup(uri) - if len(query['uri']) == 1: - uri = query['uri'] + if len(uris) == 1: + uri = uris[0] else: uri = 'spotify:search' return SearchResult(uri=uri, tracks=tracks) From 8da2495e833ebb1115a1d0f59a660cd1d80f2f98 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 00:29:37 +0100 Subject: [PATCH 104/144] spotify: Only return available tracks from lookups --- mopidy/backends/spotify/library.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a42fc21f..8e8e47f9 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -84,7 +84,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) if track.is_loaded(): - return [SpotifyTrack(track=track)] + if track.availability() == 1: + return [SpotifyTrack(track=track)] + else: + return None else: return [SpotifyTrack(uri=uri)] @@ -92,18 +95,18 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): album = Link.from_string(uri).as_album() album_browser = self.backend.spotify.session.browse_album(album) self._wait_for_object_to_load(album_browser) - return [SpotifyTrack(track=t) for t in album_browser] + return [SpotifyTrack(track=t) for t in album_browser if t.availability() == 1] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() artist_browser = self.backend.spotify.session.browse_artist(artist) self._wait_for_object_to_load(artist_browser) - return [SpotifyTrack(track=t) for t in artist_browser] + return [SpotifyTrack(track=t) for t in artist_browser if t.availability() == 1] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) - return [SpotifyTrack(track=t) for t in playlist] + return [SpotifyTrack(track=t) for t in playlist if t.availability() == 1] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): From 30a78ba84b1fd819c8b03fd2fe2b3bf57ce49b4d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 20:56:49 +0100 Subject: [PATCH 105/144] mpd: Minor refactoring --- mopidy/frontends/mpd/protocol/playback.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b8153dc9..8e08585f 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,8 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - songpos = int(songpos) - if context.core.playback.tracklist_position.get() != songpos: + if context.core.playback.tracklist_position.get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -344,9 +343,8 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - tlid = int(tlid) tl_track = context.core.playback.current_tl_track.get() - if not tl_track or tl_track.tlid != tlid: + if not tl_track or tl_track.tlid != int(tlid): playid(context, tlid) context.core.playback.seek(int(seconds) * 1000).get() From fdd4ac19ae630d679f6f5e7e22eab56415f5c3da Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 01:21:08 +0100 Subject: [PATCH 106/144] spotify: Fix wrong search return type --- mopidy/backends/spotify/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a42fc21f..45835d04 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -154,7 +154,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): logger.debug('Not connected: Spotify search cancelled') - return [] + return SearchResult(uri='spotify:search') self.backend.spotify.session.search( spotify_query, callback, From c218375100f8fbe7c7d08e4e59453c3a6597c871 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 01:40:35 +0100 Subject: [PATCH 107/144] audio: Move Spotify appsrc caps out of audio --- mopidy/audio/actor.py | 18 ++++++++++-------- mopidy/backends/spotify/playback.py | 10 +++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index bd974bed..65edd037 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -47,6 +47,7 @@ class Audio(pykka.ThreadingActor): self._volume_set = None self._appsrc = None + self._appsrc_caps = None self._appsrc_seek_data_callback = None self._appsrc_seek_data_id = None @@ -84,6 +85,7 @@ class Audio(pykka.ThreadingActor): source, self._appsrc = self._appsrc, None if source is None: return + self._appsrc_caps = None if self._appsrc_seek_data_id is not None: source.disconnect(self._appsrc_seek_data_id) self._appsrc_seek_data_id = None @@ -93,14 +95,8 @@ class Audio(pykka.ThreadingActor): if not uri or not uri.startswith('appsrc://'): return - # These caps matches the audio data provided by libspotify - default_caps = gst.Caps( - b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' - b'width=(int)16, depth=(int)16, signed=(boolean)true, ' - b'rate=(int)44100') source = element.get_property('source') - source.set_property('caps', default_caps) - # GStreamer does not like unicode + source.set_property('caps', self._appsrc_caps) source.set_property('format', b'time') source.set_property('stream-type', b'seekable') @@ -261,16 +257,22 @@ class Audio(pykka.ThreadingActor): """ self._playbin.set_property('uri', uri) - def set_appsrc(self, seek_data=None): + def set_appsrc(self, caps, seek_data=None): """ Switch to using appsrc for getting audio to be played. You *MUST* call :meth:`prepare_change` before calling this method. + :param caps: GStreamer caps string describing the audio format to + expect + :type caps: string :param seek_data: callback for when data from a new position is needed to continue playback :type seek_data: callable which takes time position in ms """ + if isinstance(caps, unicode): + caps = caps.encode('utf-8') + self._appsrc_caps = gst.Caps(caps) self._appsrc_seek_data_callback = seek_data self._playbin.set_property('uri', 'appsrc://') diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d80ef543..9069ce7e 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -17,6 +17,12 @@ def seek_data_callback(spotify_backend, time_position): class SpotifyPlaybackProvider(base.BasePlaybackProvider): + # These GStreamer caps matches the audio data provided by libspotify + _caps = ( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + def play(self, track): if track.uri is None: return False @@ -31,7 +37,9 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.play(1) self.audio.prepare_change() - self.audio.set_appsrc(seek_data=seek_data_callback_bound) + self.audio.set_appsrc( + self._caps, + seek_data=seek_data_callback_bound) self.audio.start_playback() self.audio.set_metadata(track) From 2a487ecd303e09f4471a887ed02801c5f29d901b Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 01:39:56 +0100 Subject: [PATCH 108/144] spotify: Fix flake8 warnings --- mopidy/backends/spotify/library.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 8e8e47f9..f3406821 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -95,18 +95,24 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): album = Link.from_string(uri).as_album() album_browser = self.backend.spotify.session.browse_album(album) self._wait_for_object_to_load(album_browser) - return [SpotifyTrack(track=t) for t in album_browser if t.availability() == 1] + return [ + SpotifyTrack(track=t) + for t in album_browser if t.availability() == 1] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() artist_browser = self.backend.spotify.session.browse_artist(artist) self._wait_for_object_to_load(artist_browser) - return [SpotifyTrack(track=t) for t in artist_browser if t.availability() == 1] + return [ + SpotifyTrack(track=t) + for t in artist_browser if t.availability() == 1] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) - return [SpotifyTrack(track=t) for t in playlist if t.availability() == 1] + return [ + SpotifyTrack(track=t) + for t in playlist if t.availability() == 1] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): From 75279721fb41d88bf27fe3dd8629f49a8485cb57 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 01:41:08 +0100 Subject: [PATCH 109/144] spotify: Return [] instead of None in _lookup_track --- mopidy/backends/spotify/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index f3406821..a39d674a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -87,7 +87,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if track.availability() == 1: return [SpotifyTrack(track=track)] else: - return None + return [] else: return [SpotifyTrack(uri=uri)] From 31ddbbc0171d2daea85d891d81de762f1dcec59e Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 02:07:34 +0100 Subject: [PATCH 110/144] spotify: Use TRACK_AVAILABLE constant --- mopidy/backends/spotify/library.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a39d674a..044e51d9 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -15,6 +15,8 @@ from . import translator logger = logging.getLogger('mopidy.backends.spotify') +TRACK_AVAILABLE = 1 + class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" @@ -84,7 +86,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) if track.is_loaded(): - if track.availability() == 1: + if track.availability() == TRACK_AVAILABLE: return [SpotifyTrack(track=track)] else: return [] @@ -97,7 +99,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self._wait_for_object_to_load(album_browser) return [ SpotifyTrack(track=t) - for t in album_browser if t.availability() == 1] + for t in album_browser if t.availability() == TRACK_AVAILABLE] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() @@ -105,14 +107,14 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self._wait_for_object_to_load(artist_browser) return [ SpotifyTrack(track=t) - for t in artist_browser if t.availability() == 1] + for t in artist_browser if t.availability() == TRACK_AVAILABLE] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) return [ SpotifyTrack(track=t) - for t in playlist if t.availability() == 1] + for t in playlist if t.availability() == TRACK_AVAILABLE] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): From f0ba9dd31c615448a74a8d96d5101af8a07b5e85 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 10:48:29 +0100 Subject: [PATCH 111/144] Bump version number to 0.11.0 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 049db682..2e5aeeba 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.10.0' +__version__ = '0.11.0' from mopidy import settings as default_settings_module diff --git a/tests/version_test.py b/tests/version_test.py index 271f004a..f353f201 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -32,5 +32,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.3'), SV('0.8.0')) self.assertLess(SV('0.8.0'), SV('0.8.1')) self.assertLess(SV('0.8.1'), SV('0.9.0')) - self.assertLess(SV('0.9.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.10.1')) + self.assertLess(SV('0.9.0'), SV('0.10.0')) + self.assertLess(SV('0.10.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.11.1')) From e7d9a1bcdb9e5acd588a70024b111da7d9f48ac6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 10:54:50 +0100 Subject: [PATCH 112/144] docs: Update changelog for v0.11.0 --- docs/changes.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2357590d..e705444b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,8 +5,13 @@ Changes This change log is used to track all major changes to Mopidy. -v0.11.0 (in development) -======================== +v0.11.0 (2012-12-24) +==================== + +In celebration of Mopidy's three year anniversary December 23, we're releasing +Mopidy 0.11. This release brings several improvements, most notably better +search which now includes matching artists and albums from Spotify in the +search results. **Settings** From 01bb71107465849f1a64371cf2a46992d491b5c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 11:11:39 +0100 Subject: [PATCH 113/144] docs: Add v0.12 section to changelog --- docs/changes.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index e705444b..23e53be0 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,12 @@ Changes This change log is used to track all major changes to Mopidy. +v0.12.0 (in development) +======================== + +(in development) + + v0.11.0 (2012-12-24) ==================== From 81b9d1116dd4096077b5cb48abded1e2fe2208ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 12:13:00 +0100 Subject: [PATCH 114/144] docs: Update changelog --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 23e53be0..89707b6a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,6 +10,10 @@ v0.12.0 (in development) (in development) +**Spotify** + +- Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) + v0.11.0 (2012-12-24) ==================== From 3a4a9e60e00e851d460c4aeed3cbac22fe545cb9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 24 Dec 2012 13:55:46 +0100 Subject: [PATCH 115/144] Fix use of threading.Event for Python 2.6 and clear connected state. threading.Event's wait method returns None on python pre 2.7, which means all searches would fail. This also corrects that fact that we weren't clearing the connected threading event on disconnects. I did not add any tests for this at this time as I just want to get the fix out. --- mopidy/backends/spotify/library.py | 4 +++- mopidy/backends/spotify/session_manager.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a8a9bcd6..96e5f616 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -163,7 +163,9 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): translator.to_mopidy_track(t) for t in results.tracks()]) future.set(search_result) - if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): + # Wait always returns None on python 2.6 :/ + self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT) + if not self.backend.spotify.connected.is_set(): logger.debug('Not connected: Spotify search cancelled') return SearchResult(uri='spotify:search') diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 0eed9939..ad0a806e 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -84,6 +84,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logged_out(self, session): """Callback used by pyspotify""" logger.info('Disconnected from Spotify') + self.connected.clear() def metadata_updated(self, session): """Callback used by pyspotify""" From f1aa1f9d665946b5f80085947872d211aea537d2 Mon Sep 17 00:00:00 2001 From: "Jeremy B. Merrill" Date: Mon, 24 Dec 2012 13:14:01 -0500 Subject: [PATCH 116/144] Add caveat about static in sound test file. Add a note saying that the purpose of the "aplay ... Front_Center.wav" line is merely to test whether the sound works or not, rather than to test its quality. Anecdotally, I had very static-y sound from the aplay command, which prompted me to incorrectly believe that my sound would not work with Mopidy. As it turns out, the sound works fine using Mopidy or gstreamer. This note will hopefully keep other Mopidy users from thinking their sound is broken when it is not. (I ended up installing armhf version and trying to use despotify, which didn't work, before coming back to Mopidy.) --- docs/installation/raspberrypi.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index fbb07364..8a4d9409 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -210,6 +210,10 @@ software packages, as Wheezy is going to be the next release of Debian. aplay /usr/share/sounds/alsa/Front_Center.wav + If you hear a voice saying "Front Center," then your sound is working. Don't + be concerned if this test sound includes static, output from Mopidy will not. + Test your sound with gstreamer to determine sound quality. + To make the change to analog output stick, you can add the ``amixer`` command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. From 8a0c48e61e51ed700ec2e286bd20ad4f1551a843 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 00:30:03 +0100 Subject: [PATCH 117/144] Add timestamp and duration to all spotify buffers. This fixes the issue where pausing playback would show the time of the last timestamped buffer instead of the current time. We also make sure to reset the time when we start a new track. This was done by overriding the play method on the session manager as it is also used for pausing, resuming and stopping. Ideally this should probably be reworked to avoid the gst import in mopidy.backends.spotify.playback, but for now this should do. --- mopidy/backends/spotify/playback.py | 7 ++++++- mopidy/backends/spotify/session_manager.py | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 9069ce7e..d7e622fb 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import functools @@ -35,6 +39,7 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) + self.backend.spotify.buffer_timestamp = 0 self.audio.prepare_change() self.audio.set_appsrc( @@ -54,5 +59,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.next_buffer_timestamp = time_position + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index ad0a806e..d372bfa4 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.next_buffer_timestamp = None + self.buffer_timestamp = 0 self.container_manager = None self.playlist_manager = None @@ -121,11 +121,13 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } + buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - if self.next_buffer_timestamp is not None: - buffer_.timestamp = self.next_buffer_timestamp * gst.MSECOND - self.next_buffer_timestamp = None + buffer_.timestamp = self.buffer_timestamp + buffer_.duration = num_frames * gst.SECOND / sample_rate + + self.buffer_timestamp += buffer_.duration if self.audio.emit_data(buffer_).get(): return num_frames From ce750ddbf9ef3becefb2d613954eb422b4e3026b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 03:28:44 +0100 Subject: [PATCH 118/144] Fix racecondition triggered KeyError in our DebugThread. --- mopidy/utils/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5edf287e..6be8937c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -101,7 +101,7 @@ class DebugThread(threading.Thread): stack = ''.join(traceback.format_stack(frame)) logger.debug( 'Current state of %s (%s):\n%s', - threads[ident], ident, stack) + threads.get(ident, '?'), ident, stack) del frame self.event.clear() From ab7bb2e2fa7dea4f368dc48b6d8524511dd61929 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:36:25 +0100 Subject: [PATCH 119/144] Update authors list --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index d536c059..45e1a37e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -13,3 +13,4 @@ - Matt Bray - Trygve Aaberge - Wouter van Wijk +- Jeremy B. Merrill From 0459f037a4e51886ccfbbdb59e7b70cea60e5f03 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:44:19 +0100 Subject: [PATCH 120/144] spotify: Remove gst import from spotify.playback module --- mopidy/backends/spotify/playback.py | 6 +----- mopidy/backends/spotify/session_manager.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d7e622fb..63ee45cc 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import functools @@ -59,5 +55,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND + self.backend.spotify.buffer_timestamp = time_position self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index d372bfa4..82287497 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -124,7 +124,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp + buffer_.timestamp = self.buffer_timestamp * gst.MSECOND buffer_.duration = num_frames * gst.SECOND / sample_rate self.buffer_timestamp += buffer_.duration From f9c50051c2dfae3da74dc777c01dcbe365e7706f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:53:20 +0100 Subject: [PATCH 121/144] Revert "spotify: Remove gst import from spotify.playback module" This reverts commit 0459f037a4e51886ccfbbdb59e7b70cea60e5f03. --- mopidy/backends/spotify/playback.py | 6 +++++- mopidy/backends/spotify/session_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 63ee45cc..d7e622fb 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import functools @@ -55,5 +59,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp = time_position + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 82287497..d372bfa4 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -124,7 +124,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp * gst.MSECOND + buffer_.timestamp = self.buffer_timestamp buffer_.duration = num_frames * gst.SECOND / sample_rate self.buffer_timestamp += buffer_.duration From b4028e9c577c03277cfc39b24b070fd4868a3847 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:58:05 +0100 Subject: [PATCH 122/144] spotify: Remove gst import from spotify.playback module --- mopidy/backends/spotify/playback.py | 8 ++------ mopidy/backends/spotify/session_manager.py | 8 ++++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d7e622fb..2937f881 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import functools @@ -39,7 +35,7 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.spotify.buffer_timestamp = 0 + self.backend.spotify.buffer_timestamp_in_ms = 0 self.audio.prepare_change() self.audio.set_appsrc( @@ -59,5 +55,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND + self.backend.spotify.buffer_timestamp_in_ms = time_position self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index d372bfa4..6eba6f05 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.buffer_timestamp = 0 + self.buffer_timestamp_in_ms = 0 self.container_manager = None self.playlist_manager = None @@ -124,10 +124,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp - buffer_.duration = num_frames * gst.SECOND / sample_rate + buffer_.timestamp = self.buffer_timestamp_in_ms * gst.MSECOND + buffer_.duration = num_frames * gst.SECOND // sample_rate - self.buffer_timestamp += buffer_.duration + self.buffer_timestamp_in_ms += buffer_.duration // gst.MSECOND if self.audio.emit_data(buffer_).get(): return num_frames From ea431c2f184fa432ff03b1d77778eb3a21690df0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 21:00:37 +0100 Subject: [PATCH 123/144] Revert "spotify: Remove gst import from spotify.playback module" This reverts commit b4028e9c577c03277cfc39b24b070fd4868a3847. --- mopidy/backends/spotify/playback.py | 8 ++++++-- mopidy/backends/spotify/session_manager.py | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 2937f881..d7e622fb 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import functools @@ -35,7 +39,7 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.spotify.buffer_timestamp_in_ms = 0 + self.backend.spotify.buffer_timestamp = 0 self.audio.prepare_change() self.audio.set_appsrc( @@ -55,5 +59,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp_in_ms = time_position + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 6eba6f05..d372bfa4 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.buffer_timestamp_in_ms = 0 + self.buffer_timestamp = 0 self.container_manager = None self.playlist_manager = None @@ -124,10 +124,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp_in_ms * gst.MSECOND - buffer_.duration = num_frames * gst.SECOND // sample_rate + buffer_.timestamp = self.buffer_timestamp + buffer_.duration = num_frames * gst.SECOND / sample_rate - self.buffer_timestamp_in_ms += buffer_.duration // gst.MSECOND + self.buffer_timestamp += buffer_.duration if self.audio.emit_data(buffer_).get(): return num_frames From fb8e96bbf043211858bae76cbc3a7b1754854959 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 29 Dec 2012 13:23:21 +0100 Subject: [PATCH 124/144] docs: Add woutervanwijk's web client --- .../woutervanwijk-mopidy-webclient.png | Bin 0 -> 46487 bytes docs/clients/http.rst | 21 +++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 docs/_static/woutervanwijk-mopidy-webclient.png diff --git a/docs/_static/woutervanwijk-mopidy-webclient.png b/docs/_static/woutervanwijk-mopidy-webclient.png new file mode 100644 index 0000000000000000000000000000000000000000..0dd99acc9b9a52cc21dc994d12dd5b68f6615a8f GIT binary patch literal 46487 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 z

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 Y7O;bsDhxZ^fKPpr5RnzG7Ss>;KMm1+Q2+n{ literal 0 HcmV?d00001 diff --git a/docs/clients/http.rst b/docs/clients/http.rst index e41adb5b..c6e8b7d8 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -4,11 +4,26 @@ HTTP clients ************ -Mopidy added an :ref:`http-frontend` in 0.10 which provides the building blocks -needed for creating web clients for Mopidy with the help of a WebSocket and a -JavaScript library provided by Mopidy. +Mopidy added an :ref:`HTTP frontend ` in 0.10 which provides the +building blocks needed for creating web clients for Mopidy with the help of a +WebSocket and a JavaScript library provided by Mopidy. This page will list any HTTP/web Mopidy clients. If you've created one, please notify us so we can include your client on this page. See :ref:`http-frontend` for details on how to build your own web client. + + +woutervanwijk/Mopidy-Webclient +============================== + +.. image:: /_static/woutervanwijk-mopidy-webclient.png + :width: 410 + :height: 511 + +The first web client for Mopidy is still under development, but is already very +usable. It targets both desktop and mobile browsers. + +To try it out, get a copy of https://github.com/woutervanwijk/Mopidy-WebClient +and point the :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` setting towards +your copy of the web client. From 4f11ac77aede97af8edb00098561c838af2557ad Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 03:20:05 +0100 Subject: [PATCH 125/144] settings: Update settings validator to support empty iterables. --- mopidy/utils/settings.py | 22 ++++++++++++++-------- tests/utils/settings_test.py | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 6eb462ce..8ae61e5b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -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.' diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 1dcac1bb..51f0d89c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -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( From 341dea7262071ba3384eede98ca7b22f97c596f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 03:21:20 +0100 Subject: [PATCH 126/144] stream backend: Add StreamBackend, fixes #151 Adds a basic streaming backend simply handles streaming audio and nothing else. I.e. no metadata beyond the URI we where given. #270 still needs to be solved for actual metadata to make sense in this backend. --- mopidy/backends/stream/__init__.py | 23 ++++++++++++ mopidy/backends/stream/actor.py | 57 ++++++++++++++++++++++++++++++ mopidy/settings.py | 25 +++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 mopidy/backends/stream/__init__.py create mode 100644 mopidy/backends/stream/actor.py diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py new file mode 100644 index 00000000..82755540 --- /dev/null +++ b/mopidy/backends/stream/__init__.py @@ -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 diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py new file mode 100644 index 00000000..7fc28711 --- /dev/null +++ b/mopidy/backends/stream/actor.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst + +import logging +import urlparse + +import pykka + +from mopidy import settings +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 + + available_protocols = 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 settings.STREAM_PROTOCOLS: + available_protocols.add(uri) + + self.uri_schemes = list(available_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)] + + def find_exact(self, **query): + return SearchResult() + + def search(self, **query): + return SearchResult() + + def refresh(self, uri=None): + pass diff --git a/mopidy/settings.py b/mopidy/settings.py index c2081e27..9d99a7cb 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -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', +) From 5dd7f4b07a4a996032864ffa0e5622695d5b6534 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 1 Jan 2013 16:56:34 +0100 Subject: [PATCH 127/144] core: Update BaseLibraryProvider to not require refresh, search or find_exact. These methods may now return None, and the core code has been updated to filter out missing SearchResults. --- mopidy/backends/base.py | 12 ++++++------ mopidy/backends/stream/actor.py | 9 --------- mopidy/core/library.py | 4 ++-- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 8250a24c..f49aa89b 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -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): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 7fc28711..cdf777af 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -46,12 +46,3 @@ class StreamLibraryProvider(base.BaseLibraryProvider): # Note that we would only want the stream metadata at this stage, # not the currently playing track's. return [Track(uri=uri, name=uri)] - - def find_exact(self, **query): - return SearchResult() - - def search(self, **query): - return SearchResult() - - def refresh(self, uri=None): - pass diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 39a1e99c..e4be7ce8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -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] From f1bd092e63f501bc5f3bb2dee3e65a267b61ccdc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 1 Jan 2013 17:03:48 +0100 Subject: [PATCH 128/144] core: Update tests with cases for filtering out None results. --- tests/core/library_test.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 32e618d2..e01696c7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -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') From ca82565b0889f81f8ae9890d1a729a8f014a4458 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 1 Jan 2013 17:25:07 +0100 Subject: [PATCH 129/144] audio: Move supported URI checking to mopidy.audio.utils In order to avoid gstreamer imports leaking into more of our code I'm moving this to a new utils class in audio. --- mopidy/audio/utils.py | 23 +++++++++++++++++++++++ mopidy/backends/stream/actor.py | 16 +++------------- 2 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 mopidy/audio/utils.py diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py new file mode 100644 index 00000000..3f5f685e --- /dev/null +++ b/mopidy/audio/utils.py @@ -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 diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index cdf777af..0c91f291 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -1,15 +1,12 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - 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 @@ -24,15 +21,8 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = None - available_protocols = 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 settings.STREAM_PROTOCOLS: - available_protocols.add(uri) - - self.uri_schemes = list(available_protocols) + self.uri_schemes = utils.supported_uri_schemes( + settings.STREAM_PROTOCOLS) # TODO: Should we consider letting lookup know how to expand common playlist From dfa0d648f985a58ad536b3dc5bdbaf5ea20438b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 21:39:14 +0100 Subject: [PATCH 130/144] scanner: Support symlinks --- docs/changes.rst | 4 ++++ mopidy/utils/path.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 89707b6a..fba9b80e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,10 @@ v0.12.0 (in development) - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) +**Local backend** + +- Make ``mopidy-scan`` support symlinks. + v0.11.0 (2012-12-24) ==================== diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index c4fa0ce2..7d988a90 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -120,7 +120,7 @@ def find_files(path): if not os.path.basename(path).startswith(b'.'): yield path else: - for dirpath, dirnames, filenames in os.walk(path): + for dirpath, dirnames, filenames in os.walk(path, followlinks=True): for dirname in dirnames: if dirname.startswith(b'.'): # Skip hidden folders by modifying dirnames inplace From af6ee16b3a0bec886c312c2904a2e9cc8dcbee20 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:25:32 +0100 Subject: [PATCH 131/144] Fix flake8 warnings --- mopidy/audio/utils.py | 2 +- mopidy/backends/stream/actor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 3f5f685e..e9eac9f8 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -12,7 +12,7 @@ def supported_uri_schemes(uri_schemes): :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() + supported_schemes = set() registry = gst.registry_get_default() for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 0c91f291..b7070454 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -8,7 +8,7 @@ import pykka from mopidy import settings from mopidy.audio import utils from mopidy.backends import base -from mopidy.models import SearchResult, Track +from mopidy.models import Track logger = logging.getLogger('mopidy.backends.stream') From b009606df5eb5a74cf2d372a0ad6bca575afb87b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:29:07 +0100 Subject: [PATCH 132/144] docs: Document new backend --- docs/api/backends.rst | 3 ++- docs/modules/backends/stream.rst | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 docs/modules/backends/stream.rst diff --git a/docs/api/backends.rst b/docs/api/backends.rst index f0aadd53..32c04d37 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -46,5 +46,6 @@ Backend implementations ======================= * :mod:`mopidy.backends.dummy` -* :mod:`mopidy.backends.spotify` * :mod:`mopidy.backends.local` +* :mod:`mopidy.backends.spotify` +* :mod:`mopidy.backends.stream` diff --git a/docs/modules/backends/stream.rst b/docs/modules/backends/stream.rst new file mode 100644 index 00000000..73e53048 --- /dev/null +++ b/docs/modules/backends/stream.rst @@ -0,0 +1,7 @@ +*********************************************** +:mod:`mopidy.backends.stream` -- Stream backend +*********************************************** + +.. automodule:: mopidy.backends.stream + :synopsis: Backend for playing audio streams + :members: From 052efc23eda7e73c3db4b23a0c93986c1e85be50 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:32:58 +0100 Subject: [PATCH 133/144] docs: Add stream backend to changelog --- docs/changes.rst | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index fba9b80e..22f221cd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,7 +10,7 @@ v0.12.0 (in development) (in development) -**Spotify** +**Spotify backend** - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) @@ -18,6 +18,23 @@ v0.12.0 (in development) - Make ``mopidy-scan`` support symlinks. +**Stream backend** + +We've added a new backend for playing audio streams, the :mod:`stream backend +`. It is activated by default. + +The stream backend supports the intersection of what your GStreamer +installation supports and what protocols are included in the +:attr:`mopidy.settings.STREAM_PROTOCOLS` settings. + +Current limitations: + +- No metadata about the current track in the stream is available. + +- Playlists are not parsed, so you can't play e.g. a M3U or PLS file which + contains stream URIs. You need to extract the stream URL from the playlist + yourself. See :issue:`303` for progress on this. + v0.11.0 (2012-12-24) ==================== From bd8ab175ed581527d16838aa1bfcb62c6d775409 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:35:43 +0100 Subject: [PATCH 134/144] docs: Add period for consistency --- mopidy/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 9d99a7cb..6ee9357e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -306,7 +306,7 @@ SPOTIFY_TIMEOUT = 10 #: Whitelist of URIs to support streaming from. #: -#: Used by :mod:`mopidy.backends.stream` +#: Used by :mod:`mopidy.backends.stream`. #: #: Default:: #: From fa929fd01da8d5aad193102a16ff7a8b8265069f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:36:29 +0100 Subject: [PATCH 135/144] docs: Add more periods for consistency --- mopidy/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 6ee9357e..fd3dfd6f 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -288,7 +288,7 @@ SPOTIFY_PROXY_USERNAME = None #: Spotify proxy password. #: -#: Used by :mod:`mopidy.backends.spotify` +#: Used by :mod:`mopidy.backends.spotify`. #: #: Default:: #: @@ -297,7 +297,7 @@ SPOTIFY_PROXY_PASSWORD = None #: Max number of seconds to wait for Spotify operations to complete. #: -#: Used by :mod:`mopidy.backends.spotify` +#: Used by :mod:`mopidy.backends.spotify`. #: #: Default:: #: From 62cbcee5d76d17035ce4d8fc04191214db29409f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 23:40:59 +0100 Subject: [PATCH 136/144] Fix Python 2.6.0/2.6.1 support (fixes #302) --- docs/changes.rst | 3 +++ mopidy/__main__.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 22f221cd..dd82dd5f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,6 +10,9 @@ v0.12.0 (in development) (in development) +- Make Mopidy work on Python 2.6 versions less than 2.6.2rc1. (Fixes: + :issue:`302`) + **Spotify backend** - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 952f158c..e111fcef 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -79,37 +79,40 @@ def main(): def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + # NOTE Python 2.6: To support Python versions < 2.6.2rc1 we must use + # bytestrings for the first argument to ``add_option`` + # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( - '--help-gst', + b'--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') parser.add_option( - '-i', '--interactive', + b'-i', '--interactive', action='store_true', dest='interactive', help='ask interactively for required settings which are missing') parser.add_option( - '-q', '--quiet', + b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option( - '-v', '--verbose', + b'-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') parser.add_option( - '--save-debug-log', + b'--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') parser.add_option( - '--list-settings', + b'--list-settings', action='callback', callback=settings_utils.list_settings_optparse_callback, help='list current settings') parser.add_option( - '--list-deps', + b'--list-deps', action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') parser.add_option( - '--debug-thread', + b'--debug-thread', action='store_true', dest='debug_thread', help='run background thread that dumps tracebacks on SIGUSR1') return parser.parse_args(args=mopidy_args)[0] From d34ba24cfee27b12b9de0524453926ce80c42d59 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 3 Jan 2013 10:04:05 +0100 Subject: [PATCH 137/144] Use bytestrings for the keys of **kwargs dicts Python < 2.6.5rc1 does not work with unicode keys. Fixes #302. --- docs/changes.rst | 9 +++++-- mopidy/backends/local/translator.py | 39 ++++++++++++++++------------- mopidy/scanner.py | 10 +++++--- tests/scanner_test.py | 36 ++++++++++++++------------ 4 files changed, 55 insertions(+), 39 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index dd82dd5f..c2b076fc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,8 +10,13 @@ v0.12.0 (in development) (in development) -- Make Mopidy work on Python 2.6 versions less than 2.6.2rc1. (Fixes: - :issue:`302`) +- Make Mopidy work on early Python 2.6 versions. (Fixes: :issue:`302`) + + - ``optparse`` fails if the first argument to ``add_option`` is a unicode + string on Python < 2.6.2rc1. + + - ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python + < 2.6.5rc1. **Spotify backend** diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 390fd92a..157804b4 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -98,6 +98,9 @@ def _convert_mpd_data(data, tracks, music_dir): if not data: return + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for details. + track_kwargs = {} album_kwargs = {} artist_kwargs = {} @@ -105,38 +108,38 @@ def _convert_mpd_data(data, tracks, music_dir): if 'track' in data: if '/' in data['track']: - album_kwargs['num_tracks'] = int(data['track'].split('/')[1]) - track_kwargs['track_no'] = int(data['track'].split('/')[0]) + album_kwargs[b'num_tracks'] = int(data['track'].split('/')[1]) + track_kwargs[b'track_no'] = int(data['track'].split('/')[0]) else: - track_kwargs['track_no'] = int(data['track']) + track_kwargs[b'track_no'] = int(data['track']) if 'artist' in data: - artist_kwargs['name'] = data['artist'] - albumartist_kwargs['name'] = data['artist'] + artist_kwargs[b'name'] = data['artist'] + albumartist_kwargs[b'name'] = data['artist'] if 'albumartist' in data: - albumartist_kwargs['name'] = data['albumartist'] + albumartist_kwargs[b'name'] = data['albumartist'] if 'album' in data: - album_kwargs['name'] = data['album'] + album_kwargs[b'name'] = data['album'] if 'title' in data: - track_kwargs['name'] = data['title'] + track_kwargs[b'name'] = data['title'] if 'date' in data: - track_kwargs['date'] = data['date'] + track_kwargs[b'date'] = data['date'] if 'musicbrainz_trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] + track_kwargs[b'musicbrainz_id'] = data['musicbrainz_trackid'] if 'musicbrainz_albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] + album_kwargs[b'musicbrainz_id'] = data['musicbrainz_albumid'] if 'musicbrainz_artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] + artist_kwargs[b'musicbrainz_id'] = data['musicbrainz_artistid'] if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = ( + albumartist_kwargs[b'musicbrainz_id'] = ( data['musicbrainz_albumartistid']) if data['file'][0] == '/': @@ -147,18 +150,18 @@ def _convert_mpd_data(data, tracks, music_dir): if artist_kwargs: artist = Artist(**artist_kwargs) - track_kwargs['artists'] = [artist] + track_kwargs[b'artists'] = [artist] if albumartist_kwargs: albumartist = Artist(**albumartist_kwargs) - album_kwargs['artists'] = [albumartist] + album_kwargs[b'artists'] = [albumartist] if album_kwargs: album = Album(**album_kwargs) - track_kwargs['album'] = album + track_kwargs[b'album'] = album - track_kwargs['uri'] = path_to_uri(music_dir, path) - track_kwargs['length'] = int(data.get('time', 0)) * 1000 + track_kwargs[b'uri'] = path_to_uri(music_dir, path) + track_kwargs[b'length'] = int(data.get('time', 0)) * 1000 track = Track(**track_kwargs) tracks.add(track) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 0b10d061..68d7440a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -96,9 +96,13 @@ def translator(data): artist_kwargs = {} track_kwargs = {} + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for + # details. + def _retrieve(source_key, target_key, target): if source_key in data: - target[target_key] = data[source_key] + target[str(target_key)] = data[source_key] _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) @@ -111,7 +115,7 @@ def translator(data): except ValueError: pass # Ignore invalid dates else: - track_kwargs['date'] = date.isoformat() + track_kwargs[b'date'] = date.isoformat() _retrieve(gst.TAG_TITLE, 'name', track_kwargs) _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) @@ -125,7 +129,7 @@ def translator(data): 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: - album_kwargs['artists'] = [Artist(**albumartist_kwargs)] + album_kwargs[b'artists'] = [Artist(**albumartist_kwargs)] track_kwargs['uri'] = data['uri'] track_kwargs['length'] = data[gst.TAG_DURATION] diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 92e9a269..d8466e26 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -32,36 +32,40 @@ class TranslatorTest(unittest.TestCase): 'musicbrainz-albumartistid': 'mbalbumartistid', } + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for + # details. + self.album = { - 'name': 'albumname', - 'num_tracks': 2, - 'musicbrainz_id': 'mbalbumid', + b'name': 'albumname', + b'num_tracks': 2, + b'musicbrainz_id': 'mbalbumid', } self.artist = { - 'name': 'name', - 'musicbrainz_id': 'mbartistid', + b'name': 'name', + b'musicbrainz_id': 'mbartistid', } self.albumartist = { - 'name': 'albumartistname', - 'musicbrainz_id': 'mbalbumartistid', + b'name': 'albumartistname', + b'musicbrainz_id': 'mbalbumartistid', } self.track = { - 'uri': 'uri', - 'name': 'trackname', - 'date': '2006-01-01', - 'track_no': 1, - 'length': 4531, - 'musicbrainz_id': 'mbtrackid', + b'uri': 'uri', + b'name': 'trackname', + b'date': '2006-01-01', + b'track_no': 1, + b'length': 4531, + b'musicbrainz_id': 'mbtrackid', } def build_track(self): if self.albumartist: - self.album['artists'] = [Artist(**self.albumartist)] - self.track['album'] = Album(**self.album) - self.track['artists'] = [Artist(**self.artist)] + self.album[b'artists'] = [Artist(**self.albumartist)] + self.track[b'album'] = Album(**self.album) + self.track[b'artists'] = [Artist(**self.artist)] return Track(**self.track) def check(self): From 5ff8ea451f0b8e08707822ed998327e21dd133be Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 3 Jan 2013 12:58:08 +0100 Subject: [PATCH 138/144] More **kwargs key fixing (#302) --- mopidy/scanner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 68d7440a..aba2491c 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -131,10 +131,10 @@ def translator(data): if albumartist_kwargs: album_kwargs[b'artists'] = [Artist(**albumartist_kwargs)] - track_kwargs['uri'] = data['uri'] - track_kwargs['length'] = data[gst.TAG_DURATION] - track_kwargs['album'] = Album(**album_kwargs) - track_kwargs['artists'] = [Artist(**artist_kwargs)] + track_kwargs[b'uri'] = data['uri'] + track_kwargs[b'length'] = data[gst.TAG_DURATION] + track_kwargs[b'album'] = Album(**album_kwargs) + track_kwargs[b'artists'] = [Artist(**artist_kwargs)] return Track(**track_kwargs) From ef3d5e92ceeb1ca0619885f4c6707ce6b4062b31 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 3 Jan 2013 13:14:21 +0100 Subject: [PATCH 139/144] scanner: Fix optparse on early 2.6 (#302) --- mopidy/scanner.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index aba2491c..9f8c12f7 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -79,12 +79,15 @@ def main(): def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + # NOTE Python 2.6: To support Python versions < 2.6.2rc1 we must use + # bytestrings for the first argument to ``add_option`` + # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( - '-q', '--quiet', + b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option( - '-v', '--verbose', + b'-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') return parser.parse_args(args=mopidy_args)[0] From 8d2656f75c15f7e64e53d180566f425110f5ff9b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 14:27:13 +0100 Subject: [PATCH 140/144] Temporary workaround for #300 Likely cause of this issue is libspotify getting the intial seek to early. We have not yet had time to dig beyond this point and develop has been broken for to long due to this. As such this work aroundly simply ignores the first seek to position zero outright, this avoiding what is likely a race condition in libspotify. Next step will be to create a minimal libspotify/pyspotify test case for this to verify that assumption and hopefully figure out a correct fix. We also need to look into if the intial seek can be avoided in gstreamer. --- mopidy/backends/spotify/playback.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d7e622fb..cead01bf 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -27,6 +27,10 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): 'width=(int)16, depth=(int)16, signed=(boolean)true, ' 'rate=(int)44100') + def __init__(self, *args, **kwargs): + super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) + self._first_seek = False + def play(self, track): if track.uri is None: return False @@ -35,6 +39,8 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): seek_data_callback_bound = functools.partial( seek_data_callback, spotify_backend) + self._first_seek = True + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) @@ -59,5 +65,11 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) + + if time_position == 0 and self._first_seek: + self._first_seek = False + logger.debug('Skipping seek due to issue #300') + return + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) From 88398ea355dff692c93e0a3220deeaa60ebd00a1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:35:52 +0100 Subject: [PATCH 141/144] Add new mopidy.audio.utils functions (fixes #301). Adds functions to create buffers, calcalute buffer durations based on number of samples and the sample rate and converting milliseconds to gst internal clock time. This also alows for the removal of all gst imports outside of mopidy.audio. --- mopidy/audio/__init__.py | 2 ++ mopidy/audio/utils.py | 27 ++++++++++++++++++++++ mopidy/backends/spotify/playback.py | 8 +++---- mopidy/backends/spotify/session_manager.py | 15 +++++------- mopidy/backends/stream/actor.py | 8 +++---- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 7cf1dcee..5adb333c 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -4,3 +4,5 @@ from __future__ import unicode_literals from .actor import Audio from .listener import AudioListener from .constants import PlaybackState +from .utils import (calculate_duration, create_buffer, millisecond_to_clocktime, + supported_uri_schemes) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index e9eac9f8..af80b3ab 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -5,6 +5,33 @@ pygst.require('0.10') import gst +def calculate_duration(num_samples, sample_rate): + """Determine duration of samples using a gst helper for preciese math.""" + return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) + + +def create_buffer(data, capabilites=None, timestamp=None, duration=None): + """Create a new gstreamer buffer based on provided data. + + Mainly intended to keep gst imports out of non audio modules. + """ + buffer_ = gst.Buffer(data) + if capabilites: + if isinstance(capabilites, basestring): + capabilites = gst.caps_from_string(capabilites) + buffer_.set_caps(capabilites) + if timestamp: + buffer_.timestamp = timestamp + if duration: + buffer_.duration = duration + return buffer_ + + +def millisecond_to_clocktime(value): + """Convert a millisecond time to internal gstreamer time.""" + return value * gst.MSECOND + + def supported_uri_schemes(uri_schemes): """Determine which URIs we can actually support from provided whitelist. diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index cead01bf..36d90cac 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,14 +1,11 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import functools from spotify import Link, SpotifyError +from mopidy import audio from mopidy.backends import base @@ -71,5 +68,6 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): logger.debug('Skipping seek due to issue #300') return - self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND + self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime( + time_position) self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index d372bfa4..7f71dc76 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import os import threading @@ -122,12 +118,13 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'channels': channels, } - buffer_ = gst.Buffer(bytes(frames)) - buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp - buffer_.duration = num_frames * gst.SECOND / sample_rate + duration = audio.calculate_duration(num_frames, sample_rate) + buffer_ = audio.create_buffer(bytes(frames), + capabilites=capabilites, + timestamp=self.buffer_timestamp, + duration=duration) - self.buffer_timestamp += buffer_.duration + self.buffer_timestamp += duration if self.audio.emit_data(buffer_).get(): return num_frames diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index b7070454..99b32195 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -5,8 +5,7 @@ import urlparse import pykka -from mopidy import settings -from mopidy.audio import utils +from mopidy import audio as audio_lib, settings from mopidy.backends import base from mopidy.models import Track @@ -18,10 +17,11 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, + backend=self) self.playlists = None - self.uri_schemes = utils.supported_uri_schemes( + self.uri_schemes = audio_lib.supported_uri_schemes( settings.STREAM_PROTOCOLS) From d5b19ab213604d193d1059428df05a0f3287a165 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:45:48 +0100 Subject: [PATCH 142/144] audio: Docstring typo fixing. --- mopidy/audio/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index af80b3ab..66084a16 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -6,14 +6,14 @@ import gst def calculate_duration(num_samples, sample_rate): - """Determine duration of samples using a gst helper for preciese math.""" + """Determine duration of samples using GStremer helper for precise math.""" return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) def create_buffer(data, capabilites=None, timestamp=None, duration=None): - """Create a new gstreamer buffer based on provided data. + """Create a new GStreamer buffer based on provided data. - Mainly intended to keep gst imports out of non audio modules. + Mainly intended to keep gst imports out of non-audio modules. """ buffer_ = gst.Buffer(data) if capabilites: From 364f0c68e8325c24e7fb7f9e6c676df3fa62f80a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:47:03 +0100 Subject: [PATCH 143/144] audio: Unwrap line that was less than 80 chars. --- mopidy/backends/stream/actor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 99b32195..f80ac7a9 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -17,8 +17,7 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, - backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = None self.uri_schemes = audio_lib.supported_uri_schemes( From 855987447b87f6a91f02a8dfd8751fc6d28cb08c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:51:09 +0100 Subject: [PATCH 144/144] audio: Docstring typo typo fixing. --- mopidy/audio/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 66084a16..9d0f46dd 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -6,7 +6,7 @@ import gst def calculate_duration(num_samples, sample_rate): - """Determine duration of samples using GStremer helper for precise math.""" + """Determine duration of samples using GStreamer helper for precise math.""" return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate)