From c6ca88fdfc014abe64c59d7be2e919bd33d5d7a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 23 Nov 2013 21:27:33 +0100 Subject: [PATCH 001/238] setup: Fix capitalization of CherryPy package --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 511a16e8..f43981bf 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ setup( 'Pykka >= 1.1', ], extras_require={ - 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], + 'http': ['CherryPy >= 3.2.2', 'ws4py >= 0.2.3'], }, test_suite='nose.collector', tests_require=[ From 972056643f6ebdc64f9b6f53cf7e2f8f8ceb94d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 23 Nov 2013 21:31:20 +0100 Subject: [PATCH 002/238] docs: Use twine for uploading sdist/bdist_wheel --- docs/devtools.rst | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/devtools.rst b/docs/devtools.rst index 6bdf9a27..ecae6c86 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -106,15 +106,26 @@ Creating releases git checkout master git merge --no-ff -m "Release v0.16.0" develop +#. Install/upgrade tools used for packaging:: + + pip install -U twine wheel + #. Build package and test it manually in a new virtualenv. The following assumes the use of virtualenvwrapper:: - python setup.py sdist + python setup.py sdist bdist_wheel + mktmpenv pip install path/to/dist/Mopidy-0.16.0.tar.gz toggleglobalsitepackages + # do manual test + deactivate - Then test Mopidy manually to confirm that the package is working correctly. + mktmpenv + pip install path/to/dist/Mopidy-0.16.0-py27-none-any.whl + toggleglobalsitepackages + # do manual test + deactivate #. Tag the release:: @@ -125,14 +136,10 @@ Creating releases git push git push --tags -#. Build source package and upload to PyPI:: +#. Upload the previously built and tested sdist and bdist_wheel packages to + PyPI:: - python setup.py sdist upload - -#. Build wheel package and upload to PyPI:: - - pip install -U wheel - python setup.py bdist_wheel upload + twine upload dist/Mopidy-0.16.0* #. Merge ``master`` back into ``develop`` and push the branch to GitHub. From e2b36cb0f0702f44a25fcec79cca8dd3b3bded43 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 23 Nov 2013 21:39:18 +0100 Subject: [PATCH 003/238] mpd: Format multiline patterns properly in docs --- mopidy/frontends/mpd/protocol/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index dbb96a1b..0c2e5b25 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -15,6 +15,8 @@ from __future__ import unicode_literals from collections import namedtuple import re +from mopidy.utils import formatting + #: The MPD protocol uses UTF-8 for encoding all data. ENCODING = 'UTF-8' @@ -66,8 +68,8 @@ def handle_request(pattern, auth_required=True): raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) request_handlers[compiled_pattern] = func - func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % ( - pattern, func.__doc__ or '') + func.__doc__ = '*Pattern:*\n\n.. code-block:: text\n\n%s\n\n%s' % ( + formatting.indent(pattern, places=4), func.__doc__ or '') return func return decorator From 13fb2bf6048b1045d18ce708b9463585daf536de Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 23 Nov 2013 21:40:05 +0100 Subject: [PATCH 004/238] docs: Fix reference --- docs/extensiondev.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 38fe1c55..7fa19f7a 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -386,8 +386,8 @@ such as scanning for media, adding a command is the way to go. Your top level command name will always match your extension name, but you are free to add sub-commands with names of your choosing. -The skeleton of a commands would look like this. See :ref:`command-api` for more -details. +The skeleton of a commands would look like this. See :ref:`commands-api` for +more details. :: From f383e9ad4825d9d38de92eb2ddf207d97af62c43 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 23 Nov 2013 21:47:11 +0100 Subject: [PATCH 005/238] mpd: Format multiline patterns properly, second try --- mopidy/frontends/mpd/protocol/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 0c2e5b25..8a0993d8 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -68,8 +68,18 @@ def handle_request(pattern, auth_required=True): raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) request_handlers[compiled_pattern] = func - func.__doc__ = '*Pattern:*\n\n.. code-block:: text\n\n%s\n\n%s' % ( - formatting.indent(pattern, places=4), func.__doc__ or '') + func.__doc__ = """ + *Pattern:* + + .. code-block:: text + +%(pattern)s + +%(docs)s + """ % { + 'pattern': formatting.indent(pattern, places=8, singles=True), + 'docs': func.__doc__ or '', + } return func return decorator From c128668621c0d007a488ea7275f255bd254d0c97 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 15:10:48 +0100 Subject: [PATCH 006/238] listeners: Make listeners async - Add a common Listener base class - Make send helper for sending out events with pykka - Make send async helper for avoiding blocking events This change ensures all the events now get sent out via the MainThread instead of blocking the actors. --- mopidy/audio/listener.py | 24 +++--------------------- mopidy/backends/listener.py | 21 +++------------------ mopidy/core/listener.py | 8 +++----- mopidy/listener.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 44 deletions(-) create mode 100644 mopidy/listener.py diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 464407b4..537a81dd 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -import pykka +from mopidy import listener -class AudioListener(object): +class AudioListener(listener.Listener): """ Marker interface for recipients of events sent by the audio actor. @@ -17,25 +17,7 @@ class AudioListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of audio listener events""" - listeners = pykka.ActorRegistry.get_by_class(AudioListener) - for listener in listeners: - listener.proxy().on_event(event, **kwargs) - - def on_event(self, event, **kwargs): - """ - Called on all events. - - *MAY* be implemented by actor. By default, this method forwards the - event to the specific event methods. - - For a list of what event names to expect, see the names of the other - methods in :class:`AudioListener`. - - :param event: the event name - :type event: string - :param kwargs: any other arguments to the specific event handlers - """ - getattr(self, event)(**kwargs) + listener.send_async(AudioListener, event, **kwargs) def reached_end_of_stream(self): """ diff --git a/mopidy/backends/listener.py b/mopidy/backends/listener.py index d9043079..ee4735e7 100644 --- a/mopidy/backends/listener.py +++ b/mopidy/backends/listener.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -import pykka +from mopidy import listener -class BackendListener(object): +class BackendListener(listener.Listener): """ Marker interface for recipients of events sent by the backend actors. @@ -19,22 +19,7 @@ class BackendListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of backend listener events""" - listeners = pykka.ActorRegistry.get_by_class(BackendListener) - for listener in listeners: - listener.proxy().on_event(event, **kwargs) - - def on_event(self, event, **kwargs): - """ - Called on all events. - - *MAY* be implemented by actor. By default, this method forwards the - event to the specific event methods. - - :param event: the event name - :type event: string - :param kwargs: any other arguments to the specific event handlers - """ - getattr(self, event)(**kwargs) + listener.send_async(BackendListener, event, **kwargs) def playlists_loaded(self): """ diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 40c78540..f0bb1ea3 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -import pykka +from mopidy import listener -class CoreListener(object): +class CoreListener(listener.Listener): """ Marker interface for recipients of events sent by the core actor. @@ -17,9 +17,7 @@ class CoreListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" - listeners = pykka.ActorRegistry.get_by_class(CoreListener) - for listener in listeners: - listener.proxy().on_event(event, **kwargs) + listener.send_async(CoreListener, event, **kwargs) def on_event(self, event, **kwargs): """ diff --git a/mopidy/listener.py b/mopidy/listener.py new file mode 100644 index 00000000..6461e0e5 --- /dev/null +++ b/mopidy/listener.py @@ -0,0 +1,33 @@ +from __future__ import unicode_literals + +import gobject +import logging +import pykka + +logger = logging.getLogger('mopidy.listener') + + +def send_async(cls, event, **kwargs): + gobject.idle_add(lambda: send(cls, event, **kwargs)) + + +def send(cls, event, **kwargs): + listeners = pykka.ActorRegistry.get_by_class(cls) + logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs) + for listener in listeners: + listener.proxy().on_event(event, **kwargs) + + +class Listener(object): + def on_event(self, event, **kwargs): + """ + Called on all events. + + *MAY* be implemented by actor. By default, this method forwards the + event to the specific event methods. + + :param event: the event name + :type event: string + :param kwargs: any other arguments to the specific event handlers + """ + getattr(self, event)(**kwargs) From 9e1d46a661ffcfab1a226c43af1074bb5d296413 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 16:04:21 +0100 Subject: [PATCH 007/238] listeners: Import grouping fix --- mopidy/listener.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/listener.py b/mopidy/listener.py index 6461e0e5..715beb03 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals -import gobject import logging + +import gobject import pykka logger = logging.getLogger('mopidy.listener') From 2d13734dfcd90d8120872a009ee124cf7bfca9d9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 16:42:04 +0100 Subject: [PATCH 008/238] logging: Remove use of root logger --- mopidy/__main__.py | 4 ++-- mopidy/config/__init__.py | 4 ++-- mopidy/ext.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 82e4569b..ff96cd04 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -161,9 +161,9 @@ def log_extension_info(all_extensions, enabled_extensions): # TODO: distinguish disabled vs blocked by env? enabled_names = set(e.ext_name for e in enabled_extensions) disabled_names = set(e.ext_name for e in all_extensions) - enabled_names - logging.info( + logger.info( 'Enabled extensions: %s', ', '.join(enabled_names) or 'none') - logging.info( + logger.info( 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index be2205ca..a7153ea2 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -119,8 +119,8 @@ def _load(files, defaults, overrides): with io.open(filename, 'rb') as filehandle: parser.readfp(filehandle) except configparser.MissingSectionHeaderError as e: - logging.warning('%s does not have a config section, not loaded.', - filename) + logger.warning('%s does not have a config section, not loaded.', + filename) except configparser.ParsingError as e: linenos = ', '.join(str(lineno) for lineno, line in e.errors) logger.warning( diff --git a/mopidy/ext.py b/mopidy/ext.py index e0f50c67..feadc99f 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -130,7 +130,7 @@ def load_extensions(): 'Loaded extension: %s %s', extension.dist_name, extension.version) names = (e.ext_name for e in installed_extensions) - logging.debug('Discovered extensions: %s', ', '.join(names)) + logger.debug('Discovered extensions: %s', ', '.join(names)) return installed_extensions From 03f5ff6f5744303c9a0e86b29d010c9078bf0447 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 17:15:26 +0100 Subject: [PATCH 009/238] local: Start moving tag cache code out of main local --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/local/actor.py | 2 +- mopidy/backends/local/tagcache/__init__.py | 0 mopidy/backends/local/{ => tagcache}/library.py | 5 ++--- 4 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 mopidy/backends/local/tagcache/__init__.py rename mopidy/backends/local/{ => tagcache}/library.py (98%) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 703b2562..8a2e12fd 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -34,7 +34,7 @@ class Extension(ext.Extension): return [LocalBackend] def get_library_updaters(self): - from .library import LocalLibraryUpdateProvider + from .tagcache.library import LocalLibraryUpdateProvider return [LocalLibraryUpdateProvider] def get_command(self): diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index f3611891..531b7546 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,7 +8,7 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .library import LocalLibraryProvider +from .tagcache.library import LocalLibraryProvider from .playlists import LocalPlaylistsProvider from .playback import LocalPlaybackProvider diff --git a/mopidy/backends/local/tagcache/__init__.py b/mopidy/backends/local/tagcache/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/tagcache/library.py similarity index 98% rename from mopidy/backends/local/library.py rename to mopidy/backends/local/tagcache/library.py index da4e4bfd..b6ec05ff 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/tagcache/library.py @@ -8,9 +8,9 @@ from mopidy.backends import base from mopidy.frontends.mpd import translator as mpd_translator from mopidy.models import Album, SearchResult -from .translator import local_to_file_uri, parse_mpd_tag_cache +from ..translator import local_to_file_uri, parse_mpd_tag_cache -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger('mopidy.backends.local.tagcache') class LocalLibraryProvider(base.BaseLibraryProvider): @@ -219,7 +219,6 @@ class LocalLibraryProvider(base.BaseLibraryProvider): raise LookupError('Missing query') -# TODO: rename and move to tagcache extension. class LocalLibraryUpdateProvider(base.BaseLibraryProvider): uri_schemes = ['local'] From ff9f473c2f87bc2f1dfbaafc1d13dc085a1ac589 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 17:47:52 +0100 Subject: [PATCH 010/238] local: Move tag cache translators and tests out. --- mopidy/backends/local/tagcache/library.py | 6 +- mopidy/backends/local/tagcache/translator.py | 246 +++++++++++++ mopidy/backends/local/translator.py | 125 ------- mopidy/frontends/mpd/translator.py | 115 ------ tests/backends/local/tagcache_test.py | 346 +++++++++++++++++++ tests/backends/local/translator_test.py | 106 +----- tests/frontends/mpd/translator_test.py | 235 +------------ 7 files changed, 598 insertions(+), 581 deletions(-) create mode 100644 mopidy/backends/local/tagcache/translator.py create mode 100644 tests/backends/local/tagcache_test.py diff --git a/mopidy/backends/local/tagcache/library.py b/mopidy/backends/local/tagcache/library.py index b6ec05ff..6efe6bf5 100644 --- a/mopidy/backends/local/tagcache/library.py +++ b/mopidy/backends/local/tagcache/library.py @@ -5,10 +5,10 @@ import os import tempfile from mopidy.backends import base -from mopidy.frontends.mpd import translator as mpd_translator +from mopidy.backends.local.translator import local_to_file_uri from mopidy.models import Album, SearchResult -from ..translator import local_to_file_uri, parse_mpd_tag_cache +from .translator import parse_mpd_tag_cache, tracks_to_tag_cache_format logger = logging.getLogger('mopidy.backends.local.tagcache') @@ -251,7 +251,7 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider): prefix=basename + '.', dir=directory, delete=False) try: - for row in mpd_translator.tracks_to_tag_cache_format( + for row in tracks_to_tag_cache_format( self._tracks.values(), self._media_dir): if len(row) == 1: tmp.write(('%s\n' % row).encode('utf-8')) diff --git a/mopidy/backends/local/tagcache/translator.py b/mopidy/backends/local/tagcache/translator.py new file mode 100644 index 00000000..be54cd1d --- /dev/null +++ b/mopidy/backends/local/tagcache/translator.py @@ -0,0 +1,246 @@ +from __future__ import unicode_literals + +import logging +import os +import re +import urllib + +from mopidy.frontends.mpd import translator as mpd, protocol +from mopidy.models import Track, Artist, Album +from mopidy.utils.encoding import locale_decode +from mopidy.utils.path import mtime as get_mtime, split_path, uri_to_path + +logger = logging.getLogger('mopidy.backends.local.tagcache') + + +# TODO: remove music_dir from API +def parse_mpd_tag_cache(tag_cache, music_dir=''): + """ + Converts a MPD tag_cache into a lists of tracks, artists and albums. + """ + tracks = set() + + try: + with open(tag_cache) as library: + contents = library.read() + except IOError as error: + logger.warning('Could not open tag cache: %s', locale_decode(error)) + return tracks + + current = {} + state = None + + # TODO: uris as bytes + for line in contents.split(b'\n'): + if line == b'songList begin': + state = 'songs' + continue + elif line == b'songList end': + state = None + continue + elif not state: + continue + + key, value = line.split(b': ', 1) + + if key == b'key': + _convert_mpd_data(current, tracks) + current.clear() + + current[key.lower()] = value.decode('utf-8') + + _convert_mpd_data(current, tracks) + + return tracks + + +def _convert_mpd_data(data, tracks): + if not data: + return + + track_kwargs = {} + album_kwargs = {} + artist_kwargs = {} + albumartist_kwargs = {} + + 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]) + else: + track_kwargs['track_no'] = int(data['track']) + + if 'mtime' in data: + track_kwargs['last_modified'] = int(data['mtime']) + + if 'artist' in data: + artist_kwargs['name'] = data['artist'] + + if 'albumartist' in data: + albumartist_kwargs['name'] = data['albumartist'] + + if 'composer' in data: + track_kwargs['composers'] = [Artist(name=data['composer'])] + + if 'performer' in data: + track_kwargs['performers'] = [Artist(name=data['performer'])] + + if 'album' in data: + album_kwargs['name'] = data['album'] + + if 'title' in data: + track_kwargs['name'] = data['title'] + + if 'genre' in data: + track_kwargs['genre'] = data['genre'] + + if 'date' in data: + track_kwargs['date'] = data['date'] + + if 'comment' in data: + track_kwargs['comment'] = data['comment'] + + if 'musicbrainz_trackid' in data: + track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] + + if 'musicbrainz_albumid' in data: + album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] + + if 'musicbrainz_artistid' in data: + artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] + + if 'musicbrainz_albumartistid' in data: + albumartist_kwargs['musicbrainz_id'] = ( + data['musicbrainz_albumartistid']) + + if artist_kwargs: + artist = Artist(**artist_kwargs) + track_kwargs['artists'] = [artist] + + if albumartist_kwargs: + albumartist = Artist(**albumartist_kwargs) + album_kwargs['artists'] = [albumartist] + + if album_kwargs: + album = Album(**album_kwargs) + track_kwargs['album'] = album + + if data['file'][0] == '/': + path = data['file'][1:] + else: + path = data['file'] + + track_kwargs['uri'] = 'local:track:%s' % path + track_kwargs['length'] = int(data.get('time', 0)) * 1000 + + track = Track(**track_kwargs) + tracks.add(track) + + +def tracks_to_tag_cache_format(tracks, media_dir): + """ + Format list of tracks for output to MPD tag cache + + :param tracks: the tracks + :type tracks: list of :class:`mopidy.models.Track` + :param media_dir: the path to the music dir + :type media_dir: string + :rtype: list of lists of two-tuples + """ + result = [ + ('info_begin',), + ('mpd_version', protocol.VERSION), + ('fs_charset', protocol.ENCODING), + ('info_end',) + ] + tracks.sort(key=lambda t: t.uri) + dirs, files = tracks_to_directory_tree(tracks, media_dir) + _add_to_tag_cache(result, dirs, files, media_dir) + return result + + +# TODO: bytes only +def _add_to_tag_cache(result, dirs, files, media_dir): + base_path = media_dir.encode('utf-8') + + for path, (entry_dirs, entry_files) in dirs.items(): + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + 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)))) + result.append(('begin', name)) + _add_to_tag_cache(result, entry_dirs, entry_files, media_dir) + result.append(('end', name)) + + result.append(('songList begin',)) + + for track in files: + track_result = dict(mpd.track_to_mpd_format(track)) + + # XXX Don't save comments to the tag cache as they may span multiple + # lines. We'll start saving track comments when we move from tag_cache + # to a JSON file. See #579 for details. + if 'Comment' in track_result: + del track_result['Comment'] + + path = uri_to_path(track_result['file']) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.quote(path).decode('utf-8') + relative_path = os.path.relpath(path, base_path) + relative_uri = urllib.quote(relative_path) + + # TODO: use track.last_modified + 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',)) + + +def tracks_to_directory_tree(tracks, media_dir): + directories = ({}, []) + + for track in tracks: + path = b'' + current = directories + + absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) + relative_track_dir_path = re.sub( + '^' + re.escape(media_dir), b'', absolute_track_dir_path) + + for part in split_path(relative_track_dir_path): + path = os.path.join(path, part) + if path not in current[0]: + current[0][path] = ({}, []) + current = current[0][path] + current[1].append(track) + return directories + + +MPD_KEY_ORDER = ''' + key file Time Artist Album AlbumArtist Title Track Genre Date Composer + Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID + MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime +'''.split() + + +def order_mpd_track_info(result): + """ + Order results from + :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it + matches MPD's ordering. Simply a cosmetic fix for easier diffing of + tag_caches. + + :param result: the track info + :type result: list of tuples + :rtype: list of tuples + """ + return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index b9aad3e0..dc266d1c 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -4,7 +4,6 @@ import logging import os import urlparse -from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path @@ -63,127 +62,3 @@ def parse_m3u(file_path, media_dir): uris.append(path) return uris - - -# TODO: remove music_dir from API -def parse_mpd_tag_cache(tag_cache, music_dir=''): - """ - Converts a MPD tag_cache into a lists of tracks, artists and albums. - """ - tracks = set() - - try: - with open(tag_cache) as library: - contents = library.read() - except IOError as error: - logger.warning('Could not open tag cache: %s', locale_decode(error)) - return tracks - - current = {} - state = None - - # TODO: uris as bytes - for line in contents.split(b'\n'): - if line == b'songList begin': - state = 'songs' - continue - elif line == b'songList end': - state = None - continue - elif not state: - continue - - key, value = line.split(b': ', 1) - - if key == b'key': - _convert_mpd_data(current, tracks) - current.clear() - - current[key.lower()] = value.decode('utf-8') - - _convert_mpd_data(current, tracks) - - return tracks - - -def _convert_mpd_data(data, tracks): - if not data: - return - - track_kwargs = {} - album_kwargs = {} - artist_kwargs = {} - albumartist_kwargs = {} - - 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]) - else: - track_kwargs['track_no'] = int(data['track']) - - if 'mtime' in data: - track_kwargs['last_modified'] = int(data['mtime']) - - if 'artist' in data: - artist_kwargs['name'] = data['artist'] - - if 'albumartist' in data: - albumartist_kwargs['name'] = data['albumartist'] - - if 'composer' in data: - track_kwargs['composers'] = [Artist(name=data['composer'])] - - if 'performer' in data: - track_kwargs['performers'] = [Artist(name=data['performer'])] - - if 'album' in data: - album_kwargs['name'] = data['album'] - - if 'title' in data: - track_kwargs['name'] = data['title'] - - if 'genre' in data: - track_kwargs['genre'] = data['genre'] - - if 'date' in data: - track_kwargs['date'] = data['date'] - - if 'comment' in data: - track_kwargs['comment'] = data['comment'] - - if 'musicbrainz_trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] - - if 'musicbrainz_albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] - - if 'musicbrainz_artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] - - if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = ( - data['musicbrainz_albumartistid']) - - if artist_kwargs: - artist = Artist(**artist_kwargs) - track_kwargs['artists'] = [artist] - - if albumartist_kwargs: - albumartist = Artist(**albumartist_kwargs) - album_kwargs['artists'] = [albumartist] - - if album_kwargs: - album = Album(**album_kwargs) - track_kwargs['album'] = album - - if data['file'][0] == '/': - path = data['file'][1:] - else: - path = data['file'] - - track_kwargs['uri'] = 'local:track:%s' % path - track_kwargs['length'] = int(data.get('time', 0)) * 1000 - - track = Track(**track_kwargs) - tracks.add(track) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 4f38effa..671bfae7 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -1,14 +1,9 @@ from __future__ import unicode_literals -import os -import re import shlex -import urllib -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 # TODO: special handling of local:// uri scheme @@ -87,27 +82,6 @@ def track_to_mpd_format(track, position=None): return result -MPD_KEY_ORDER = ''' - key file Time Artist Album AlbumArtist Title Track Genre Date Composer - Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID - MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime -'''.split() - - -def order_mpd_track_info(result): - """ - Order results from - :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it - matches MPD's ordering. Simply a cosmetic fix for easier diffing of - tag_caches. - - :param result: the track info - :type result: list of tuples - :rtype: list of tuples - """ - return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) - - def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. @@ -197,92 +171,3 @@ def query_from_mpd_list_format(field, mpd_query): return query else: raise MpdArgError('not able to parse args', command='list') - - -# TODO: move to tagcache backend. -def tracks_to_tag_cache_format(tracks, media_dir): - """ - Format list of tracks for output to MPD tag cache - - :param tracks: the tracks - :type tracks: list of :class:`mopidy.models.Track` - :param media_dir: the path to the music dir - :type media_dir: string - :rtype: list of lists of two-tuples - """ - result = [ - ('info_begin',), - ('mpd_version', protocol.VERSION), - ('fs_charset', protocol.ENCODING), - ('info_end',) - ] - tracks.sort(key=lambda t: t.uri) - dirs, files = tracks_to_directory_tree(tracks, media_dir) - _add_to_tag_cache(result, dirs, files, media_dir) - return result - - -# TODO: bytes only -def _add_to_tag_cache(result, dirs, files, media_dir): - base_path = media_dir.encode('utf-8') - - for path, (entry_dirs, entry_files) in dirs.items(): - try: - text_path = path.decode('utf-8') - except UnicodeDecodeError: - 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)))) - result.append(('begin', name)) - _add_to_tag_cache(result, entry_dirs, entry_files, media_dir) - result.append(('end', name)) - - result.append(('songList begin',)) - - for track in files: - track_result = dict(track_to_mpd_format(track)) - - # XXX Don't save comments to the tag cache as they may span multiple - # lines. We'll start saving track comments when we move from tag_cache - # to a JSON file. See #579 for details. - if 'Comment' in track_result: - del track_result['Comment'] - - path = uri_to_path(track_result['file']) - try: - text_path = path.decode('utf-8') - except UnicodeDecodeError: - text_path = urllib.quote(path).decode('utf-8') - relative_path = os.path.relpath(path, base_path) - relative_uri = urllib.quote(relative_path) - - # TODO: use track.last_modified - 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',)) - - -def tracks_to_directory_tree(tracks, media_dir): - directories = ({}, []) - - for track in tracks: - path = b'' - current = directories - - absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) - relative_track_dir_path = re.sub( - '^' + re.escape(media_dir), b'', absolute_track_dir_path) - - for part in split_path(relative_track_dir_path): - path = os.path.join(path, part) - if path not in current[0]: - current[0][path] = ({}, []) - current = current[0][path] - current[1].append(track) - return directories diff --git a/tests/backends/local/tagcache_test.py b/tests/backends/local/tagcache_test.py new file mode 100644 index 00000000..6d0b7469 --- /dev/null +++ b/tests/backends/local/tagcache_test.py @@ -0,0 +1,346 @@ +# encoding: utf-8 + +from __future__ import unicode_literals + +import os +import unittest + +from mopidy.utils.path import mtime, uri_to_path +from mopidy.frontends.mpd import translator as mpd, protocol +from mopidy.backends.local.tagcache import translator +from mopidy.models import Album, Artist, Track + +from tests import path_to_data_dir + + +class TracksToTagCacheFormatTest(unittest.TestCase): + def setUp(self): + self.media_dir = '/dir/subdir' + mtime.set_fake_time(1234567) + + def tearDown(self): + mtime.undo_fake() + + def translate(self, track): + base_path = self.media_dir.encode('utf-8') + result = dict(mpd.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()) + + def consume_headers(self, result): + self.assertEqual(('info_begin',), result[0]) + self.assertEqual(('mpd_version', protocol.VERSION), result[1]) + self.assertEqual(('fs_charset', protocol.ENCODING), result[2]) + self.assertEqual(('info_end',), result[3]) + return result[4:] + + def consume_song_list(self, result): + self.assertEqual(('songList begin',), result[0]) + for i, row in enumerate(result): + if row == ('songList end',): + return result[1:i], result[i + 1:] + self.fail("Couldn't find songList end in result") + + def consume_directory(self, result): + self.assertEqual('directory', result[0][0]) + self.assertEqual(('mtime', mtime('.')), result[1]) + self.assertEqual(('begin', os.path.split(result[0][1])[1]), result[2]) + directory = result[2][1] + for i, row in enumerate(result): + if row == ('end', directory): + return result[3:i], result[i + 1:] + self.fail("Couldn't find end %s in result" % directory) + + def test_empty_tag_cache_has_header(self): + result = translator.tracks_to_tag_cache_format([], self.media_dir) + result = self.consume_headers(result) + + def test_empty_tag_cache_has_song_list(self): + result = translator.tracks_to_tag_cache_format([], self.media_dir) + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(len(song_list), 0) + self.assertEqual(len(result), 0) + + def test_tag_cache_has_header(self): + track = Track(uri='file:///dir/subdir/song.mp3') + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + result = self.consume_headers(result) + + def test_tag_cache_has_song_list(self): + track = Track(uri='file:///dir/subdir/song.mp3') + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assert_(song_list) + self.assertEqual(len(result), 0) + + def test_tag_cache_has_formated_track(self): + track = Track(uri='file:///dir/subdir/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(formated, song_list) + self.assertEqual(len(result), 0) + + def test_tag_cache_has_formated_track_with_key_and_mtime(self): + track = Track(uri='file:///dir/subdir/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(formated, song_list) + self.assertEqual(len(result), 0) + + def test_tag_cache_supports_directories(self): + track = Track(uri='file:///dir/subdir/folder/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + + result = self.consume_headers(result) + dir_data, result = self.consume_directory(result) + song_list, result = self.consume_song_list(result) + self.assertEqual(len(song_list), 0) + self.assertEqual(len(result), 0) + + song_list, result = self.consume_song_list(dir_data) + self.assertEqual(len(result), 0) + self.assertEqual(formated, song_list) + + def test_tag_cache_diretory_header_is_right(self): + track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + + result = self.consume_headers(result) + dir_data, result = self.consume_directory(result) + + self.assertEqual(('directory', 'folder/sub'), dir_data[0]) + self.assertEqual(('mtime', mtime('.')), dir_data[1]) + self.assertEqual(('begin', 'sub'), dir_data[2]) + + def test_tag_cache_suports_sub_directories(self): + track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) + + result = self.consume_headers(result) + + dir_data, result = self.consume_directory(result) + song_list, result = self.consume_song_list(result) + self.assertEqual(len(song_list), 0) + self.assertEqual(len(result), 0) + + dir_data, result = self.consume_directory(dir_data) + song_list, result = self.consume_song_list(result) + self.assertEqual(len(result), 0) + self.assertEqual(len(song_list), 0) + + song_list, result = self.consume_song_list(dir_data) + self.assertEqual(len(result), 0) + self.assertEqual(formated, song_list) + + def test_tag_cache_supports_multiple_tracks(self): + tracks = [ + Track(uri='file:///dir/subdir/song1.mp3'), + Track(uri='file:///dir/subdir/song2.mp3'), + ] + + formated = [] + formated.extend(self.translate(tracks[0])) + formated.extend(self.translate(tracks[1])) + + result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) + + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(formated, song_list) + self.assertEqual(len(result), 0) + + def test_tag_cache_supports_multiple_tracks_in_dirs(self): + tracks = [ + Track(uri='file:///dir/subdir/song1.mp3'), + Track(uri='file:///dir/subdir/folder/song2.mp3'), + ] + + formated = [] + formated.append(self.translate(tracks[0])) + formated.append(self.translate(tracks[1])) + + result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) + + result = self.consume_headers(result) + dir_data, result = self.consume_directory(result) + song_list, song_result = self.consume_song_list(dir_data) + + self.assertEqual(formated[1], song_list) + self.assertEqual(len(song_result), 0) + + song_list, result = self.consume_song_list(result) + self.assertEqual(len(result), 0) + self.assertEqual(formated[0], song_list) + + +class TracksToDirectoryTreeTest(unittest.TestCase): + def setUp(self): + self.media_dir = '/root' + + def test_no_tracks_gives_emtpy_tree(self): + tree = translator.tracks_to_directory_tree([], self.media_dir) + self.assertEqual(tree, ({}, [])) + + def test_top_level_files(self): + tracks = [ + Track(uri='file:///root/file1.mp3'), + Track(uri='file:///root/file2.mp3'), + Track(uri='file:///root/file3.mp3'), + ] + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) + self.assertEqual(tree, ({}, tracks)) + + def test_single_file_in_subdir(self): + tracks = [Track(uri='file:///root/dir/file1.mp3')] + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) + expected = ({'dir': ({}, tracks)}, []) + self.assertEqual(tree, expected) + + def test_single_file_in_sub_subdir(self): + tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) + expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) + self.assertEqual(tree, expected) + + def test_complex_file_structure(self): + tracks = [ + Track(uri='file:///root/file1.mp3'), + Track(uri='file:///root/dir1/file2.mp3'), + Track(uri='file:///root/dir1/file3.mp3'), + Track(uri='file:///root/dir2/file4.mp3'), + Track(uri='file:///root/dir2/sub/file5.mp3'), + ] + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) + expected = ( + { + 'dir1': ({}, [tracks[1], tracks[2]]), + 'dir2': ( + { + 'dir2/sub': ({}, [tracks[4]]) + }, + [tracks[3]] + ), + }, + [tracks[0]] + ) + self.assertEqual(tree, expected) + + +expected_artists = [Artist(name='name')] +expected_albums = [ + Album(name='albumname', artists=expected_artists, num_tracks=2), + Album(name='albumname', num_tracks=2), +] +expected_tracks = [] + + +def generate_track(path, ident, album_id): + uri = 'local:track:%s' % path + track = Track( + uri=uri, name='trackname', artists=expected_artists, + album=expected_albums[album_id], track_no=1, date='2006', length=4000, + last_modified=1272319626) + expected_tracks.append(track) + + +generate_track('song1.mp3', 6, 0) +generate_track('song2.mp3', 7, 0) +generate_track('song3.mp3', 8, 1) +generate_track('subdir1/song4.mp3', 2, 0) +generate_track('subdir1/song5.mp3', 3, 0) +generate_track('subdir2/song6.mp3', 4, 1) +generate_track('subdir2/song7.mp3', 5, 1) +generate_track('subdir1/subsubdir/song8.mp3', 0, 0) +generate_track('subdir1/subsubdir/song9.mp3', 1, 1) + + +class MPDTagCacheToTracksTest(unittest.TestCase): + def test_emtpy_cache(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) + self.assertEqual(set(), tracks) + + def test_simple_cache(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) + track = Track( + uri='local:track:song1.mp3', name='trackname', + artists=expected_artists, track_no=1, album=expected_albums[0], + date='2006', length=4000, last_modified=1272319626) + self.assertEqual(set([track]), tracks) + + def test_advanced_cache(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) + self.assertEqual(set(expected_tracks), tracks) + + def test_unicode_cache(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) + + artists = [Artist(name='æøå')] + album = Album(name='æøå', artists=artists) + track = Track( + uri='local:track:song1.mp3', name='æøå', artists=artists, + composers=artists, performers=artists, genre='æøå', + album=album, length=4000, last_modified=1272319626, + comment='æøå&^`ൂ㔶') + + self.assertEqual(track, list(tracks)[0]) + + @unittest.SkipTest + def test_misencoded_cache(self): + # FIXME not sure if this can happen + pass + + def test_cache_with_blank_track_info(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) + expected = Track( + uri='local:track:song1.mp3', length=4000, last_modified=1272319626) + self.assertEqual(set([expected]), tracks) + + def test_musicbrainz_tagcache(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) + artist = list(expected_tracks[0].artists)[0].copy( + musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') + albumartist = list(expected_tracks[0].artists)[0].copy( + name='albumartistname', + musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') + album = expected_tracks[0].album.copy( + artists=[albumartist], + musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') + track = expected_tracks[0].copy( + artists=[artist], album=album, + musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') + + self.assertEqual(track, list(tracks)[0]) + + def test_albumartist_tag_cache(self): + tracks = translator.parse_mpd_tag_cache( + path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) + artist = Artist(name='albumartistname') + album = expected_albums[0].copy(artists=[artist]) + track = Track( + uri='local:track:song1.mp3', name='trackname', + artists=expected_artists, track_no=1, album=album, date='2006', + length=4000, last_modified=1272319626) + self.assertEqual(track, list(tracks)[0]) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 5623c787..e5747f68 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -6,8 +6,7 @@ import os import tempfile import unittest -from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache -from mopidy.models import Track, Artist, Album +from mopidy.backends.local.translator import parse_m3u from mopidy.utils.path import path_to_uri from tests import path_to_data_dir @@ -89,106 +88,3 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass - - -expected_artists = [Artist(name='name')] -expected_albums = [ - Album(name='albumname', artists=expected_artists, num_tracks=2), - Album(name='albumname', num_tracks=2), -] -expected_tracks = [] - - -def generate_track(path, ident, album_id): - uri = 'local:track:%s' % path - track = Track( - uri=uri, name='trackname', artists=expected_artists, - album=expected_albums[album_id], track_no=1, date='2006', length=4000, - last_modified=1272319626) - expected_tracks.append(track) - - -generate_track('song1.mp3', 6, 0) -generate_track('song2.mp3', 7, 0) -generate_track('song3.mp3', 8, 1) -generate_track('subdir1/song4.mp3', 2, 0) -generate_track('subdir1/song5.mp3', 3, 0) -generate_track('subdir2/song6.mp3', 4, 1) -generate_track('subdir2/song7.mp3', 5, 1) -generate_track('subdir1/subsubdir/song8.mp3', 0, 0) -generate_track('subdir1/subsubdir/song9.mp3', 1, 1) - - -class MPDTagCacheToTracksTest(unittest.TestCase): - def test_emtpy_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) - self.assertEqual(set(), tracks) - - def test_simple_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) - track = Track( - uri='local:track:song1.mp3', name='trackname', - artists=expected_artists, track_no=1, album=expected_albums[0], - date='2006', length=4000, last_modified=1272319626) - self.assertEqual(set([track]), tracks) - - def test_advanced_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) - self.assertEqual(set(expected_tracks), tracks) - - def test_unicode_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) - - artists = [Artist(name='æøå')] - album = Album(name='æøå', artists=artists) - track = Track( - uri='local:track:song1.mp3', name='æøå', artists=artists, - composers=artists, performers=artists, genre='æøå', - album=album, length=4000, last_modified=1272319626, - comment='æøå&^`ൂ㔶') - - self.assertEqual(track, list(tracks)[0]) - - @unittest.SkipTest - def test_misencoded_cache(self): - # FIXME not sure if this can happen - pass - - def test_cache_with_blank_track_info(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) - expected = Track( - uri='local:track:song1.mp3', length=4000, last_modified=1272319626) - self.assertEqual(set([expected]), tracks) - - def test_musicbrainz_tagcache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) - artist = list(expected_tracks[0].artists)[0].copy( - musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - albumartist = list(expected_tracks[0].artists)[0].copy( - name='albumartistname', - musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - album = expected_tracks[0].album.copy( - artists=[albumartist], - musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') - track = expected_tracks[0].copy( - artists=[artist], album=album, - musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') - - self.assertEqual(track, list(tracks)[0]) - - def test_albumartist_tag_cache(self): - tracks = parse_mpd_tag_cache( - path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) - artist = Artist(name='albumartistname') - album = expected_albums[0].copy(artists=[artist]) - track = Track( - uri='local:track:song1.mp3', name='trackname', - artists=expected_artists, track_no=1, album=album, date='2006', - length=4000, last_modified=1272319626) - self.assertEqual(track, list(tracks)[0]) diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index a6a2eaa9..1db10ab9 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -1,11 +1,10 @@ from __future__ import unicode_literals import datetime -import os import unittest -from mopidy.utils.path import mtime, uri_to_path -from mopidy.frontends.mpd import translator, protocol +from mopidy.utils.path import mtime +from mopidy.frontends.mpd import translator from mopidy.models import Album, Artist, TlTrack, Playlist, Track @@ -126,233 +125,3 @@ class PlaylistMpdFormatTest(unittest.TestCase): result = translator.playlist_to_mpd_format(playlist, 1, 2) self.assertEqual(len(result), 1) self.assertEqual(dict(result[0])['Track'], 2) - - -class TracksToTagCacheFormatTest(unittest.TestCase): - def setUp(self): - self.media_dir = '/dir/subdir' - mtime.set_fake_time(1234567) - - def tearDown(self): - mtime.undo_fake() - - def translate(self, track): - base_path = self.media_dir.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()) - - def consume_headers(self, result): - self.assertEqual(('info_begin',), result[0]) - self.assertEqual(('mpd_version', protocol.VERSION), result[1]) - self.assertEqual(('fs_charset', protocol.ENCODING), result[2]) - self.assertEqual(('info_end',), result[3]) - return result[4:] - - def consume_song_list(self, result): - self.assertEqual(('songList begin',), result[0]) - for i, row in enumerate(result): - if row == ('songList end',): - return result[1:i], result[i + 1:] - self.fail("Couldn't find songList end in result") - - def consume_directory(self, result): - self.assertEqual('directory', result[0][0]) - self.assertEqual(('mtime', mtime('.')), result[1]) - self.assertEqual(('begin', os.path.split(result[0][1])[1]), result[2]) - directory = result[2][1] - for i, row in enumerate(result): - if row == ('end', directory): - return result[3:i], result[i + 1:] - self.fail("Couldn't find end %s in result" % directory) - - def test_empty_tag_cache_has_header(self): - result = translator.tracks_to_tag_cache_format([], self.media_dir) - result = self.consume_headers(result) - - def test_empty_tag_cache_has_song_list(self): - result = translator.tracks_to_tag_cache_format([], self.media_dir) - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_header(self): - track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - result = self.consume_headers(result) - - def test_tag_cache_has_song_list(self): - track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assert_(song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_formated_track(self): - track = Track(uri='file:///dir/subdir/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_formated_track_with_key_and_mtime(self): - track = Track(uri='file:///dir/subdir/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_supports_directories(self): - track = Track(uri='file:///dir/subdir/folder/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - song_list, result = self.consume_song_list(dir_data) - self.assertEqual(len(result), 0) - self.assertEqual(formated, song_list) - - def test_tag_cache_diretory_header_is_right(self): - track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - - self.assertEqual(('directory', 'folder/sub'), dir_data[0]) - self.assertEqual(('mtime', mtime('.')), dir_data[1]) - self.assertEqual(('begin', 'sub'), dir_data[2]) - - def test_tag_cache_suports_sub_directories(self): - track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - - dir_data, result = self.consume_directory(result) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - dir_data, result = self.consume_directory(dir_data) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(result), 0) - self.assertEqual(len(song_list), 0) - - song_list, result = self.consume_song_list(dir_data) - self.assertEqual(len(result), 0) - self.assertEqual(formated, song_list) - - def test_tag_cache_supports_multiple_tracks(self): - tracks = [ - Track(uri='file:///dir/subdir/song1.mp3'), - Track(uri='file:///dir/subdir/song2.mp3'), - ] - - formated = [] - formated.extend(self.translate(tracks[0])) - formated.extend(self.translate(tracks[1])) - - result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_supports_multiple_tracks_in_dirs(self): - tracks = [ - Track(uri='file:///dir/subdir/song1.mp3'), - Track(uri='file:///dir/subdir/folder/song2.mp3'), - ] - - formated = [] - formated.append(self.translate(tracks[0])) - formated.append(self.translate(tracks[1])) - - result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - song_list, song_result = self.consume_song_list(dir_data) - - self.assertEqual(formated[1], song_list) - self.assertEqual(len(song_result), 0) - - song_list, result = self.consume_song_list(result) - self.assertEqual(len(result), 0) - self.assertEqual(formated[0], song_list) - - -class TracksToDirectoryTreeTest(unittest.TestCase): - def setUp(self): - self.media_dir = '/root' - - def test_no_tracks_gives_emtpy_tree(self): - tree = translator.tracks_to_directory_tree([], self.media_dir) - self.assertEqual(tree, ({}, [])) - - def test_top_level_files(self): - tracks = [ - Track(uri='file:///root/file1.mp3'), - Track(uri='file:///root/file2.mp3'), - Track(uri='file:///root/file3.mp3'), - ] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - self.assertEqual(tree, ({}, tracks)) - - def test_single_file_in_subdir(self): - tracks = [Track(uri='file:///root/dir/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ({'dir': ({}, tracks)}, []) - self.assertEqual(tree, expected) - - def test_single_file_in_sub_subdir(self): - tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) - self.assertEqual(tree, expected) - - def test_complex_file_structure(self): - tracks = [ - Track(uri='file:///root/file1.mp3'), - Track(uri='file:///root/dir1/file2.mp3'), - Track(uri='file:///root/dir1/file3.mp3'), - Track(uri='file:///root/dir2/file4.mp3'), - Track(uri='file:///root/dir2/sub/file5.mp3'), - ] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ( - { - 'dir1': ({}, [tracks[1], tracks[2]]), - 'dir2': ( - { - 'dir2/sub': ({}, [tracks[4]]) - }, - [tracks[3]] - ), - }, - [tracks[0]] - ) - self.assertEqual(tree, expected) From 2baa00b3a87866a735c313ccf115d958463ed1d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 26 Nov 2013 21:16:18 +0100 Subject: [PATCH 011/238] docs: Update changelog --- docs/changelog.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index acb94e3d..bd27d729 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,16 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.18.0 (UNRELEASED) +==================== + +**Internal changes** + +- Events from the audio actor, backends, and core actor are now emitted + asyncronously through the GObject event loop. This should resolve the issue + that has blocked the merge of the EOT-vs-EOS fix for a long time. + + v0.17.0 (2013-11-23) ==================== From 76ca38dd63109bb0a8cce9a0ad1019aa95abdc8c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 23:22:15 +0100 Subject: [PATCH 012/238] main: Only log creation of config when file does not exist. --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ff96cd04..1aca9cf4 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -129,7 +129,7 @@ def create_file_structures_and_config(args, extensions): # Initialize whatever the last config file is with defaults config_file = args.config_files[-1] - if os.path.exists(config_file): + if os.path.exists(path.expand_path(config_file)): return try: From 04044d035f4ce71e4d6131844ac8504dac930252 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 22:20:55 +0100 Subject: [PATCH 013/238] core: Refactor core Backends helper Replaces the jungle of extra dicts/lists with an OrderedDict per backend feature type. Also makes sure that each type/scheme is unique instead of the scheme alone. --- mopidy/core/actor.py | 50 ++++++++++++++++++---------------------- mopidy/core/library.py | 12 +++++----- mopidy/core/playback.py | 2 +- mopidy/core/playlists.py | 27 ++++++++++------------ tests/core/actor_test.py | 20 ++++++++++++++-- 5 files changed, 60 insertions(+), 51 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index cd4ba180..3cba20db 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import collections import itertools import pykka @@ -79,34 +80,29 @@ class Backends(list): def __init__(self, backends): super(Backends, self).__init__(backends) - # These lists keeps the backends in the original order, but only - # includes those which implements the required backend provider. Since - # it is important to keep the order, we can't simply use .values() on - # the X_by_uri_scheme dicts below. - self.with_library = [b for b in backends if b.has_library().get()] - self.with_playback = [b for b in backends if b.has_playback().get()] - self.with_playlists = [ - b for b in backends if b.has_playlists().get()] + self.with_library = collections.OrderedDict() + self.with_playback = collections.OrderedDict() + self.with_playlists = collections.OrderedDict() - self.by_uri_scheme = {} for backend in backends: - for uri_scheme in backend.uri_schemes.get(): - assert uri_scheme not in self.by_uri_scheme, ( - 'Cannot add URI scheme %s for %s, ' - 'it is already handled by %s' - ) % ( - uri_scheme, backend.__class__.__name__, - self.by_uri_scheme[uri_scheme].__class__.__name__) - self.by_uri_scheme[uri_scheme] = backend + has_library = backend.has_library().get() + has_playback = backend.has_playback().get() + has_playlists = backend.has_playlists().get() - self.with_library_by_uri_scheme = {} - self.with_playback_by_uri_scheme = {} - self.with_playlists_by_uri_scheme = {} + for scheme in backend.uri_schemes.get(): + self.add(self.with_library, has_library, scheme, backend) + self.add(self.with_playback, has_playback, scheme, backend) + self.add(self.with_playlists, has_playlists, scheme, backend) - for uri_scheme, backend in self.by_uri_scheme.items(): - if backend.has_library().get(): - self.with_library_by_uri_scheme[uri_scheme] = backend - if backend.has_playback().get(): - self.with_playback_by_uri_scheme[uri_scheme] = backend - if backend.has_playlists().get(): - self.with_playlists_by_uri_scheme[uri_scheme] = backend + def add(self, registry, supported, uri_scheme, backend): + if not supported: + return + + if uri_scheme not in registry: + registry[uri_scheme] = backend + return + + get_name = lambda actor: actor.actor_ref.actor_class.__name__ + raise AssertionError( + 'Cannot add URI scheme %s for %s, it is already handled by %s' % + (uri_scheme, get_name(backend), get_name(registry[uri_scheme]))) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index cdc3f53a..2e73e0db 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from collections import defaultdict +import collections import urlparse import pykka @@ -15,18 +15,18 @@ class LibraryController(object): def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) + return self.backends.with_library.get(uri_scheme, None) def _get_backends_to_uris(self, uris): if uris: - backends_to_uris = defaultdict(list) + backends_to_uris = collections.defaultdict(list) for uri in uris: backend = self._get_backend(uri) if backend is not None: backends_to_uris[backend].append(uri) else: backends_to_uris = dict([ - (b, None) for b in self.backends.with_library]) + (b, None) for b in self.backends.with_library.values()]) return backends_to_uris def find_exact(self, query=None, uris=None, **kwargs): @@ -103,8 +103,8 @@ class LibraryController(object): if backend: backend.library.refresh(uri).get() else: - futures = [ - b.library.refresh(uri) for b in self.backends.with_library] + futures = [b.library.refresh(uri) + for b in self.backends.with_library.values()] pykka.get_all(futures) def search(self, query=None, uris=None, **kwargs): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d127fbbe..3c0e43fa 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -28,7 +28,7 @@ class PlaybackController(object): return None uri = self.current_tl_track.track.uri uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) + return self.backends.with_playback.get(uri_scheme, None) ### Properties diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index f0187d44..d5c03bb3 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -16,8 +16,8 @@ class PlaylistsController(object): self.core = core def get_playlists(self, include_tracks=True): - futures = [ - b.playlists.playlists for b in self.backends.with_playlists] + futures = [b.playlists.playlists + for b in self.backends.with_playlists.values()] results = pykka.get_all(futures) playlists = list(itertools.chain(*results)) if not include_tracks: @@ -49,10 +49,11 @@ class PlaylistsController(object): :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - if uri_scheme in self.backends.with_playlists_by_uri_scheme: - backend = self.backends.by_uri_scheme[uri_scheme] + if uri_scheme in self.backends.with_playlists: + backend = self.backends.with_playlists[uri_scheme] else: - backend = self.backends.with_playlists[0] + # TODO: this fallback looks suspicious + backend = self.backends.with_playlists.values()[0] playlist = backend.playlists.create(name).get() listener.CoreListener.send('playlist_changed', playlist=playlist) return playlist @@ -68,8 +69,7 @@ class PlaylistsController(object): :type uri: string """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: backend.playlists.delete(uri).get() @@ -111,8 +111,7 @@ class PlaylistsController(object): :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: return backend.playlists.lookup(uri).get() else: @@ -131,13 +130,12 @@ class PlaylistsController(object): :type uri_scheme: string """ if uri_scheme is None: - futures = [ - b.playlists.refresh() for b in self.backends.with_playlists] + futures = [b.playlists.refresh() + for b in self.backends.with_playlists.values()] pykka.get_all(futures) listener.CoreListener.send('playlists_loaded') else: - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: backend.playlists.refresh().get() listener.CoreListener.send('playlists_loaded') @@ -167,8 +165,7 @@ class PlaylistsController(object): if playlist.uri is None: return uri_scheme = urlparse.urlparse(playlist.uri).scheme - backend = self.backends.with_playlists_by_uri_scheme.get( - uri_scheme, None) + backend = self.backends.with_playlists.get(uri_scheme, None) if backend: playlist = backend.playlists.save(playlist).get() listener.CoreListener.send('playlist_changed', playlist=playlist) diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index c4952af3..ce50d5ed 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -28,10 +28,26 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): - self.backend1.__class__.__name__ = b'B1' - self.backend2.__class__.__name__ = b'B2' + self.backend1.actor_ref.actor_class.__name__ = b'B1' + self.backend2.actor_ref.actor_class.__name__ = b'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', Core, audio=None, backends=[self.backend1, self.backend2]) + + def test_backends_with_colliding_uri_schemes_passes(self): + # Checks that backends with overlapping schemes, but distinct sub parts + # provided can co-exist. + self.backend1.has_library().get.return_value = False + self.backend1.has_playlists().get.return_value = False + + self.backend2.uri_schemes().get.return_value = ['dummy1'] + self.backend2.has_playback().get.return_value = False + self.backend2.has_playlists().get.return_value = False + + core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.assertEqual(core.backends.with_playback, + {'dummy1': self.backend1}) + self.assertEqual(core.backends.with_library, + {'dummy2': self.backend2}) From 3c1c6bac719d664ef750a73774b0924829ca4229 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 22:59:31 +0100 Subject: [PATCH 014/238] local: Always return track with just uri for local playlist tracks This is related to #527, but is only a stop gap until we fix it right. Note that this actually causes a regression, as not playlist tracks will have any metadata after this change. --- mopidy/backends/local/playlists.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 081bc335..e8996b51 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -51,11 +51,8 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): tracks = [] for track_uri in parse_m3u(m3u, self._media_dir): - result = self.backend.library.lookup(track_uri) - if result: - tracks += self.backend.library.lookup(track_uri) - else: - tracks.append(Track(uri=track_uri)) + # TODO: switch to having playlists being a list of uris + tracks.append(Track(uri=track_uri)) playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) From c025b87076d50f4c10464fd48ee3e67aed6d9e84 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 26 Nov 2013 23:03:08 +0100 Subject: [PATCH 015/238] tagcache: Split out to own extension for eventual deletion. --- mopidy/backends/local/__init__.py | 4 --- mopidy/backends/local/actor.py | 9 ------- mopidy/backends/local/tagcache/__init__.py | 31 ++++++++++++++++++++++ mopidy/backends/local/tagcache/actor.py | 30 +++++++++++++++++++++ mopidy/backends/local/tagcache/ext.conf | 2 ++ mopidy/backends/local/tagcache/library.py | 6 ++--- setup.py | 1 + tests/backends/local/library_test.py | 6 ++--- 8 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 mopidy/backends/local/tagcache/actor.py create mode 100644 mopidy/backends/local/tagcache/ext.conf diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 8a2e12fd..723eb056 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -33,10 +33,6 @@ class Extension(ext.Extension): from .actor import LocalBackend return [LocalBackend] - def get_library_updaters(self): - from .tagcache.library import LocalLibraryUpdateProvider - return [LocalLibraryUpdateProvider] - def get_command(self): from .commands import LocalCommand return LocalCommand() diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 531b7546..a73f627e 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,7 +8,6 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .tagcache.library import LocalLibraryProvider from .playlists import LocalPlaylistsProvider from .playback import LocalPlaybackProvider @@ -23,7 +22,6 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() - self.library = LocalLibraryProvider(backend=self) self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) @@ -40,10 +38,3 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): logger.warning( 'Could not create local playlists dir: %s', encoding.locale_decode(error)) - - try: - path.get_or_create_file(self.config['local']['tag_cache_file']) - except EnvironmentError as error: - logger.warning( - 'Could not create empty tag cache file: %s', - encoding.locale_decode(error)) diff --git a/mopidy/backends/local/tagcache/__init__.py b/mopidy/backends/local/tagcache/__init__.py index e69de29b..c7364e8b 100644 --- a/mopidy/backends/local/tagcache/__init__.py +++ b/mopidy/backends/local/tagcache/__init__.py @@ -0,0 +1,31 @@ +from __future__ import unicode_literals + +import os + +import mopidy +from mopidy import config, ext + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-Local-Tagcache' + ext_name = 'local-tagcache' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + # Config only contains local-tagcache/enabled since we are not setting our + # own schema. + + def validate_environment(self): + pass + + def get_backend_classes(self): + from .actor import LocalTagcacheBackend + return [LocalTagcacheBackend] + + def get_library_updaters(self): + from .library import LocalTagcacheLibraryUpdateProvider + return [LocalTagcacheLibraryUpdateProvider] diff --git a/mopidy/backends/local/tagcache/actor.py b/mopidy/backends/local/tagcache/actor.py new file mode 100644 index 00000000..f052debb --- /dev/null +++ b/mopidy/backends/local/tagcache/actor.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals + +import logging + +import pykka + +from mopidy.backends import base +from mopidy.utils import encoding, path + +from .library import LocalTagcacheLibraryProvider + +logger = logging.getLogger('mopidy.backends.local.tagcache') + + +class LocalTagcacheBackend(pykka.ThreadingActor, base.Backend): + def __init__(self, config, audio): + super(LocalTagcacheBackend, self).__init__() + + self.config = config + self.check_dirs_and_files() + self.library = LocalTagcacheLibraryProvider(backend=self) + self.uri_schemes = ['local'] + + def check_dirs_and_files(self): + try: + path.get_or_create_file(self.config['local']['tag_cache_file']) + except EnvironmentError as error: + logger.warning( + 'Could not create empty tag cache file: %s', + encoding.locale_decode(error)) diff --git a/mopidy/backends/local/tagcache/ext.conf b/mopidy/backends/local/tagcache/ext.conf new file mode 100644 index 00000000..48a3c763 --- /dev/null +++ b/mopidy/backends/local/tagcache/ext.conf @@ -0,0 +1,2 @@ +[local-tagcache] +enabled = true diff --git a/mopidy/backends/local/tagcache/library.py b/mopidy/backends/local/tagcache/library.py index 6efe6bf5..c795cdc1 100644 --- a/mopidy/backends/local/tagcache/library.py +++ b/mopidy/backends/local/tagcache/library.py @@ -13,9 +13,9 @@ from .translator import parse_mpd_tag_cache, tracks_to_tag_cache_format logger = logging.getLogger('mopidy.backends.local.tagcache') -class LocalLibraryProvider(base.BaseLibraryProvider): +class LocalTagcacheLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): - super(LocalLibraryProvider, self).__init__(*args, **kwargs) + super(LocalTagcacheLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} self._media_dir = self.backend.config['local']['media_dir'] self._tag_cache_file = self.backend.config['local']['tag_cache_file'] @@ -219,7 +219,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): raise LookupError('Missing query') -class LocalLibraryUpdateProvider(base.BaseLibraryProvider): +class LocalTagcacheLibraryUpdateProvider(base.BaseLibraryProvider): uri_schemes = ['local'] def __init__(self, config): diff --git a/setup.py b/setup.py index f43981bf..7d4b2cd8 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ setup( 'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', 'local = mopidy.backends.local:Extension', + 'local-tagcache = mopidy.backends.local.tagcache:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'stream = mopidy.backends.stream:Extension', ], diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index c38fd74f..c04b81f5 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -6,7 +6,7 @@ import unittest import pykka from mopidy import core -from mopidy.backends.local import actor +from mopidy.backends.local.tagcache import actor from mopidy.models import Track, Album, Artist from tests import path_to_data_dir @@ -66,7 +66,7 @@ class LocalLibraryProviderTest(unittest.TestCase): } def setUp(self): - self.backend = actor.LocalBackend.start( + self.backend = actor.LocalTagcacheBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library @@ -92,7 +92,7 @@ class LocalLibraryProviderTest(unittest.TestCase): config = {'local': self.config['local'].copy()} config['local']['tag_cache_file'] = tag_cache.name - backend = actor.LocalBackend(config=config, audio=None) + backend = actor.LocalTagcacheBackend(config=config, audio=None) # Sanity check that value is in tag cache result = backend.library.lookup(self.tracks[0].uri) From 603b57ef3c353c48d97645b32efdc607a51283e0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 27 Nov 2013 22:49:07 +0100 Subject: [PATCH 016/238] utils: Remove find_uris and update find_files - find_uris is no more - find_files now returns file paths relative to path being searched - find_files now only works on directories - find_files tests have been updated to reflect changes - local scanner has gotten a minimal update to reflect this alteration --- mopidy/backends/local/commands.py | 5 +-- mopidy/utils/path.py | 28 +++++++---------- tests/audio/scan_test.py | 35 ++++++++++++--------- tests/data/{ => find}/.blank.mp3 | Bin tests/data/{ => find}/.hidden/.gitignore | 0 tests/data/find/baz/file | 0 tests/data/find/foo/bar/file | 0 tests/data/find/foo/file | 0 tests/utils/path_test.py | 38 ++++------------------- 9 files changed, 40 insertions(+), 66 deletions(-) rename tests/data/{ => find}/.blank.mp3 (100%) rename tests/data/{ => find}/.hidden/.gitignore (100%) create mode 100644 tests/data/find/baz/file create mode 100644 tests/data/find/foo/bar/file create mode 100644 tests/data/find/foo/file diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index c2ef143c..c0d6d23a 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -68,12 +68,13 @@ class ScanCommand(commands.Command): local_updater.remove(uri) logger.info('Checking %s for unknown tracks.', media_dir) - for uri in path.find_uris(media_dir): - file_extension = os.path.splitext(path.uri_to_path(uri))[1] + for relpath in path.find_files(media_dir): + file_extension = os.path.splitext(relpath)[1] if file_extension.lower() in excluded_file_extensions: logger.debug('Skipped %s: File extension excluded.', uri) continue + uri = path.path_to_uri(os.path.join(media_dir, relpath)) if uri not in uris_library: uris_update.add(uri) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 32dcb721..b8dcc589 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -119,26 +119,20 @@ def find_files(path): path = path.encode('utf-8') if os.path.isfile(path): - if not os.path.basename(path).startswith(b'.'): - yield path - else: - for dirpath, dirnames, filenames in os.walk(path, followlinks=True): - for dirname in dirnames: - if dirname.startswith(b'.'): - # Skip hidden dirs by modifying dirnames inplace - dirnames.remove(dirname) + return - for filename in filenames: - if filename.startswith(b'.'): - # Skip hidden files - continue + for dirpath, dirnames, filenames in os.walk(path, followlinks=True): + for dirname in dirnames: + if dirname.startswith(b'.'): + # Skip hidden dirs by modifying dirnames inplace + dirnames.remove(dirname) - yield os.path.join(dirpath, filename) + for filename in filenames: + if filename.startswith(b'.'): + # Skip hidden files + continue - -def find_uris(path): - for p in find_files(path): - yield path_to_uri(p) + yield os.path.relpath(os.path.join(dirpath, filename), path) def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/tests/audio/scan_test.py b/tests/audio/scan_test.py index 4acbecb6..ed3f8e01 100644 --- a/tests/audio/scan_test.py +++ b/tests/audio/scan_test.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import os import unittest from mopidy import exceptions @@ -240,11 +241,15 @@ class ScannerTest(unittest.TestCase): self.errors = {} self.data = {} - def scan(self, path): - paths = path_lib.find_files(path_to_data_dir(path)) - uris = (path_lib.path_to_uri(p) for p in paths) + def find(self, path): + media_dir = path_to_data_dir(path) + for path in path_lib.find_files(media_dir): + yield os.path.join(media_dir, path) + + def scan(self, paths): scanner = scan.Scanner() - for uri in uris: + for path in paths: + uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: self.data[key] = scanner.scan(uri) @@ -256,15 +261,15 @@ class ScannerTest(unittest.TestCase): self.assertEqual(self.data[name][key], value) def test_data_is_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.assert_(self.data) def test_errors_is_not_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.assert_(not self.errors) def test_uri_is_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.check( 'scanner/simple/song1.mp3', 'uri', 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) @@ -273,39 +278,39 @@ class ScannerTest(unittest.TestCase): 'file://%s' % path_to_data_dir('scanner/simple/song1.ogg')) def test_duration_is_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.check('scanner/simple/song1.mp3', 'duration', 4680000000) self.check('scanner/simple/song1.ogg', 'duration', 4680000000) def test_artist_is_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.check('scanner/simple/song1.mp3', 'artist', 'name') self.check('scanner/simple/song1.ogg', 'artist', 'name') def test_album_is_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.check('scanner/simple/song1.mp3', 'album', 'albumname') self.check('scanner/simple/song1.ogg', 'album', 'albumname') def test_track_is_set(self): - self.scan('scanner/simple') + self.scan(self.find('scanner/simple')) self.check('scanner/simple/song1.mp3', 'title', 'trackname') self.check('scanner/simple/song1.ogg', 'title', 'trackname') def test_nonexistant_dir_does_not_fail(self): - self.scan('scanner/does-not-exist') + self.scan(self.find('scanner/does-not-exist')) self.assert_(not self.errors) def test_other_media_is_ignored(self): - self.scan('scanner/image') + self.scan(self.find('scanner/image')) self.assert_(self.errors) def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self): - self.scan('scanner/example.log') + self.scan([path_to_data_dir('scanner/example.log')]) self.assert_(self.errors) def test_empty_wav_file_is_ignored(self): - self.scan('scanner/empty.wav') + self.scan([path_to_data_dir('scanner/empty.wav')]) self.assert_(self.errors) @unittest.SkipTest diff --git a/tests/data/.blank.mp3 b/tests/data/find/.blank.mp3 similarity index 100% rename from tests/data/.blank.mp3 rename to tests/data/find/.blank.mp3 diff --git a/tests/data/.hidden/.gitignore b/tests/data/find/.hidden/.gitignore similarity index 100% rename from tests/data/.hidden/.gitignore rename to tests/data/find/.hidden/.gitignore diff --git a/tests/data/find/baz/file b/tests/data/find/baz/file new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/find/foo/bar/file b/tests/data/find/foo/bar/file new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/find/foo/file b/tests/data/find/foo/file new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 673fda73..316b4f38 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -221,9 +221,12 @@ class FindFilesTest(unittest.TestCase): self.assertEqual(self.find('does-not-exist'), []) def test_file(self): - files = self.find('blank.mp3') - self.assertEqual(len(files), 1) - self.assertEqual(files[0], path_to_data_dir('blank.mp3')) + self.assertEqual([], self.find('blank.mp3')) + + def test_files(self): + files = self.find('find') + excepted = [b'foo/bar/file', b'foo/file', b'baz/file'] + self.assertItemsEqual(excepted, files) def test_names_are_bytestrings(self): is_bytes = lambda f: isinstance(f, bytes) @@ -231,35 +234,6 @@ class FindFilesTest(unittest.TestCase): self.assert_( is_bytes(name), '%s is not bytes object' % repr(name)) - def test_ignores_hidden_dirs(self): - self.assertEqual(self.find('.hidden'), []) - - def test_ignores_hidden_files(self): - self.assertEqual(self.find('.blank.mp3'), []) - - -class FindUrisTest(unittest.TestCase): - def find(self, value): - return list(path.find_uris(path_to_data_dir(value))) - - def test_basic_dir(self): - self.assert_(self.find('')) - - def test_nonexistant_dir(self): - self.assertEqual(self.find('does-not-exist'), []) - - def test_file(self): - uris = self.find('blank.mp3') - expected = path.path_to_uri(path_to_data_dir('blank.mp3')) - self.assertEqual(len(uris), 1) - self.assertEqual(uris[0], expected) - - def test_ignores_hidden_dirs(self): - self.assertEqual(self.find('.hidden'), []) - - def test_ignores_hidden_files(self): - self.assertEqual(self.find('.blank.mp3'), []) - # TODO: kill this in favour of just os.path.getmtime + mocks class MtimeTest(unittest.TestCase): From 4161c2bf2785809845cea2521737a85bbb276b18 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 27 Nov 2013 23:16:42 +0100 Subject: [PATCH 017/238] local: Fix inconsistent uri handling in local scanner We now only operate on local track uris, instead of a funny mix of local and file uris. To achieve this we instead maintain a uri->path mapping to use for the actual scanning. --- mopidy/backends/local/commands.py | 42 ++++++++++++++--------------- mopidy/backends/local/translator.py | 15 +++++++++++ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index c0d6d23a..48ae4e9f 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -44,27 +44,26 @@ class ScanCommand(commands.Command): local_updater = updaters.values()[0](config) - # TODO: cleanup to consistently use local urls, not a random mix of - # local and file uris depending on how the data was loaded. - uris_library = set() - uris_update = set() - uris_remove = set() + uri_path_mapping = {} + uris_in_library = set() + uris_to_update = set() + uris_to_remove = set() tracks = local_updater.load() logger.info('Checking %d tracks from library.', len(tracks)) for track in tracks: + track_path = translator.local_to_path(track.uri, media_dir) + uri_path_mapping[track.uri] = track_path try: - uri = translator.local_to_file_uri(track.uri, media_dir) - stat = os.stat(path.uri_to_path(uri)) - if int(stat.st_mtime) > track.last_modified: - uris_update.add(uri) - uris_library.add(uri) + if int(os.stat(track_path).st_mtime) > track.last_modified: + uris_to_update.add(track.uri) + uris_in_library.add(track.uri) except OSError: logger.debug('Missing file %s', track.uri) - uris_remove.add(track.uri) + uris_to_remove.add(track.uri) - logger.info('Removing %d missing tracks.', len(uris_remove)) - for uri in uris_remove: + logger.info('Removing %d missing tracks.', len(uris_to_remove)) + for uri in uris_to_remove: local_updater.remove(uri) logger.info('Checking %s for unknown tracks.', media_dir) @@ -74,20 +73,21 @@ class ScanCommand(commands.Command): logger.debug('Skipped %s: File extension excluded.', uri) continue - uri = path.path_to_uri(os.path.join(media_dir, relpath)) - if uri not in uris_library: - uris_update.add(uri) + uri = translator.path_to_local(relpath) + if uri not in uris_in_library: + uris_to_update.add(uri) + uri_path_mapping[uri] = os.path.join(media_dir, relpath) - logger.info('Found %d unknown tracks.', len(uris_update)) + logger.info('Found %d unknown tracks.', len(uris_to_update)) logger.info('Scanning...') scanner = scan.Scanner(scan_timeout) - progress = Progress(len(uris_update)) + progress = Progress(len(uris_to_update)) - for uri in sorted(uris_update): + for uri in sorted(uris_to_update): try: - data = scanner.scan(uri) - track = scan.audio_data_to_track(data) + data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) + track = scan.audio_data_to_track(data).copy(uri=uri) local_updater.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index dc266d1c..0f82b05e 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import logging import os import urlparse +import urllib from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path @@ -10,6 +11,7 @@ from mopidy.utils.path import path_to_uri, uri_to_path logger = logging.getLogger('mopidy.backends.local') +# TODO: remove once tag cache is gone def local_to_file_uri(uri, media_dir): # TODO: check that type is correct. file_path = uri_to_path(uri).split(b':', 1)[1] @@ -17,6 +19,19 @@ def local_to_file_uri(uri, media_dir): return path_to_uri(file_path) +def local_to_path(uri, media_dir): + if not uri.startswith('local:track:'): + raise Exception + file_path = uri_to_path(uri).split(b':', 1)[1] + return os.path.join(media_dir, file_path) + + +def path_to_local(relpath): + if isinstance(relpath, unicode): + relpath = relpath.encode('utf-8') + return b'local:track:%s' % urllib.quote(relpath) + + def parse_m3u(file_path, media_dir): r""" Convert M3U file list of uris From ca358e05db85fce299a48a1102ed6c58f24f4cda Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 27 Nov 2013 23:27:31 +0100 Subject: [PATCH 018/238] local: Move find_exact and search out of tag cache. --- mopidy/backends/local/search.py | 179 ++++++++++++++++++++++ mopidy/backends/local/tagcache/library.py | 173 +-------------------- 2 files changed, 184 insertions(+), 168 deletions(-) create mode 100644 mopidy/backends/local/search.py diff --git a/mopidy/backends/local/search.py b/mopidy/backends/local/search.py new file mode 100644 index 00000000..870afcfd --- /dev/null +++ b/mopidy/backends/local/search.py @@ -0,0 +1,179 @@ +from __future__ import unicode_literals + +from mopidy.models import Album, SearchResult + + +def find_exact(tracks, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + + if query is None: + query = {} + + _validate_query(query) + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + if field == 'track_no': + q = _convert_to_int(value) + else: + q = value.strip() + + uri_filter = lambda t: q == t.uri + track_name_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) + albumartist_filter = lambda t: any([ + q == a.name + for a in getattr(t.album, 'artists', [])]) + composer_filter = lambda t: any([ + q == a.name + for a in getattr(t, 'composers', [])]) + performer_filter = lambda t: any([ + q == a.name + for a in getattr(t, 'performers', [])]) + track_no_filter = lambda t: q == t.track_no + genre_filter = lambda t: t.genre and q == t.genre + date_filter = lambda t: q == t.date + comment_filter = lambda t: q == t.comment + any_filter = lambda t: ( + uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) + + if field == 'uri': + tracks = filter(uri_filter, tracks) + elif field == 'track_name': + tracks = filter(track_name_filter, tracks) + elif field == 'album': + tracks = filter(album_filter, tracks) + elif field == 'artist': + tracks = filter(artist_filter, tracks) + elif field == 'albumartist': + tracks = filter(albumartist_filter, tracks) + elif field == 'composer': + tracks = filter(composer_filter, tracks) + elif field == 'performer': + tracks = filter(performer_filter, tracks) + elif field == 'track_no': + tracks = filter(track_no_filter, tracks) + elif field == 'genre': + tracks = filter(genre_filter, tracks) + elif field == 'date': + tracks = filter(date_filter, tracks) + elif field == 'comment': + tracks = filter(comment_filter, tracks) + elif field == 'any': + tracks = filter(any_filter, tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=tracks) + + +def search(tracks, query=None, uris=None): + # TODO Only return results within URI roots given by ``uris`` + + if query is None: + query = {} + + _validate_query(query) + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + if field == 'track_no': + q = _convert_to_int(value) + else: + q = value.strip().lower() + + uri_filter = lambda t: q in t.uri.lower() + track_name_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) + albumartist_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t.album, 'artists', [])]) + composer_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t, 'composers', [])]) + performer_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t, 'performers', [])]) + track_no_filter = lambda t: q == t.track_no + genre_filter = lambda t: t.genre and q in t.genre.lower() + date_filter = lambda t: t.date and t.date.startswith(q) + comment_filter = lambda t: t.comment and q in t.comment.lower() + any_filter = lambda t: ( + uri_filter(t) or + track_name_filter(t) or + album_filter(t) or + artist_filter(t) or + albumartist_filter(t) or + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) + + if field == 'uri': + tracks = filter(uri_filter, tracks) + elif field == 'track_name': + tracks = filter(track_name_filter, tracks) + elif field == 'album': + tracks = filter(album_filter, tracks) + elif field == 'artist': + tracks = filter(artist_filter, tracks) + elif field == 'albumartist': + tracks = filter(albumartist_filter, tracks) + elif field == 'composer': + tracks = filter(composer_filter, tracks) + elif field == 'performer': + tracks = filter(performer_filter, tracks) + elif field == 'track_no': + tracks = filter(track_no_filter, tracks) + elif field == 'genre': + tracks = filter(genre_filter, tracks) + elif field == 'date': + tracks = filter(date_filter, tracks) + elif field == 'comment': + tracks = filter(comment_filter, tracks) + elif field == 'any': + tracks = filter(any_filter, tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=tracks) + + +def _validate_query(query): + for (_, values) in query.iteritems(): + if not values: + raise LookupError('Missing query') + for value in values: + if not value: + raise LookupError('Missing query') + + +def _convert_to_int(string): + try: + return int(string) + except ValueError: + return object() diff --git a/mopidy/backends/local/tagcache/library.py b/mopidy/backends/local/tagcache/library.py index c795cdc1..fdc0be35 100644 --- a/mopidy/backends/local/tagcache/library.py +++ b/mopidy/backends/local/tagcache/library.py @@ -6,7 +6,7 @@ import tempfile from mopidy.backends import base from mopidy.backends.local.translator import local_to_file_uri -from mopidy.models import Album, SearchResult +from mopidy.backends.local import search from .translator import parse_mpd_tag_cache, tracks_to_tag_cache_format @@ -21,12 +21,6 @@ class LocalTagcacheLibraryProvider(base.BaseLibraryProvider): self._tag_cache_file = self.backend.config['local']['tag_cache_file'] self.refresh() - def _convert_to_int(self, string): - try: - return int(string) - except ValueError: - return object() - def refresh(self, uri=None): logger.debug( 'Loading local tracks from %s using %s', @@ -54,169 +48,12 @@ class LocalTagcacheLibraryProvider(base.BaseLibraryProvider): return [] def find_exact(self, query=None, uris=None): - # TODO Only return results within URI roots given by ``uris`` - - if query is None: - query = {} - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - if field == 'track_no': - q = self._convert_to_int(value) - else: - q = value.strip() - - uri_filter = lambda t: q == t.uri - track_name_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) - albumartist_filter = lambda t: any([ - q == a.name - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q == a.name - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q == t.genre - date_filter = lambda t: q == t.date - comment_filter = lambda t: q == t.comment - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) - - if field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'track_name': - result_tracks = filter(track_name_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 == 'albumartist': - result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'composer': - result_tracks = filter(composer_filter, result_tracks) - elif field == 'performer': - result_tracks = filter(performer_filter, result_tracks) - elif field == 'track_no': - result_tracks = filter(track_no_filter, result_tracks) - elif field == 'genre': - result_tracks = filter(genre_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) - elif field == 'comment': - result_tracks = filter(comment_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - # TODO: add local:search: - return SearchResult(uri='local:search', tracks=result_tracks) + tracks = self._uri_mapping.values() + return search.find_exact(tracks, query=query, uris=uris) def search(self, query=None, uris=None): - # TODO Only return results within URI roots given by ``uris`` - - if query is None: - query = {} - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - if field == 'track_no': - q = self._convert_to_int(value) - else: - q = value.strip().lower() - - uri_filter = lambda t: q in t.uri.lower() - track_name_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) - albumartist_filter = lambda t: any([ - q in a.name.lower() - for a in getattr(t.album, 'artists', [])]) - composer_filter = lambda t: any([ - q in a.name.lower() - for a in getattr(t, 'composers', [])]) - performer_filter = lambda t: any([ - q in a.name.lower() - for a in getattr(t, 'performers', [])]) - track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q in t.genre.lower() - date_filter = lambda t: t.date and t.date.startswith(q) - comment_filter = lambda t: t.comment and q in t.comment.lower() - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - composer_filter(t) or - performer_filter(t) or - track_no_filter(t) or - genre_filter(t) or - date_filter(t) or - comment_filter(t)) - - if field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'track_name': - result_tracks = filter(track_name_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 == 'albumartist': - result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'composer': - result_tracks = filter(composer_filter, result_tracks) - elif field == 'performer': - result_tracks = filter(performer_filter, result_tracks) - elif field == 'track_no': - result_tracks = filter(track_no_filter, result_tracks) - elif field == 'genre': - result_tracks = filter(genre_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) - elif field == 'comment': - result_tracks = filter(comment_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - # TODO: add local:search: - return SearchResult(uri='local:search', tracks=result_tracks) - - def _validate_query(self, query): - for (_, values) in query.iteritems(): - if not values: - raise LookupError('Missing query') - for value in values: - if not value: - raise LookupError('Missing query') + tracks = self._uri_mapping.values() + return search.search(tracks, query=query, uris=uris) class LocalTagcacheLibraryUpdateProvider(base.BaseLibraryProvider): From 118095e5228fed1f4c4dccc9cbf7cccc99f6f4b5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 27 Nov 2013 23:39:53 +0100 Subject: [PATCH 019/238] local: Add new json based library - Sets local-tagcache as disabled - Implements new library that uses a gzip compressed json as storage. - Thanks to reuse of existing serialization code this is a fairly small change. --- mopidy/backends/local/json/__init__.py | 33 ++++++++ mopidy/backends/local/json/actor.py | 30 +++++++ mopidy/backends/local/json/ext.conf | 3 + mopidy/backends/local/json/library.py | 106 ++++++++++++++++++++++++ mopidy/backends/local/tagcache/ext.conf | 2 +- setup.py | 1 + 6 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 mopidy/backends/local/json/__init__.py create mode 100644 mopidy/backends/local/json/actor.py create mode 100644 mopidy/backends/local/json/ext.conf create mode 100644 mopidy/backends/local/json/library.py diff --git a/mopidy/backends/local/json/__init__.py b/mopidy/backends/local/json/__init__.py new file mode 100644 index 00000000..d1103ac6 --- /dev/null +++ b/mopidy/backends/local/json/__init__.py @@ -0,0 +1,33 @@ +from __future__ import unicode_literals + +import os + +import mopidy +from mopidy import config, ext + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-Local-JSON' + ext_name = 'local-json' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + schema['json_file'] = config.Path() + return schema + + def validate_environment(self): + pass + + def get_backend_classes(self): + from .actor import LocalJsonBackend + return [LocalJsonBackend] + + def get_library_updaters(self): + from .library import LocalJsonLibraryUpdateProvider + return [LocalJsonLibraryUpdateProvider] diff --git a/mopidy/backends/local/json/actor.py b/mopidy/backends/local/json/actor.py new file mode 100644 index 00000000..df9ac447 --- /dev/null +++ b/mopidy/backends/local/json/actor.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals + +import logging + +import pykka + +from mopidy.backends import base +from mopidy.utils import encoding, path + +from .library import LocalJsonLibraryProvider + +logger = logging.getLogger('mopidy.backends.local.json') + + +class LocalJsonBackend(pykka.ThreadingActor, base.Backend): + def __init__(self, config, audio): + super(LocalJsonBackend, self).__init__() + + self.config = config + self.check_dirs_and_files() + self.library = LocalJsonLibraryProvider(backend=self) + self.uri_schemes = ['local'] + + def check_dirs_and_files(self): + try: + path.get_or_create_file(self.config['local-json']['json_file']) + except EnvironmentError as error: + logger.warning( + 'Could not create empty json file: %s', + encoding.locale_decode(error)) diff --git a/mopidy/backends/local/json/ext.conf b/mopidy/backends/local/json/ext.conf new file mode 100644 index 00000000..db0b784a --- /dev/null +++ b/mopidy/backends/local/json/ext.conf @@ -0,0 +1,3 @@ +[local-json] +enabled = true +json_file = $XDG_DATA_DIR/mopidy/local/library.json.gz diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py new file mode 100644 index 00000000..abb620e9 --- /dev/null +++ b/mopidy/backends/local/json/library.py @@ -0,0 +1,106 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import gzip +import json +import logging +import os +import tempfile + +import mopidy +from mopidy import models +from mopidy.backends import base +from mopidy.backends.local import search + +logger = logging.getLogger('mopidy.backends.local.json') + + +def _load_tracks(json_file): + try: + with gzip.open(json_file, 'rb') as fp: + result = json.load(fp, object_hook=models.model_json_decoder) + except IOError: + return [] + return result.get('tracks', []) + + +class LocalJsonLibraryProvider(base.BaseLibraryProvider): + def __init__(self, *args, **kwargs): + super(LocalJsonLibraryProvider, self).__init__(*args, **kwargs) + self._uri_mapping = {} + self._media_dir = self.backend.config['local']['media_dir'] + self._json_file = self.backend.config['local-json']['json_file'] + self.refresh() + + def refresh(self, uri=None): + logger.debug( + 'Loading local tracks from %s using %s', + self._media_dir, self._json_file) + + tracks = _load_tracks(self._json_file) + uris_to_remove = set(self._uri_mapping) + + for track in tracks: + self._uri_mapping[track.uri] = track + uris_to_remove.discard(track.uri) + + for uri in uris_to_remove: + del self._uri_mapping[uri] + + logger.info( + 'Loaded %d local tracks from %s using %s', + len(tracks), self._media_dir, self._json_file) + + def lookup(self, uri): + try: + return [self._uri_mapping[uri]] + except KeyError: + logger.debug('Failed to lookup %r', uri) + return [] + + def find_exact(self, query=None, uris=None): + tracks = self._uri_mapping.values() + return search.find_exact(tracks, query=query, uris=uris) + + def search(self, query=None, uris=None): + tracks = self._uri_mapping.values() + return search.search(tracks, query=query, uris=uris) + + +class LocalJsonLibraryUpdateProvider(base.BaseLibraryProvider): + uri_schemes = ['local'] + + def __init__(self, config): + self._tracks = {} + self._media_dir = config['local']['media_dir'] + self._json_file = config['local-json']['json_file'] + + def load(self): + for track in _load_tracks(self._json_file): + self._tracks[track.uri] = track + return self._tracks.values() + + def add(self, track): + self._tracks[track.uri] = track + + def remove(self, uri): + if uri in self._tracks: + del self._tracks[uri] + + def commit(self): + directory, basename = os.path.split(self._json_file) + + # TODO: cleanup directory/basename.* files. + tmp = tempfile.NamedTemporaryFile( + prefix=basename + '.', dir=directory, delete=False) + + try: + with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: + data = {'version': mopidy.__version__, + 'tracks': self._tracks.values()} + json.dump(data, fp, cls=models.ModelJSONEncoder, + indent=2, separators=(',', ': ')) + os.rename(tmp.name, self._json_file) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) diff --git a/mopidy/backends/local/tagcache/ext.conf b/mopidy/backends/local/tagcache/ext.conf index 48a3c763..749959e8 100644 --- a/mopidy/backends/local/tagcache/ext.conf +++ b/mopidy/backends/local/tagcache/ext.conf @@ -1,2 +1,2 @@ [local-tagcache] -enabled = true +enabled = false diff --git a/setup.py b/setup.py index 7d4b2cd8..11855553 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ setup( 'http = mopidy.frontends.http:Extension [http]', 'local = mopidy.backends.local:Extension', 'local-tagcache = mopidy.backends.local.tagcache:Extension', + 'local-json = mopidy.backends.local.json:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'stream = mopidy.backends.stream:Extension', ], From 3bbcb4d121631f3737333de0de720307865caea8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 28 Nov 2013 23:20:03 +0100 Subject: [PATCH 020/238] local: Review comment fixes --- mopidy/backends/local/commands.py | 9 +++++---- mopidy/backends/local/json/__init__.py | 3 --- mopidy/backends/local/json/library.py | 1 - mopidy/backends/local/tagcache/__init__.py | 3 --- mopidy/backends/local/tagcache/library.py | 2 +- mopidy/backends/local/translator.py | 7 ++++--- tests/backends/local/tagcache_test.py | 4 ++-- tests/core/actor_test.py | 11 +++++++---- tests/utils/path_test.py | 4 ++-- 9 files changed, 21 insertions(+), 23 deletions(-) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 48ae4e9f..5e9b42e6 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -52,10 +52,11 @@ class ScanCommand(commands.Command): tracks = local_updater.load() logger.info('Checking %d tracks from library.', len(tracks)) for track in tracks: - track_path = translator.local_to_path(track.uri, media_dir) - uri_path_mapping[track.uri] = track_path + uri_path_mapping[track.uri] = translator.local_track_uri_to_path( + track.uri, media_dir) try: - if int(os.stat(track_path).st_mtime) > track.last_modified: + stat = os.stat(uri_path_mapping[track.uri]) + if int(stat.st_mtime) > track.last_modified: uris_to_update.add(track.uri) uris_in_library.add(track.uri) except OSError: @@ -73,7 +74,7 @@ class ScanCommand(commands.Command): logger.debug('Skipped %s: File extension excluded.', uri) continue - uri = translator.path_to_local(relpath) + uri = translator.path_to_local_track_uri(relpath) if uri not in uris_in_library: uris_to_update.add(uri) uri_path_mapping[uri] = os.path.join(media_dir, relpath) diff --git a/mopidy/backends/local/json/__init__.py b/mopidy/backends/local/json/__init__.py index d1103ac6..031dae51 100644 --- a/mopidy/backends/local/json/__init__.py +++ b/mopidy/backends/local/json/__init__.py @@ -21,9 +21,6 @@ class Extension(ext.Extension): schema['json_file'] = config.Path() return schema - def validate_environment(self): - pass - def get_backend_classes(self): from .actor import LocalJsonBackend return [LocalJsonBackend] diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py index abb620e9..6bfef783 100644 --- a/mopidy/backends/local/json/library.py +++ b/mopidy/backends/local/json/library.py @@ -1,4 +1,3 @@ -from __future__ import absolute_import from __future__ import unicode_literals import gzip diff --git a/mopidy/backends/local/tagcache/__init__.py b/mopidy/backends/local/tagcache/__init__.py index c7364e8b..b51b88bf 100644 --- a/mopidy/backends/local/tagcache/__init__.py +++ b/mopidy/backends/local/tagcache/__init__.py @@ -19,9 +19,6 @@ class Extension(ext.Extension): # Config only contains local-tagcache/enabled since we are not setting our # own schema. - def validate_environment(self): - pass - def get_backend_classes(self): from .actor import LocalTagcacheBackend return [LocalTagcacheBackend] diff --git a/mopidy/backends/local/tagcache/library.py b/mopidy/backends/local/tagcache/library.py index fdc0be35..5b541d19 100644 --- a/mopidy/backends/local/tagcache/library.py +++ b/mopidy/backends/local/tagcache/library.py @@ -5,8 +5,8 @@ import os import tempfile from mopidy.backends import base -from mopidy.backends.local.translator import local_to_file_uri from mopidy.backends.local import search +from mopidy.backends.local.translator import local_to_file_uri from .translator import parse_mpd_tag_cache, tracks_to_tag_cache_format diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 0f82b05e..1153b1b3 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -19,14 +19,15 @@ def local_to_file_uri(uri, media_dir): return path_to_uri(file_path) -def local_to_path(uri, media_dir): +def local_track_uri_to_path(uri, media_dir): if not uri.startswith('local:track:'): - raise Exception + raise ValueError('Invalid uri.') file_path = uri_to_path(uri).split(b':', 1)[1] return os.path.join(media_dir, file_path) -def path_to_local(relpath): +def path_to_local_track_uri(relpath): + """Convert path releative to media_dir to local track uri""" if isinstance(relpath, unicode): relpath = relpath.encode('utf-8') return b'local:track:%s' % urllib.quote(relpath) diff --git a/tests/backends/local/tagcache_test.py b/tests/backends/local/tagcache_test.py index 6d0b7469..b40b3346 100644 --- a/tests/backends/local/tagcache_test.py +++ b/tests/backends/local/tagcache_test.py @@ -5,10 +5,10 @@ from __future__ import unicode_literals import os import unittest -from mopidy.utils.path import mtime, uri_to_path -from mopidy.frontends.mpd import translator as mpd, protocol from mopidy.backends.local.tagcache import translator +from mopidy.frontends.mpd import translator as mpd, protocol from mopidy.models import Album, Artist, Track +from mopidy.utils.path import mtime, uri_to_path from tests import path_to_data_dir diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index ce50d5ed..38e33baa 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -37,12 +37,15 @@ class CoreActorTest(unittest.TestCase): Core, audio=None, backends=[self.backend1, self.backend2]) def test_backends_with_colliding_uri_schemes_passes(self): - # Checks that backends with overlapping schemes, but distinct sub parts - # provided can co-exist. + """ + Checks that backends with overlapping schemes, but distinct sub parts + provided can co-exist. + """ + self.backend1.has_library().get.return_value = False self.backend1.has_playlists().get.return_value = False - self.backend2.uri_schemes().get.return_value = ['dummy1'] + self.backend2.uri_schemes.get.return_value = ['dummy1'] self.backend2.has_playback().get.return_value = False self.backend2.has_playlists().get.return_value = False @@ -50,4 +53,4 @@ class CoreActorTest(unittest.TestCase): self.assertEqual(core.backends.with_playback, {'dummy1': self.backend1}) self.assertEqual(core.backends.with_library, - {'dummy2': self.backend2}) + {'dummy1': self.backend2}) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 316b4f38..3accab39 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -225,8 +225,8 @@ class FindFilesTest(unittest.TestCase): def test_files(self): files = self.find('find') - excepted = [b'foo/bar/file', b'foo/file', b'baz/file'] - self.assertItemsEqual(excepted, files) + expected = [b'foo/bar/file', b'foo/file', b'baz/file'] + self.assertItemsEqual(expected, files) def test_names_are_bytestrings(self): is_bytes = lambda f: isinstance(f, bytes) From 121d2c782a1553c857fb3715b39a738d9c5777de Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 28 Nov 2013 23:42:12 +0100 Subject: [PATCH 021/238] docs: Add info about local-json and pluggable libraries --- docs/ext/local.rst | 54 +++++++++++++++++++++++++++++++++++-------- docs/extensiondev.rst | 36 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 23baa5d1..583a7427 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -62,29 +62,65 @@ Usage If you want use Mopidy to play music you have locally at your machine, you need to review and maybe change some of the local extension config values. See above -for a complete list. Then you need to generate a tag cache for your local +for a complete list. Then you need to generate a local library for your local music... -.. _generating-a-tag-cache: +.. _generating-a-local-library: -Generating a tag cache ----------------------- +Generating a local library +-------------------------- The command :command:`mopidy local scan` will scan the path set in the -:confval:`local/media_dir` config value for any media files and build a MPD -compatible ``tag_cache``. +:confval:`local/media_dir` config value for any audio files and build a +library. -To make a ``tag_cache`` of your local music available for Mopidy: +To make a local library for your music available for Mopidy: #. Ensure that the :confval:`local/media_dir` config value points to where your music is located. Check the current setting by running:: mopidy config -#. Scan your media library. The command writes the ``tag_cache`` to - the :confval:`local/tag_cache_file`:: +#. Scan your media library.:: mopidy local scan #. Start Mopidy, find the music library in a client, and play some local music! + + +Plugable library support +------------------------ + +Local libraries are fully pluggable. What this means is that users may opt to +disable the current default library `local-json`, replacing it with a third +party one. When running :command:`mopidy local scan` mopidy will populate +whatever the current active library is with data. Only one library may be +active at a time. + + +***************** +Mopidy-Local-JSON +***************** + +Extension for storing local music library in a JSON file, default built in +library for local files. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/local/json/ext.conf + :language: ini + + +Configuration values +==================== + +.. confval:: local-json/enabled + + If the local extension should be enabled or not. + +.. confval:: local-json/json_file + + Path to a file to store the gziped json data in. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 7fa19f7a..2709690a 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -309,6 +309,10 @@ This is ``mopidy_soundspot/__init__.py``:: from .commands import SoundspotCommand return SoundspotCommand() + def get_library_updaters(self): + from .library import SoundspotLibraryUpdateProvider + return [SoundspotLibraryUpdateProvider] + def register_gstreamer_elements(self): from .mixer import SoundspotMixer gobject.type_register(SoundspotMixer) @@ -406,6 +410,38 @@ more details. return 0 +Example library provider +======================== + +Currently library providers are only really relevant for people who want to +replace the default local library. Providing this in addition to a backend that +exposes a library for the `local` uri scheme lets you plug in whatever storage +solution you happen to prefer. + +:: + + from mopidy.backends import base + + + class SoundspotLibraryUpdateProvider(base.BaseLibraryProvider): + def __init__(self, config): + super(SoundspotLibraryUpdateProvider, self).__init__(config) + self.config = config + + def load(self): + # Your track loading code + return tracks + + def add(self, track): + # Your code for handling adding a new track + + def remove(self, uri): + # Your code for removing the track coresponding to this uri + + def commit(self): + # Your code to persist the library, if needed. + + Example GStreamer element ========================= From 26ec956a082d7c15aec3693e21b07941bd380fdd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 29 Nov 2013 00:13:06 +0100 Subject: [PATCH 022/238] config: Add deprecated config value support. This makes it possible to mark a key as deprecated, this implies it will be ignored if present, and never included in the resulting config. --- mopidy/config/schemas.py | 2 ++ mopidy/config/types.py | 18 ++++++++++++++++++ tests/config/schemas_test.py | 10 +++++++++- tests/config/types_test.py | 10 ++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index a535b493..67473b88 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -74,6 +74,8 @@ class ConfigSchema(collections.OrderedDict): if key not in result and key not in errors: result[key] = None errors[key] = 'config key not found.' + if isinstance(result[key], types.DeprecatedValue): + del result[key] return result, errors diff --git a/mopidy/config/types.py b/mopidy/config/types.py index d264de30..6aeaaaa7 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -31,6 +31,10 @@ class ExpandedPath(bytes): self.original = original +class DeprecatedValue(object): + pass + + class ConfigValue(object): """Represents a config key's value and how to handle it. @@ -59,6 +63,20 @@ class ConfigValue(object): return bytes(value) +class Deprecated(ConfigValue): + """Deprecated value + + Used for ignoring old config values that are no longer in use, but should + not cause the config parser to crash. + """ + + def deserialize(self, value): + return DeprecatedValue() + + def serialize(self, value, display=False): + return DeprecatedValue() + + class String(ConfigValue): """String value. diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index 9da8f667..82ea159b 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -4,7 +4,7 @@ import logging import mock import unittest -from mopidy.config import schemas +from mopidy.config import schemas, types from tests import any_unicode @@ -77,6 +77,14 @@ class ConfigSchemaTest(unittest.TestCase): self.assertIsNone(result['bar']) self.assertIsNone(result['baz']) + def test_deserialize_none_value(self): + self.schema['foo'].deserialize.return_value = types.DeprecatedValue() + + result, errors = self.schema.deserialize(self.values) + print result, errors + self.assertItemsEqual(['bar', 'baz'], result.keys()) + self.assertNotIn('foo', errors) + class LogLevelConfigSchemaTest(unittest.TestCase): def test_conversion(self): diff --git a/tests/config/types_test.py b/tests/config/types_test.py index 0df3dfb4..c4b9ec88 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -33,6 +33,16 @@ class ConfigValueTest(unittest.TestCase): self.assertIsInstance(value.serialize(object(), display=True), bytes) +class DeprecatedTest(unittest.TestCase): + def test_deserialize_returns_deprecated_value(self): + self.assertIsInstance(types.Deprecated().deserialize(b'foobar'), + types.DeprecatedValue) + + def test_serialize_returns_deprecated_value(self): + self.assertIsInstance(types.Deprecated().serialize('foobar'), + types.DeprecatedValue) + + class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.String() From 81e437ae7033fd2c87cb1ee9f0b93ede49da9231 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 3 Dec 2013 19:07:25 +0100 Subject: [PATCH 023/238] docs: Document value separation in config values with lists --- docs/ext/local.rst | 3 ++- docs/ext/stream.rst | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 23baa5d1..14807781 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -54,7 +54,8 @@ Configuration values .. confval:: local/excluded_file_extensions - File extensions to exclude when scanning the media directory. + File extensions to exclude when scanning the media directory. Values + should be separated by either comma or newline. Usage diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index bb30e924..ee413b31 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -39,7 +39,8 @@ Configuration values .. confval:: stream/protocols - Whitelist of URI schemas to allow streaming from. + Whitelist of URI schemas to allow streaming from. Values should be + separated by either comma or newline. Usage From 2a2d725804ac1f066dc17f77c5773eea6b46ec8f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 4 Dec 2013 22:31:41 +0100 Subject: [PATCH 024/238] docs: Add Moped client, update other web clients --- docs/_static/martijnboland-moped.png | Bin 0 -> 184702 bytes .../woutervanwijk-mopidy-webclient.png | Bin 47992 -> 83749 bytes docs/clients/http.rst | 50 ++++++++++++++---- 3 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 docs/_static/martijnboland-moped.png diff --git a/docs/_static/martijnboland-moped.png b/docs/_static/martijnboland-moped.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c871125d6a7ca2cbda2e2a69060a11020e353c GIT binary patch literal 184702 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

@`{u&(3B> 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 z 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

}Ls-E0$H|2n^8FO<5l8$5OT_hz@H4a{NXrS6 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]t{@&IJ{aTXA>sUjz55}`kjdD&V@BX>R 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 diff --git a/docs/clients/http.rst b/docs/clients/http.rst index 7b4ea72b..a31636cd 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -19,15 +19,18 @@ woutervanwijk/Mopidy-Webclient ============================== .. image:: /_static/woutervanwijk-mopidy-webclient.png - :width: 382 - :height: 621 + :width: 1275 + :height: 600 -The first web client for Mopidy is still under development, but is already very -usable. It targets both desktop and mobile browsers. +The first web client for Mopidy, made with jQuery Mobile by Wouter van Wijk. +Also the web client used for Wouter's popular `Pi Musicbox +`_ image for Raspberry Pi. -The web client used for the `Pi Musicbox -`_ is also available for other users -of Mopidy. See https://github.com/woutervanwijk/Mopidy-WebClient for details. + With Mopidy Browser Client, you can play your music on your computer (or + Rapsberry Pi) and remotely control it from a computer, phone, tablet, + laptop. From your couch. + + -- https://github.com/woutervanwijk/Mopidy-WebClient Mopidy Lux @@ -37,15 +40,40 @@ Mopidy Lux :width: 1000 :height: 645 -New web client developed by Janez Troha. See -https://github.com/dz0ny/mopidy-lux for details. +A Mopidy web client made with AngularJS by Janez Troha. + + A shiny new remote web control interface for Mopidy player. + + -- https://github.com/dz0ny/mopidy-lux + + +Moped +===== + +.. image:: /_static/martijnboland-moped.png + :width: 720 + :height: 450 + +A Mopidy web client made with Durandal and KnockoutJS by Martijn Boland. + + Moped is a responsive web client for the Mopidy music server. It is + inspired by Mopidy-Webclient, but built from scratch based on a different + technology stack with Durandal and Bootstrap 3. + + -- https://github.com/martijnboland/moped JukePi ====== -New web client developed by Meantime IT in the UK for their office jukebox. See -https://github.com/meantimeit/jukepi for details. +A Mopidy web client made with Backbone.js by Meantime IT in the UK for their +office jukebox. + + JukePi is a web client for the Mopidy music server. Mopidy empowers you to + create a custom music server that can connect to Spotify, play local mp3s + and more. + + -- https://github.com/meantimeit/jukepi Other web clients From da63942b488ea03b09dec7192316d9f7501d88b0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 21:09:04 +0100 Subject: [PATCH 025/238] config: Improve handling of Deprecated config values --- mopidy/config/__init__.py | 2 ++ mopidy/config/schemas.py | 6 +++--- tests/config/schemas_test.py | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a7153ea2..f68567e7 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -167,6 +167,8 @@ def _format(config, comments, schemas, display, disable): continue output.append(b'[%s]' % bytes(schema.name)) for key, value in serialized.items(): + if isinstance(value, types.DeprecatedValue): + continue comment = bytes(comments.get(schema.name, {}).get(key, '')) output.append(b'%s =' % bytes(key)) if value is not None: diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 67473b88..b026ac2b 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -71,11 +71,11 @@ class ConfigSchema(collections.OrderedDict): errors[key] = str(e) for key in self.keys(): - if key not in result and key not in errors: + if isinstance(self[key], types.Deprecated): + result.pop(key, None) + elif key not in result and key not in errors: result[key] = None errors[key] = 'config key not found.' - if isinstance(result[key], types.DeprecatedValue): - del result[key] return result, errors diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index 82ea159b..6eb35ed3 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -77,11 +77,10 @@ class ConfigSchemaTest(unittest.TestCase): self.assertIsNone(result['bar']) self.assertIsNone(result['baz']) - def test_deserialize_none_value(self): - self.schema['foo'].deserialize.return_value = types.DeprecatedValue() + def test_deserialize_deprecated_value(self): + self.schema['foo'] = types.Deprecated() result, errors = self.schema.deserialize(self.values) - print result, errors self.assertItemsEqual(['bar', 'baz'], result.keys()) self.assertNotIn('foo', errors) From 9c2d38e989dea2d0bc669ebd129aacc9e7f263a0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 21:12:28 +0100 Subject: [PATCH 026/238] local: Remove tag cache support - Updates doc references to tag cache - Removes tag cache from config and marks it deprecated - Removes tag cache from setup.py - Removes tag cache from config converter - Removes tag cache from tests - Converts local library tests to use JSON. --- docs/devtools.rst | 7 +- docs/ext/local.rst | 4 - mopidy/backends/local/__init__.py | 2 +- mopidy/backends/local/ext.conf | 1 - mopidy/backends/local/tagcache/__init__.py | 28 -- mopidy/backends/local/tagcache/actor.py | 30 -- mopidy/backends/local/tagcache/ext.conf | 2 - mopidy/backends/local/tagcache/library.py | 101 ------ mopidy/backends/local/tagcache/translator.py | 246 ------------- mopidy/config/convert.py | 1 - setup.py | 1 - tests/backends/local/events_test.py | 1 - tests/backends/local/library_test.py | 46 +-- tests/backends/local/playback_test.py | 1 - tests/backends/local/playlists_test.py | 1 - tests/backends/local/tagcache_test.py | 346 ------------------- tests/backends/local/tracklist_test.py | 1 - tests/data/advanced_tag_cache | 107 ------ tests/data/albumartist_tag_cache | 16 - tests/data/blank_tag_cache | 10 - tests/data/empty_tag_cache | 6 - tests/data/library.json.gz | Bin 0 -> 394 bytes tests/data/library_tag_cache | 56 --- tests/data/musicbrainz_tag_cache | 20 -- tests/data/scanner/advanced_cache | 81 ----- tests/data/scanner/empty_cache | 6 - tests/data/scanner/simple_cache | 15 - tests/data/simple_tag_cache | 16 - tests/data/utf8_tag_cache | 18 - 29 files changed, 28 insertions(+), 1142 deletions(-) delete mode 100644 mopidy/backends/local/tagcache/__init__.py delete mode 100644 mopidy/backends/local/tagcache/actor.py delete mode 100644 mopidy/backends/local/tagcache/ext.conf delete mode 100644 mopidy/backends/local/tagcache/library.py delete mode 100644 mopidy/backends/local/tagcache/translator.py delete mode 100644 tests/backends/local/tagcache_test.py delete mode 100644 tests/data/advanced_tag_cache delete mode 100644 tests/data/albumartist_tag_cache delete mode 100644 tests/data/blank_tag_cache delete mode 100644 tests/data/empty_tag_cache create mode 100644 tests/data/library.json.gz delete mode 100644 tests/data/library_tag_cache delete mode 100644 tests/data/musicbrainz_tag_cache delete mode 100644 tests/data/scanner/advanced_cache delete mode 100644 tests/data/scanner/empty_cache delete mode 100644 tests/data/scanner/simple_cache delete mode 100644 tests/data/simple_tag_cache delete mode 100644 tests/data/utf8_tag_cache diff --git a/docs/devtools.rst b/docs/devtools.rst index ecae6c86..858cc7f8 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -66,11 +66,8 @@ Sample session:: -OK +ACK [2@0] {listallinfo} incorrect arguments -To ensure that Mopidy and MPD have comparable state it is suggested you setup -both to use ``tests/data/advanced_tag_cache`` for their tag cache and -``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for -playlists. - +To ensure that Mopidy and MPD have comparable state it is suggested you scan +the same media directory with both servers. Documentation writing ===================== diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 583a7427..51268c51 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -43,10 +43,6 @@ Configuration values Path to playlists directory with m3u files for local media. -.. confval:: local/tag_cache_file - - Path to tag cache for local media. - .. confval:: local/scan_timeout Number of milliseconds before giving up scanning a file and moving on to diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 723eb056..d24ab010 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -20,7 +20,7 @@ class Extension(ext.Extension): schema = super(Extension, self).get_config_schema() schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() - schema['tag_cache_file'] = config.Path() + schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000*60*60) schema['excluded_file_extensions'] = config.List(optional=True) diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index afc13c7d..f906a04f 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -2,7 +2,6 @@ enabled = true media_dir = $XDG_MUSIC_DIR playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists -tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache scan_timeout = 1000 excluded_file_extensions = .html diff --git a/mopidy/backends/local/tagcache/__init__.py b/mopidy/backends/local/tagcache/__init__.py deleted file mode 100644 index b51b88bf..00000000 --- a/mopidy/backends/local/tagcache/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Local-Tagcache' - ext_name = 'local-tagcache' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - # Config only contains local-tagcache/enabled since we are not setting our - # own schema. - - def get_backend_classes(self): - from .actor import LocalTagcacheBackend - return [LocalTagcacheBackend] - - def get_library_updaters(self): - from .library import LocalTagcacheLibraryUpdateProvider - return [LocalTagcacheLibraryUpdateProvider] diff --git a/mopidy/backends/local/tagcache/actor.py b/mopidy/backends/local/tagcache/actor.py deleted file mode 100644 index f052debb..00000000 --- a/mopidy/backends/local/tagcache/actor.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import unicode_literals - -import logging - -import pykka - -from mopidy.backends import base -from mopidy.utils import encoding, path - -from .library import LocalTagcacheLibraryProvider - -logger = logging.getLogger('mopidy.backends.local.tagcache') - - -class LocalTagcacheBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(LocalTagcacheBackend, self).__init__() - - self.config = config - self.check_dirs_and_files() - self.library = LocalTagcacheLibraryProvider(backend=self) - self.uri_schemes = ['local'] - - def check_dirs_and_files(self): - try: - path.get_or_create_file(self.config['local']['tag_cache_file']) - except EnvironmentError as error: - logger.warning( - 'Could not create empty tag cache file: %s', - encoding.locale_decode(error)) diff --git a/mopidy/backends/local/tagcache/ext.conf b/mopidy/backends/local/tagcache/ext.conf deleted file mode 100644 index 749959e8..00000000 --- a/mopidy/backends/local/tagcache/ext.conf +++ /dev/null @@ -1,2 +0,0 @@ -[local-tagcache] -enabled = false diff --git a/mopidy/backends/local/tagcache/library.py b/mopidy/backends/local/tagcache/library.py deleted file mode 100644 index 5b541d19..00000000 --- a/mopidy/backends/local/tagcache/library.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import tempfile - -from mopidy.backends import base -from mopidy.backends.local import search -from mopidy.backends.local.translator import local_to_file_uri - -from .translator import parse_mpd_tag_cache, tracks_to_tag_cache_format - -logger = logging.getLogger('mopidy.backends.local.tagcache') - - -class LocalTagcacheLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalTagcacheLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self._media_dir = self.backend.config['local']['media_dir'] - self._tag_cache_file = self.backend.config['local']['tag_cache_file'] - self.refresh() - - def refresh(self, uri=None): - logger.debug( - 'Loading local tracks from %s using %s', - self._media_dir, self._tag_cache_file) - - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) - uris_to_remove = set(self._uri_mapping) - - for track in tracks: - self._uri_mapping[track.uri] = track - uris_to_remove.discard(track.uri) - - for uri in uris_to_remove: - del self._uri_mapping[uri] - - logger.info( - 'Loaded %d local tracks from %s using %s', - len(tracks), self._media_dir, self._tag_cache_file) - - def lookup(self, uri): - try: - return [self._uri_mapping[uri]] - except KeyError: - logger.debug('Failed to lookup %r', uri) - return [] - - def find_exact(self, query=None, uris=None): - tracks = self._uri_mapping.values() - return search.find_exact(tracks, query=query, uris=uris) - - def search(self, query=None, uris=None): - tracks = self._uri_mapping.values() - return search.search(tracks, query=query, uris=uris) - - -class LocalTagcacheLibraryUpdateProvider(base.BaseLibraryProvider): - uri_schemes = ['local'] - - def __init__(self, config): - self._tracks = {} - self._media_dir = config['local']['media_dir'] - self._tag_cache_file = config['local']['tag_cache_file'] - - def load(self): - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) - for track in tracks: - # TODO: this should use uris as is, i.e. hack that should go away - # with tag caches. - uri = local_to_file_uri(track.uri, self._media_dir) - self._tracks[uri] = track.copy(uri=uri) - return tracks - - def add(self, track): - self._tracks[track.uri] = track - - def remove(self, uri): - if uri in self._tracks: - del self._tracks[uri] - - def commit(self): - directory, basename = os.path.split(self._tag_cache_file) - - # TODO: cleanup directory/basename.* files. - tmp = tempfile.NamedTemporaryFile( - prefix=basename + '.', dir=directory, delete=False) - - try: - for row in tracks_to_tag_cache_format( - self._tracks.values(), self._media_dir): - if len(row) == 1: - tmp.write(('%s\n' % row).encode('utf-8')) - else: - tmp.write(('%s: %s\n' % row).encode('utf-8')) - - os.rename(tmp.name, self._tag_cache_file) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) diff --git a/mopidy/backends/local/tagcache/translator.py b/mopidy/backends/local/tagcache/translator.py deleted file mode 100644 index be54cd1d..00000000 --- a/mopidy/backends/local/tagcache/translator.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os -import re -import urllib - -from mopidy.frontends.mpd import translator as mpd, protocol -from mopidy.models import Track, Artist, Album -from mopidy.utils.encoding import locale_decode -from mopidy.utils.path import mtime as get_mtime, split_path, uri_to_path - -logger = logging.getLogger('mopidy.backends.local.tagcache') - - -# TODO: remove music_dir from API -def parse_mpd_tag_cache(tag_cache, music_dir=''): - """ - Converts a MPD tag_cache into a lists of tracks, artists and albums. - """ - tracks = set() - - try: - with open(tag_cache) as library: - contents = library.read() - except IOError as error: - logger.warning('Could not open tag cache: %s', locale_decode(error)) - return tracks - - current = {} - state = None - - # TODO: uris as bytes - for line in contents.split(b'\n'): - if line == b'songList begin': - state = 'songs' - continue - elif line == b'songList end': - state = None - continue - elif not state: - continue - - key, value = line.split(b': ', 1) - - if key == b'key': - _convert_mpd_data(current, tracks) - current.clear() - - current[key.lower()] = value.decode('utf-8') - - _convert_mpd_data(current, tracks) - - return tracks - - -def _convert_mpd_data(data, tracks): - if not data: - return - - track_kwargs = {} - album_kwargs = {} - artist_kwargs = {} - albumartist_kwargs = {} - - 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]) - else: - track_kwargs['track_no'] = int(data['track']) - - if 'mtime' in data: - track_kwargs['last_modified'] = int(data['mtime']) - - if 'artist' in data: - artist_kwargs['name'] = data['artist'] - - if 'albumartist' in data: - albumartist_kwargs['name'] = data['albumartist'] - - if 'composer' in data: - track_kwargs['composers'] = [Artist(name=data['composer'])] - - if 'performer' in data: - track_kwargs['performers'] = [Artist(name=data['performer'])] - - if 'album' in data: - album_kwargs['name'] = data['album'] - - if 'title' in data: - track_kwargs['name'] = data['title'] - - if 'genre' in data: - track_kwargs['genre'] = data['genre'] - - if 'date' in data: - track_kwargs['date'] = data['date'] - - if 'comment' in data: - track_kwargs['comment'] = data['comment'] - - if 'musicbrainz_trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] - - if 'musicbrainz_albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] - - if 'musicbrainz_artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] - - if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = ( - data['musicbrainz_albumartistid']) - - if artist_kwargs: - artist = Artist(**artist_kwargs) - track_kwargs['artists'] = [artist] - - if albumartist_kwargs: - albumartist = Artist(**albumartist_kwargs) - album_kwargs['artists'] = [albumartist] - - if album_kwargs: - album = Album(**album_kwargs) - track_kwargs['album'] = album - - if data['file'][0] == '/': - path = data['file'][1:] - else: - path = data['file'] - - track_kwargs['uri'] = 'local:track:%s' % path - track_kwargs['length'] = int(data.get('time', 0)) * 1000 - - track = Track(**track_kwargs) - tracks.add(track) - - -def tracks_to_tag_cache_format(tracks, media_dir): - """ - Format list of tracks for output to MPD tag cache - - :param tracks: the tracks - :type tracks: list of :class:`mopidy.models.Track` - :param media_dir: the path to the music dir - :type media_dir: string - :rtype: list of lists of two-tuples - """ - result = [ - ('info_begin',), - ('mpd_version', protocol.VERSION), - ('fs_charset', protocol.ENCODING), - ('info_end',) - ] - tracks.sort(key=lambda t: t.uri) - dirs, files = tracks_to_directory_tree(tracks, media_dir) - _add_to_tag_cache(result, dirs, files, media_dir) - return result - - -# TODO: bytes only -def _add_to_tag_cache(result, dirs, files, media_dir): - base_path = media_dir.encode('utf-8') - - for path, (entry_dirs, entry_files) in dirs.items(): - try: - text_path = path.decode('utf-8') - except UnicodeDecodeError: - 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)))) - result.append(('begin', name)) - _add_to_tag_cache(result, entry_dirs, entry_files, media_dir) - result.append(('end', name)) - - result.append(('songList begin',)) - - for track in files: - track_result = dict(mpd.track_to_mpd_format(track)) - - # XXX Don't save comments to the tag cache as they may span multiple - # lines. We'll start saving track comments when we move from tag_cache - # to a JSON file. See #579 for details. - if 'Comment' in track_result: - del track_result['Comment'] - - path = uri_to_path(track_result['file']) - try: - text_path = path.decode('utf-8') - except UnicodeDecodeError: - text_path = urllib.quote(path).decode('utf-8') - relative_path = os.path.relpath(path, base_path) - relative_uri = urllib.quote(relative_path) - - # TODO: use track.last_modified - 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',)) - - -def tracks_to_directory_tree(tracks, media_dir): - directories = ({}, []) - - for track in tracks: - path = b'' - current = directories - - absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) - relative_track_dir_path = re.sub( - '^' + re.escape(media_dir), b'', absolute_track_dir_path) - - for part in split_path(relative_track_dir_path): - path = os.path.join(path, part) - if path not in current[0]: - current[0][path] = ({}, []) - current = current[0][path] - current[1].append(track) - return directories - - -MPD_KEY_ORDER = ''' - key file Time Artist Album AlbumArtist Title Track Genre Date Composer - Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID - MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime -'''.split() - - -def order_mpd_track_info(result): - """ - Order results from - :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it - matches MPD's ordering. Simply a cosmetic fix for easier diffing of - tag_caches. - - :param result: the track info - :type result: list of tuples - :rtype: list of tuples - """ - return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py index 3c3edb85..87bf4ed5 100644 --- a/mopidy/config/convert.py +++ b/mopidy/config/convert.py @@ -45,7 +45,6 @@ def convert(settings): helper('local/media_dir', 'LOCAL_MUSIC_PATH') helper('local/playlists_dir', 'LOCAL_PLAYLIST_PATH') - helper('local/tag_cache_file', 'LOCAL_TAG_CACHE_FILE') helper('spotify/username', 'SPOTIFY_USERNAME') helper('spotify/password', 'SPOTIFY_PASSWORD') diff --git a/setup.py b/setup.py index 11855553..bc2fe222 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,6 @@ setup( 'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', 'local = mopidy.backends.local:Extension', - 'local-tagcache = mopidy.backends.local.tagcache:Extension', 'local-json = mopidy.backends.local.json:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'stream = mopidy.backends.stream:Extension', diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 725c580f..1e26a68c 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -18,7 +18,6 @@ class LocalBackendEventsTest(unittest.TestCase): 'local': { 'media_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index c04b81f5..e4c00570 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals +import copy import tempfile import unittest import pykka from mopidy import core -from mopidy.backends.local.tagcache import actor +from mopidy.backends.local.json import actor from mopidy.models import Track, Album, Artist from tests import path_to_data_dir @@ -61,12 +62,14 @@ class LocalLibraryProviderTest(unittest.TestCase): 'local': { 'media_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('library_tag_cache'), - } + }, + 'local-json': { + 'json_file': path_to_data_dir('library.json.gz'), + }, } def setUp(self): - self.backend = actor.LocalTagcacheBackend.start( + self.backend = actor.LocalJsonBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library @@ -85,27 +88,27 @@ class LocalLibraryProviderTest(unittest.TestCase): # Verifies that https://github.com/mopidy/mopidy/issues/500 # has been fixed. - tag_cache = tempfile.NamedTemporaryFile() - with open(self.config['local']['tag_cache_file']) as fh: - tag_cache.write(fh.read()) - tag_cache.flush() + with tempfile.NamedTemporaryFile() as library: + with open(self.config['local-json']['json_file']) as fh: + library.write(fh.read()) + library.flush() - config = {'local': self.config['local'].copy()} - config['local']['tag_cache_file'] = tag_cache.name - backend = actor.LocalTagcacheBackend(config=config, audio=None) + config = copy.deepcopy(self.config) + config['local-json']['json_file'] = library.name + backend = actor.LocalJsonBackend(config=config, audio=None) - # Sanity check that value is in tag cache - result = backend.library.lookup(self.tracks[0].uri) - self.assertEqual(result, self.tracks[0:1]) + # Sanity check that value is in the library + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, self.tracks[0:1]) - # Clear tag cache and refresh - tag_cache.seek(0) - tag_cache.truncate() - backend.library.refresh() + # Clear library and refresh + library.seek(0) + library.truncate() + backend.library.refresh() - # Now it should be gone. - result = backend.library.lookup(self.tracks[0].uri) - self.assertEqual(result, []) + # Now it should be gone. + result = backend.library.lookup(self.tracks[0].uri) + self.assertEqual(result, []) def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) @@ -115,6 +118,7 @@ class LocalLibraryProviderTest(unittest.TestCase): tracks = self.library.lookup('fake uri') self.assertEqual(tracks, []) + # TODO: move to search_test module def test_find_exact_no_hits(self): result = self.library.find_exact(track_name=['unknown track']) self.assertEqual(list(result[0].tracks), []) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 8fbc4415..4c3dd70d 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -23,7 +23,6 @@ class LocalPlaybackProviderTest(unittest.TestCase): 'local': { 'media_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index c8fedd62..c02e1d23 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -20,7 +20,6 @@ class LocalPlaylistsProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), - 'tag_cache_file': path_to_data_dir('library_tag_cache'), } } diff --git a/tests/backends/local/tagcache_test.py b/tests/backends/local/tagcache_test.py deleted file mode 100644 index b40b3346..00000000 --- a/tests/backends/local/tagcache_test.py +++ /dev/null @@ -1,346 +0,0 @@ -# encoding: utf-8 - -from __future__ import unicode_literals - -import os -import unittest - -from mopidy.backends.local.tagcache import translator -from mopidy.frontends.mpd import translator as mpd, protocol -from mopidy.models import Album, Artist, Track -from mopidy.utils.path import mtime, uri_to_path - -from tests import path_to_data_dir - - -class TracksToTagCacheFormatTest(unittest.TestCase): - def setUp(self): - self.media_dir = '/dir/subdir' - mtime.set_fake_time(1234567) - - def tearDown(self): - mtime.undo_fake() - - def translate(self, track): - base_path = self.media_dir.encode('utf-8') - result = dict(mpd.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()) - - def consume_headers(self, result): - self.assertEqual(('info_begin',), result[0]) - self.assertEqual(('mpd_version', protocol.VERSION), result[1]) - self.assertEqual(('fs_charset', protocol.ENCODING), result[2]) - self.assertEqual(('info_end',), result[3]) - return result[4:] - - def consume_song_list(self, result): - self.assertEqual(('songList begin',), result[0]) - for i, row in enumerate(result): - if row == ('songList end',): - return result[1:i], result[i + 1:] - self.fail("Couldn't find songList end in result") - - def consume_directory(self, result): - self.assertEqual('directory', result[0][0]) - self.assertEqual(('mtime', mtime('.')), result[1]) - self.assertEqual(('begin', os.path.split(result[0][1])[1]), result[2]) - directory = result[2][1] - for i, row in enumerate(result): - if row == ('end', directory): - return result[3:i], result[i + 1:] - self.fail("Couldn't find end %s in result" % directory) - - def test_empty_tag_cache_has_header(self): - result = translator.tracks_to_tag_cache_format([], self.media_dir) - result = self.consume_headers(result) - - def test_empty_tag_cache_has_song_list(self): - result = translator.tracks_to_tag_cache_format([], self.media_dir) - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_header(self): - track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - result = self.consume_headers(result) - - def test_tag_cache_has_song_list(self): - track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assert_(song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_formated_track(self): - track = Track(uri='file:///dir/subdir/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_has_formated_track_with_key_and_mtime(self): - track = Track(uri='file:///dir/subdir/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_supports_directories(self): - track = Track(uri='file:///dir/subdir/folder/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - song_list, result = self.consume_song_list(dir_data) - self.assertEqual(len(result), 0) - self.assertEqual(formated, song_list) - - def test_tag_cache_diretory_header_is_right(self): - track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - - self.assertEqual(('directory', 'folder/sub'), dir_data[0]) - self.assertEqual(('mtime', mtime('.')), dir_data[1]) - self.assertEqual(('begin', 'sub'), dir_data[2]) - - def test_tag_cache_suports_sub_directories(self): - track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.media_dir) - - result = self.consume_headers(result) - - dir_data, result = self.consume_directory(result) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(song_list), 0) - self.assertEqual(len(result), 0) - - dir_data, result = self.consume_directory(dir_data) - song_list, result = self.consume_song_list(result) - self.assertEqual(len(result), 0) - self.assertEqual(len(song_list), 0) - - song_list, result = self.consume_song_list(dir_data) - self.assertEqual(len(result), 0) - self.assertEqual(formated, song_list) - - def test_tag_cache_supports_multiple_tracks(self): - tracks = [ - Track(uri='file:///dir/subdir/song1.mp3'), - Track(uri='file:///dir/subdir/song2.mp3'), - ] - - formated = [] - formated.extend(self.translate(tracks[0])) - formated.extend(self.translate(tracks[1])) - - result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) - - result = self.consume_headers(result) - song_list, result = self.consume_song_list(result) - - self.assertEqual(formated, song_list) - self.assertEqual(len(result), 0) - - def test_tag_cache_supports_multiple_tracks_in_dirs(self): - tracks = [ - Track(uri='file:///dir/subdir/song1.mp3'), - Track(uri='file:///dir/subdir/folder/song2.mp3'), - ] - - formated = [] - formated.append(self.translate(tracks[0])) - formated.append(self.translate(tracks[1])) - - result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) - - result = self.consume_headers(result) - dir_data, result = self.consume_directory(result) - song_list, song_result = self.consume_song_list(dir_data) - - self.assertEqual(formated[1], song_list) - self.assertEqual(len(song_result), 0) - - song_list, result = self.consume_song_list(result) - self.assertEqual(len(result), 0) - self.assertEqual(formated[0], song_list) - - -class TracksToDirectoryTreeTest(unittest.TestCase): - def setUp(self): - self.media_dir = '/root' - - def test_no_tracks_gives_emtpy_tree(self): - tree = translator.tracks_to_directory_tree([], self.media_dir) - self.assertEqual(tree, ({}, [])) - - def test_top_level_files(self): - tracks = [ - Track(uri='file:///root/file1.mp3'), - Track(uri='file:///root/file2.mp3'), - Track(uri='file:///root/file3.mp3'), - ] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - self.assertEqual(tree, ({}, tracks)) - - def test_single_file_in_subdir(self): - tracks = [Track(uri='file:///root/dir/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ({'dir': ({}, tracks)}, []) - self.assertEqual(tree, expected) - - def test_single_file_in_sub_subdir(self): - tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) - self.assertEqual(tree, expected) - - def test_complex_file_structure(self): - tracks = [ - Track(uri='file:///root/file1.mp3'), - Track(uri='file:///root/dir1/file2.mp3'), - Track(uri='file:///root/dir1/file3.mp3'), - Track(uri='file:///root/dir2/file4.mp3'), - Track(uri='file:///root/dir2/sub/file5.mp3'), - ] - tree = translator.tracks_to_directory_tree(tracks, self.media_dir) - expected = ( - { - 'dir1': ({}, [tracks[1], tracks[2]]), - 'dir2': ( - { - 'dir2/sub': ({}, [tracks[4]]) - }, - [tracks[3]] - ), - }, - [tracks[0]] - ) - self.assertEqual(tree, expected) - - -expected_artists = [Artist(name='name')] -expected_albums = [ - Album(name='albumname', artists=expected_artists, num_tracks=2), - Album(name='albumname', num_tracks=2), -] -expected_tracks = [] - - -def generate_track(path, ident, album_id): - uri = 'local:track:%s' % path - track = Track( - uri=uri, name='trackname', artists=expected_artists, - album=expected_albums[album_id], track_no=1, date='2006', length=4000, - last_modified=1272319626) - expected_tracks.append(track) - - -generate_track('song1.mp3', 6, 0) -generate_track('song2.mp3', 7, 0) -generate_track('song3.mp3', 8, 1) -generate_track('subdir1/song4.mp3', 2, 0) -generate_track('subdir1/song5.mp3', 3, 0) -generate_track('subdir2/song6.mp3', 4, 1) -generate_track('subdir2/song7.mp3', 5, 1) -generate_track('subdir1/subsubdir/song8.mp3', 0, 0) -generate_track('subdir1/subsubdir/song9.mp3', 1, 1) - - -class MPDTagCacheToTracksTest(unittest.TestCase): - def test_emtpy_cache(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) - self.assertEqual(set(), tracks) - - def test_simple_cache(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) - track = Track( - uri='local:track:song1.mp3', name='trackname', - artists=expected_artists, track_no=1, album=expected_albums[0], - date='2006', length=4000, last_modified=1272319626) - self.assertEqual(set([track]), tracks) - - def test_advanced_cache(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) - self.assertEqual(set(expected_tracks), tracks) - - def test_unicode_cache(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) - - artists = [Artist(name='æøå')] - album = Album(name='æøå', artists=artists) - track = Track( - uri='local:track:song1.mp3', name='æøå', artists=artists, - composers=artists, performers=artists, genre='æøå', - album=album, length=4000, last_modified=1272319626, - comment='æøå&^`ൂ㔶') - - self.assertEqual(track, list(tracks)[0]) - - @unittest.SkipTest - def test_misencoded_cache(self): - # FIXME not sure if this can happen - pass - - def test_cache_with_blank_track_info(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) - expected = Track( - uri='local:track:song1.mp3', length=4000, last_modified=1272319626) - self.assertEqual(set([expected]), tracks) - - def test_musicbrainz_tagcache(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) - artist = list(expected_tracks[0].artists)[0].copy( - musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - albumartist = list(expected_tracks[0].artists)[0].copy( - name='albumartistname', - musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - album = expected_tracks[0].album.copy( - artists=[albumartist], - musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') - track = expected_tracks[0].copy( - artists=[artist], album=album, - musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') - - self.assertEqual(track, list(tracks)[0]) - - def test_albumartist_tag_cache(self): - tracks = translator.parse_mpd_tag_cache( - path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) - artist = Artist(name='albumartistname') - album = expected_albums[0].copy(artists=[artist]) - track = Track( - uri='local:track:song1.mp3', name='trackname', - artists=expected_artists, track_no=1, album=album, date='2006', - length=4000, last_modified=1272319626) - self.assertEqual(track, list(tracks)[0]) diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index ac135a25..c7cfe51f 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -19,7 +19,6 @@ class LocalTracklistProviderTest(unittest.TestCase): 'local': { 'media_dir': path_to_data_dir(''), 'playlists_dir': b'', - 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } tracks = [ diff --git a/tests/data/advanced_tag_cache b/tests/data/advanced_tag_cache deleted file mode 100644 index be299fb6..00000000 --- a/tests/data/advanced_tag_cache +++ /dev/null @@ -1,107 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -directory: subdir1 -begin: subdir1 -directory: subsubdir -begin: subdir1/subsubdir -songList begin -key: song8.mp3 -file: subdir1/subsubdir/song8.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song9.mp3 -file: subdir1/subsubdir/song9.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end -end: subdir1/subsubdir -songList begin -key: song4.mp3 -file: subdir1/song4.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song5.mp3 -file: subdir1/song5.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end -end: subdir1 -directory: subdir2 -begin: subdir2 -songList begin -key: song6.mp3 -file: subdir2/song6.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song7.mp3 -file: subdir2/song7.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end -end: subdir2 -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song2.mp3 -file: /song2.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -key: song3.mp3 -file: /song3.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end diff --git a/tests/data/albumartist_tag_cache b/tests/data/albumartist_tag_cache deleted file mode 100644 index 29942a75..00000000 --- a/tests/data/albumartist_tag_cache +++ /dev/null @@ -1,16 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -AlbumArtist: albumartistname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end diff --git a/tests/data/blank_tag_cache b/tests/data/blank_tag_cache deleted file mode 100644 index a6d33386..00000000 --- a/tests/data/blank_tag_cache +++ /dev/null @@ -1,10 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -mtime: 1272319626 -songList end diff --git a/tests/data/empty_tag_cache b/tests/data/empty_tag_cache deleted file mode 100644 index 84053d90..00000000 --- a/tests/data/empty_tag_cache +++ /dev/null @@ -1,6 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -songList end diff --git a/tests/data/library.json.gz b/tests/data/library.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..07cd48d128bb4cffcdf379a74ce7bf66469830d7 GIT binary patch literal 394 zcmV;50d@W#iwFoGcb`%M|7>Yua$$0LE^2dcZUDuVOKyWO5Qg`h!t%O_{2)}yu6O9J zijYg3fC@G;PB$obuW^DQY6#F^6(KbIGak>($DA zPL__6r8CG2`X+D;?QDqv0q4oqlP=_~>Ic0$iR2d>mV;0Q-?gm-X6 zXn;rPjR&2`BOK&W%8IMy3fep>=>LFjMuBd|-mfDU$|kf1_VMX@ro*VyORf%56-#1` o9$_7rXf$u4?av^%riS09flP`f0Il)s8o}WF0}y$t#qtRN0Om2m9{>OV literal 0 HcmV?d00001 diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache deleted file mode 100644 index 6d00cf97..00000000 --- a/tests/data/library_tag_cache +++ /dev/null @@ -1,56 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: key1 -file: /path1 -Artist: artist1 -AlbumArtist: artist1 -Title: track1 -Album: album1 -Date: 2001-02-03 -Track: 1 -Time: 4 -key: key2 -file: /path2 -Artist: artist2 -AlbumArtist: artist2 -Title: track2 -Album: album2 -Date: 2002 -Track: 2 -Time: 4 -key: key3 -file: /path3 -Artist: artist4 -AlbumArtist: artist3 -Title: track3 -Album: album3 -Date: 2003 -Track: 3 -Time: 4 -key: key4 -file: /path4 -Artist: artist3 -Title: track4 -Album: album4 -Date: 2004 -Track: 4 -Comment: This is a fantastic track -Time: 60 -key: key5 -file: /path5 -Composer: artist5 -Title: track5 -Album: album4 -Genre: genre1 -Time: 4 -key: key6 -file: /path6 -Performer: artist6 -Title: track6 -Album: album4 -Genre: genre2 -Time: 4 -songList end diff --git a/tests/data/musicbrainz_tag_cache b/tests/data/musicbrainz_tag_cache deleted file mode 100644 index 0e9dca46..00000000 --- a/tests/data/musicbrainz_tag_cache +++ /dev/null @@ -1,20 +0,0 @@ -info_begin -mpd_version: 0.16.0 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -Title: trackname -Album: albumname -AlbumArtist: albumartistname -Track: 1/2 -Date: 2006 -MUSICBRAINZ_ALBUMID: cb5f1603-d314-4c9c-91e5-e295cfb125d2 -MUSICBRAINZ_ALBUMARTISTID: 7364dea6-ca9a-48e3-be01-b44ad0d19897 -MUSICBRAINZ_ARTISTID: 7364dea6-ca9a-48e3-be01-b44ad0d19897 -MUSICBRAINZ_TRACKID: 90488461-8c1f-4a4e-826b-4c6dc70801f0 -mtime: 1272319626 -songList end diff --git a/tests/data/scanner/advanced_cache b/tests/data/scanner/advanced_cache deleted file mode 100644 index 60f7fca6..00000000 --- a/tests/data/scanner/advanced_cache +++ /dev/null @@ -1,81 +0,0 @@ -info_begin -mpd_version: 0.15.4 -fs_charset: UTF-8 -info_end -directory: subdir1 -mtime: 1288121499 -begin: subdir1 -songList begin -key: song4.mp3 -file: subdir1/song4.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song5.mp3 -file: subdir1/song5.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end -end: subdir1 -directory: subdir2 -mtime: 1288121499 -begin: subdir2 -songList begin -key: song6.mp3 -file: subdir2/song6.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song7.mp3 -file: subdir2/song7.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end -end: subdir2 -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song2.mp3 -file: /song2.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -key: song3.mp3 -file: /song3.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end diff --git a/tests/data/scanner/empty_cache b/tests/data/scanner/empty_cache deleted file mode 100644 index 3c466a32..00000000 --- a/tests/data/scanner/empty_cache +++ /dev/null @@ -1,6 +0,0 @@ -info_begin -mpd_version: 0.15.4 -fs_charset: UTF-8 -info_end -songList begin -songList end diff --git a/tests/data/scanner/simple_cache b/tests/data/scanner/simple_cache deleted file mode 100644 index db11c324..00000000 --- a/tests/data/scanner/simple_cache +++ /dev/null @@ -1,15 +0,0 @@ -info_begin -mpd_version: 0.15.4 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 5 -Artist: name -Title: trackname -Album: albumname -Track: 01/02 -Date: 2006 -mtime: 1288121370 -songList end diff --git a/tests/data/simple_tag_cache b/tests/data/simple_tag_cache deleted file mode 100644 index 07a474b3..00000000 --- a/tests/data/simple_tag_cache +++ /dev/null @@ -1,16 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: name -AlbumArtist: name -Title: trackname -Album: albumname -Track: 1/2 -Date: 2006 -mtime: 1272319626 -songList end diff --git a/tests/data/utf8_tag_cache b/tests/data/utf8_tag_cache deleted file mode 100644 index 83fbcad4..00000000 --- a/tests/data/utf8_tag_cache +++ /dev/null @@ -1,18 +0,0 @@ -info_begin -mpd_version: 0.14.2 -fs_charset: UTF-8 -info_end -songList begin -key: song1.mp3 -file: /song1.mp3 -Time: 4 -Artist: æøå -AlbumArtist: æøå -Composer: æøå -Performer: æøå -Title: æøå -Album: æøå -Genre: æøå -Comment: æøå&^`ൂ㔶 -mtime: 1272319626 -songList end From ad53a067aef04f7fb7e9ea4f744f03d26c94a0a2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 21:33:04 +0100 Subject: [PATCH 027/238] local: Split out library reading and writting - Create $XDG_DATA_DIR/mopidy/local in the local extension's validate env. - Make sure we handle bad data causing ValueError in JSON decoding - Initializing empty file causes more harm than good as it just leads to a ValueError. Switched to doing write_library(json_file, {}) - Helpers have been updated to be library oriented, not track. This paves the way for having {tracks: {uri: ...}, artist: {uri: ...}, ...} type denormalized data. --- mopidy/backends/local/__init__.py | 10 +++++- mopidy/backends/local/json/actor.py | 22 ++++++------ mopidy/backends/local/json/library.py | 49 ++++++++++++++------------- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index d24ab010..dedc868c 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,9 +1,13 @@ from __future__ import unicode_literals +import logging import os import mopidy from mopidy import config, ext +from mopidy.utils import encoding, path + +logger = logging.getLogger('mopidy.backends.local') class Extension(ext.Extension): @@ -27,7 +31,11 @@ class Extension(ext.Extension): return schema def validate_environment(self): - pass + try: + path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy/local') + except EnvironmentError as error: + error = encoding.locale_decode(error) + logger.warning('Could not create local data dir: %s', error) def get_backend_classes(self): from .actor import LocalBackend diff --git a/mopidy/backends/local/json/actor.py b/mopidy/backends/local/json/actor.py index df9ac447..66a6fbd5 100644 --- a/mopidy/backends/local/json/actor.py +++ b/mopidy/backends/local/json/actor.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals import logging +import os import pykka from mopidy.backends import base -from mopidy.utils import encoding, path +from mopidy.utils import encoding -from .library import LocalJsonLibraryProvider +from . import library logger = logging.getLogger('mopidy.backends.local.json') @@ -17,14 +18,13 @@ class LocalJsonBackend(pykka.ThreadingActor, base.Backend): super(LocalJsonBackend, self).__init__() self.config = config - self.check_dirs_and_files() - self.library = LocalJsonLibraryProvider(backend=self) + self.library = library.LocalJsonLibraryProvider(backend=self) self.uri_schemes = ['local'] - def check_dirs_and_files(self): - try: - path.get_or_create_file(self.config['local-json']['json_file']) - except EnvironmentError as error: - logger.warning( - 'Could not create empty json file: %s', - encoding.locale_decode(error)) + if not os.path.exists(config['local-json']['json_file']): + try: + library.write_library(config['local-json']['json_file'], {}) + logger.info('Created empty local JSON library.') + except EnvironmentError as error: + error = encoding.locale_decode(error) + logger.warning('Could not create local library: %s', error) diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py index 6bfef783..33427231 100644 --- a/mopidy/backends/local/json/library.py +++ b/mopidy/backends/local/json/library.py @@ -14,13 +14,31 @@ from mopidy.backends.local import search logger = logging.getLogger('mopidy.backends.local.json') -def _load_tracks(json_file): +def load_library(json_file): try: with gzip.open(json_file, 'rb') as fp: - result = json.load(fp, object_hook=models.model_json_decoder) - except IOError: - return [] - return result.get('tracks', []) + return json.load(fp, object_hook=models.model_json_decoder) + except (IOError, ValueError) as e: + logger.warning('Loading JSON local library failed: %s', e) + return {} + + +def write_library(json_file, data): + data['version'] = mopidy.__version__ + directory, basename = os.path.split(json_file) + + # TODO: cleanup directory/basename.* files. + tmp = tempfile.NamedTemporaryFile( + prefix=basename + '.', dir=directory, delete=False) + + try: + with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: + json.dump(data, fp, cls=models.ModelJSONEncoder, + indent=2, separators=(',', ': ')) + os.rename(tmp.name, json_file) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) class LocalJsonLibraryProvider(base.BaseLibraryProvider): @@ -36,7 +54,7 @@ class LocalJsonLibraryProvider(base.BaseLibraryProvider): 'Loading local tracks from %s using %s', self._media_dir, self._json_file) - tracks = _load_tracks(self._json_file) + tracks = load_library(self._json_file).get('tracks', []) uris_to_remove = set(self._uri_mapping) for track in tracks: @@ -75,7 +93,7 @@ class LocalJsonLibraryUpdateProvider(base.BaseLibraryProvider): self._json_file = config['local-json']['json_file'] def load(self): - for track in _load_tracks(self._json_file): + for track in load_library(self._json_file).get('tracks', []): self._tracks[track.uri] = track return self._tracks.values() @@ -87,19 +105,4 @@ class LocalJsonLibraryUpdateProvider(base.BaseLibraryProvider): del self._tracks[uri] def commit(self): - directory, basename = os.path.split(self._json_file) - - # TODO: cleanup directory/basename.* files. - tmp = tempfile.NamedTemporaryFile( - prefix=basename + '.', dir=directory, delete=False) - - try: - with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: - data = {'version': mopidy.__version__, - 'tracks': self._tracks.values()} - json.dump(data, fp, cls=models.ModelJSONEncoder, - indent=2, separators=(',', ': ')) - os.rename(tmp.name, self._json_file) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) + write_library(self._json_file, {'tracks': self._tracks.values()}) From 4f7176cac859d5e532031e0aa6c34d23b02ea026 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 22:48:37 +0100 Subject: [PATCH 028/238] local: Cleanup uri conversion helper naming and implementation. --- mopidy/backends/local/playback.py | 5 ++--- mopidy/backends/local/translator.py | 8 ++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py index b264dac7..ae8eeb82 100644 --- a/mopidy/backends/local/playback.py +++ b/mopidy/backends/local/playback.py @@ -11,7 +11,6 @@ logger = logging.getLogger('mopidy.backends.local') class LocalPlaybackProvider(base.BasePlaybackProvider): def change_track(self, track): - media_dir = self.backend.config['local']['media_dir'] - uri = translator.local_to_file_uri(track.uri, media_dir) - track = track.copy(uri=uri) + track = track.copy(uri=translator.local_track_uri_to_file_uri( + track.uri, self.backend.config['local']['media_dir'])) return super(LocalPlaybackProvider, self).change_track(track) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 1153b1b3..2c0523e8 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -11,12 +11,8 @@ from mopidy.utils.path import path_to_uri, uri_to_path logger = logging.getLogger('mopidy.backends.local') -# TODO: remove once tag cache is gone -def local_to_file_uri(uri, media_dir): - # TODO: check that type is correct. - file_path = uri_to_path(uri).split(b':', 1)[1] - file_path = os.path.join(media_dir, file_path) - return path_to_uri(file_path) +def local_track_uri_to_file_uri(uri, media_dir): + return path_to_uri(local_track_uri_to_path(uri, media_dir)) def local_track_uri_to_path(uri, media_dir): From 9794826f267ea4084208a66d87ed2117451e5c17 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 22:52:24 +0100 Subject: [PATCH 029/238] local: Review changes --- docs/ext/local.rst | 10 +++++----- docs/extensiondev.rst | 13 +------------ mopidy/backends/local/translator.py | 4 ++-- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 51268c51..9e7c645c 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -85,11 +85,11 @@ To make a local library for your music available for Mopidy: #. Start Mopidy, find the music library in a client, and play some local music! -Plugable library support ------------------------- +Pluggable library support +------------------------- Local libraries are fully pluggable. What this means is that users may opt to -disable the current default library `local-json`, replacing it with a third +disable the current default library ``local-json``, replacing it with a third party one. When running :command:`mopidy local scan` mopidy will populate whatever the current active library is with data. Only one library may be active at a time. @@ -115,8 +115,8 @@ Configuration values .. confval:: local-json/enabled - If the local extension should be enabled or not. + If the local-json extension should be enabled or not. .. confval:: local-json/json_file - Path to a file to store the gziped json data in. + Path to a file to store the GZiped JSON data in. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 2709690a..82144d0a 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -428,18 +428,7 @@ solution you happen to prefer. super(SoundspotLibraryUpdateProvider, self).__init__(config) self.config = config - def load(self): - # Your track loading code - return tracks - - def add(self, track): - # Your code for handling adding a new track - - def remove(self, uri): - # Your code for removing the track coresponding to this uri - - def commit(self): - # Your code to persist the library, if needed. + # Your library provider implementation here. Example GStreamer element diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 2c0523e8..243eb314 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -17,13 +17,13 @@ def local_track_uri_to_file_uri(uri, media_dir): def local_track_uri_to_path(uri, media_dir): if not uri.startswith('local:track:'): - raise ValueError('Invalid uri.') + raise ValueError('Invalid URI.') file_path = uri_to_path(uri).split(b':', 1)[1] return os.path.join(media_dir, file_path) def path_to_local_track_uri(relpath): - """Convert path releative to media_dir to local track uri""" + """Convert path releative to media_dir to local track URI.""" if isinstance(relpath, unicode): relpath = relpath.encode('utf-8') return b'local:track:%s' % urllib.quote(relpath) From acdc65e9c7a87ac3079ecef80b38e7778692aa50 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 23:04:30 +0100 Subject: [PATCH 030/238] docs: Update changelog with pluggable libraries. --- docs/changelog.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index acb94e3d..92ec3707 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,23 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.18.0 (Unreleased) +==================== + +**Pluggable libraries** + +Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes +a temporary regression of :issue:`527`. + +- Finished the work on creating pluggable libraries. Users can now + reconfigure mopidy to use alternate library providers of their choosing + for local files. +- Switched default library provider to JSON. This greatly simplifies our + library code and reuses or existing serialisation code. +- Killed our outdated and bug-ridden tag cache implementation. +- Added support for deprecated config values in order to allow for + graceful removal of :confval:`local/tag_cache_file` + v0.17.0 (2013-11-23) ==================== From 14ee030daddd27d033d10af0254ab3e784e7f960 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 4 Dec 2013 23:08:21 +0100 Subject: [PATCH 031/238] config: Formatting --- mopidy/config/schemas.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index a535b493..b59e7986 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -54,7 +54,8 @@ class ConfigSchema(collections.OrderedDict): def deserialize(self, values): """Validates the given ``values`` using the config schema. - Returns a tuple with cleaned values and errors.""" + Returns a tuple with cleaned values and errors. + """ errors = {} result = {} From 4a599eec0ca76e8f801083d6469aff89aae32feb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 4 Dec 2013 23:30:01 +0100 Subject: [PATCH 032/238] docs: Minor tweaks --- docs/changelog.rst | 22 +++++++++++----------- docs/ext/local.rst | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8b545669..395e968b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,26 +4,26 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.18.0 (Unreleased) +v0.18.0 (UNRELEASED) ==================== -**Pluggable libraries** +**Pluggable local libraries** Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes a temporary regression of :issue:`527`. - Finished the work on creating pluggable libraries. Users can now - reconfigure mopidy to use alternate library providers of their choosing + reconfigure Mopidy to use alternate library providers of their choosing for local files. -- Switched default library provider to JSON. This greatly simplifies our - library code and reuses or existing serialisation code. -- Killed our outdated and bug-ridden tag cache implementation. + +- Switched default local library provider from "tag cache" to JSON. This + greatly simplifies our library code and reuses our existing serialization + code. + +- Killed our outdated and bug-ridden "tag cache" implementation. + - Added support for deprecated config values in order to allow for - graceful removal of :confval:`local/tag_cache_file` - - -v0.18.0 (UNRELEASED) -==================== + graceful removal of :confval:`local/tag_cache_file`. **Internal changes** diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 43b405e1..cbde826f 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -120,4 +120,4 @@ Configuration values .. confval:: local-json/json_file - Path to a file to store the GZiped JSON data in. + Path to a file to store the gzipped JSON data in. From 10a448f90d62a5aa770593e6bbd6c16a6e4abcd4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 4 Dec 2013 23:41:39 +0100 Subject: [PATCH 033/238] audio: Cleanup scanner code and support live sources - Make attributes internal with _ naming - Cleanup handling of min durtion. Min set to None disables the check. - Check state change result for no preroll which indicates a live source which must transistion to playing to get tags etc. --- mopidy/audio/scan.py | 58 ++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 6eb5576c..12476c2c 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -24,24 +24,24 @@ class Scanner(object): """ def __init__(self, timeout=1000, min_duration=100): - self.timeout_ms = timeout - self.min_duration_ms = min_duration + self._timeout_ms = timeout + self._min_duration_ms = min_duration sink = gst.element_factory_make('fakesink') audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') pad_added = lambda src, pad: pad.link(sink.get_pad('sink')) - self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property('caps', audio_caps) - self.uribin.connect('pad-added', pad_added) + self._uribin = gst.element_factory_make('uridecodebin') + self._uribin.set_property('caps', audio_caps) + self._uribin.connect('pad-added', pad_added) - self.pipe = gst.element_factory_make('pipeline') - self.pipe.add(self.uribin) - self.pipe.add(sink) + self._pipe = gst.element_factory_make('pipeline') + self._pipe.add(self._uribin) + self._pipe.add(sink) - self.bus = self.pipe.get_bus() - self.bus.set_flushing(True) + self._bus = self._pipe.get_bus() + self._bus.set_flushing(True) def scan(self, uri): """ @@ -54,34 +54,40 @@ class Scanner(object): try: self._setup(uri) data = self._collect() - # Make sure uri and duration does not come from tags. + # Make sure uri, mtime and duration does not come from tags. data[b'uri'] = uri data[b'mtime'] = self._query_mtime(uri) data[gst.TAG_DURATION] = self._query_duration() finally: self._reset() - if data[gst.TAG_DURATION] < self.min_duration_ms * gst.MSECOND: - raise exceptions.ScannerError('Rejecting file with less than %dms ' - 'audio data.' % self.min_duration_ms) - return data + if self._min_duration_ms is None: + return data + elif data[gst.TAG_DURATION] >= self._min_duration_ms * gst.MSECOND: + return data + + raise exceptions.ScannerError('Rejecting file with less than %dms ' + 'audio data.' % self._min_duration_ms) def _setup(self, uri): """Primes the pipeline for collection.""" - self.pipe.set_state(gst.STATE_READY) - self.uribin.set_property(b'uri', uri) - self.bus.set_flushing(False) - self.pipe.set_state(gst.STATE_PAUSED) + self._pipe.set_state(gst.STATE_READY) + self._uribin.set_property(b'uri', uri) + self._bus.set_flushing(False) + result = self._pipe.set_state(gst.STATE_PAUSED) + if result == gst.STATE_CHANGE_NO_PREROLL: + # Live sources don't pre-roll, so set to playing to get data. + self._pipe.set_state(gst.STATE_PLAYING) def _collect(self): """Polls for messages to collect data.""" start = time.time() - timeout_s = self.timeout_ms / float(1000) + timeout_s = self._timeout_ms / float(1000) poll_timeout_ns = 1000 data = {} while time.time() - start < timeout_s: - message = self.bus.poll(gst.MESSAGE_ANY, poll_timeout_ns) + message = self._bus.poll(gst.MESSAGE_ANY, poll_timeout_ns) if message is None: pass # polling the bus timed out. @@ -90,23 +96,23 @@ class Scanner(object): elif message.type == gst.MESSAGE_EOS: return data elif message.type == gst.MESSAGE_ASYNC_DONE: - if message.src == self.pipe: + if message.src == self._pipe: return data elif message.type == gst.MESSAGE_TAG: taglist = message.parse_tag() for key in taglist.keys(): data[key] = taglist[key] - raise exceptions.ScannerError('Timeout after %dms' % self.timeout_ms) + raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) def _reset(self): """Ensures we cleanup child elements and flush the bus.""" - self.bus.set_flushing(True) - self.pipe.set_state(gst.STATE_NULL) + self._bus.set_flushing(True) + self._pipe.set_state(gst.STATE_NULL) def _query_duration(self): try: - return self.pipe.query_duration(gst.FORMAT_TIME, None)[0] + return self._pipe.query_duration(gst.FORMAT_TIME, None)[0] except gst.QueryError: return None From 4142f0285b32ee505b7a0bc2d49bbacdcd1500a9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 5 Dec 2013 09:00:31 +0100 Subject: [PATCH 034/238] Remove 'Music Server' from app name in mopidy.desktop --- data/mopidy.desktop | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/mopidy.desktop b/data/mopidy.desktop index 88dd5ae4..91daeab3 100644 --- a/data/mopidy.desktop +++ b/data/mopidy.desktop @@ -1,8 +1,8 @@ [Desktop Entry] Type=Application Version=1.0 -Name=Mopidy Music Server -Comment=MPD music server with Spotify support +Name=Mopidy +Comment=Music server with support for MPD and HTTP clients Icon=audio-x-generic TryExec=mopidy Exec=mopidy From 91710e3618aba8f604c33c6224f8058f9001a43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20Ruud?= Date: Thu, 5 Dec 2013 21:49:46 +0100 Subject: [PATCH 035/238] Document workaround example for #492 The example is using an Icecast fallback stream that plays a silent MP3 file when Mopidy sends the shutdown signal. Tested and found working, however had minor issues with the client (mpg123) playing the stream was getting out of sync when rapidly changing songs. Looking at the Icecast logs, it revealed that the switch between the mopidy and the fallback stream happened immediately, so the issue is probably with the client. --- docs/config.rst | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 82957b92..69971d69 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -221,7 +221,7 @@ Streaming through SHOUTcast/Icecast Currently, Mopidy does not handle end-of-track vs end-of-stream signalling in GStreamer correctly. This causes the SHOUTcast stream to be disconnected at the end of each track, rendering it quite useless. For further details, - see :issue:`492`. + see :issue:`492`. You can also try the workaround_ mentioned below. If you want to play the audio on another computer than the one running Mopidy, you can stream the audio from Mopidy through an SHOUTcast or Icecast audio @@ -237,17 +237,37 @@ server simultaneously. To use the SHOUTcast output, do the following: #. You might also need to change the ``shout2send`` default settings, run ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely you want to change ``ip``, ``username``, ``password``, and ``mount``. For - example, to set the username and password, use: + example: .. code-block:: ini [audio] - output = lame ! shout2send username="alice" password="secret" + output = lame ! shout2send username="alice" password="secret" mount="mopidy" Other advanced setups are also possible for outputs. Basically, anything you can use with the ``gst-launch-0.10`` command can be plugged into :confval:`audio/output`. +.. _workaround: + +**Workaround for end-of-track issues - fallback streams** + +By using a *fallback stream* playing silence, you can somewhat mitigate the +signalling issues. + +Example Icecast configuration: + +.. code-block:: xml + + + /mopidy + /silence.mp3 + 1 + + +The ``silence.mp3`` file needs to be placed in the directory defined by +``...``. + New configuration values ------------------------ From a036e84a20c7430d42ba4d05110ce546bcf0ced1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 Dec 2013 22:43:27 +0100 Subject: [PATCH 036/238] audio: Update scanner to not use gst.Bus.poll Turns out poll sets up it's own mainloop in the default context causing us to segfault. --- mopidy/audio/scan.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 12476c2c..6999d664 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -83,15 +83,14 @@ class Scanner(object): """Polls for messages to collect data.""" start = time.time() timeout_s = self._timeout_ms / float(1000) - poll_timeout_ns = 1000 data = {} while time.time() - start < timeout_s: - message = self._bus.poll(gst.MESSAGE_ANY, poll_timeout_ns) + if not self._bus.have_pending(): + continue + message = self._bus.pop() - if message is None: - pass # polling the bus timed out. - elif message.type == gst.MESSAGE_ERROR: + if message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError(message.parse_error()[0]) elif message.type == gst.MESSAGE_EOS: return data From 1379c38370770a994da67bbe48c2fff297f0b600 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 Dec 2013 22:56:19 +0100 Subject: [PATCH 037/238] audio: Improve audio_data_to_track handling. - Handle missing or none data for duration and mtime - Add organization, location and copyright mapping used for streams. --- mopidy/audio/scan.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 6999d664..f797a84d 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -132,7 +132,7 @@ def audio_data_to_track(data): def _retrieve(source_key, target_key, target): if source_key in data: - target[target_key] = data[source_key] + target.setdefault(target_key, data[source_key]) _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) @@ -155,6 +155,11 @@ def audio_data_to_track(data): _retrieve( 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) + # For streams, will not override if a better value has already been set. + _retrieve(gst.TAG_ORGANIZATION, 'name', track_kwargs) + _retrieve(gst.TAG_LOCATION, 'comment', track_kwargs) + _retrieve(gst.TAG_COPYRIGHT, 'comment', track_kwargs) + if gst.TAG_DATE in data and data[gst.TAG_DATE]: date = data[gst.TAG_DATE] try: @@ -167,9 +172,13 @@ def audio_data_to_track(data): if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] + if data['mtime']: + track_kwargs['last_modified'] = int(data['mtime']) + + if data[gst.TAG_DURATION]: + track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND + track_kwargs['uri'] = data['uri'] - track_kwargs['last_modified'] = int(data['mtime']) - track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND track_kwargs['album'] = Album(**album_kwargs) if ('name' in artist_kwargs From d9b704d0d888328ffe7b1f7107f9a9623047dc25 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 Dec 2013 22:58:48 +0100 Subject: [PATCH 038/238] streaming: Add scanner lookup of stream metadata. This adds support for looking up metada for all any any protocols the streaming backend will support. This should also ensure that file:// files get metadata. --- mopidy/backends/stream/__init__.py | 2 ++ mopidy/backends/stream/actor.py | 26 +++++++++++++++++--------- mopidy/backends/stream/ext.conf | 1 + 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 061ac5d0..28e2deba 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -19,6 +19,8 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() + schema['timeout'] = config.Integer( + minimum=1000, maximum=1000*60*60) return schema def validate_environment(self): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 86df447d..49034191 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -5,7 +5,8 @@ import urlparse import pykka -from mopidy import audio as audio_lib +from mopidy import audio as audio_lib, exceptions +from mopidy.audio import scan from mopidy.backends import base from mopidy.models import Track @@ -16,7 +17,8 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): def __init__(self, config, audio): super(StreamBackend, self).__init__() - self.library = StreamLibraryProvider(backend=self) + self.library = StreamLibraryProvider( + backend=self, timeout=config['stream']['timeout']) self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = None @@ -24,14 +26,20 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): config['stream']['protocols']) -# TODO: Should we consider letting lookup know how to expand common playlist -# formats (m3u, pls, etc) for http(s) URIs? class StreamLibraryProvider(base.BaseLibraryProvider): + def __init__(self, backend, timeout): + super(StreamLibraryProvider, self).__init__(backend) + self._scanner = scan.Scanner(min_duration=None, timeout=timeout) + 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)] + + try: + data = self._scanner.scan(uri) + track = scan.audio_data_to_track(data) + except exceptions.ScannerError as e: + logger.warning('Problem looking up %s - %s', uri, e) + track = Track(uri=uri, name=uri) + + return [track] diff --git a/mopidy/backends/stream/ext.conf b/mopidy/backends/stream/ext.conf index dc0287da..811dec88 100644 --- a/mopidy/backends/stream/ext.conf +++ b/mopidy/backends/stream/ext.conf @@ -8,3 +8,4 @@ protocols = rtmp rtmps rtsp +timeout = 5000 From e37b1a17548f47b3c672421ebac4d24fb48a6dfe Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 Dec 2013 23:01:57 +0100 Subject: [PATCH 039/238] docs: Add streaming metadata lookup to changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 395e968b..09b7840c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,10 @@ a temporary regression of :issue:`527`. - Added support for deprecated config values in order to allow for graceful removal of :confval:`local/tag_cache_file`. +**Streaming backend** + +- Live lookup of URI metadata has been added. (Fixes :issue:`540`) + **Internal changes** - Events from the audio actor, backends, and core actor are now emitted From 7c7db636595443ba4b54b5601dd7b2e0fbf56955 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 5 Dec 2013 23:03:50 +0100 Subject: [PATCH 040/238] docs: Add stream/timeout config value to docs --- docs/ext/stream.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index ee413b31..30bc22ab 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -42,6 +42,10 @@ Configuration values Whitelist of URI schemas to allow streaming from. Values should be separated by either comma or newline. +.. confval:: stream/timeout + + Number of milliseconds before giving up looking up stream metadata. + Usage ===== From 0fac8120d4466d88e5499be8f7a17959d915b6a7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 6 Dec 2013 00:04:36 +0100 Subject: [PATCH 041/238] streaming: Code review adjustments --- mopidy/backends/stream/__init__.py | 2 +- mopidy/backends/stream/actor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 28e2deba..47dd6151 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -20,7 +20,7 @@ class Extension(ext.Extension): schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() schema['timeout'] = config.Integer( - minimum=1000, maximum=1000*60*60) + minimum=1000, maximum=1000 * 60 * 60) return schema def validate_environment(self): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 49034191..c807e09d 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -39,7 +39,7 @@ class StreamLibraryProvider(base.BaseLibraryProvider): data = self._scanner.scan(uri) track = scan.audio_data_to_track(data) except exceptions.ScannerError as e: - logger.warning('Problem looking up %s - %s', uri, e) + logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri, name=uri) return [track] From 101600d8d2e3528dae1b8b5fa8cd5e1a82d01744 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 6 Dec 2013 10:09:54 +0100 Subject: [PATCH 042/238] requirements: Remove core.txt, since it duplicates setup.py --- requirements/core.txt | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 requirements/core.txt diff --git a/requirements/core.txt b/requirements/core.txt deleted file mode 100644 index d8e81e61..00000000 --- a/requirements/core.txt +++ /dev/null @@ -1,5 +0,0 @@ -setuptools -# Available as python-setuptools in Debian/Ubuntu - -Pykka >= 1.1 -# Available as python-pykka from apt.mopidy.com From 8295dd4f4ab3f61ed0c2af3f270e46c979bc956d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 6 Dec 2013 10:10:16 +0100 Subject: [PATCH 043/238] requirements: Fold http.txt into Mopidy-HTTP docs --- docs/ext/http.rst | 10 +++++++++- requirements/http.txt | 6 ------ 2 files changed, 9 insertions(+), 7 deletions(-) delete mode 100644 requirements/http.txt diff --git a/docs/ext/http.rst b/docs/ext/http.rst index ce79588e..d011a4b9 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -18,7 +18,15 @@ https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend Dependencies ============ -.. literalinclude:: ../../requirements/http.txt +- cherrypy >= 3.2.2. Available as python-cherrypy3 in Debian/Ubuntu. + +- ws4py >= 0.2.3. Available as python-ws4py in newer Debian/Ubuntu and from + apt.mopidy.com for older releases of Debian/Ubuntu. + +If you're installing Mopidy with pip, you can run the following command to +install Mopidy with the extra dependencies for required for Mopidy-HTTP:: + + pip install --upgrade Mopidy[http] Default configuration diff --git a/requirements/http.txt b/requirements/http.txt deleted file mode 100644 index f38bfa3c..00000000 --- a/requirements/http.txt +++ /dev/null @@ -1,6 +0,0 @@ -cherrypy >= 3.2.2 -# Available as python-cherrypy3 in Debian/Ubuntu - -ws4py >= 0.2.3 -# Available as python-ws4py in newer Debian/Ubuntu and from apt.mopidy.com for -# older releases of Debian/Ubuntu From a937d5e1174b2d2535854cf7cecf2a895cb644b4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 6 Dec 2013 10:23:50 +0100 Subject: [PATCH 044/238] requirements: Fold testing requirements into docs --- docs/contributing.rst | 2 +- requirements/tests.txt | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 requirements/tests.txt diff --git a/docs/contributing.rst b/docs/contributing.rst index 22df8ced..2436ffc0 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -85,7 +85,7 @@ Mopidy to come with tests. #. To run tests, you need a couple of dependencies. They can be installed using ``pip``:: - pip install -r requirements/tests.txt + pip install --upgrade coverage flake8 mock nose #. Then, to run all tests, go to the project directory and run:: diff --git a/requirements/tests.txt b/requirements/tests.txt deleted file mode 100644 index 8aacebbc..00000000 --- a/requirements/tests.txt +++ /dev/null @@ -1,4 +0,0 @@ -coverage -flake8 -mock >= 1.0 -nose From e90c3eaf19b509af62a7aa2175096d10a22630a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 6 Dec 2013 10:27:35 +0100 Subject: [PATCH 045/238] requirements: Move docs requirements to docs/ --- MANIFEST.in | 1 - requirements/docs.txt => docs/requirements.txt | 0 requirements/README.rst | 11 ----------- 3 files changed, 12 deletions(-) rename requirements/docs.txt => docs/requirements.txt (100%) delete mode 100644 requirements/README.rst diff --git a/MANIFEST.in b/MANIFEST.in index f1968205..cacaa924 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,6 +8,5 @@ prune docs/_build recursive-include mopidy *.conf recursive-include mopidy/frontends/http/data * -recursive-include requirements * recursive-include tests *.py recursive-include tests/data * diff --git a/requirements/docs.txt b/docs/requirements.txt similarity index 100% rename from requirements/docs.txt rename to docs/requirements.txt diff --git a/requirements/README.rst b/requirements/README.rst deleted file mode 100644 index e1a6d757..00000000 --- a/requirements/README.rst +++ /dev/null @@ -1,11 +0,0 @@ -********************* -pip requirement files -********************* - -The files found here are `requirement files -`_ that may be used -with `pip `_. - -To install the dependencies found in one of these files, simply run e.g.:: - - pip install -r requirements/tests.txt From a5c02c103460e513a4a025a68ea3790688424dcb Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Sun, 8 Dec 2013 15:23:23 +0100 Subject: [PATCH 046/238] basic EXTM3U playlist support --- mopidy/backends/local/playlists.py | 7 ++-- mopidy/backends/local/translator.py | 53 ++++++++++++++++++++----- tests/backends/local/translator_test.py | 42 +++++++++++--------- 3 files changed, 69 insertions(+), 33 deletions(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index e8996b51..48c60c2a 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -6,7 +6,7 @@ import os import shutil from mopidy.backends import base, listener -from mopidy.models import Playlist, Track +from mopidy.models import Playlist from mopidy.utils import formatting, path from .translator import parse_m3u @@ -50,9 +50,8 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): uri = 'local:playlist:%s' % name tracks = [] - for track_uri in parse_m3u(m3u, self._media_dir): - # TODO: switch to having playlists being a list of uris - tracks.append(Track(uri=track_uri)) + for track in parse_m3u(m3u, self._media_dir): + tracks.append(track) playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 243eb314..1aee553b 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -2,12 +2,16 @@ from __future__ import unicode_literals import logging import os +import re import urlparse import urllib +from mopidy.models import Track from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path +EXTINF_RE = re.compile(r'^#EXTINF:\s*(-1|\d+)\s*,\s*(.+?)\s*$') + logger = logging.getLogger('mopidy.backends.local') @@ -29,9 +33,22 @@ def path_to_local_track_uri(relpath): return b'local:track:%s' % urllib.quote(relpath) +def extm3u_directive_to_track(line): + """Convert extended M3U directive to track template.""" + m = EXTINF_RE.match(line) + if not m: + logger.warning('Invalid extended M3U directive: %s', line) + return Track() + (runtime, title) = m.groups() + if int(runtime) > 0: + return Track(name=title, length=1000*int(runtime)) + else: + return Track(name=title) + + def parse_m3u(file_path, media_dir): r""" - Convert M3U file list of uris + Convert M3U file list to list of tracks Example M3U data:: @@ -43,34 +60,50 @@ def parse_m3u(file_path, media_dir): http://www.example.com:8000/Listen.pls http://www.example.com/~user/Mine.mp3 + Example extended M3U data:: + + #EXTM3U + #EXTINF:123, Sample artist - Sample title + Sample.mp3 + #EXTINF:321,Example Artist - Example title + Greatest Hits\Example.ogg + #EXTINF:-1,Radio XMP + http://mp3stream.example.com:8000/ + - Relative paths of songs should be with respect to location of M3U. - - Paths are normaly platform specific. - - Lines starting with # should be ignored. + - Paths are normally platform specific. + - Lines starting with # are ignored, except for extended M3U directives. + - Track.name and Track.length are set from extended M3U directives. - m3u files are latin-1. - - This function does not bother with Extended M3U directives. """ # TODO: uris as bytes - uris = [] + tracks = [] try: with open(file_path) as m3u: contents = m3u.readlines() except IOError as error: logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) - return uris + return tracks + extm3u = contents and contents[0].decode('latin1').startswith('#EXTM3U') + + track = Track() for line in contents: line = line.strip().decode('latin1') if line.startswith('#'): + if extm3u and line.startswith('#EXTINF'): + track = extm3u_directive_to_track(line) continue if urlparse.urlsplit(line).scheme: - uris.append(line) + tracks.append(track.copy(uri=line)) elif os.path.normpath(line) == os.path.abspath(line): path = path_to_uri(line) - uris.append(path) + tracks.append(track.copy(uri=path)) else: path = path_to_uri(os.path.join(media_dir, line)) - uris.append(path) + tracks.append(track.copy(uri=path)) - return uris + track = Track() + return tracks diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index e5747f68..de0e5a94 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -7,6 +7,7 @@ import tempfile import unittest from mopidy.backends.local.translator import parse_m3u +from mopidy.models import Track from mopidy.utils.path import path_to_uri from tests import path_to_data_dir @@ -18,29 +19,32 @@ encoded_path = path_to_data_dir('æøå.mp3') song1_uri = path_to_uri(song1_path) song2_uri = path_to_uri(song2_path) encoded_uri = path_to_uri(encoded_path) +song1_track = Track(uri=song1_uri) +song2_track = Track(uri=song2_uri) +encoded_track = Track(uri=encoded_uri) + # FIXME use mock instead of tempfile.NamedTemporaryFile - class M3UToUriTest(unittest.TestCase): def test_empty_file(self): - uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) - self.assertEqual([], uris) + tracks = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) + self.assertEqual([], tracks) def test_basic_file(self): - uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir) - self.assertEqual([song1_uri], uris) + tracks = parse_m3u(path_to_data_dir('one.m3u'), data_dir) + self.assertEqual([song1_track], tracks) def test_file_with_comment(self): - uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) - self.assertEqual([song1_uri], uris) + tracks = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) + self.assertEqual([song1_track], tracks) def test_file_is_relative_to_correct_dir(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write('song1.mp3') try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri], uris) + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) @@ -49,8 +53,8 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri], uris) + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) @@ -61,8 +65,8 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri, song2_uri], uris) + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track, song2_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) @@ -71,19 +75,19 @@ class M3UToUriTest(unittest.TestCase): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - uris = parse_m3u(tmp.name, data_dir) - self.assertEqual([song1_uri], uris) + tracks = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_track], tracks) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) - self.assertEqual([encoded_uri], uris) + tracks = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) + self.assertEqual([encoded_track], tracks) def test_open_missing_file(self): - uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) - self.assertEqual([], uris) + tracks = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) + self.assertEqual([], tracks) class URItoM3UTest(unittest.TestCase): From 5d3851b3e15a769432c533eba8c8a54936b530f1 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 9 Dec 2013 06:09:16 +0100 Subject: [PATCH 047/238] EXTM3U playlist saving --- mopidy/backends/local/playlists.py | 10 ++++++++++ mopidy/backends/local/translator.py | 15 +++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 48c60c2a..ff8ffd41 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -90,10 +90,20 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) return file_path + def _write_m3u_extinf(self, file_handle, track): + title = track.name.encode('latin-1', 'replace') + runtime = track.length / 1000 if track.length else -1 + file_handle.write('#EXTINF:' + runtime + ',' + title + '\n') + def _save_m3u(self, playlist): file_path = self._m3u_uri_to_path(playlist.uri) + extended = any(track.name for track in playlist.tracks) with open(file_path, 'w') as file_handle: + if extended: + file_handle.write('#EXTM3U\n') for track in playlist.tracks: + if extended and track.name: + self._write_m3u_extinf(file_handle, track) file_handle.write(track.uri + '\n') def _delete_m3u(self, uri): diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 1aee553b..2b2008e4 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -10,7 +10,7 @@ from mopidy.models import Track from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path -EXTINF_RE = re.compile(r'^#EXTINF:\s*(-1|\d+)\s*,\s*(.+?)\s*$') +M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)') logger = logging.getLogger('mopidy.backends.local') @@ -33,9 +33,9 @@ def path_to_local_track_uri(relpath): return b'local:track:%s' % urllib.quote(relpath) -def extm3u_directive_to_track(line): +def m3u_extinf_to_track(line): """Convert extended M3U directive to track template.""" - m = EXTINF_RE.match(line) + m = M3U_EXTINF_RE.match(line) if not m: logger.warning('Invalid extended M3U directive: %s', line) return Track() @@ -85,15 +85,18 @@ def parse_m3u(file_path, media_dir): logger.warning('Couldn\'t open m3u: %s', locale_decode(error)) return tracks - extm3u = contents and contents[0].decode('latin1').startswith('#EXTM3U') + if not contents: + return tracks + + extended = contents[0].decode('latin1').startswith('#EXTM3U') track = Track() for line in contents: line = line.strip().decode('latin1') if line.startswith('#'): - if extm3u and line.startswith('#EXTINF'): - track = extm3u_directive_to_track(line) + if extended and line.startswith('#EXTINF'): + track = m3u_extinf_to_track(line) continue if urlparse.urlsplit(line).scheme: From b6ee1707d3bef5e64e6ef985ea1f384506fd5d85 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 9 Dec 2013 15:52:46 +0100 Subject: [PATCH 048/238] add translator tests for EXTM3U files --- tests/data/comment-ext.m3u | 2 ++ tests/data/empty-ext.m3u | 1 + tests/data/encoding-ext.m3u | 1 + tests/data/one-ext.m3u | 3 +++ tests/data/two-ext.m3u | 3 +++ 5 files changed, 10 insertions(+) create mode 100644 tests/data/comment-ext.m3u create mode 100644 tests/data/empty-ext.m3u create mode 100644 tests/data/encoding-ext.m3u create mode 100644 tests/data/one-ext.m3u create mode 100644 tests/data/two-ext.m3u diff --git a/tests/data/comment-ext.m3u b/tests/data/comment-ext.m3u new file mode 100644 index 00000000..af37f706 --- /dev/null +++ b/tests/data/comment-ext.m3u @@ -0,0 +1,2 @@ +# test +song1.mp3 diff --git a/tests/data/empty-ext.m3u b/tests/data/empty-ext.m3u new file mode 100644 index 00000000..fcd71879 --- /dev/null +++ b/tests/data/empty-ext.m3u @@ -0,0 +1 @@ +#EXTM3U diff --git a/tests/data/encoding-ext.m3u b/tests/data/encoding-ext.m3u new file mode 100644 index 00000000..383aa526 --- /dev/null +++ b/tests/data/encoding-ext.m3u @@ -0,0 +1 @@ +æøå.mp3 diff --git a/tests/data/one-ext.m3u b/tests/data/one-ext.m3u new file mode 100644 index 00000000..7e94d5e9 --- /dev/null +++ b/tests/data/one-ext.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTINF:-1,song1 +song1.mp3 diff --git a/tests/data/two-ext.m3u b/tests/data/two-ext.m3u new file mode 100644 index 00000000..7e94d5e9 --- /dev/null +++ b/tests/data/two-ext.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTINF:-1,song1 +song1.mp3 From 18c44e0d8aa557a66e29f772ea1b10087598d3ba Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 9 Dec 2013 15:54:28 +0100 Subject: [PATCH 049/238] add translator tests for EXTM3U files --- tests/backends/local/translator_test.py | 22 ++++++++++++++++++++++ tests/data/comment-ext.m3u | 3 +++ tests/data/encoding-ext.m3u | 2 ++ tests/data/two-ext.m3u | 2 ++ 4 files changed, 29 insertions(+) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index de0e5a94..92e1a8d7 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -22,6 +22,9 @@ encoded_uri = path_to_uri(encoded_path) song1_track = Track(uri=song1_uri) song2_track = Track(uri=song2_uri) encoded_track = Track(uri=encoded_uri) +song1_ext_track = song1_track.copy(name='song1') +song2_ext_track = song2_track.copy(name='song2', length=60000) +encoded_ext_track = encoded_track.copy(name='æøå') # FIXME use mock instead of tempfile.NamedTemporaryFile @@ -89,6 +92,25 @@ class M3UToUriTest(unittest.TestCase): tracks = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) self.assertEqual([], tracks) + def test_empty_ext_file(self): + tracks = parse_m3u(path_to_data_dir('empty-ext.m3u'), data_dir) + self.assertEqual([], tracks) + + def test_basic_ext_file(self): + tracks = parse_m3u(path_to_data_dir('one-ext.m3u'), data_dir) + self.assertEqual([song1_ext_track], tracks) + + def test_multi_ext_file(self): + tracks = parse_m3u(path_to_data_dir('two-ext.m3u'), data_dir) + self.assertEqual([song1_ext_track, song2_ext_track], tracks) + + def test_ext_file_with_comment(self): + tracks = parse_m3u(path_to_data_dir('comment-ext.m3u'), data_dir) + self.assertEqual([song1_ext_track], tracks) + + def test_ext_encoding_is_latin1(self): + tracks = parse_m3u(path_to_data_dir('encoding-ext.m3u'), data_dir) + self.assertEqual([encoded_ext_track], tracks) class URItoM3UTest(unittest.TestCase): pass diff --git a/tests/data/comment-ext.m3u b/tests/data/comment-ext.m3u index af37f706..95983d06 100644 --- a/tests/data/comment-ext.m3u +++ b/tests/data/comment-ext.m3u @@ -1,2 +1,5 @@ +#EXTM3U +# test +#EXTINF:-1,song1 # test song1.mp3 diff --git a/tests/data/encoding-ext.m3u b/tests/data/encoding-ext.m3u index 383aa526..1c59a322 100644 --- a/tests/data/encoding-ext.m3u +++ b/tests/data/encoding-ext.m3u @@ -1 +1,3 @@ +#EXTM3U +#EXTINF:-1,æøå æøå.mp3 diff --git a/tests/data/two-ext.m3u b/tests/data/two-ext.m3u index 7e94d5e9..c2bf3e75 100644 --- a/tests/data/two-ext.m3u +++ b/tests/data/two-ext.m3u @@ -1,3 +1,5 @@ #EXTM3U #EXTINF:-1,song1 song1.mp3 +#EXTINF:60,song2 +song2.mp3 From a7b5e455a07e2894d5a3469ae73b9bd45f3a9b4d Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 9 Dec 2013 16:12:46 +0100 Subject: [PATCH 050/238] fix writing of runtime to EXTM3U --- mopidy/backends/local/playlists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index ff8ffd41..91981d83 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -93,7 +93,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def _write_m3u_extinf(self, file_handle, track): title = track.name.encode('latin-1', 'replace') runtime = track.length / 1000 if track.length else -1 - file_handle.write('#EXTINF:' + runtime + ',' + title + '\n') + file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n') def _save_m3u(self, playlist): file_path = self._m3u_uri_to_path(playlist.uri) From 584dc1eaf6b341cc735232b2d107f781ed4c2c36 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Mon, 9 Dec 2013 16:13:05 +0100 Subject: [PATCH 051/238] add playlists test for writing EXTM3U files --- tests/backends/local/playlists_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index c02e1d23..3c6a444a 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -99,6 +99,18 @@ class LocalPlaylistsProviderTest(unittest.TestCase): self.assertEqual(track.uri, contents.strip()) + def test_extended_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test', length=60000) + playlist = self.core.playlists.create('test') + playlist_path = os.path.join(self.playlists_dir, 'test.m3u') + playlist = playlist.copy(tracks=[track]) + playlist = self.core.playlists.save(playlist) + + with open(playlist_path) as playlist_file: + contents = playlist_file.read().splitlines() + + self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) + def test_playlists_are_loaded_at_startup(self): track = Track(uri='local:track:path2') playlist = self.core.playlists.create('test') From 9bf7e394259ae1ce9f58037bb960cd384f373e63 Mon Sep 17 00:00:00 2001 From: Thomas Kemmer Date: Tue, 10 Dec 2013 06:57:16 +0100 Subject: [PATCH 052/238] fix flake8 error E302 --- tests/backends/local/translator_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 92e1a8d7..407a7860 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -112,5 +112,6 @@ class M3UToUriTest(unittest.TestCase): tracks = parse_m3u(path_to_data_dir('encoding-ext.m3u'), data_dir) self.assertEqual([encoded_ext_track], tracks) + class URItoM3UTest(unittest.TestCase): pass From 418e5689dc59b70fdf8efa754f037919eaf326ff Mon Sep 17 00:00:00 2001 From: Paul Connolley Date: Sun, 15 Dec 2013 01:52:24 +0000 Subject: [PATCH 053/238] Preliminary commit for browserify compatibility This is the first stage of my commits for issue #609 that will make the npm module browserify friendly and browser friendly. The grunt-browserify module has been introduced to replace grunt-contrib-concat. Browserify automatically concatenates files and so there is no need for a concat step. The faye-websocket module was problematic so I moved the require out to a separate module within the lib directory. The websocket module is a folder containing a package.json, directing library consumers to the entry point that is appropriate for their environment. Browserify picks browser.js (which simply returns an object holding window.WebSocket) while everyone else gets the faye-websocket module. In addition, as browserify handles all the requires, there's no need to detect the environment or include any pre-built modules. I've removed the pre-built when and faye-websocket files in favour of letting browserify use the modules within node_modules. This should make it easier to maintain dependencies in future versions of this library. One side effect of this browserify compatibility is that, in order to allow the library to be globally available in the browser as `Mopidy`, I've had to set Mopidy as the exported object instead of as a key of the exported object. To elaborate further, the current API would be like the following: var Mopidy = require('mopidy').Mopidy; However, with this change, the API would be like this: var Mopidy = require('mopidy'); I'm not sure whether this would be an issue and so I think it's worth discussing further. It's possible that node developers won't have a problem but, if they did, a potential workaround within the mopidy.js file would be: Mopidy.Mopidy = Mopidy; This would allow developers to choose either of the following: var Mopidy = require('mopidy'); var Mopidy = require('mopidy').Mopidy; Could be a little odd to do this though When testing the browserify build, I noticed a strange error thrown when making the initial websocket connection. I managed to track it down to an IE 'feature' that crops up when you alias in-built functions. In particular, the when module was aliasing setImmediate to an internal function (nextTick.) In a newer version of when, the function is instead aliased to the browserify process.nextTick. This works well because substack already had that covered. With when@2.7.0, IE11 appears to be working well. IE10 is still pending a test. --- js/Gruntfile.js | 26 +- js/lib/bane-1.0.0.js | 171 ----- js/lib/websocket/browser.js | 1 + js/lib/websocket/package.json | 4 + js/lib/websocket/server.js | 1 + js/lib/when-2.4.0.js | 922 ----------------------- js/package.json | 4 +- js/src/mopidy.js | 20 +- mopidy/frontends/http/data/mopidy.js | 452 ++++++----- mopidy/frontends/http/data/mopidy.min.js | 4 +- 10 files changed, 294 insertions(+), 1311 deletions(-) delete mode 100644 js/lib/bane-1.0.0.js create mode 100644 js/lib/websocket/browser.js create mode 100644 js/lib/websocket/package.json create mode 100644 js/lib/websocket/server.js delete mode 100644 js/lib/when-2.4.0.js diff --git a/js/Gruntfile.js b/js/Gruntfile.js index 43a4770b..f59ed9f8 100644 --- a/js/Gruntfile.js +++ b/js/Gruntfile.js @@ -11,6 +11,7 @@ module.exports = function (grunt) { " * Licensed under the Apache License, Version 2.0 */\n", files: { own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"], + main: "src/mopidy.js", concat: "../mopidy/frontends/http/data/mopidy.js", minified: "../mopidy/frontends/http/data/mopidy.min.js" } @@ -18,19 +19,16 @@ module.exports = function (grunt) { buster: { all: {} }, - concat: { - options: { - banner: "<%= meta.banner %>", - stripBanners: true - }, - all: { + browserify: { + dist: { files: { - "<%= meta.files.concat %>": [ - "lib/bane-*.js", - "lib/when-define-shim.js", - "lib/when-*.js", - "src/mopidy.js" - ] + "<%= meta.files.concat %>": "<%= meta.files.main %>" + }, + options: { + postBundleCB: function (err, src, next) { + next(null, grunt.template.process("<%= meta.banner %>") + src); + }, + standalone: "Mopidy" } } }, @@ -71,11 +69,11 @@ module.exports = function (grunt) { }); grunt.registerTask("test", ["jshint", "buster"]); - grunt.registerTask("build", ["test", "concat", "uglify"]); + grunt.registerTask("build", ["test", "browserify", "uglify"]); grunt.registerTask("default", ["build"]); grunt.loadNpmTasks("grunt-buster"); - grunt.loadNpmTasks("grunt-contrib-concat"); + grunt.loadNpmTasks("grunt-browserify"); grunt.loadNpmTasks("grunt-contrib-jshint"); grunt.loadNpmTasks("grunt-contrib-uglify"); grunt.loadNpmTasks("grunt-contrib-watch"); diff --git a/js/lib/bane-1.0.0.js b/js/lib/bane-1.0.0.js deleted file mode 100644 index 8051764d..00000000 --- a/js/lib/bane-1.0.0.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * BANE - Browser globals, AMD and Node Events - * - * https://github.com/busterjs/bane - * - * @version 1.0.0 - */ - -((typeof define === "function" && define.amd && function (m) { define("bane", m); }) || - (typeof module === "object" && function (m) { module.exports = m(); }) || - function (m) { this.bane = m(); } -)(function () { - "use strict"; - var slice = Array.prototype.slice; - - function handleError(event, error, errbacks) { - var i, l = errbacks.length; - if (l > 0) { - for (i = 0; i < l; ++i) { errbacks[i](event, error); } - return; - } - setTimeout(function () { - error.message = event + " listener threw error: " + error.message; - throw error; - }, 0); - } - - function assertFunction(fn) { - if (typeof fn !== "function") { - throw new TypeError("Listener is not function"); - } - return fn; - } - - function supervisors(object) { - if (!object.supervisors) { object.supervisors = []; } - return object.supervisors; - } - - function listeners(object, event) { - if (!object.listeners) { object.listeners = {}; } - if (event && !object.listeners[event]) { object.listeners[event] = []; } - return event ? object.listeners[event] : object.listeners; - } - - function errbacks(object) { - if (!object.errbacks) { object.errbacks = []; } - return object.errbacks; - } - - /** - * @signature var emitter = bane.createEmitter([object]); - * - * Create a new event emitter. If an object is passed, it will be modified - * by adding the event emitter methods (see below). - */ - function createEventEmitter(object) { - object = object || {}; - - function notifyListener(event, listener, args) { - try { - listener.listener.apply(listener.thisp || object, args); - } catch (e) { - handleError(event, e, errbacks(object)); - } - } - - object.on = function (event, listener, thisp) { - if (typeof event === "function") { - return supervisors(this).push({ - listener: event, - thisp: listener - }); - } - listeners(this, event).push({ - listener: assertFunction(listener), - thisp: thisp - }); - }; - - object.off = function (event, listener) { - var fns, events, i, l; - if (!event) { - fns = supervisors(this); - fns.splice(0, fns.length); - - events = listeners(this); - for (i in events) { - if (events.hasOwnProperty(i)) { - fns = listeners(this, i); - fns.splice(0, fns.length); - } - } - - fns = errbacks(this); - fns.splice(0, fns.length); - - return; - } - if (typeof event === "function") { - fns = supervisors(this); - listener = event; - } else { - fns = listeners(this, event); - } - if (!listener) { - fns.splice(0, fns.length); - return; - } - for (i = 0, l = fns.length; i < l; ++i) { - if (fns[i].listener === listener) { - fns.splice(i, 1); - return; - } - } - }; - - object.once = function (event, listener, thisp) { - var wrapper = function () { - object.off(event, wrapper); - listener.apply(this, arguments); - }; - - object.on(event, wrapper, thisp); - }; - - object.bind = function (object, events) { - var prop, i, l; - if (!events) { - for (prop in object) { - if (typeof object[prop] === "function") { - this.on(prop, object[prop], object); - } - } - } else { - for (i = 0, l = events.length; i < l; ++i) { - if (typeof object[events[i]] === "function") { - this.on(events[i], object[events[i]], object); - } else { - throw new Error("No such method " + events[i]); - } - } - } - return object; - }; - - object.emit = function (event) { - var toNotify = supervisors(this); - var args = slice.call(arguments), i, l; - - for (i = 0, l = toNotify.length; i < l; ++i) { - notifyListener(event, toNotify[i], args); - } - - toNotify = listeners(this, event).slice(); - args = slice.call(arguments, 1); - for (i = 0, l = toNotify.length; i < l; ++i) { - notifyListener(event, toNotify[i], args); - } - }; - - object.errback = function (listener) { - if (!this.errbacks) { this.errbacks = []; } - this.errbacks.push(assertFunction(listener)); - }; - - return object; - } - - return { createEventEmitter: createEventEmitter }; -}); diff --git a/js/lib/websocket/browser.js b/js/lib/websocket/browser.js new file mode 100644 index 00000000..e594246c --- /dev/null +++ b/js/lib/websocket/browser.js @@ -0,0 +1 @@ +module.exports = { Client: window.WebSocket }; diff --git a/js/lib/websocket/package.json b/js/lib/websocket/package.json new file mode 100644 index 00000000..d1e2ac63 --- /dev/null +++ b/js/lib/websocket/package.json @@ -0,0 +1,4 @@ +{ + "browser": "browser.js", + "main": "server.js" +} diff --git a/js/lib/websocket/server.js b/js/lib/websocket/server.js new file mode 100644 index 00000000..dd24f4be --- /dev/null +++ b/js/lib/websocket/server.js @@ -0,0 +1 @@ +module.exports = require('faye-websocket'); diff --git a/js/lib/when-2.4.0.js b/js/lib/when-2.4.0.js deleted file mode 100644 index aa386275..00000000 --- a/js/lib/when-2.4.0.js +++ /dev/null @@ -1,922 +0,0 @@ -/** @license MIT License (c) copyright 2011-2013 original author or authors */ - -/** - * A lightweight CommonJS Promises/A and when() implementation - * when is part of the cujo.js family of libraries (http://cujojs.com/) - * - * Licensed under the MIT License at: - * http://www.opensource.org/licenses/mit-license.php - * - * @author Brian Cavalier - * @author John Hann - * @version 2.4.0 - */ -(function(define, global) { 'use strict'; -define(function (require) { - - // Public API - - when.promise = promise; // Create a pending promise - when.resolve = resolve; // Create a resolved promise - when.reject = reject; // Create a rejected promise - when.defer = defer; // Create a {promise, resolver} pair - - when.join = join; // Join 2 or more promises - - when.all = all; // Resolve a list of promises - when.map = map; // Array.map() for promises - when.reduce = reduce; // Array.reduce() for promises - when.settle = settle; // Settle a list of promises - - when.any = any; // One-winner race - when.some = some; // Multi-winner race - - when.isPromise = isPromiseLike; // DEPRECATED: use isPromiseLike - when.isPromiseLike = isPromiseLike; // Is something promise-like, aka thenable - - /** - * Register an observer for a promise or immediate value. - * - * @param {*} promiseOrValue - * @param {function?} [onFulfilled] callback to be called when promiseOrValue is - * successfully fulfilled. If promiseOrValue is an immediate value, callback - * will be invoked immediately. - * @param {function?} [onRejected] callback to be called when promiseOrValue is - * rejected. - * @param {function?} [onProgress] callback to be called when progress updates - * are issued for promiseOrValue. - * @returns {Promise} a new {@link Promise} that will complete with the return - * value of callback or errback or the completion value of promiseOrValue if - * callback and/or errback is not supplied. - */ - function when(promiseOrValue, onFulfilled, onRejected, onProgress) { - // Get a trusted promise for the input promiseOrValue, and then - // register promise handlers - return resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress); - } - - /** - * Trusted Promise constructor. A Promise created from this constructor is - * a trusted when.js promise. Any other duck-typed promise is considered - * untrusted. - * @constructor - * @param {function} sendMessage function to deliver messages to the promise's handler - * @param {function?} inspect function that reports the promise's state - * @name Promise - */ - function Promise(sendMessage, inspect) { - this._message = sendMessage; - this.inspect = inspect; - } - - Promise.prototype = { - /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise - */ - then: function(onFulfilled, onRejected, onProgress) { - /*jshint unused:false*/ - var args, sendMessage; - - args = arguments; - sendMessage = this._message; - - return _promise(function(resolve, reject, notify) { - sendMessage('when', args, resolve, notify); - }, this._status && this._status.observed()); - }, - - /** - * Register a rejection handler. Shortcut for .then(undefined, onRejected) - * @param {function?} onRejected - * @return {Promise} - */ - otherwise: function(onRejected) { - return this.then(undef, onRejected); - }, - - /** - * Ensures that onFulfilledOrRejected will be called regardless of whether - * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT - * receive the promises' value or reason. Any returned value will be disregarded. - * onFulfilledOrRejected may throw or return a rejected promise to signal - * an additional error. - * @param {function} onFulfilledOrRejected handler to be called regardless of - * fulfillment or rejection - * @returns {Promise} - */ - ensure: function(onFulfilledOrRejected) { - return this.then(injectHandler, injectHandler)['yield'](this); - - function injectHandler() { - return resolve(onFulfilledOrRejected()); - } - }, - - /** - * Shortcut for .then(function() { return value; }) - * @param {*} value - * @return {Promise} a promise that: - * - is fulfilled if value is not a promise, or - * - if value is a promise, will fulfill with its value, or reject - * with its reason. - */ - 'yield': function(value) { - return this.then(function() { - return value; - }); - }, - - /** - * Runs a side effect when this promise fulfills, without changing the - * fulfillment value. - * @param {function} onFulfilledSideEffect - * @returns {Promise} - */ - tap: function(onFulfilledSideEffect) { - return this.then(onFulfilledSideEffect)['yield'](this); - }, - - /** - * Assumes that this promise will fulfill with an array, and arranges - * for the onFulfilled to be called with the array as its argument list - * i.e. onFulfilled.apply(undefined, array). - * @param {function} onFulfilled function to receive spread arguments - * @return {Promise} - */ - spread: function(onFulfilled) { - return this.then(function(array) { - // array may contain promises, so resolve its contents. - return all(array, function(array) { - return onFulfilled.apply(undef, array); - }); - }); - }, - - /** - * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) - * @deprecated - */ - always: function(onFulfilledOrRejected, onProgress) { - return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); - } - }; - - /** - * Returns a resolved promise. The returned promise will be - * - fulfilled with promiseOrValue if it is a value, or - * - if promiseOrValue is a promise - * - fulfilled with promiseOrValue's value after it is fulfilled - * - rejected with promiseOrValue's reason after it is rejected - * @param {*} value - * @return {Promise} - */ - function resolve(value) { - return promise(function(resolve) { - resolve(value); - }); - } - - /** - * Returns a rejected promise for the supplied promiseOrValue. The returned - * promise will be rejected with: - * - promiseOrValue, if it is a value, or - * - if promiseOrValue is a promise - * - promiseOrValue's value after it is fulfilled - * - promiseOrValue's reason after it is rejected - * @param {*} promiseOrValue the rejected value of the returned {@link Promise} - * @return {Promise} rejected {@link Promise} - */ - function reject(promiseOrValue) { - return when(promiseOrValue, rejected); - } - - /** - * Creates a {promise, resolver} pair, either or both of which - * may be given out safely to consumers. - * The resolver has resolve, reject, and progress. The promise - * has then plus extended promise API. - * - * @return {{ - * promise: Promise, - * resolve: function:Promise, - * reject: function:Promise, - * notify: function:Promise - * resolver: { - * resolve: function:Promise, - * reject: function:Promise, - * notify: function:Promise - * }}} - */ - function defer() { - var deferred, pending, resolved; - - // Optimize object shape - deferred = { - promise: undef, resolve: undef, reject: undef, notify: undef, - resolver: { resolve: undef, reject: undef, notify: undef } - }; - - deferred.promise = pending = promise(makeDeferred); - - return deferred; - - function makeDeferred(resolvePending, rejectPending, notifyPending) { - deferred.resolve = deferred.resolver.resolve = function(value) { - if(resolved) { - return resolve(value); - } - resolved = true; - resolvePending(value); - return pending; - }; - - deferred.reject = deferred.resolver.reject = function(reason) { - if(resolved) { - return resolve(rejected(reason)); - } - resolved = true; - rejectPending(reason); - return pending; - }; - - deferred.notify = deferred.resolver.notify = function(update) { - notifyPending(update); - return update; - }; - } - } - - /** - * Creates a new promise whose fate is determined by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @returns {Promise} promise whose fate is determine by resolver - */ - function promise(resolver) { - return _promise(resolver, monitorApi.PromiseStatus && monitorApi.PromiseStatus()); - } - - /** - * Creates a new promise, linked to parent, whose fate is determined - * by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @param {Promise?} status promise from which the new promise is begotten - * @returns {Promise} promise whose fate is determine by resolver - * @private - */ - function _promise(resolver, status) { - var self, value, consumers = []; - - self = new Promise(_message, inspect); - self._status = status; - - // Call the provider resolver to seal the promise's fate - try { - resolver(promiseResolve, promiseReject, promiseNotify); - } catch(e) { - promiseReject(e); - } - - // Return the promise - return self; - - /** - * Private message delivery. Queues and delivers messages to - * the promise's ultimate fulfillment value or rejection reason. - * @private - * @param {String} type - * @param {Array} args - * @param {Function} resolve - * @param {Function} notify - */ - function _message(type, args, resolve, notify) { - consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); - - function deliver(p) { - p._message(type, args, resolve, notify); - } - } - - /** - * Returns a snapshot of the promise's state at the instant inspect() - * is called. The returned object is not live and will not update as - * the promise's state changes. - * @returns {{ state:String, value?:*, reason?:* }} status snapshot - * of the promise. - */ - function inspect() { - return value ? value.inspect() : toPendingState(); - } - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the ultimate fulfillment or rejection - * @param {*|Promise} val resolution value - */ - function promiseResolve(val) { - if(!consumers) { - return; - } - - value = coerce(val); - scheduleConsumers(consumers, value); - consumers = undef; - - if(status) { - updateStatus(value, status); - } - } - - /** - * Reject this promise with the supplied reason, which will be used verbatim. - * @param {*} reason reason for the rejection - */ - function promiseReject(reason) { - promiseResolve(rejected(reason)); - } - - /** - * Issue a progress event, notifying all progress listeners - * @param {*} update progress event payload to pass to all listeners - */ - function promiseNotify(update) { - if(consumers) { - scheduleConsumers(consumers, progressed(update)); - } - } - } - - /** - * Creates a fulfilled, local promise as a proxy for a value - * NOTE: must never be exposed - * @param {*} value fulfillment value - * @returns {Promise} - */ - function fulfilled(value) { - return near( - new NearFulfilledProxy(value), - function() { return toFulfilledState(value); } - ); - } - - /** - * Creates a rejected, local promise with the supplied reason - * NOTE: must never be exposed - * @param {*} reason rejection reason - * @returns {Promise} - */ - function rejected(reason) { - return near( - new NearRejectedProxy(reason), - function() { return toRejectedState(reason); } - ); - } - - /** - * Creates a near promise using the provided proxy - * NOTE: must never be exposed - * @param {object} proxy proxy for the promise's ultimate value or reason - * @param {function} inspect function that returns a snapshot of the - * returned near promise's state - * @returns {Promise} - */ - function near(proxy, inspect) { - return new Promise(function (type, args, resolve) { - try { - resolve(proxy[type].apply(proxy, args)); - } catch(e) { - resolve(rejected(e)); - } - }, inspect); - } - - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} update - * @return {Promise} progress promise - */ - function progressed(update) { - return new Promise(function (type, args, _, notify) { - var onProgress = args[2]; - try { - notify(typeof onProgress === 'function' ? onProgress(update) : update); - } catch(e) { - notify(e); - } - }); - } - - /** - * Coerces x to a trusted Promise - * - * @private - * @param {*} x thing to coerce - * @returns {*} Guaranteed to return a trusted Promise. If x - * is trusted, returns x, otherwise, returns a new, trusted, already-resolved - * Promise whose resolution value is: - * * the resolution value of x if it's a foreign promise, or - * * x if it's a value - */ - function coerce(x) { - if (x instanceof Promise) { - return x; - } - - if (!(x === Object(x) && 'then' in x)) { - return fulfilled(x); - } - - return promise(function(resolve, reject, notify) { - enqueue(function() { - try { - // We must check and assimilate in the same tick, but not the - // current tick, careful only to access promiseOrValue.then once. - var untrustedThen = x.then; - - if(typeof untrustedThen === 'function') { - fcall(untrustedThen, x, resolve, reject, notify); - } else { - // It's a value, create a fulfilled wrapper - resolve(fulfilled(x)); - } - - } catch(e) { - // Something went wrong, reject - reject(e); - } - }); - }); - } - - /** - * Proxy for a near, fulfilled value - * @param {*} value - * @constructor - */ - function NearFulfilledProxy(value) { - this.value = value; - } - - NearFulfilledProxy.prototype.when = function(onResult) { - return typeof onResult === 'function' ? onResult(this.value) : this.value; - }; - - /** - * Proxy for a near rejection - * @param {*} reason - * @constructor - */ - function NearRejectedProxy(reason) { - this.reason = reason; - } - - NearRejectedProxy.prototype.when = function(_, onError) { - if(typeof onError === 'function') { - return onError(this.reason); - } else { - throw this.reason; - } - }; - - /** - * Schedule a task that will process a list of handlers - * in the next queue drain run. - * @private - * @param {Array} handlers queue of handlers to execute - * @param {*} value passed as the only arg to each handler - */ - function scheduleConsumers(handlers, value) { - enqueue(function() { - var handler, i = 0; - while (handler = handlers[i++]) { - handler(value); - } - }); - } - - function updateStatus(value, status) { - value.then(statusFulfilled, statusRejected); - - function statusFulfilled() { status.fulfilled(); } - function statusRejected(r) { status.rejected(r); } - } - - /** - * Determines if x is promise-like, i.e. a thenable object - * NOTE: Will return true for *any thenable object*, and isn't truly - * safe, since it may attempt to access the `then` property of x (i.e. - * clever/malicious getters may do weird things) - * @param {*} x anything - * @returns {boolean} true if x is promise-like - */ - function isPromiseLike(x) { - return x && typeof x.then === 'function'; - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * howMany of the supplied promisesOrValues have resolved, or will reject when - * it becomes impossible for howMany to resolve, for example, when - * (promisesOrValues.length - howMany) + 1 input promises reject. - * - * @param {Array} promisesOrValues array of anything, may contain a mix - * of promises and values - * @param howMany {number} number of promisesOrValues to resolve - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} promise that will resolve to an array of howMany values that - * resolved first, or will reject with an array of - * (promisesOrValues.length - howMany) + 1 rejection reasons. - */ - function some(promisesOrValues, howMany, onFulfilled, onRejected, onProgress) { - - return when(promisesOrValues, function(promisesOrValues) { - - return promise(resolveSome).then(onFulfilled, onRejected, onProgress); - - function resolveSome(resolve, reject, notify) { - var toResolve, toReject, values, reasons, fulfillOne, rejectOne, len, i; - - len = promisesOrValues.length >>> 0; - - toResolve = Math.max(0, Math.min(howMany, len)); - values = []; - - toReject = (len - toResolve) + 1; - reasons = []; - - // No items in the input, resolve immediately - if (!toResolve) { - resolve(values); - - } else { - rejectOne = function(reason) { - reasons.push(reason); - if(!--toReject) { - fulfillOne = rejectOne = identity; - reject(reasons); - } - }; - - fulfillOne = function(val) { - // This orders the values based on promise resolution order - values.push(val); - if (!--toResolve) { - fulfillOne = rejectOne = identity; - resolve(values); - } - }; - - for(i = 0; i < len; ++i) { - if(i in promisesOrValues) { - when(promisesOrValues[i], fulfiller, rejecter, notify); - } - } - } - - function rejecter(reason) { - rejectOne(reason); - } - - function fulfiller(val) { - fulfillOne(val); - } - } - }); - } - - /** - * Initiates a competitive race, returning a promise that will resolve when - * any one of the supplied promisesOrValues has resolved or will reject when - * *all* promisesOrValues have rejected. - * - * @param {Array|Promise} promisesOrValues array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} promise that will resolve to the value that resolved first, or - * will reject with an array of all rejected inputs. - */ - function any(promisesOrValues, onFulfilled, onRejected, onProgress) { - - function unwrapSingleResult(val) { - return onFulfilled ? onFulfilled(val[0]) : val[0]; - } - - return some(promisesOrValues, 1, unwrapSingleResult, onRejected, onProgress); - } - - /** - * Return a promise that will resolve only once all the supplied promisesOrValues - * have resolved. The resolution value of the returned promise will be an array - * containing the resolution values of each of the promisesOrValues. - * @memberOf when - * - * @param {Array|Promise} promisesOrValues array of anything, may contain a mix - * of {@link Promise}s and values - * @param {function?} [onFulfilled] DEPRECATED, use returnedPromise.then() - * @param {function?} [onRejected] DEPRECATED, use returnedPromise.then() - * @param {function?} [onProgress] DEPRECATED, use returnedPromise.then() - * @returns {Promise} - */ - function all(promisesOrValues, onFulfilled, onRejected, onProgress) { - return _map(promisesOrValues, identity).then(onFulfilled, onRejected, onProgress); - } - - /** - * Joins multiple promises into a single returned promise. - * @return {Promise} a promise that will fulfill when *all* the input promises - * have fulfilled, or will reject when *any one* of the input promises rejects. - */ - function join(/* ...promises */) { - return _map(arguments, identity); - } - - /** - * Settles all input promises such that they are guaranteed not to - * be pending once the returned promise fulfills. The returned promise - * will always fulfill, except in the case where `array` is a promise - * that rejects. - * @param {Array|Promise} array or promise for array of promises to settle - * @returns {Promise} promise that always fulfills with an array of - * outcome snapshots for each input promise. - */ - function settle(array) { - return _map(array, toFulfilledState, toRejectedState); - } - - /** - * Promise-aware array map function, similar to `Array.prototype.map()`, - * but input array may contain promises or values. - * @param {Array|Promise} array array of anything, may contain promises and values - * @param {function} mapFunc map function which may return a promise or value - * @returns {Promise} promise that will fulfill with an array of mapped values - * or reject if any input promise rejects. - */ - function map(array, mapFunc) { - return _map(array, mapFunc); - } - - /** - * Internal map that allows a fallback to handle rejections - * @param {Array|Promise} array array of anything, may contain promises and values - * @param {function} mapFunc map function which may return a promise or value - * @param {function?} fallback function to handle rejected promises - * @returns {Promise} promise that will fulfill with an array of mapped values - * or reject if any input promise rejects. - */ - function _map(array, mapFunc, fallback) { - return when(array, function(array) { - - return _promise(resolveMap); - - function resolveMap(resolve, reject, notify) { - var results, len, toResolve, i; - - // Since we know the resulting length, we can preallocate the results - // array to avoid array expansions. - toResolve = len = array.length >>> 0; - results = []; - - if(!toResolve) { - resolve(results); - return; - } - - // Since mapFunc may be async, get all invocations of it into flight - for(i = 0; i < len; i++) { - if(i in array) { - resolveOne(array[i], i); - } else { - --toResolve; - } - } - - function resolveOne(item, i) { - when(item, mapFunc, fallback).then(function(mapped) { - results[i] = mapped; - notify(mapped); - - if(!--toResolve) { - resolve(results); - } - }, reject); - } - } - }); - } - - /** - * Traditional reduce function, similar to `Array.prototype.reduce()`, but - * input may contain promises and/or values, and reduceFunc - * may return either a value or a promise, *and* initialValue may - * be a promise for the starting value. - * - * @param {Array|Promise} promise array or promise for an array of anything, - * may contain a mix of promises and values. - * @param {function} reduceFunc reduce function reduce(currentValue, nextValue, index, total), - * where total is the total number of items being reduced, and will be the same - * in each call to reduceFunc. - * @returns {Promise} that will resolve to the final reduced value - */ - function reduce(promise, reduceFunc /*, initialValue */) { - var args = fcall(slice, arguments, 1); - - return when(promise, function(array) { - var total; - - total = array.length; - - // Wrap the supplied reduceFunc with one that handles promises and then - // delegates to the supplied. - args[0] = function (current, val, i) { - return when(current, function (c) { - return when(val, function (value) { - return reduceFunc(c, value, i, total); - }); - }); - }; - - return reduceArray.apply(array, args); - }); - } - - // Snapshot states - - /** - * Creates a fulfilled state snapshot - * @private - * @param {*} x any value - * @returns {{state:'fulfilled',value:*}} - */ - function toFulfilledState(x) { - return { state: 'fulfilled', value: x }; - } - - /** - * Creates a rejected state snapshot - * @private - * @param {*} x any reason - * @returns {{state:'rejected',reason:*}} - */ - function toRejectedState(x) { - return { state: 'rejected', reason: x }; - } - - /** - * Creates a pending state snapshot - * @private - * @returns {{state:'pending'}} - */ - function toPendingState() { - return { state: 'pending' }; - } - - // - // Internals, utilities, etc. - // - - var reduceArray, slice, fcall, nextTick, handlerQueue, - setTimeout, funcProto, call, arrayProto, monitorApi, - cjsRequire, undef; - - cjsRequire = require; - - // - // Shared handler queue processing - // - // Credit to Twisol (https://github.com/Twisol) for suggesting - // this type of extensible queue + trampoline approach for - // next-tick conflation. - - handlerQueue = []; - - /** - * Enqueue a task. If the queue is not currently scheduled to be - * drained, schedule it. - * @param {function} task - */ - function enqueue(task) { - if(handlerQueue.push(task) === 1) { - nextTick(drainQueue); - } - } - - /** - * Drain the handler queue entirely, being careful to allow the - * queue to be extended while it is being processed, and to continue - * processing until it is truly empty. - */ - function drainQueue() { - var task, i = 0; - - while(task = handlerQueue[i++]) { - task(); - } - - handlerQueue = []; - } - - // capture setTimeout to avoid being caught by fake timers - // used in time based tests - setTimeout = global.setTimeout; - - // Allow attaching the monitor to when() if env has no console - monitorApi = typeof console != 'undefined' ? console : when; - - // Prefer setImmediate or MessageChannel, cascade to node, - // vertx and finally setTimeout - /*global setImmediate,MessageChannel,process*/ - if (typeof setImmediate === 'function') { - nextTick = setImmediate.bind(global); - } else if(typeof MessageChannel !== 'undefined') { - var channel = new MessageChannel(); - channel.port1.onmessage = drainQueue; - nextTick = function() { channel.port2.postMessage(0); }; - } else if (typeof process === 'object' && process.nextTick) { - nextTick = process.nextTick; - } else { - try { - // vert.x 1.x || 2.x - nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; - } catch(ignore) { - nextTick = function(t) { setTimeout(t, 0); }; - } - } - - // - // Capture/polyfill function and array utils - // - - // Safe function calls - funcProto = Function.prototype; - call = funcProto.call; - fcall = funcProto.bind - ? call.bind(call) - : function(f, context) { - return f.apply(context, slice.call(arguments, 2)); - }; - - // Safe array ops - arrayProto = []; - slice = arrayProto.slice; - - // ES5 reduce implementation if native not available - // See: http://es5.github.com/#x15.4.4.21 as there are many - // specifics and edge cases. ES5 dictates that reduce.length === 1 - // This implementation deviates from ES5 spec in the following ways: - // 1. It does not check if reduceFunc is a Callable - reduceArray = arrayProto.reduce || - function(reduceFunc /*, initialValue */) { - /*jshint maxcomplexity: 7*/ - var arr, args, reduced, len, i; - - i = 0; - arr = Object(this); - len = arr.length >>> 0; - args = arguments; - - // If no initialValue, use first item of array (we know length !== 0 here) - // and adjust i to start at second item - if(args.length <= 1) { - // Skip to the first real element in the array - for(;;) { - if(i in arr) { - reduced = arr[i++]; - break; - } - - // If we reached the end of the array without finding any real - // elements, it's a TypeError - if(++i >= len) { - throw new TypeError(); - } - } - } else { - // If initialValue provided, use it - reduced = args[1]; - } - - // Do the actual reduce - for(;i < len; ++i) { - if(i in arr) { - reduced = reduceFunc(reduced, arr[i], i, arr); - } - } - - return reduced; - }; - - function identity(x) { - return x; - } - - return when; -}); -})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }, this); diff --git a/js/package.json b/js/package.json index 5b8e46d8..6d6c8a89 100644 --- a/js/package.json +++ b/js/package.json @@ -16,13 +16,13 @@ "dependencies": { "bane": "~1.0.0", "faye-websocket": "~0.7.0", - "when": "~2.4.0" + "when": "~2.7.0" }, "devDependencies": { "buster": "~0.6.13", "grunt": "~0.4.1", "grunt-buster": "~0.2.1", - "grunt-contrib-concat": "~0.3.0", + "grunt-browserify": "~1.3.0", "grunt-contrib-jshint": "~0.6.4", "grunt-contrib-uglify": "~0.2.4", "grunt-contrib-watch": "~0.5.3", diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 980256b5..1667f9b1 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,10 +1,8 @@ -/*global exports:false, require:false*/ +/*global module:true, require:false*/ -if (typeof module === "object" && typeof require === "function") { - var bane = require("bane"); - var websocket = require("faye-websocket"); - var when = require("when"); -} +var bane = require("bane"); +var websocket = require("../lib/websocket/"); +var when = require("when"); function Mopidy(settings) { if (!(this instanceof Mopidy)) { @@ -26,11 +24,7 @@ function Mopidy(settings) { } } -if (typeof module === "object" && typeof require === "function") { - Mopidy.WebSocket = websocket.Client; -} else { - Mopidy.WebSocket = window.WebSocket; -} +Mopidy.WebSocket = websocket.Client; Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && @@ -295,6 +289,4 @@ Mopidy.prototype._snakeToCamel = function (name) { }); }; -if (typeof exports === "object") { - exports.Mopidy = Mopidy; -} +module.exports = Mopidy; diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 3e4e832e..857d826b 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1,7 +1,11 @@ -/*! Mopidy.js - built 2013-09-17 +/*! Mopidy.js - built 2013-12-15 * http://www.mopidy.com/ * Copyright (c) 2013 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ +!function(e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):"undefined"!=typeof window?window.Mopidy=e():"undefined"!=typeof global?global.Mopidy=e():"undefined"!=typeof self&&(self.Mopidy=e())}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 0) { + var fn = queue.shift(); + fn(); + } + } + }, true); + + return function nextTick(fn) { + queue.push(fn); + window.postMessage('process-tick', '*'); + }; + } + + return function nextTick(fn) { + setTimeout(fn, 0); }; - window.define.amd = {}; +})(); + +process.title = 'browser'; +process.browser = true; +process.env = {}; +process.argv = []; + +process.binding = function (name) { + throw new Error('process.binding is not supported'); } +// TODO(shtylman) +process.cwd = function () { return '/' }; +process.chdir = function (dir) { + throw new Error('process.chdir is not supported'); +}; + +},{}],4:[function(require,module,exports){ +var process=require("__browserify_process");/** @license MIT License (c) copyright 2011-2013 original author or authors */ + /** * A lightweight CommonJS Promises/A and when() implementation * when is part of the cujo.js family of libraries (http://cujojs.com/) @@ -187,9 +236,9 @@ if (typeof window !== "undefined") { * * @author Brian Cavalier * @author John Hann - * @version 2.4.0 + * @version 2.7.0 */ -(function(define, global) { 'use strict'; +(function(define) { 'use strict'; define(function (require) { // Public API @@ -230,7 +279,11 @@ define(function (require) { function when(promiseOrValue, onFulfilled, onRejected, onProgress) { // Get a trusted promise for the input promiseOrValue, and then // register promise handlers - return resolve(promiseOrValue).then(onFulfilled, onRejected, onProgress); + return cast(promiseOrValue).then(onFulfilled, onRejected, onProgress); + } + + function cast(x) { + return x instanceof Promise ? x : resolve(x); } /** @@ -247,102 +300,118 @@ define(function (require) { this.inspect = inspect; } - Promise.prototype = { - /** - * Register handlers for this promise. - * @param [onFulfilled] {Function} fulfillment handler - * @param [onRejected] {Function} rejection handler - * @param [onProgress] {Function} progress handler - * @return {Promise} new Promise - */ - then: function(onFulfilled, onRejected, onProgress) { - /*jshint unused:false*/ - var args, sendMessage; + var promisePrototype = Promise.prototype; - args = arguments; - sendMessage = this._message; + /** + * Register handlers for this promise. + * @param [onFulfilled] {Function} fulfillment handler + * @param [onRejected] {Function} rejection handler + * @param [onProgress] {Function} progress handler + * @return {Promise} new Promise + */ + promisePrototype.then = function(onFulfilled, onRejected, onProgress) { + /*jshint unused:false*/ + var args, sendMessage; - return _promise(function(resolve, reject, notify) { - sendMessage('when', args, resolve, notify); - }, this._status && this._status.observed()); - }, + args = arguments; + sendMessage = this._message; - /** - * Register a rejection handler. Shortcut for .then(undefined, onRejected) - * @param {function?} onRejected - * @return {Promise} - */ - otherwise: function(onRejected) { - return this.then(undef, onRejected); - }, + return _promise(function(resolve, reject, notify) { + sendMessage('when', args, resolve, notify); + }, this._status && this._status.observed()); + }; - /** - * Ensures that onFulfilledOrRejected will be called regardless of whether - * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT - * receive the promises' value or reason. Any returned value will be disregarded. - * onFulfilledOrRejected may throw or return a rejected promise to signal - * an additional error. - * @param {function} onFulfilledOrRejected handler to be called regardless of - * fulfillment or rejection - * @returns {Promise} - */ - ensure: function(onFulfilledOrRejected) { - return this.then(injectHandler, injectHandler)['yield'](this); + /** + * Register a rejection handler. Shortcut for .then(undefined, onRejected) + * @param {function?} onRejected + * @return {Promise} + */ + promisePrototype['catch'] = promisePrototype.otherwise = function(onRejected) { + return this.then(undef, onRejected); + }; - function injectHandler() { - return resolve(onFulfilledOrRejected()); - } - }, + /** + * Ensures that onFulfilledOrRejected will be called regardless of whether + * this promise is fulfilled or rejected. onFulfilledOrRejected WILL NOT + * receive the promises' value or reason. Any returned value will be disregarded. + * onFulfilledOrRejected may throw or return a rejected promise to signal + * an additional error. + * @param {function} onFulfilledOrRejected handler to be called regardless of + * fulfillment or rejection + * @returns {Promise} + */ + promisePrototype['finally'] = promisePrototype.ensure = function(onFulfilledOrRejected) { + return typeof onFulfilledOrRejected === 'function' + ? this.then(injectHandler, injectHandler)['yield'](this) + : this; - /** - * Shortcut for .then(function() { return value; }) - * @param {*} value - * @return {Promise} a promise that: - * - is fulfilled if value is not a promise, or - * - if value is a promise, will fulfill with its value, or reject - * with its reason. - */ - 'yield': function(value) { - return this.then(function() { - return value; - }); - }, - - /** - * Runs a side effect when this promise fulfills, without changing the - * fulfillment value. - * @param {function} onFulfilledSideEffect - * @returns {Promise} - */ - tap: function(onFulfilledSideEffect) { - return this.then(onFulfilledSideEffect)['yield'](this); - }, - - /** - * Assumes that this promise will fulfill with an array, and arranges - * for the onFulfilled to be called with the array as its argument list - * i.e. onFulfilled.apply(undefined, array). - * @param {function} onFulfilled function to receive spread arguments - * @return {Promise} - */ - spread: function(onFulfilled) { - return this.then(function(array) { - // array may contain promises, so resolve its contents. - return all(array, function(array) { - return onFulfilled.apply(undef, array); - }); - }); - }, - - /** - * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) - * @deprecated - */ - always: function(onFulfilledOrRejected, onProgress) { - return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); + function injectHandler() { + return resolve(onFulfilledOrRejected()); } }; + /** + * Terminate a promise chain by handling the ultimate fulfillment value or + * rejection reason, and assuming responsibility for all errors. if an + * error propagates out of handleResult or handleFatalError, it will be + * rethrown to the host, resulting in a loud stack track on most platforms + * and a crash on some. + * @param {function?} handleResult + * @param {function?} handleError + * @returns {undefined} + */ + promisePrototype.done = function(handleResult, handleError) { + this.then(handleResult, handleError).otherwise(crash); + }; + + /** + * Shortcut for .then(function() { return value; }) + * @param {*} value + * @return {Promise} a promise that: + * - is fulfilled if value is not a promise, or + * - if value is a promise, will fulfill with its value, or reject + * with its reason. + */ + promisePrototype['yield'] = function(value) { + return this.then(function() { + return value; + }); + }; + + /** + * Runs a side effect when this promise fulfills, without changing the + * fulfillment value. + * @param {function} onFulfilledSideEffect + * @returns {Promise} + */ + promisePrototype.tap = function(onFulfilledSideEffect) { + return this.then(onFulfilledSideEffect)['yield'](this); + }; + + /** + * Assumes that this promise will fulfill with an array, and arranges + * for the onFulfilled to be called with the array as its argument list + * i.e. onFulfilled.apply(undefined, array). + * @param {function} onFulfilled function to receive spread arguments + * @return {Promise} + */ + promisePrototype.spread = function(onFulfilled) { + return this.then(function(array) { + // array may contain promises, so resolve its contents. + return all(array, function(array) { + return onFulfilled.apply(undef, array); + }); + }); + }; + + /** + * Shortcut for .then(onFulfilledOrRejected, onFulfilledOrRejected) + * @deprecated + */ + promisePrototype.always = function(onFulfilledOrRejected, onProgress) { + return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); + }; + /** * Returns a resolved promise. The returned promise will be * - fulfilled with promiseOrValue if it is a value, or @@ -499,13 +568,17 @@ define(function (require) { return; } - value = coerce(val); - scheduleConsumers(consumers, value); + var queue = consumers; consumers = undef; - if(status) { - updateStatus(value, status); - } + enqueue(function () { + value = coerce(self, val); + if(status) { + updateStatus(value, status); + } + runHandlers(queue, value); + }); + } /** @@ -522,11 +595,24 @@ define(function (require) { */ function promiseNotify(update) { if(consumers) { - scheduleConsumers(consumers, progressed(update)); + var queue = consumers; + enqueue(function () { + runHandlers(queue, progressed(update)); + }); } } } + /** + * Run a queue of functions as quickly as possible, passing + * value to each. + */ + function runHandlers(queue, value) { + for (var i = 0; i < queue.length; i++) { + queue[i](value); + } + } + /** * Creates a fulfilled, local promise as a proxy for a value * NOTE: must never be exposed @@ -590,8 +676,6 @@ define(function (require) { /** * Coerces x to a trusted Promise - * - * @private * @param {*} x thing to coerce * @returns {*} Guaranteed to return a trusted Promise. If x * is trusted, returns x, otherwise, returns a new, trusted, already-resolved @@ -599,34 +683,35 @@ define(function (require) { * * the resolution value of x if it's a foreign promise, or * * x if it's a value */ - function coerce(x) { + function coerce(self, x) { + if (x === self) { + return rejected(new TypeError()); + } + if (x instanceof Promise) { return x; } - if (!(x === Object(x) && 'then' in x)) { - return fulfilled(x); + try { + var untrustedThen = x === Object(x) && x.then; + + return typeof untrustedThen === 'function' + ? assimilate(untrustedThen, x) + : fulfilled(x); + } catch(e) { + return rejected(e); } + } - return promise(function(resolve, reject, notify) { - enqueue(function() { - try { - // We must check and assimilate in the same tick, but not the - // current tick, careful only to access promiseOrValue.then once. - var untrustedThen = x.then; - - if(typeof untrustedThen === 'function') { - fcall(untrustedThen, x, resolve, reject, notify); - } else { - // It's a value, create a fulfilled wrapper - resolve(fulfilled(x)); - } - - } catch(e) { - // Something went wrong, reject - reject(e); - } - }); + /** + * Safely assimilates a foreign thenable by wrapping it in a trusted promise + * @param {function} untrustedThen x's then() method + * @param {object|function} x thenable + * @returns {Promise} + */ + function assimilate(untrustedThen, x) { + return promise(function (resolve, reject) { + fcall(untrustedThen, x, resolve, reject); }); } @@ -660,22 +745,6 @@ define(function (require) { } }; - /** - * Schedule a task that will process a list of handlers - * in the next queue drain run. - * @private - * @param {Array} handlers queue of handlers to execute - * @param {*} value passed as the only arg to each handler - */ - function scheduleConsumers(handlers, value) { - enqueue(function() { - var handler, i = 0; - while (handler = handlers[i++]) { - handler(value); - } - }); - } - function updateStatus(value, status) { value.then(statusFulfilled, statusRejected); @@ -879,12 +948,11 @@ define(function (require) { function resolveOne(item, i) { when(item, mapFunc, fallback).then(function(mapped) { results[i] = mapped; - notify(mapped); if(!--toResolve) { resolve(results); } - }, reject); + }, reject, notify); } } }); @@ -961,8 +1029,8 @@ define(function (require) { // var reduceArray, slice, fcall, nextTick, handlerQueue, - setTimeout, funcProto, call, arrayProto, monitorApi, - cjsRequire, undef; + funcProto, call, arrayProto, monitorApi, + capturedSetTimeout, cjsRequire, MutationObs, undef; cjsRequire = require; @@ -992,39 +1060,39 @@ define(function (require) { * processing until it is truly empty. */ function drainQueue() { - var task, i = 0; - - while(task = handlerQueue[i++]) { - task(); - } - + runHandlers(handlerQueue); handlerQueue = []; } - // capture setTimeout to avoid being caught by fake timers - // used in time based tests - setTimeout = global.setTimeout; - // Allow attaching the monitor to when() if env has no console - monitorApi = typeof console != 'undefined' ? console : when; + monitorApi = typeof console !== 'undefined' ? console : when; - // Prefer setImmediate or MessageChannel, cascade to node, - // vertx and finally setTimeout - /*global setImmediate,MessageChannel,process*/ - if (typeof setImmediate === 'function') { - nextTick = setImmediate.bind(global); - } else if(typeof MessageChannel !== 'undefined') { - var channel = new MessageChannel(); - channel.port1.onmessage = drainQueue; - nextTick = function() { channel.port2.postMessage(0); }; - } else if (typeof process === 'object' && process.nextTick) { + // Sniff "best" async scheduling option + // Prefer process.nextTick or MutationObserver, then check for + // vertx and finally fall back to setTimeout + /*global process,document,setTimeout,MutationObserver,WebKitMutationObserver*/ + if (typeof process === 'object' && process.nextTick) { nextTick = process.nextTick; + } else if(MutationObs = + (typeof MutationObserver === 'function' && MutationObserver) || + (typeof WebKitMutationObserver === 'function' && WebKitMutationObserver)) { + nextTick = (function(document, MutationObserver, drainQueue) { + var el = document.createElement('div'); + new MutationObserver(drainQueue).observe(el, { attributes: true }); + + return function() { + el.setAttribute('x', 'x'); + }; + }(document, MutationObs, drainQueue)); } else { try { // vert.x 1.x || 2.x nextTick = cjsRequire('vertx').runOnLoop || cjsRequire('vertx').runOnContext; } catch(ignore) { - nextTick = function(t) { setTimeout(t, 0); }; + // capture setTimeout to avoid being caught by fake timers + // used in time based tests + capturedSetTimeout = setTimeout; + nextTick = function(t) { capturedSetTimeout(t, 0); }; } } @@ -1095,15 +1163,28 @@ define(function (require) { return x; } + function crash(fatalError) { + if(typeof monitorApi.reportUnhandled === 'function') { + monitorApi.reportUnhandled(); + } else { + enqueue(function() { + throw fatalError; + }); + } + + throw fatalError; + } + return when; }); -})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }, this); +})(typeof define === 'function' && define.amd ? define : function (factory) { module.exports = factory(require); }); -if (typeof module === "object" && typeof require === "function") { - var bane = require("bane"); - var websocket = require("faye-websocket"); - var when = require("when"); -} +},{"__browserify_process":3}],5:[function(require,module,exports){ +/*global module:true, require:false*/ + +var bane = require("bane"); +var websocket = require("../lib/websocket/"); +var when = require("when"); function Mopidy(settings) { if (!(this instanceof Mopidy)) { @@ -1125,11 +1206,7 @@ function Mopidy(settings) { } } -if (typeof module === "object" && typeof require === "function") { - Mopidy.WebSocket = websocket.Client; -} else { - Mopidy.WebSocket = window.WebSocket; -} +Mopidy.WebSocket = websocket.Client; Mopidy.prototype._configure = function (settings) { var currentHost = (typeof document !== "undefined" && @@ -1394,6 +1471,9 @@ Mopidy.prototype._snakeToCamel = function (name) { }); }; -if (typeof exports === "object") { - exports.Mopidy = Mopidy; -} +module.exports = Mopidy; + +},{"../lib/websocket/":1,"bane":2,"when":4}]},{},[5]) +(5) +}); +; \ No newline at end of file diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index 75d9fff1..5e61a3f6 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! Mopidy.js - built 2013-09-17 +/*! Mopidy.js - built 2013-12-15 * http://www.mopidy.com/ * Copyright (c) 2013 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(a){return this instanceof Mopidy?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new Mopidy(a)}if(("function"==typeof define&&define.amd&&function(a){define("bane",a)}||"object"==typeof module&&function(a){module.exports=a()}||function(a){this.bane=a()})(function(){"use strict";function a(a,b,c){var d,e=c.length;if(e>0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f}}),"undefined"!=typeof window&&(window.define=function(a){try{delete window.define}catch(b){window.define=void 0}window.when=a()},window.define.amd={}),function(a,b){"use strict";a(function(a){function c(a,b,c,d){return e(a).then(b,c,d)}function d(a,b){this._message=a,this.inspect=b}function e(a){return h(function(b){b(a)})}function f(a){return c(a,k)}function g(){function a(a,f,g){b.resolve=b.resolver.resolve=function(b){return d?e(b):(d=!0,a(b),c)},b.reject=b.resolver.reject=function(a){return d?e(k(a)):(d=!0,f(a),c)},b.notify=b.resolver.notify=function(a){return g(a),a}}var b,c,d;return b={promise:S,resolve:S,reject:S,notify:S,resolver:{resolve:S,reject:S,notify:S}},b.promise=c=h(a),b}function h(a){return i(a,Q.PromiseStatus&&Q.PromiseStatus())}function i(a,b){function c(a,b,c,d){function e(e){e._message(a,b,c,d)}l?l.push(e):E(function(){e(j)})}function e(){return j?j.inspect():D()}function f(a){l&&(j=n(a),q(l,j),l=S,b&&r(j,b))}function g(a){f(k(a))}function h(a){l&&q(l,m(a))}var i,j,l=[];i=new d(c,e),i._status=b;try{a(f,g,h)}catch(o){g(o)}return i}function j(a){return l(new o(a),function(){return B(a)})}function k(a){return l(new p(a),function(){return C(a)})}function l(a,b){return new d(function(b,c,d){try{d(a[b].apply(a,c))}catch(e){d(k(e))}},b)}function m(a){return new d(function(b,c,d,e){var f=c[2];try{e("function"==typeof f?f(a):a)}catch(g){e(g)}})}function n(a){return a instanceof d?a:a===Object(a)&&"then"in a?h(function(b,c,d){E(function(){try{var e=a.then;"function"==typeof e?J(e,a,b,c,d):b(j(a))}catch(f){c(f)}})}):j(a)}function o(a){this.value=a}function p(a){this.reason=a}function q(a,b){E(function(){for(var c,d=0;c=a[d++];)c(b)})}function r(a,b){function c(){b.fulfilled()}function d(a){b.rejected(a)}a.then(c,d)}function s(a){return a&&"function"==typeof a.then}function t(a,b,d,e,f){return c(a,function(a){function g(d,e,f){function g(a){n(a)}function h(a){m(a)}var i,j,k,l,m,n,o,p;if(o=a.length>>>0,i=Math.max(0,Math.min(b,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=G,e(l))},m=function(a){k.push(a),--i||(m=n=G,d(k))},p=0;o>p;++p)p in a&&c(a[p],h,g,f);else d(k)}return h(g).then(d,e,f)})}function u(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return t(a,1,e,c,d)}function v(a,b,c,d){return z(a,G).then(b,c,d)}function w(){return z(arguments,G)}function x(a){return z(a,B,C)}function y(a,b){return z(a,b)}function z(a,b,d){return c(a,function(a){function e(e,f,g){function h(a,h){c(a,b,d).then(function(a){i[h]=a,g(a),--k||e(i)},f)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return i(e)})}function A(a,b){var d=J(I,arguments,1);return c(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return c(a,function(a){return c(d,function(c){return b(a,c,f,e)})})},H.apply(a,d)})}function B(a){return{state:"fulfilled",value:a}}function C(a){return{state:"rejected",reason:a}}function D(){return{state:"pending"}}function E(a){1===L.push(a)&&K(F)}function F(){for(var a,b=0;a=L[b++];)a();L=[]}function G(a){return a}c.promise=h,c.resolve=e,c.reject=f,c.defer=g,c.join=w,c.all=v,c.map=y,c.reduce=A,c.settle=x,c.any=u,c.some=t,c.isPromise=s,c.isPromiseLike=s,d.prototype={then:function(){var a,b;return a=arguments,b=this._message,i(function(c,d,e){b("when",a,c,e)},this._status&&this._status.observed())},otherwise:function(a){return this.then(S,a)},ensure:function(a){function b(){return e(a())}return this.then(b,b).yield(this)},yield:function(a){return this.then(function(){return a})},tap:function(a){return this.then(a).yield(this)},spread:function(a){return this.then(function(b){return v(b,function(b){return a.apply(S,b)})})},always:function(a,b){return this.then(a,a,b)}},o.prototype.when=function(a){return"function"==typeof a?a(this.value):this.value},p.prototype.when=function(a,b){if("function"==typeof b)return b(this.reason);throw this.reason};var H,I,J,K,L,M,N,O,P,Q,R,S;if(R=a,L=[],M=b.setTimeout,Q="undefined"!=typeof console?console:c,"function"==typeof setImmediate)K=setImmediate.bind(b);else if("undefined"!=typeof MessageChannel){var T=new MessageChannel;T.port1.onmessage=F,K=function(){T.port2.postMessage(0)}}else if("object"==typeof process&&process.nextTick)K=process.nextTick;else try{K=R("vertx").runOnLoop||R("vertx").runOnContext}catch(U){K=function(a){M(a,0)}}return N=Function.prototype,O=N.call,J=N.bind?O.bind(O):function(a,b){return a.apply(b,I.call(arguments,2))},P=[],I=P.slice,H=P.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},c})}("function"==typeof define&&define.amd?define:function(a){module.exports=a(require)},this),"object"==typeof module&&"function"==typeof require)var bane=require("bane"),websocket=require("faye-websocket"),when=require("when");Mopidy.WebSocket="object"==typeof module&&"function"==typeof require?websocket.Client:window.WebSocket,Mopidy.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},Mopidy.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},Mopidy.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},Mopidy.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===Mopidy.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new Mopidy.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},Mopidy.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},Mopidy.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},Mopidy.prototype._send=function(a){var b=when.defer();switch(this._webSocket.readyState){case Mopidy.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case Mopidy.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case Mopidy.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},Mopidy.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),Mopidy.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},Mopidy.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},Mopidy.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},Mopidy.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},"object"==typeof exports&&(exports.Mopidy=Mopidy); \ No newline at end of file +!function(a){"object"==typeof exports?module.exports=a():"function"==typeof define&&define.amd?define(a):"undefined"!=typeof window?window.Mopidy=a():"undefined"!=typeof global?global.Mopidy=a():"undefined"!=typeof self&&(self.Mopidy=a())}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f}})},{}],3:[function(a,b){var c=b.exports={};c.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){if(a.source===window&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var b=c.shift();b()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),c.title="browser",c.browser=!0,c.env={},c.argv=[],c.binding=function(){throw new Error("process.binding is not supported")},c.cwd=function(){return"/"},c.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){var d=b("__browserify_process");!function(a){"use strict";a(function(a){function b(a,b,d,e){return c(a).then(b,d,e)}function c(a){return a instanceof e?a:f(a)}function e(a,b){this._message=a,this.inspect=b}function f(a){return i(function(b){b(a)})}function g(a){return b(a,m)}function h(){function a(a,e,g){b.resolve=b.resolver.resolve=function(b){return d?f(b):(d=!0,a(b),c)},b.reject=b.resolver.reject=function(a){return d?f(m(a)):(d=!0,e(a),c)},b.notify=b.resolver.notify=function(a){return g(a),a}}var b,c,d;return b={promise:X,resolve:X,reject:X,notify:X,resolver:{resolve:X,reject:X,notify:X}},b.promise=c=i(a),b}function i(a){return j(a,T.PromiseStatus&&T.PromiseStatus())}function j(a,b){function c(a,b,c,d){function e(e){e._message(a,b,c,d)}l?l.push(e):G(function(){e(j)})}function d(){return j?j.inspect():F()}function f(a){if(l){var c=l;l=X,G(function(){j=p(i,a),b&&t(j,b),k(c,j)})}}function g(a){f(m(a))}function h(a){if(l){var b=l;G(function(){k(b,o(a))})}}var i,j,l=[];i=new e(c,d),i._status=b;try{a(f,g,h)}catch(n){g(n)}return i}function k(a,b){for(var c=0;c>>0,i=Math.max(0,Math.min(c,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=I,e(l))},m=function(a){k.push(a),--i||(m=n=I,d(k))},p=0;o>p;++p)p in a&&b(a[p],h,g,f);else d(k)}return i(g).then(d,e,f)})}function w(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return v(a,1,e,c,d)}function x(a,b,c,d){return B(a,I).then(b,c,d)}function y(){return B(arguments,I)}function z(a){return B(a,D,E)}function A(a,b){return B(a,b)}function B(a,c,d){return b(a,function(a){function e(e,f,g){function h(a,h){b(a,c,d).then(function(a){i[h]=a,--k||e(i)},f,g)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return j(e)})}function C(a,c){var d=N(M,arguments,1);return b(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return b(a,function(a){return b(d,function(b){return c(a,b,f,e)})})},L.apply(a,d)})}function D(a){return{state:"fulfilled",value:a}}function E(a){return{state:"rejected",reason:a}}function F(){return{state:"pending"}}function G(a){1===P.push(a)&&O(H)}function H(){k(P),P=[]}function I(a){return a}function J(a){throw"function"==typeof T.reportUnhandled?T.reportUnhandled():G(function(){throw a}),a}b.promise=i,b.resolve=f,b.reject=g,b.defer=h,b.join=y,b.all=x,b.map=A,b.reduce=C,b.settle=z,b.any=w,b.some=v,b.isPromise=u,b.isPromiseLike=u;var K=e.prototype;K.then=function(){var a,b;return a=arguments,b=this._message,j(function(c,d,e){b("when",a,c,e)},this._status&&this._status.observed())},K["catch"]=K.otherwise=function(a){return this.then(X,a)},K["finally"]=K.ensure=function(a){function b(){return f(a())}return"function"==typeof a?this.then(b,b).yield(this):this},K.done=function(a,b){this.then(a,b).otherwise(J)},K.yield=function(a){return this.then(function(){return a})},K.tap=function(a){return this.then(a).yield(this)},K.spread=function(a){return this.then(function(b){return x(b,function(b){return a.apply(X,b)})})},K.always=function(a,b){return this.then(a,a,b)},r.prototype.when=function(a){return"function"==typeof a?a(this.value):this.value},s.prototype.when=function(a,b){if("function"==typeof b)return b(this.reason);throw this.reason};var L,M,N,O,P,Q,R,S,T,U,V,W,X;if(V=a,P=[],T="undefined"!=typeof console?console:b,"object"==typeof d&&d.nextTick)O=d.nextTick;else if(W="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)O=function(a,b,c){var d=a.createElement("div");return new b(c).observe(d,{attributes:!0}),function(){d.setAttribute("x","x")}}(document,W,H);else try{O=V("vertx").runOnLoop||V("vertx").runOnContext}catch(Y){U=setTimeout,O=function(a){U(a,0)}}return Q=Function.prototype,R=Q.call,N=Q.bind?R.bind(R):function(a,b){return a.apply(b,M.call(arguments,2))},S=[],M=S.slice,L=S.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{__browserify_process:3}],5:[function(a,b){function c(a){return this instanceof c?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.WebSocket=e.Client,c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},c.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){var b=f.defer();switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case c.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case c.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},c.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:4}]},{},[5])(5)}); \ No newline at end of file From e1bb03789b392cbd625e9e5ec08247ec90ccfab9 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 15 Dec 2013 18:50:05 +0000 Subject: [PATCH 054/238] expose mopidy version to core API --- mopidy/core/actor.py | 4 ++++ mopidy/frontends/http/ws.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 3cba20db..9bc58cd9 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,6 +7,7 @@ import pykka from mopidy.audio import AudioListener, PlaybackState from mopidy.backends.listener import BackendListener +from mopidy.utils import versioning from .library import LibraryController from .listener import CoreListener @@ -75,6 +76,9 @@ class Core(pykka.ThreadingActor, AudioListener, BackendListener): # Forward event from backend to frontends CoreListener.send('playlists_loaded') + def get_version(self): + return versioning.get_version() + class Backends(list): def __init__(self, backends): diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py index b46b450e..d773b422 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/frontends/http/ws.py @@ -18,6 +18,7 @@ class WebSocketResource(object): inspector = jsonrpc.JsonRpcInspector( objects={ 'core.get_uri_schemes': core.Core.get_uri_schemes, + 'core.get_version': core.Core.get_version, 'core.library': core.LibraryController, 'core.playback': core.PlaybackController, 'core.playlists': core.PlaylistsController, @@ -27,6 +28,7 @@ class WebSocketResource(object): objects={ 'core.describe': inspector.describe, 'core.get_uri_schemes': self._core.get_uri_schemes, + 'core.get_version': self._core.get_version, 'core.library': self._core.library, 'core.playback': self._core.playback, 'core.playlists': self._core.playlists, From 9f93fbaa3689af4fc4ecddcfc73bd404f5db8fea Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 15 Dec 2013 20:37:41 +0000 Subject: [PATCH 055/238] property for get_version and documented --- mopidy/core/actor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 9bc58cd9..5e4f9ccb 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -79,6 +79,9 @@ class Core(pykka.ThreadingActor, AudioListener, BackendListener): def get_version(self): return versioning.get_version() + version = property(get_version) + """Version of the Mopidy core API""" + class Backends(list): def __init__(self, backends): From 69b1f5e27046aa2f3b844bf453a40205031fa746 Mon Sep 17 00:00:00 2001 From: kingosticks Date: Sun, 15 Dec 2013 20:38:45 +0000 Subject: [PATCH 056/238] get_version changelog entry --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 09b7840c..5c1363fc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,11 @@ This changelog is used to track all major changes to Mopidy. v0.18.0 (UNRELEASED) ==================== +**Core API** + +- Expose :meth:`mopidy.core.Core.get_version` to HTTP clients for managing + compatability between API versions. (Fixes: :issue:`597`) + **Pluggable local libraries** Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes From 7b667028e140ee56f6a365c8135f812eaaaaa284 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 15 Dec 2013 21:38:48 +0100 Subject: [PATCH 057/238] docs: Unbreak docs building --- docs/config.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index b39b33b2..5099f04d 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -29,9 +29,8 @@ config value, you **should not** add it to :file:`~/.config/mopidy/mopidy.conf`. To see what's the effective configuration for your Mopidy installation, you can -run :option:`mopidy config`. It will print your full effective config -with passwords masked out so that you safely can share the output with others -for debugging. +run ``mopidy config``. It will print your full effective config with passwords +masked out so that you safely can share the output with others for debugging. You can find a description of all config values belonging to Mopidy's core below, together with their default values. In addition, all :ref:`extensions From b0b2e37950208c71b7a98d5eb8110626c93ff6ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 15 Dec 2013 21:39:53 +0100 Subject: [PATCH 058/238] docs: Include API docs for the Core class --- docs/api/core.rst | 4 +++- mopidy/core/actor.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index de85557c..0fd3e0c8 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -7,11 +7,13 @@ Core API .. module:: mopidy.core :synopsis: Core API for use by frontends - The core API is the interface that is used by frontends like :mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the backends. +.. autoclass:: mopidy.core.Core + :members: + Playback controller =================== diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 3cba20db..b7eca93c 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -16,21 +16,21 @@ from .tracklist import TracklistController class Core(pykka.ThreadingActor, AudioListener, BackendListener): - #: The library controller. An instance of - # :class:`mopidy.core.LibraryController`. library = None + """The library controller. An instance of + :class:`mopidy.core.LibraryController`.""" - #: The playback controller. An instance of - #: :class:`mopidy.core.PlaybackController`. playback = None + """The playback controller. An instance of + :class:`mopidy.core.PlaybackController`.""" - #: The playlists controller. An instance of - #: :class:`mopidy.core.PlaylistsController`. playlists = None + """The playlists controller. An instance of + :class:`mopidy.core.PlaylistsController`.""" - #: The tracklist controller. An instance of - #: :class:`mopidy.core.TracklistController`. tracklist = None + """The tracklist controller. An instance of + :class:`mopidy.core.TracklistController`.""" def __init__(self, audio=None, backends=None): super(Core, self).__init__() From 3859448e06e230781220868af578b6b476f7650a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 15 Dec 2013 22:49:33 +0100 Subject: [PATCH 059/238] core: Test version property, fix typo in changelog --- docs/changelog.rst | 4 ++-- mopidy/core/actor.py | 12 ++++++------ tests/core/actor_test.py | 4 ++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5c1363fc..b04c6b95 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,8 +9,8 @@ v0.18.0 (UNRELEASED) **Core API** -- Expose :meth:`mopidy.core.Core.get_version` to HTTP clients for managing - compatability between API versions. (Fixes: :issue:`597`) +- Expose :meth:`mopidy.core.Core.version` for HTTP clients to manage + compatibility between API versions. (Fixes: :issue:`597`) **Pluggable local libraries** diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 65228cc4..4924cca2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -57,6 +57,12 @@ class Core(pykka.ThreadingActor, AudioListener, BackendListener): uri_schemes = property(get_uri_schemes) """List of URI schemes we can handle""" + def get_version(self): + return versioning.get_version() + + version = property(get_version) + """Version of the Mopidy core API""" + def reached_end_of_stream(self): self.playback.on_end_of_track() @@ -76,12 +82,6 @@ class Core(pykka.ThreadingActor, AudioListener, BackendListener): # Forward event from backend to frontends CoreListener.send('playlists_loaded') - def get_version(self): - return versioning.get_version() - - version = property(get_version) - """Version of the Mopidy core API""" - class Backends(list): def __init__(self, backends): diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index 38e33baa..e9e5f396 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -6,6 +6,7 @@ import unittest import pykka from mopidy.core import Core +from mopidy.utils import versioning class CoreActorTest(unittest.TestCase): @@ -54,3 +55,6 @@ class CoreActorTest(unittest.TestCase): {'dummy1': self.backend1}) self.assertEqual(core.backends.with_library, {'dummy1': self.backend2}) + + def test_version(self): + self.assertEqual(self.core.version, versioning.get_version()) From 147c304ee84a9e3e471624ce9eef90dfbd72426e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 15 Dec 2013 22:52:07 +0100 Subject: [PATCH 060/238] docs: Update authors --- .mailmap | 1 + AUTHORS | 1 + 2 files changed, 2 insertions(+) diff --git a/.mailmap b/.mailmap index e19cc5cc..2cc42b4c 100644 --- a/.mailmap +++ b/.mailmap @@ -10,3 +10,4 @@ Alexandre Petitjean Alexandre Petitjean Javier Domingo Cansino Lasse Bigum +Nick Steel diff --git a/AUTHORS b/AUTHORS index 8269452d..d3e86ef1 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,3 +29,4 @@ - Javier Domingo - Lasse Bigum - David Eisner +- PÃ¥l Ruud From a83b71239bba9216e51e724ffb12f3c4d931ce0e Mon Sep 17 00:00:00 2001 From: Paul Connolley Date: Tue, 17 Dec 2013 08:57:44 +0000 Subject: [PATCH 061/238] Update test so that it correctly requires the mopidy module As part of issue #609, the require statement in mopidy-test.js should have been updated as the API to require mopidy has changed from: require('mopidy').Mopidy; to: require('mopidy'); --- js/test/mopidy-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 0bf97f60..ee34d845 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -2,7 +2,7 @@ if (typeof module === "object" && typeof require === "function") { var buster = require("buster"); - var Mopidy = require("../src/mopidy").Mopidy; + var Mopidy = require("../src/mopidy"); var when = require("when"); } From 26b8490672167e1528cf2559630d1a6477e9725b Mon Sep 17 00:00:00 2001 From: Paul Connolley Date: Tue, 17 Dec 2013 16:52:35 +0000 Subject: [PATCH 062/238] Updated the test process for mopidy.js Following on from the previous issue #609 commits, I have updated the build process to cater to the fact that the files are no longer available to test in the browser environment. 2 new browserify tasks build the mopidy.js file and then when.js file (available in node_modules.) These files are placed in test/lib/ (This directory has been added to the .gitignore file) prior to the running of the buster tests. As these files are ignored in the .gitignore, this will prevent them from being committed to git and also prevent them from being packaged up to npm. Once the tests have completed, the main browserify task will run to build the official browser release. --- .gitignore | 1 + js/Gruntfile.js | 24 ++++++++++++++++++++++-- js/buster.js | 12 +----------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 79230110..1ec12cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules/ nosetests.xml *~ *.orig +js/test/lib/ diff --git a/js/Gruntfile.js b/js/Gruntfile.js index f59ed9f8..df0145ab 100644 --- a/js/Gruntfile.js +++ b/js/Gruntfile.js @@ -20,6 +20,25 @@ module.exports = function (grunt) { all: {} }, browserify: { + test_mopidy: { + files: { + "test/lib/mopidy.js": "<%= meta.files.main %>" + }, + options: { + postBundleCB: function (err, src, next) { + next(null, grunt.template.process("<%= meta.banner %>") + src); + }, + standalone: "Mopidy" + } + }, + test_when: { + files: { + "test/lib/when.js": "node_modules/when/when.js" + }, + options: { + standalone: "when" + } + }, dist: { files: { "<%= meta.files.concat %>": "<%= meta.files.main %>" @@ -68,8 +87,9 @@ module.exports = function (grunt) { } }); - grunt.registerTask("test", ["jshint", "buster"]); - grunt.registerTask("build", ["test", "browserify", "uglify"]); + grunt.registerTask("test_build", ["browserify:test_when", "browserify:test_mopidy"]); + grunt.registerTask("test", ["jshint", "test_build", "buster"]); + grunt.registerTask("build", ["test", "browserify:dist", "uglify"]); grunt.registerTask("default", ["build"]); grunt.loadNpmTasks("grunt-buster"); diff --git a/js/buster.js b/js/buster.js index 1cc517c8..c5dec850 100644 --- a/js/buster.js +++ b/js/buster.js @@ -2,23 +2,13 @@ var config = module.exports; config.browser_tests = { environment: "browser", - libs: [ - "lib/bane-*.js", - "lib/when-define-shim.js", - "lib/when-*.js" - ], - sources: ["src/**/*.js"], + libs: ["test/lib/*.js"], testHelpers: ["test/**/*-helper.js"], tests: ["test/**/*-test.js"] }; config.node_tests = { environment: "node", - libs: [ - "lib/bane-*.js", - "lib/when-define-shim.js", - "lib/when-*.js" - ], sources: ["src/**/*.js"], testHelpers: ["test/**/*-helper.js"], tests: ["test/**/*-test.js"] From 89638e55d64388c61895521fd2b522bd893bcd4e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 15 Dec 2013 23:05:29 +0100 Subject: [PATCH 063/238] flags: use $XDG_CONFIG_DIRS and $XDG_CONFIG_HOME as defaults This does not add support for '$XDG_CONFIG_DIRS' expansion, it just makes the default include what it is set to. --- mopidy/commands.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index 36f5ae1a..cba247fc 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -6,6 +6,7 @@ import logging import os import sys +import glib import gobject from mopidy import config as config_lib @@ -15,6 +16,12 @@ from mopidy.utils import deps, process, versioning logger = logging.getLogger('mopidy.commands') +_default_config = [] +for base in glib.get_system_config_dirs() + (glib.get_user_config_dir(),): + _default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf')) +DEFAULT_CONFIG = b':'.join(_default_config) +print DEFAULT_CONFIG + def config_files_type(value): return value.split(b':') @@ -243,7 +250,7 @@ class RootCommand(Command): self.add_argument( '--config', action='store', dest='config_files', type=config_files_type, - default=b'$XDG_CONFIG_DIR/mopidy/mopidy.conf', metavar='FILES', + default=DEFAULT_CONFIG, metavar='FILES', help='config files to use, colon seperated, later files override') self.add_argument( '-o', '--option', From 2a57190d7a73f7c4cfdf01497cd2f5d15bb305f3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Dec 2013 22:13:17 +0100 Subject: [PATCH 064/238] docs: Update change log with XDG_CONFIG_DIRS change --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b04c6b95..bfc4a742 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -40,6 +40,11 @@ a temporary regression of :issue:`527`. asyncronously through the GObject event loop. This should resolve the issue that has blocked the merge of the EOT-vs-EOS fix for a long time. +**Config file loading** + +- The default for the config flag has been uptated to include + ``$XDG_CONFIG_DIRS`` in addition to ``$XDG_CONFIG_DIR``. (Fixes :issue:`431`) + v0.17.0 (2013-11-23) ==================== From 279618fcde9e2ceb996c25d32add5f0fc12df1a3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 19 Dec 2013 23:42:33 +0100 Subject: [PATCH 065/238] commands: Remove print statement --- mopidy/commands.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index cba247fc..851bfb83 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -20,7 +20,6 @@ _default_config = [] for base in glib.get_system_config_dirs() + (glib.get_user_config_dir(),): _default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf')) DEFAULT_CONFIG = b':'.join(_default_config) -print DEFAULT_CONFIG def config_files_type(value): From 0bbfaff0854585e85cb93583cf89a818703c8cde Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 20 Dec 2013 00:14:02 +0100 Subject: [PATCH 066/238] docs: Fix typo in changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index bfc4a742..daad3cc1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -42,7 +42,7 @@ a temporary regression of :issue:`527`. **Config file loading** -- The default for the config flag has been uptated to include +- The default for the config flag has been updated to include ``$XDG_CONFIG_DIRS`` in addition to ``$XDG_CONFIG_DIR``. (Fixes :issue:`431`) From ef596ae391fafd70840536f35398108f4711e69e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Dec 2013 21:25:16 +0100 Subject: [PATCH 067/238] docs: Link to PyPI instead of Crate.io which has been discontinued --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 515fa3ba..0b003815 100644 --- a/README.rst +++ b/README.rst @@ -26,11 +26,11 @@ To get started with Mopidy, check out `the docs `_. - Twitter: `@mopidy `_ .. image:: https://pypip.in/v/Mopidy/badge.png - :target: https://crate.io/packages/Mopidy/ + :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version .. image:: https://pypip.in/d/Mopidy/badge.png - :target: https://crate.io/packages/Mopidy/ + :target: https://pypi.python.org/pypi/Mopidy/ :alt: Number of PyPI downloads .. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop From 0ffeb2710b8ed33211a52c5130ce484ee572ced9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 21 Dec 2013 21:25:31 +0100 Subject: [PATCH 068/238] docs: Fix typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index daad3cc1..601de39e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1755,7 +1755,7 @@ to this problem. - Packaging and distribution: - - Setup APT repository and crate Debian packages of Mopidy. See + - Setup APT repository and create Debian packages of Mopidy. See :ref:`installation` for instructions for how to install Mopidy, including all dependencies, from APT. From 0d7fea0a43aff849b73d33785ce050ff73386ad6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 6 Dec 2013 19:15:59 +0100 Subject: [PATCH 069/238] ext: Convert commands to use new registry system. Creates a placeholder registry using the existing hooks, and updates the commands to use these. The actual registry still needs to be created. --- mopidy/__main__.py | 8 ++++++- mopidy/backends/local/commands.py | 15 +++++-------- mopidy/commands.py | 36 +++++++++++++------------------ 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1aca9cf4..a2174899 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -108,10 +108,16 @@ def main(): args.extension.ext_name) return 1 + registry = {'backends': [], 'frontends': [], 'local:library': []} + for extension in enabled_extensions: + registry['backends'].extend(extension.get_backend_classes()) + registry['frontends'].extend(extension.get_frontend_classes()) + registry['local:library'].extend(extension.get_library_updaters()) + # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors can have been started. try: - return args.command.run(args, proxied_config, enabled_extensions) + return args.command.run(args, proxied_config, registry) except NotImplementedError: print root_cmd.format_help() return 1 diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 5e9b42e6..de84760b 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -22,27 +22,22 @@ class LocalCommand(commands.Command): class ScanCommand(commands.Command): help = "Scan local media files and populate the local library." - def run(self, args, config, extensions): + def run(self, args, config, registry): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] excluded_file_extensions = set( ext.lower() for ext in config['local']['excluded_file_extensions']) - updaters = {} - for e in extensions: - for updater_class in e.get_library_updaters(): - if updater_class and 'local' in updater_class.uri_schemes: - updaters[e.ext_name] = updater_class - + # TODO: select updater / library to use by name + updaters = registry['local:library'] if not updaters: logger.error('No usable library updaters found.') return 1 elif len(updaters) > 1: logger.error('More than one library updater found. ' - 'Provided by: %s', ', '.join(updaters.keys())) + 'Provided by: %s', ', '.join(updaters)) return 1 - - local_updater = updaters.values()[0](config) + local_updater = updaters[0](config) uri_path_mapping = {} uris_in_library = set() diff --git a/mopidy/commands.py b/mopidy/commands.py index 851bfb83..35175e07 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -257,22 +257,22 @@ class RootCommand(Command): type=config_override_type, metavar='OPTIONS', help='`section/key=value` values to override config options') - def run(self, args, config, extensions): + def run(self, args, config, registry): loop = gobject.MainLoop() try: audio = self.start_audio(config) - backends = self.start_backends(config, extensions, audio) + backends = self.start_backends(config, registry, audio) core = self.start_core(audio, backends) - self.start_frontends(config, extensions, core) + self.start_frontends(config, registry, core) loop.run() except KeyboardInterrupt: logger.info('Interrupted. Exiting...') return finally: loop.quit() - self.stop_frontends(extensions) + self.stop_frontends(registry) self.stop_core() - self.stop_backends(extensions) + self.stop_backends(registry) self.stop_audio() process.stop_remaining_actors() @@ -280,10 +280,8 @@ class RootCommand(Command): logger.info('Starting Mopidy audio') return Audio.start(config=config).proxy() - def start_backends(self, config, extensions, audio): - backend_classes = [] - for extension in extensions: - backend_classes.extend(extension.get_backend_classes()) + def start_backends(self, config, registry, audio): + backend_classes = registry['backends'] logger.info( 'Starting Mopidy backends: %s', @@ -300,10 +298,8 @@ class RootCommand(Command): logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() - def start_frontends(self, config, extensions, core): - frontend_classes = [] - for extension in extensions: - frontend_classes.extend(extension.get_frontend_classes()) + def start_frontends(self, config, registry, core): + frontend_classes = registry['frontends'] logger.info( 'Starting Mopidy frontends: %s', @@ -312,21 +308,19 @@ class RootCommand(Command): for frontend_class in frontend_classes: frontend_class.start(config=config, core=core) - def stop_frontends(self, extensions): + def stop_frontends(self, registry): logger.info('Stopping Mopidy frontends') - for extension in extensions: - for frontend_class in extension.get_frontend_classes(): - process.stop_actors_by_class(frontend_class) + for frontend_class in registry['frontends']: + process.stop_actors_by_class(frontend_class) def stop_core(self): logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) - def stop_backends(self, extensions): + def stop_backends(self, registry): logger.info('Stopping Mopidy backends') - for extension in extensions: - for backend_class in extension.get_backend_classes(): - process.stop_actors_by_class(backend_class) + for backend_class in registry['backends']: + process.stop_actors_by_class(backend_class) def stop_audio(self): logger.info('Stopping Mopidy audio') From decce4ccf6598f99816745faed974a6a48e939bf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 7 Dec 2013 01:00:55 +0100 Subject: [PATCH 070/238] ext: Add basic global registry and switch to Extension.setup() --- mopidy/__main__.py | 8 ++---- mopidy/backends/local/commands.py | 9 +++--- mopidy/commands.py | 32 ++++++++++----------- mopidy/ext.py | 48 +++++++++++++++++++++++-------- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a2174899..f571e0ff 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -84,7 +84,6 @@ def main(): enabled_extensions.append(extension) log_extension_info(installed_extensions, enabled_extensions) - ext.register_gstreamer_elements(enabled_extensions) # Config and deps commands are simply special cased for now. if args.command == config_cmd: @@ -108,16 +107,13 @@ def main(): args.extension.ext_name) return 1 - registry = {'backends': [], 'frontends': [], 'local:library': []} for extension in enabled_extensions: - registry['backends'].extend(extension.get_backend_classes()) - registry['frontends'].extend(extension.get_frontend_classes()) - registry['local:library'].extend(extension.get_library_updaters()) + extension.setup() # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors can have been started. try: - return args.command.run(args, proxied_config, registry) + return args.command.run(args, proxied_config) except NotImplementedError: print root_cmd.format_help() return 1 diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index de84760b..8a951929 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -4,7 +4,7 @@ import logging import os import time -from mopidy import commands, exceptions +from mopidy import commands, exceptions, ext from mopidy.audio import scan from mopidy.utils import path @@ -22,14 +22,15 @@ class LocalCommand(commands.Command): class ScanCommand(commands.Command): help = "Scan local media files and populate the local library." - def run(self, args, config, registry): + def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] + excluded_file_extensions = config['local']['excluded_file_extensions'] excluded_file_extensions = set( - ext.lower() for ext in config['local']['excluded_file_extensions']) + file_ext.lower() for file_ext in excluded_file_extensions) # TODO: select updater / library to use by name - updaters = registry['local:library'] + updaters = ext.registry['local:library'] if not updaters: logger.error('No usable library updaters found.') return 1 diff --git a/mopidy/commands.py b/mopidy/commands.py index 35175e07..372550a0 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -9,7 +9,7 @@ import sys import glib import gobject -from mopidy import config as config_lib +from mopidy import config as config_lib, ext from mopidy.audio import Audio from mopidy.core import Core from mopidy.utils import deps, process, versioning @@ -257,22 +257,26 @@ class RootCommand(Command): type=config_override_type, metavar='OPTIONS', help='`section/key=value` values to override config options') - def run(self, args, config, registry): + def run(self, args, config): loop = gobject.MainLoop() + + backend_classes = ext.registry['backend'] + frontend_classes = ext.registry['frontend'] + try: audio = self.start_audio(config) - backends = self.start_backends(config, registry, audio) + backends = self.start_backends(config, backend_classes, audio) core = self.start_core(audio, backends) - self.start_frontends(config, registry, core) + self.start_frontends(config, frontend_classes, core) loop.run() except KeyboardInterrupt: logger.info('Interrupted. Exiting...') return finally: loop.quit() - self.stop_frontends(registry) + self.stop_frontends(frontend_classes) self.stop_core() - self.stop_backends(registry) + self.stop_backends(backend_classes) self.stop_audio() process.stop_remaining_actors() @@ -280,9 +284,7 @@ class RootCommand(Command): logger.info('Starting Mopidy audio') return Audio.start(config=config).proxy() - def start_backends(self, config, registry, audio): - backend_classes = registry['backends'] - + def start_backends(self, config, backend_classes, audio): logger.info( 'Starting Mopidy backends: %s', ', '.join(b.__name__ for b in backend_classes) or 'none') @@ -298,9 +300,7 @@ class RootCommand(Command): logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() - def start_frontends(self, config, registry, core): - frontend_classes = registry['frontends'] - + def start_frontends(self, config, frontend_classes, core): logger.info( 'Starting Mopidy frontends: %s', ', '.join(f.__name__ for f in frontend_classes) or 'none') @@ -308,18 +308,18 @@ class RootCommand(Command): for frontend_class in frontend_classes: frontend_class.start(config=config, core=core) - def stop_frontends(self, registry): + def stop_frontends(self, frontend_classes): logger.info('Stopping Mopidy frontends') - for frontend_class in registry['frontends']: + for frontend_class in frontend_classes: process.stop_actors_by_class(frontend_class) def stop_core(self): logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) - def stop_backends(self, registry): + def stop_backends(self, backend_classes): logger.info('Stopping Mopidy backends') - for backend_class in registry['backends']: + for backend_class in backend_classes: process.stop_actors_by_class(backend_class) def stop_audio(self): diff --git a/mopidy/ext.py b/mopidy/ext.py index feadc99f..d1c81dc5 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import collections import logging import pkg_resources @@ -61,6 +62,18 @@ class Extension(object): """ pass + def setup(self): + for backend_class in self.get_backend_classes(): + registry.add('backend', backend_class) + + for frontend_class in self.get_frontend_classes(): + registry.add('frontend', frontend_class) + + for library_updater in self.get_library_updaters(): + registry.add('local:library', library_updater) + + self.register_gstreamer_elements() + def get_frontend_classes(self): """List of frontend actor classes @@ -112,6 +125,29 @@ class Extension(object): pass +class _Registry(collections.Mapping): + def __init__(self): + self._registry = collections.defaultdict(list) + + def add(self, name, cls): + self._registry[name].append(cls) + + def __getitem__(self, name): + return self._registry[name] + + def __iter__(self): + return iter(self._registry) + + def __len__(self): + return len(self._registry) + + +# TODO: document the registry +# TODO: consider if we should avoid having this as a global and pass an +# instance from __main__ around instead? +registry = _Registry() + + def load_extensions(): """Find all installed extensions. @@ -166,15 +202,3 @@ def validate_extension(extension): return False return True - - -def register_gstreamer_elements(enabled_extensions): - """Registers custom GStreamer elements from extensions. - - :param enabled_extensions: list of enabled extensions - """ - - for extension in enabled_extensions: - logger.debug( - 'Registering GStreamer elements for: %s', extension.ext_name) - extension.register_gstreamer_elements() From 353782e2c89eb88f325dbd3d5fbce48b7e445719 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Dec 2013 21:34:21 +0100 Subject: [PATCH 071/238] local: Add local/data_dir config value. Not in use yet but, needed for future changes planed in this branch. --- docs/ext/local.rst | 5 +++++ mopidy/backends/local/__init__.py | 9 +-------- mopidy/backends/local/actor.py | 8 ++++++++ mopidy/backends/local/ext.conf | 1 + tests/backends/local/events_test.py | 1 + tests/backends/local/playback_test.py | 1 + tests/backends/local/playlists_test.py | 1 + tests/backends/local/tracklist_test.py | 1 + 8 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index cbde826f..43996deb 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -39,6 +39,11 @@ Configuration values Path to directory with local media files. +.. confval:: local/data_dir + + Path to directory to store local metadata such as libraries and playlists + in. + .. confval:: local/playlists_dir Path to playlists directory with m3u files for local media. diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index dedc868c..5caa6826 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -5,7 +5,6 @@ import os import mopidy from mopidy import config, ext -from mopidy.utils import encoding, path logger = logging.getLogger('mopidy.backends.local') @@ -23,6 +22,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['media_dir'] = config.Path() + schema['data_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( @@ -30,13 +30,6 @@ class Extension(ext.Extension): schema['excluded_file_extensions'] = config.List(optional=True) return schema - def validate_environment(self): - try: - path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy/local') - except EnvironmentError as error: - error = encoding.locale_decode(error) - logger.warning('Could not create local data dir: %s', error) - def get_backend_classes(self): from .actor import LocalBackend return [LocalBackend] diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index a73f627e..78caf39e 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -32,6 +32,14 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): logger.warning('Local media dir %s does not exist.' % self.config['local']['media_dir']) + try: + path.get_or_create_dir(self.config['local']['data_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local data dir: %s', + encoding.locale_decode(error)) + + # TODO: replace with data dir? try: path.get_or_create_dir(self.config['local']['playlists_dir']) except EnvironmentError as error: diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index f906a04f..e826a451 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -1,6 +1,7 @@ [local] enabled = true media_dir = $XDG_MUSIC_DIR +data_dir = $XDG_DATA_DIR/mopidy/local playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 excluded_file_extensions = diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 1e26a68c..2424ed42 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -17,6 +17,7 @@ class LocalBackendEventsTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', } } diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4c3dd70d..4da420ef 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -22,6 +22,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', } } diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index c02e1d23..447de3f8 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -20,6 +20,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), } } diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index c7cfe51f..14bf678d 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -18,6 +18,7 @@ class LocalTracklistProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', } } From 8a94d81c42528d9a712be0a0b14649089c880378 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Dec 2013 21:46:24 +0100 Subject: [PATCH 072/238] ext: Move away from global registry to ease testing. Extension's setup method are now passed the active registry allowing them to "steal" a list of the registry items they care about. In the case of commands the registry is passed via args.registry. --- mopidy/__main__.py | 6 ++++-- mopidy/backends/local/commands.py | 4 ++-- mopidy/commands.py | 6 +++--- mopidy/ext.py | 17 ++++++----------- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index f571e0ff..f66ac6c1 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -40,11 +40,13 @@ def main(): signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) try: + registry = ext.Registry() + root_cmd = commands.RootCommand() config_cmd = commands.ConfigCommand() deps_cmd = commands.DepsCommand() - root_cmd.set(extension=None) + root_cmd.set(extension=None, registry=registry) root_cmd.add_child('config', config_cmd) root_cmd.add_child('deps', deps_cmd) @@ -108,7 +110,7 @@ def main(): return 1 for extension in enabled_extensions: - extension.setup() + extension.setup(registry) # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors can have been started. diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 8a951929..2f6f744e 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -4,7 +4,7 @@ import logging import os import time -from mopidy import commands, exceptions, ext +from mopidy import commands, exceptions from mopidy.audio import scan from mopidy.utils import path @@ -30,7 +30,7 @@ class ScanCommand(commands.Command): file_ext.lower() for file_ext in excluded_file_extensions) # TODO: select updater / library to use by name - updaters = ext.registry['local:library'] + updaters = args.registry['local:library'] if not updaters: logger.error('No usable library updaters found.') return 1 diff --git a/mopidy/commands.py b/mopidy/commands.py index 372550a0..1bba63fa 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -9,7 +9,7 @@ import sys import glib import gobject -from mopidy import config as config_lib, ext +from mopidy import config as config_lib from mopidy.audio import Audio from mopidy.core import Core from mopidy.utils import deps, process, versioning @@ -260,8 +260,8 @@ class RootCommand(Command): def run(self, args, config): loop = gobject.MainLoop() - backend_classes = ext.registry['backend'] - frontend_classes = ext.registry['frontend'] + backend_classes = args.registry['backend'] + frontend_classes = args.registry['frontend'] try: audio = self.start_audio(config) diff --git a/mopidy/ext.py b/mopidy/ext.py index d1c81dc5..c79ebe40 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -62,7 +62,7 @@ class Extension(object): """ pass - def setup(self): + def setup(self, registry): for backend_class in self.get_backend_classes(): registry.add('backend', backend_class) @@ -125,15 +125,16 @@ class Extension(object): pass -class _Registry(collections.Mapping): +# TODO: document +class Registry(collections.Mapping): def __init__(self): - self._registry = collections.defaultdict(list) + self._registry = {} def add(self, name, cls): - self._registry[name].append(cls) + self._registry.setdefault(name, []).append(cls) def __getitem__(self, name): - return self._registry[name] + return self._registry.setdefault(name, []) def __iter__(self): return iter(self._registry) @@ -142,12 +143,6 @@ class _Registry(collections.Mapping): return len(self._registry) -# TODO: document the registry -# TODO: consider if we should avoid having this as a global and pass an -# instance from __main__ around instead? -registry = _Registry() - - def load_extensions(): """Find all installed extensions. From 4c0b54317bd06c2f8cfd33f9e588cbd1d54f8e0d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Dec 2013 22:16:03 +0100 Subject: [PATCH 073/238] local: Add new library interface to local backend. This forms the basis of our plugable local libraries that we intend to ship. --- mopidy/backends/local/__init__.py | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5caa6826..e0e917f7 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -37,3 +37,78 @@ class Extension(ext.Extension): def get_command(self): from .commands import LocalCommand return LocalCommand() + + +class Library(object): + #: Name of the local library implementation. + name = None + + def __init__(self, config): + self._config = config + + def load(self): + """ + Initialize whatever resources are needed for this library. + + This is where you load the tracks into memory, setup a database + conection etc. + + :rtype: :class:`int` representing number of tracks in library. + """ + return 0 + + def add(self, track): + """ + Add the given track to library. + + :param track: Track to add to the library/ + :type track: :class:`mopidy.models.Track` + """ + raise NotImplementedError + + def remove(self, uri): + """ + Remove the given track from the library. + + :param uri: URI to remove from the library/ + :type uri: string + """ + raise NotImplementedError + + def commit(self): + """ + Persist any changes to the library. + + This is where you write your data file to disk, commit transactions + etc. depending on the requirements of your library implementation. + """ + pass + + def lookup(self, uri): + """ + Lookup the given URI. + + If the URI expands to multiple tracks, the returned list will contain + them all. + + :param uri: track URI + :type uri: string + :rtype: list of :class:`mopidy.models.Track` + """ + raise NotImplementedError + + # TODO: support case with returning all tracks? + # TODO: remove uris? + def search(self, query=None, exact=False, uris=None): + """ + Search the library for tracks where ``field`` contains ``values``. + + :param query: one or more queries to search for + :type query: dict + :param exact: look for exact matches? + :type query: boolean + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`mopidy.models.SearchResult` + """ + raise NotImplementedError From 7e063774b39dcb5460524fca69f6151a37e458ff Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Dec 2013 22:21:23 +0100 Subject: [PATCH 074/238] local: Remove local-json as a split out extension. Will be re-added using the new library interface. This commit does break tests. --- docs/ext/local.rst | 29 +------ mopidy/backends/local/json/__init__.py | 30 ------- mopidy/backends/local/json/actor.py | 30 ------- mopidy/backends/local/json/ext.conf | 3 - mopidy/backends/local/json/library.py | 108 ------------------------- setup.py | 1 - 6 files changed, 1 insertion(+), 200 deletions(-) delete mode 100644 mopidy/backends/local/json/__init__.py delete mode 100644 mopidy/backends/local/json/actor.py delete mode 100644 mopidy/backends/local/json/ext.conf delete mode 100644 mopidy/backends/local/json/library.py diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 43996deb..eed51829 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -95,34 +95,7 @@ Pluggable library support ------------------------- Local libraries are fully pluggable. What this means is that users may opt to -disable the current default library ``local-json``, replacing it with a third +disable the current default library ``json``, replacing it with a third party one. When running :command:`mopidy local scan` mopidy will populate whatever the current active library is with data. Only one library may be active at a time. - - -***************** -Mopidy-Local-JSON -***************** - -Extension for storing local music library in a JSON file, default built in -library for local files. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/local/json/ext.conf - :language: ini - - -Configuration values -==================== - -.. confval:: local-json/enabled - - If the local-json extension should be enabled or not. - -.. confval:: local-json/json_file - - Path to a file to store the gzipped JSON data in. diff --git a/mopidy/backends/local/json/__init__.py b/mopidy/backends/local/json/__init__.py deleted file mode 100644 index 031dae51..00000000 --- a/mopidy/backends/local/json/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import unicode_literals - -import os - -import mopidy -from mopidy import config, ext - - -class Extension(ext.Extension): - - dist_name = 'Mopidy-Local-JSON' - ext_name = 'local-json' - version = mopidy.__version__ - - def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) - - def get_config_schema(self): - schema = super(Extension, self).get_config_schema() - schema['json_file'] = config.Path() - return schema - - def get_backend_classes(self): - from .actor import LocalJsonBackend - return [LocalJsonBackend] - - def get_library_updaters(self): - from .library import LocalJsonLibraryUpdateProvider - return [LocalJsonLibraryUpdateProvider] diff --git a/mopidy/backends/local/json/actor.py b/mopidy/backends/local/json/actor.py deleted file mode 100644 index 66a6fbd5..00000000 --- a/mopidy/backends/local/json/actor.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import unicode_literals - -import logging -import os - -import pykka - -from mopidy.backends import base -from mopidy.utils import encoding - -from . import library - -logger = logging.getLogger('mopidy.backends.local.json') - - -class LocalJsonBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, config, audio): - super(LocalJsonBackend, self).__init__() - - self.config = config - self.library = library.LocalJsonLibraryProvider(backend=self) - self.uri_schemes = ['local'] - - if not os.path.exists(config['local-json']['json_file']): - try: - library.write_library(config['local-json']['json_file'], {}) - logger.info('Created empty local JSON library.') - except EnvironmentError as error: - error = encoding.locale_decode(error) - logger.warning('Could not create local library: %s', error) diff --git a/mopidy/backends/local/json/ext.conf b/mopidy/backends/local/json/ext.conf deleted file mode 100644 index db0b784a..00000000 --- a/mopidy/backends/local/json/ext.conf +++ /dev/null @@ -1,3 +0,0 @@ -[local-json] -enabled = true -json_file = $XDG_DATA_DIR/mopidy/local/library.json.gz diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py deleted file mode 100644 index 33427231..00000000 --- a/mopidy/backends/local/json/library.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import unicode_literals - -import gzip -import json -import logging -import os -import tempfile - -import mopidy -from mopidy import models -from mopidy.backends import base -from mopidy.backends.local import search - -logger = logging.getLogger('mopidy.backends.local.json') - - -def load_library(json_file): - try: - with gzip.open(json_file, 'rb') as fp: - return json.load(fp, object_hook=models.model_json_decoder) - except (IOError, ValueError) as e: - logger.warning('Loading JSON local library failed: %s', e) - return {} - - -def write_library(json_file, data): - data['version'] = mopidy.__version__ - directory, basename = os.path.split(json_file) - - # TODO: cleanup directory/basename.* files. - tmp = tempfile.NamedTemporaryFile( - prefix=basename + '.', dir=directory, delete=False) - - try: - with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: - json.dump(data, fp, cls=models.ModelJSONEncoder, - indent=2, separators=(',', ': ')) - os.rename(tmp.name, json_file) - finally: - if os.path.exists(tmp.name): - os.remove(tmp.name) - - -class LocalJsonLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalJsonLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self._media_dir = self.backend.config['local']['media_dir'] - self._json_file = self.backend.config['local-json']['json_file'] - self.refresh() - - def refresh(self, uri=None): - logger.debug( - 'Loading local tracks from %s using %s', - self._media_dir, self._json_file) - - tracks = load_library(self._json_file).get('tracks', []) - uris_to_remove = set(self._uri_mapping) - - for track in tracks: - self._uri_mapping[track.uri] = track - uris_to_remove.discard(track.uri) - - for uri in uris_to_remove: - del self._uri_mapping[uri] - - logger.info( - 'Loaded %d local tracks from %s using %s', - len(tracks), self._media_dir, self._json_file) - - def lookup(self, uri): - try: - return [self._uri_mapping[uri]] - except KeyError: - logger.debug('Failed to lookup %r', uri) - return [] - - def find_exact(self, query=None, uris=None): - tracks = self._uri_mapping.values() - return search.find_exact(tracks, query=query, uris=uris) - - def search(self, query=None, uris=None): - tracks = self._uri_mapping.values() - return search.search(tracks, query=query, uris=uris) - - -class LocalJsonLibraryUpdateProvider(base.BaseLibraryProvider): - uri_schemes = ['local'] - - def __init__(self, config): - self._tracks = {} - self._media_dir = config['local']['media_dir'] - self._json_file = config['local-json']['json_file'] - - def load(self): - for track in load_library(self._json_file).get('tracks', []): - self._tracks[track.uri] = track - return self._tracks.values() - - def add(self, track): - self._tracks[track.uri] = track - - def remove(self, uri): - if uri in self._tracks: - del self._tracks[uri] - - def commit(self): - write_library(self._json_file, {'tracks': self._tracks.values()}) diff --git a/setup.py b/setup.py index bc2fe222..f43981bf 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,6 @@ setup( 'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', 'local = mopidy.backends.local:Extension', - 'local-json = mopidy.backends.local.json:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'stream = mopidy.backends.stream:Extension', ], From e065f349db424d81228663039c6f98ff76b38e2f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Dec 2013 23:36:01 +0100 Subject: [PATCH 075/238] local: Add local library provider back - Re-add a local library provider that uses our new library interface - Re-add our json library using the new interface - Hardcode these to use each other for now - Scanner bit is still missing, will re-add in one of the next commits - Bypassed test for #500 for the time being --- mopidy/backends/local/actor.py | 12 +++-- mopidy/backends/local/json.py | 80 ++++++++++++++++++++++++++++ mopidy/backends/local/library.py | 29 ++++++++++ tests/backends/local/library_test.py | 13 ++--- 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 mopidy/backends/local/json.py create mode 100644 mopidy/backends/local/library.py diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 78caf39e..3da246db 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,13 +8,17 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .playlists import LocalPlaylistsProvider +from .json import JsonLibrary +from .library import LocalLibraryProvider from .playback import LocalPlaybackProvider +from .playlists import LocalPlaylistsProvider logger = logging.getLogger('mopidy.backends.local') class LocalBackend(pykka.ThreadingActor, base.Backend): + uri_schemes = ['local'] + def __init__(self, config, audio): super(LocalBackend, self).__init__() @@ -22,10 +26,12 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() + # TODO: move to getting this from registry + library = JsonLibrary(config) + self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - - self.uri_schemes = ['local'] + self.library = LocalLibraryProvider(backend=self, library=library) def check_dirs_and_files(self): if not os.path.isdir(self.config['local']['media_dir']): diff --git a/mopidy/backends/local/json.py b/mopidy/backends/local/json.py new file mode 100644 index 00000000..327f706c --- /dev/null +++ b/mopidy/backends/local/json.py @@ -0,0 +1,80 @@ +from __future__ import absolute_import, unicode_literals + +import gzip +import json +import logging +import os +import tempfile + +import mopidy +from mopidy import models +from mopidy.backends import local +from mopidy.backends.local import search + +logger = logging.getLogger('mopidy.backends.local.json') + + +# TODO: move to load and dump in models? +def load_library(json_file): + try: + with gzip.open(json_file, 'rb') as fp: + return json.load(fp, object_hook=models.model_json_decoder) + except (IOError, ValueError) as e: + logger.warning('Loading JSON local library failed: %s', e) + return {} + + +def write_library(json_file, data): + data['version'] = mopidy.__version__ + directory, basename = os.path.split(json_file) + + # TODO: cleanup directory/basename.* files. + tmp = tempfile.NamedTemporaryFile( + prefix=basename + '.', dir=directory, delete=False) + + try: + with gzip.GzipFile(fileobj=tmp, mode='wb') as fp: + json.dump(data, fp, cls=models.ModelJSONEncoder, + indent=2, separators=(',', ': ')) + os.rename(tmp.name, json_file) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + + +class JsonLibrary(local.Library): + name = b'json' + + def __init__(self, config): + self._tracks = {} + self._media_dir = config['local']['media_dir'] + self._json_file = os.path.join( + config['local']['data_dir'], b'library.json.gz') + + def load(self): + library = load_library(self._json_file) + self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) + return len(self._tracks) + + def add(self, track): + self._tracks[track.uri] = track + + def remove(self, uri): + self._tracks.pop(uri, None) + + def commit(self): + write_library(self._json_file, {'tracks': self._tracks.values()}) + + def lookup(self, uri): + try: + return [self._tracks[uri]] + except KeyError: + logger.debug('Failed to lookup %r', uri) + return [] + + def search(self, query=None, uris=None, exact=False): + tracks = self._tracks.values() + if exact: + return search.find_exact(tracks, query=query, uris=uris) + else: + return search.search(tracks, query=query, uris=uris) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py new file mode 100644 index 00000000..89a6da4d --- /dev/null +++ b/mopidy/backends/local/library.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +import logging + +from mopidy.backends import base + +logger = logging.getLogger('mopidy.backends.local') + + +class LocalLibraryProvider(base.BaseLibraryProvider): + """Proxy library that delegates work to our active local library.""" + def __init__(self, backend, library): + super(LocalLibraryProvider, self).__init__(backend) + self._library = library + self.refresh() + + def refresh(self, uri=None): + num_tracks = self._library.load() + logger.info('Loaded %d local tracks using %s', + num_tracks, self._library.name) + + def lookup(self, uri): + return self._library.lookup(uri) + + def find_exact(self, query=None, uris=None): + return self._library.search(query=query, uris=uris, exact=True) + + def search(self, query=None, uris=None): + return self._library.search(query=query, uris=uris, exact=False) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index e4c00570..9bfcb233 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -7,7 +7,7 @@ import unittest import pykka from mopidy import core -from mopidy.backends.local.json import actor +from mopidy.backends.local import actor from mopidy.models import Track, Album, Artist from tests import path_to_data_dir @@ -61,15 +61,13 @@ class LocalLibraryProviderTest(unittest.TestCase): config = { 'local': { 'media_dir': path_to_data_dir(''), + 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', }, - 'local-json': { - 'json_file': path_to_data_dir('library.json.gz'), - }, } def setUp(self): - self.backend = actor.LocalJsonBackend.start( + self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library @@ -88,6 +86,9 @@ class LocalLibraryProviderTest(unittest.TestCase): # Verifies that https://github.com/mopidy/mopidy/issues/500 # has been fixed. + # TODO: re-add something that tests this in a more sane way + return + with tempfile.NamedTemporaryFile() as library: with open(self.config['local-json']['json_file']) as fh: library.write(fh.read()) @@ -95,7 +96,7 @@ class LocalLibraryProviderTest(unittest.TestCase): config = copy.deepcopy(self.config) config['local-json']['json_file'] = library.name - backend = actor.LocalJsonBackend(config=config, audio=None) + backend = actor.LocalBackend(config=config, audio=None) # Sanity check that value is in the library result = backend.library.lookup(self.tracks[0].uri) From d93d3e6fcdfb0076ea467ae7cac0789f322f87f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Dec 2013 00:22:58 +0100 Subject: [PATCH 076/238] local: Add local/library config value - Updated library provider to support missing library - Added config value to select local library provider - Updated tests to use library config value --- docs/ext/local.rst | 5 +++++ mopidy/backends/local/__init__.py | 10 ++++++++-- mopidy/backends/local/actor.py | 13 ++++++++++--- mopidy/backends/local/ext.conf | 1 + mopidy/backends/local/library.py | 8 ++++++++ mopidy/ext.py | 4 +--- tests/backends/local/events_test.py | 1 + tests/backends/local/library_test.py | 5 ++++- tests/backends/local/playback_test.py | 1 + tests/backends/local/playlists_test.py | 1 + tests/backends/local/tracklist_test.py | 1 + 11 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index eed51829..5cf4b2d3 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -35,6 +35,11 @@ Configuration values If the local extension should be enabled or not. +.. confval:: local/library + + Local library provider to use, change this if you want to use a third party + library for local files. + .. confval:: local/media_dir Path to directory with local media files. diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e0e917f7..d42f08ce 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -21,6 +21,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() + schema['library'] = config.String() schema['media_dir'] = config.Path() schema['data_dir'] = config.Path() schema['playlists_dir'] = config.Path() @@ -30,9 +31,14 @@ class Extension(ext.Extension): schema['excluded_file_extensions'] = config.List(optional=True) return schema - def get_backend_classes(self): + def setup(self, registry): from .actor import LocalBackend - return [LocalBackend] + from .json import JsonLibrary + + LocalBackend.libraries = registry['local:library'] + + registry.add('backend', LocalBackend) + registry.add('local:library', JsonLibrary) def get_command(self): from .commands import LocalCommand diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 3da246db..5925f993 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,7 +8,6 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .json import JsonLibrary from .library import LocalLibraryProvider from .playback import LocalPlaybackProvider from .playlists import LocalPlaylistsProvider @@ -18,6 +17,7 @@ logger = logging.getLogger('mopidy.backends.local') class LocalBackend(pykka.ThreadingActor, base.Backend): uri_schemes = ['local'] + libraries = [] def __init__(self, config, audio): super(LocalBackend, self).__init__() @@ -26,8 +26,15 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() - # TODO: move to getting this from registry - library = JsonLibrary(config) + libraries = dict((l.name, l) for l in self.libraries) + library_name = config['local']['library'] + + if library_name in libraries: + library = libraries[library_name](config) + logger.debug('Using %s as the local library', library_name) + else: + library = None + logger.warning('Local library %s not found', library_name) self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index e826a451..5f83db05 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -1,5 +1,6 @@ [local] enabled = true +library = json media_dir = $XDG_MUSIC_DIR data_dir = $XDG_DATA_DIR/mopidy/local playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 89a6da4d..aeb7736b 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -15,15 +15,23 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): + if not self._library: + return 0 num_tracks = self._library.load() logger.info('Loaded %d local tracks using %s', num_tracks, self._library.name) def lookup(self, uri): + if not self._library: + return [] return self._library.lookup(uri) def find_exact(self, query=None, uris=None): + if not self._library: + return None return self._library.search(query=query, uris=uris, exact=True) def search(self, query=None, uris=None): + if not self._library: + return None return self._library.search(query=query, uris=uris, exact=False) diff --git a/mopidy/ext.py b/mopidy/ext.py index c79ebe40..a9ca2519 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -69,9 +69,6 @@ class Extension(object): for frontend_class in self.get_frontend_classes(): registry.add('frontend', frontend_class) - for library_updater in self.get_library_updaters(): - registry.add('local:library', library_updater) - self.register_gstreamer_elements() def get_frontend_classes(self): @@ -92,6 +89,7 @@ class Extension(object): """ return [] + # TODO: remove def get_library_updaters(self): """List of library updater classes diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 2424ed42..967d4cdb 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -19,6 +19,7 @@ class LocalBackendEventsTest(unittest.TestCase): 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', } } diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 9bfcb233..40bece7d 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -7,7 +7,7 @@ import unittest import pykka from mopidy import core -from mopidy.backends.local import actor +from mopidy.backends.local import actor, json from mopidy.models import Track, Album, Artist from tests import path_to_data_dir @@ -63,10 +63,12 @@ class LocalLibraryProviderTest(unittest.TestCase): 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', }, } def setUp(self): + actor.LocalBackend.libraries = [json.JsonLibrary] self.backend = actor.LocalBackend.start( config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) @@ -74,6 +76,7 @@ class LocalLibraryProviderTest(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() + actor.LocalBackend.libraries = [] def test_refresh(self): self.library.refresh() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4da420ef..7d48cfea 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -24,6 +24,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', } } diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 447de3f8..6c602282 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -21,6 +21,7 @@ class LocalPlaylistsProviderTest(unittest.TestCase): 'local': { 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), + 'library': 'json', } } diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 14bf678d..28def50c 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -20,6 +20,7 @@ class LocalTracklistProviderTest(unittest.TestCase): 'media_dir': path_to_data_dir(''), 'data_dir': path_to_data_dir(''), 'playlists_dir': b'', + 'library': 'json', } } tracks = [ From ff5743999535c20ae758cda9ed90e3dd2b829659 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Dec 2013 00:54:02 +0100 Subject: [PATCH 077/238] local: Update library interface - Add track iterator for use in scanner - Update lookup to only return a single track --- mopidy/backends/local/__init__.py | 10 +++++++++- mopidy/backends/local/json.py | 8 +++++--- mopidy/backends/local/library.py | 6 +++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index d42f08ce..0f9b7502 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -63,6 +63,14 @@ class Library(object): """ return 0 + def tracks(self): + """ + Iterator over all tracks. + + :rtype: :class:`mopidy.models.Track` iterator + """ + raise NotImplementedError + def add(self, track): """ Add the given track to library. @@ -99,7 +107,7 @@ class Library(object): :param uri: track URI :type uri: string - :rtype: list of :class:`mopidy.models.Track` + :rtype: :class:`mopidy.models.Track` """ raise NotImplementedError diff --git a/mopidy/backends/local/json.py b/mopidy/backends/local/json.py index 327f706c..20b922e6 100644 --- a/mopidy/backends/local/json.py +++ b/mopidy/backends/local/json.py @@ -56,6 +56,9 @@ class JsonLibrary(local.Library): self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) return len(self._tracks) + def tracks(self): + return self._tracks.itervalues() + def add(self, track): self._tracks[track.uri] = track @@ -67,10 +70,9 @@ class JsonLibrary(local.Library): def lookup(self, uri): try: - return [self._tracks[uri]] + return self._tracks[uri] except KeyError: - logger.debug('Failed to lookup %r', uri) - return [] + return None def search(self, query=None, uris=None, exact=False): tracks = self._tracks.values() diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index aeb7736b..eadea027 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -24,7 +24,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider): def lookup(self, uri): if not self._library: return [] - return self._library.lookup(uri) + track = self._library.lookup(uri) + if not uri: + logger.debug('Failed to lookup %r', uri) + return [] + return [track] def find_exact(self, query=None, uris=None): if not self._library: From ba642aa6805c5b8daf3d548ef1ee82b236743cbb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Dec 2013 00:57:57 +0100 Subject: [PATCH 078/238] local: Update scanner to use new library interface. --- mopidy/backends/local/commands.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 2f6f744e..63970c2d 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -29,25 +29,24 @@ class ScanCommand(commands.Command): excluded_file_extensions = set( file_ext.lower() for file_ext in excluded_file_extensions) - # TODO: select updater / library to use by name - updaters = args.registry['local:library'] - if not updaters: - logger.error('No usable library updaters found.') + libraries = dict((l.name, l) for l in args.registry['local:library']) + library_name = config['local']['library'] + + if library_name not in libraries: + logger.warning('Local library %s not found', library_name) return 1 - elif len(updaters) > 1: - logger.error('More than one library updater found. ' - 'Provided by: %s', ', '.join(updaters)) - return 1 - local_updater = updaters[0](config) + + library = libraries[library_name](config) + logger.debug('Using %s as the local library', library_name) uri_path_mapping = {} uris_in_library = set() uris_to_update = set() uris_to_remove = set() - tracks = local_updater.load() - logger.info('Checking %d tracks from library.', len(tracks)) - for track in tracks: + num_tracks = library.load() + logger.info('Checking %d tracks from library.', num_tracks) + for track in library.tracks(): uri_path_mapping[track.uri] = translator.local_track_uri_to_path( track.uri, media_dir) try: @@ -61,7 +60,7 @@ class ScanCommand(commands.Command): logger.info('Removing %d missing tracks.', len(uris_to_remove)) for uri in uris_to_remove: - local_updater.remove(uri) + library.remove(uri) logger.info('Checking %s for unknown tracks.', media_dir) for relpath in path.find_files(media_dir): @@ -85,7 +84,7 @@ class ScanCommand(commands.Command): try: data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) track = scan.audio_data_to_track(data).copy(uri=uri) - local_updater.add(track) + library.add(track) logger.debug('Added %s', track.uri) except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) @@ -93,7 +92,7 @@ class ScanCommand(commands.Command): progress.increment() logger.info('Commiting changes.') - local_updater.commit() + library.commit() return 0 From fbd307bbf6dbbc1b7e7b0c246759157af319effa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 27 Dec 2013 13:40:17 +0100 Subject: [PATCH 079/238] docs: Update mock to support glib.get_{system,user}_config_dir{s,} --- docs/conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 25e0b145..5417a55c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,6 +28,12 @@ class Mock(object): def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' + elif name == 'get_system_config_dirs': + # glib.get_system_config_dirs() + return tuple + elif name == 'get_user_config_dir': + # glib.get_user_config_dir() + return str elif (name[0] == name[0].upper() # gst.interfaces.MIXER_TRACK_* and not name.startswith('MIXER_TRACK_') From 414708b405b7fb9bd6ebf1d559bc69e32831d515 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 27 Dec 2013 13:44:06 +0100 Subject: [PATCH 080/238] docs: Move images out of the _static dir Sphinx copies the images to _images when the docs are built. Thus the mopidy-docs debian package contains two copies of all images; one in _static and one in _images. By keeping the images next to the source documents we only get the _images copy included in the Debian package, reducing the package size by 1.5MB. --- docs/{_static => clients}/dz0ny-mopidy-lux.png | Bin docs/clients/http.rst | 6 +++--- docs/{_static => clients}/martijnboland-moped.png | Bin docs/{_static => clients}/mpd-client-gmpc.png | Bin docs/{_static => clients}/mpd-client-mpad.jpg | Bin docs/{_static => clients}/mpd-client-mpdroid.jpg | Bin docs/{_static => clients}/mpd-client-mpod.jpg | Bin docs/{_static => clients}/mpd-client-ncmpcpp.png | Bin docs/{_static => clients}/mpd-client-sonata.png | Bin docs/clients/mpd.rst | 14 +++++++------- docs/clients/mpris.rst | 2 +- docs/{_static => clients}/rompr.png | Bin docs/{_static => clients}/ubuntu-sound-menu.png | Bin .../woutervanwijk-mopidy-webclient.png | Bin .../raspberry-pi-by-jwrodgers.jpg | Bin docs/installation/raspberrypi.rst | 2 +- 16 files changed, 12 insertions(+), 12 deletions(-) rename docs/{_static => clients}/dz0ny-mopidy-lux.png (100%) rename docs/{_static => clients}/martijnboland-moped.png (100%) rename docs/{_static => clients}/mpd-client-gmpc.png (100%) rename docs/{_static => clients}/mpd-client-mpad.jpg (100%) rename docs/{_static => clients}/mpd-client-mpdroid.jpg (100%) rename docs/{_static => clients}/mpd-client-mpod.jpg (100%) rename docs/{_static => clients}/mpd-client-ncmpcpp.png (100%) rename docs/{_static => clients}/mpd-client-sonata.png (100%) rename docs/{_static => clients}/rompr.png (100%) rename docs/{_static => clients}/ubuntu-sound-menu.png (100%) rename docs/{_static => clients}/woutervanwijk-mopidy-webclient.png (100%) rename docs/{_static => installation}/raspberry-pi-by-jwrodgers.jpg (100%) diff --git a/docs/_static/dz0ny-mopidy-lux.png b/docs/clients/dz0ny-mopidy-lux.png similarity index 100% rename from docs/_static/dz0ny-mopidy-lux.png rename to docs/clients/dz0ny-mopidy-lux.png diff --git a/docs/clients/http.rst b/docs/clients/http.rst index a31636cd..9ef3b131 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -18,7 +18,7 @@ See :ref:`http-api` for details on how to build your own web client. woutervanwijk/Mopidy-Webclient ============================== -.. image:: /_static/woutervanwijk-mopidy-webclient.png +.. image:: woutervanwijk-mopidy-webclient.png :width: 1275 :height: 600 @@ -36,7 +36,7 @@ Also the web client used for Wouter's popular `Pi Musicbox Mopidy Lux ========== -.. image:: /_static/dz0ny-mopidy-lux.png +.. image:: dz0ny-mopidy-lux.png :width: 1000 :height: 645 @@ -50,7 +50,7 @@ A Mopidy web client made with AngularJS by Janez Troha. Moped ===== -.. image:: /_static/martijnboland-moped.png +.. image:: martijnboland-moped.png :width: 720 :height: 450 diff --git a/docs/_static/martijnboland-moped.png b/docs/clients/martijnboland-moped.png similarity index 100% rename from docs/_static/martijnboland-moped.png rename to docs/clients/martijnboland-moped.png diff --git a/docs/_static/mpd-client-gmpc.png b/docs/clients/mpd-client-gmpc.png similarity index 100% rename from docs/_static/mpd-client-gmpc.png rename to docs/clients/mpd-client-gmpc.png diff --git a/docs/_static/mpd-client-mpad.jpg b/docs/clients/mpd-client-mpad.jpg similarity index 100% rename from docs/_static/mpd-client-mpad.jpg rename to docs/clients/mpd-client-mpad.jpg diff --git a/docs/_static/mpd-client-mpdroid.jpg b/docs/clients/mpd-client-mpdroid.jpg similarity index 100% rename from docs/_static/mpd-client-mpdroid.jpg rename to docs/clients/mpd-client-mpdroid.jpg diff --git a/docs/_static/mpd-client-mpod.jpg b/docs/clients/mpd-client-mpod.jpg similarity index 100% rename from docs/_static/mpd-client-mpod.jpg rename to docs/clients/mpd-client-mpod.jpg diff --git a/docs/_static/mpd-client-ncmpcpp.png b/docs/clients/mpd-client-ncmpcpp.png similarity index 100% rename from docs/_static/mpd-client-ncmpcpp.png rename to docs/clients/mpd-client-ncmpcpp.png diff --git a/docs/_static/mpd-client-sonata.png b/docs/clients/mpd-client-sonata.png similarity index 100% rename from docs/_static/mpd-client-sonata.png rename to docs/clients/mpd-client-sonata.png diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 0993303d..4a2736fe 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -51,7 +51,7 @@ ncmpcpp A console client that works well with Mopidy, and is regularly used by Mopidy developers. -.. image:: /_static/mpd-client-ncmpcpp.png +.. image:: mpd-client-ncmpcpp.png :width: 575 :height: 426 @@ -84,7 +84,7 @@ GMPC `GMPC `_ is a graphical MPD client (GTK+) which works well with Mopidy. -.. image:: /_static/mpd-client-gmpc.png +.. image:: mpd-client-gmpc.png :width: 1000 :height: 565 @@ -101,7 +101,7 @@ Sonata `Sonata `_ is a graphical MPD client (GTK+). It generally works well with Mopidy, except for search. -.. image:: /_static/mpd-client-sonata.png +.. image:: mpd-client-sonata.png :width: 475 :height: 424 @@ -140,7 +140,7 @@ Test date: Tested version: 1.03.1 (released 2012-10-16) -.. image:: /_static/mpd-client-mpdroid.jpg +.. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 @@ -269,7 +269,7 @@ Test date: Tested version: 1.7.1 -.. image:: /_static/mpd-client-mpod.jpg +.. image:: mpd-client-mpod.jpg :width: 320 :height: 480 @@ -297,7 +297,7 @@ Test date: Tested version: 1.7.1 -.. image:: /_static/mpd-client-mpad.jpg +.. image:: mpd-client-mpad.jpg :width: 480 :height: 360 @@ -332,7 +332,7 @@ other web clients, see :ref:`http-clients`. Rompr ----- -.. image:: /_static/rompr.png +.. image:: rompr.png :width: 557 :height: 600 diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index e1bd4bff..650372e6 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -24,7 +24,7 @@ sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the Rhytmbox music player, but many other players can integrate with the sound menu, including the official Spotify player and Mopidy. -.. image:: /_static/ubuntu-sound-menu.png +.. image:: ubuntu-sound-menu.png :height: 480 :width: 955 diff --git a/docs/_static/rompr.png b/docs/clients/rompr.png similarity index 100% rename from docs/_static/rompr.png rename to docs/clients/rompr.png diff --git a/docs/_static/ubuntu-sound-menu.png b/docs/clients/ubuntu-sound-menu.png similarity index 100% rename from docs/_static/ubuntu-sound-menu.png rename to docs/clients/ubuntu-sound-menu.png diff --git a/docs/_static/woutervanwijk-mopidy-webclient.png b/docs/clients/woutervanwijk-mopidy-webclient.png similarity index 100% rename from docs/_static/woutervanwijk-mopidy-webclient.png rename to docs/clients/woutervanwijk-mopidy-webclient.png diff --git a/docs/_static/raspberry-pi-by-jwrodgers.jpg b/docs/installation/raspberry-pi-by-jwrodgers.jpg similarity index 100% rename from docs/_static/raspberry-pi-by-jwrodgers.jpg rename to docs/installation/raspberry-pi-by-jwrodgers.jpg diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index e266dee2..4bc17a26 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -9,7 +9,7 @@ January 2013, Mopidy will run with Spotify support on both the armel (soft-float) and armhf (hard-float) architectures, which includes the Raspbian distribution. -.. image:: /_static/raspberry-pi-by-jwrodgers.jpg +.. image:: raspberry-pi-by-jwrodgers.jpg :width: 640 :height: 427 From 0f671516ed129d4b51fac2eec88272d6ed56b314 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 28 Dec 2013 00:00:10 +0100 Subject: [PATCH 081/238] local: Make sure excluded file extension case does not error out --- mopidy/backends/local/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 63970c2d..60bc8ad4 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -64,12 +64,13 @@ class ScanCommand(commands.Command): logger.info('Checking %s for unknown tracks.', media_dir) for relpath in path.find_files(media_dir): + uri = translator.path_to_local_track_uri(relpath) file_extension = os.path.splitext(relpath)[1] + if file_extension.lower() in excluded_file_extensions: logger.debug('Skipped %s: File extension excluded.', uri) continue - uri = translator.path_to_local_track_uri(relpath) if uri not in uris_in_library: uris_to_update.add(uri) uri_path_mapping[uri] = os.path.join(media_dir, relpath) From 565b3aeb985897773ead5ba397c9956dc05faa24 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 28 Dec 2013 00:06:16 +0100 Subject: [PATCH 082/238] audio: Workaround fact that genre can sometimes be a list --- mopidy/audio/scan.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index f797a84d..0c8e3478 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -181,6 +181,12 @@ def audio_data_to_track(data): track_kwargs['uri'] = data['uri'] track_kwargs['album'] = Album(**album_kwargs) + # TODO: this feels like a half assed workaround. we need to be sure that we + # don't suddenly have lists in our models where we expect strings etc + if ('genre' in track_kwargs and + not isinstance(track_kwargs['genre'], basestring)): + track_kwargs['genre'] = ', '.join(track_kwargs['genre']) + if ('name' in artist_kwargs and not isinstance(artist_kwargs['name'], basestring)): track_kwargs['artists'] = [Artist(name=artist) From 40af4c5f9e68c4f30f918f33e9445a0ba94d2b4a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 28 Dec 2013 00:17:31 +0100 Subject: [PATCH 083/238] local: Review comments --- mopidy/backends/local/__init__.py | 8 +++++--- mopidy/backends/local/library.py | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 0f9b7502..436f43d4 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -111,18 +111,20 @@ class Library(object): """ raise NotImplementedError - # TODO: support case with returning all tracks? - # TODO: remove uris? + # TODO: remove uris, replacing it with support in query language. + # TODO: remove exact, replacing it with support in query language. def search(self, query=None, exact=False, uris=None): """ Search the library for tracks where ``field`` contains ``values``. :param query: one or more queries to search for :type query: dict - :param exact: look for exact matches? + :param exact: whether to look for exact matches :type query: boolean :param uris: zero or more URI roots to limit the search to :type uris: list of strings or :class:`None` :rtype: :class:`mopidy.models.SearchResult` """ raise NotImplementedError + + # TODO: add file browsing support. diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index eadea027..d8cc2320 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -4,11 +4,12 @@ import logging from mopidy.backends import base -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger('mopidy.backends.local.library') class LocalLibraryProvider(base.BaseLibraryProvider): """Proxy library that delegates work to our active local library.""" + def __init__(self, backend, library): super(LocalLibraryProvider, self).__init__(backend) self._library = library @@ -25,7 +26,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): if not self._library: return [] track = self._library.lookup(uri) - if not uri: + if track is None: logger.debug('Failed to lookup %r', uri) return [] return [track] From 413d539a7b9d1f4f6a345cf5691802aec9027fca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 28 Dec 2013 19:23:04 +0100 Subject: [PATCH 084/238] py3: Use print() function --- mopidy/__main__.py | 4 ++-- mopidy/commands.py | 8 ++++---- mopidy/config/convert.py | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1aca9cf4..6a6c0eb8 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import logging import os @@ -113,7 +113,7 @@ def main(): try: return args.command.run(args, proxied_config, enabled_extensions) except NotImplementedError: - print root_cmd.format_help() + print(root_cmd.format_help()) return 1 except KeyboardInterrupt: diff --git a/mopidy/commands.py b/mopidy/commands.py index 851bfb83..ba5a42f1 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import argparse import collections @@ -112,7 +112,7 @@ class Command(object): def exit(self, status_code=0, message=None, usage=None): """Optionally print a message and exit.""" - print '\n\n'.join(m for m in (usage, message) if m) + print('\n\n'.join(m for m in (usage, message) if m)) sys.exit(status_code) def format_usage(self, prog=None): @@ -341,7 +341,7 @@ class ConfigCommand(Command): self.set(base_verbosity_level=-1) def run(self, config, errors, extensions): - print config_lib.format(config, extensions, errors) + print(config_lib.format(config, extensions, errors)) return 0 @@ -353,5 +353,5 @@ class DepsCommand(Command): self.set(base_verbosity_level=-1) def run(self): - print deps.format_dependency_list() + print(deps.format_dependency_list()) return 0 diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py index 87bf4ed5..7012b56e 100644 --- a/mopidy/config/convert.py +++ b/mopidy/config/convert.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import io import os.path @@ -10,13 +10,13 @@ from mopidy.utils import path def load(): settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py') - print 'Checking %s' % settings_file + print('Checking %s' % settings_file) setting_globals = {} try: execfile(settings_file, setting_globals) except Exception as e: - print 'Problem loading settings: %s' % e + print('Problem loading settings: %s' % e) return setting_globals @@ -106,20 +106,20 @@ def main(): 'spotify', 'scrobbler', 'mpd', 'mpris', 'local', 'stream', 'http'] extensions = [e for e in ext.load_extensions() if e.ext_name in known] - print b'Converted config:\n' - print config_lib.format(config, extensions) + print(b'Converted config:\n') + print(config_lib.format(config, extensions)) conf_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf') if os.path.exists(conf_file): - print '%s exists, exiting.' % conf_file + print('%s exists, exiting.' % conf_file) sys.exit(1) - print 'Write new config to %s? [yN]' % conf_file, + print('Write new config to %s? [yN]' % conf_file, end=' ') if raw_input() != 'y': - print 'Not saving, exiting.' + print('Not saving, exiting.') sys.exit(0) serialized_config = config_lib.format(config, extensions, display=False) with io.open(conf_file, 'wb') as filehandle: filehandle.write(serialized_config) - print 'Done.' + print('Done.') From 4758a0ac129c95a0f4ccecd3663bf874bcb5b5d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 29 Dec 2013 15:04:47 +0100 Subject: [PATCH 085/238] tools: Remove unused dev tools --- docs/devtools.rst | 42 --------- tools/debug-proxy.py | 195 ----------------------------------------- tools/idle.py | 203 ------------------------------------------- 3 files changed, 440 deletions(-) delete mode 100755 tools/debug-proxy.py delete mode 100644 tools/idle.py diff --git a/docs/devtools.rst b/docs/devtools.rst index 858cc7f8..64bb7e6b 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -27,48 +27,6 @@ code. So, if you're out of work, the code coverage and flake8 data at the CI server should give you a place to start. -Protocol debugger -================= - -Since the main interface provided to Mopidy is through the MPD protocol, it is -crucial that we try and stay in sync with protocol developments. In an attempt -to make it easier to debug differences Mopidy and MPD protocol handling we have -created ``tools/debug-proxy.py``. - -This tool is proxy that sits in front of two MPD protocol aware servers and -sends all requests to both, returning the primary response to the client and -then printing any diff in the two responses. - -Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time -of writing. See :option:`tools/debug-proxy.py --help` for available options. -Sample session:: - - [127.0.0.1]:59714 - listallinfo - --- Reference response - +++ Actual response - @@ -1,16 +1,1 @@ - -file: uri1 - -Time: 4 - -Artist: artist1 - -Title: track1 - -Album: album1 - -file: uri2 - -Time: 4 - -Artist: artist2 - -Title: track2 - -Album: album2 - -file: uri3 - -Time: 4 - -Artist: artist3 - -Title: track3 - -Album: album3 - -OK - +ACK [2@0] {listallinfo} incorrect arguments - -To ensure that Mopidy and MPD have comparable state it is suggested you scan -the same media directory with both servers. - Documentation writing ===================== diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py deleted file mode 100755 index 938afa57..00000000 --- a/tools/debug-proxy.py +++ /dev/null @@ -1,195 +0,0 @@ -#! /usr/bin/env python - -from __future__ import unicode_literals - -import argparse -import difflib -import sys - -from gevent import select, server, socket - -COLORS = ['\033[1;%dm' % (30 + i) for i in range(8)] -BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS -RESET = "\033[0m" -BOLD = "\033[1m" - - -def proxy(client, address, reference_address, actual_address): - """Main handler code that gets called for each connection.""" - client.setblocking(False) - - reference = connect(reference_address) - actual = connect(actual_address) - - if reference and actual: - loop(client, address, reference, actual) - else: - print 'Could not connect to one of the backends.' - - for sock in (client, reference, actual): - close(sock) - - -def connect(address): - """Connect to given address and set socket non blocking.""" - try: - sock = socket.socket() - sock.connect(address) - sock.setblocking(False) - except socket.error: - return None - return sock - - -def close(sock): - """Shutdown and close our sockets.""" - try: - sock.shutdown(socket.SHUT_WR) - sock.close() - except socket.error: - pass - - -def loop(client, address, reference, actual): - """Loop that handles one MPD reqeust/response pair per iteration.""" - - # Consume banners from backends - responses = dict() - disconnected = read( - [reference, actual], responses, find_response_end_token) - diff(address, '', responses[reference], responses[actual]) - - # We lost a backend, might as well give up. - if disconnected: - return - - client.sendall(responses[reference]) - - while True: - responses = dict() - - # Get the command from the client. Not sure how an if this will handle - # client sending multiple commands currently :/ - disconnected = read([client], responses, find_request_end_token) - - # We lost the client, might as well give up. - if disconnected: - return - - # Send the entire command to both backends. - reference.sendall(responses[client]) - actual.sendall(responses[client]) - - # Get the entire resonse from both backends. - disconnected = read( - [reference, actual], responses, find_response_end_token) - - # Send the client the complete reference response - client.sendall(responses[reference]) - - # Compare our responses - diff(address, - responses[client], responses[reference], responses[actual]) - - # Give up if we lost a backend. - if disconnected: - return - - -def read(sockets, responses, find_end_token): - """Keep reading from sockets until they disconnet or we find our token.""" - - # This function doesn't go to well with idle when backends are out of sync. - disconnected = False - - for sock in sockets: - responses.setdefault(sock, '') - - while sockets: - for sock in select.select(sockets, [], [])[0]: - data = sock.recv(4096) - responses[sock] += data - - if find_end_token(responses[sock]): - sockets.remove(sock) - - if not data: - sockets.remove(sock) - disconnected = True - - return disconnected - - -def find_response_end_token(data): - """Find token that indicates the response is over.""" - for line in data.splitlines(True): - if line.startswith(('OK', 'ACK')) and line.endswith('\n'): - return True - return False - - -def find_request_end_token(data): - """Find token that indicates that request is over.""" - lines = data.splitlines(True) - if not lines: - return False - elif 'command_list_ok_begin' == lines[0].strip(): - return 'command_list_end' == lines[-1].strip() - else: - return lines[0].endswith('\n') - - -def diff(address, command, reference_response, actual_response): - """Print command from client and a unified diff of the responses.""" - sys.stdout.write('[%s]:%s\n%s' % (address[0], address[1], command)) - for line in difflib.unified_diff(reference_response.splitlines(True), - actual_response.splitlines(True), - fromfile='Reference response', - tofile='Actual response'): - - if line.startswith('+') and not line.startswith('+++'): - sys.stdout.write(GREEN) - elif line.startswith('-') and not line.startswith('---'): - sys.stdout.write(RED) - elif line.startswith('@@'): - sys.stdout.write(CYAN) - - sys.stdout.write(line) - sys.stdout.write(RESET) - - sys.stdout.flush() - - -def parse_args(): - """Handle flag parsing.""" - parser = argparse.ArgumentParser( - description='Proxy and compare MPD protocol interactions.') - parser.add_argument('--listen', default=':6600', type=parse_address, - help='address:port to listen on.') - parser.add_argument('--reference', default=':6601', type=parse_address, - help='address:port for the reference backend.') - parser.add_argument('--actual', default=':6602', type=parse_address, - help='address:port for the actual backend.') - - return parser.parse_args() - - -def parse_address(address): - """Convert host:port or port to address to pass to connect.""" - if ':' not in address: - return ('', int(address)) - host, port = address.rsplit(':', 1) - return (host, int(port)) - - -if __name__ == '__main__': - args = parse_args() - - def handle(client, address): - """Wrapper that adds reference and actual backends to proxy calls.""" - return proxy(client, address, args.reference, args.actual) - - try: - server.StreamServer(args.listen, handle).serve_forever() - except (KeyboardInterrupt, SystemExit): - pass diff --git a/tools/idle.py b/tools/idle.py deleted file mode 100644 index 122e998d..00000000 --- a/tools/idle.py +++ /dev/null @@ -1,203 +0,0 @@ -#! /usr/bin/env python - -# This script is helper to systematicly test the behaviour of MPD's idle -# command. It is simply provided as a quick hack, expect nothing more. - -from __future__ import unicode_literals - -import logging -import pprint -import socket - -host = '' -port = 6601 - -url = "13 - a-ha - White Canvas.mp3" -artist = "a-ha" - -data = {'id': None, 'id2': None, 'url': url, 'artist': artist} - -# Commands to run before test requests to coerce MPD into right state -setup_requests = [ - 'clear', - 'add "%(url)s"', - 'add "%(url)s"', - 'add "%(url)s"', - 'play', - #'pause', # Uncomment to test paused idle behaviour - #'stop', # Uncomment to test stopped idle behaviour -] - -# List of commands to test for idle behaviour. Ordering of list is important in -# order to keep MPD state as intended. Commands that are obviously -# informational only or "harmfull" have been excluded. -test_requests = [ - 'add "%(url)s"', - 'addid "%(url)s" "1"', - 'clear', - #'clearerror', - #'close', - #'commands', - 'consume "1"', - 'consume "0"', - # 'count', - 'crossfade "1"', - 'crossfade "0"', - #'currentsong', - #'delete "1:2"', - 'delete "0"', - 'deleteid "%(id)s"', - 'disableoutput "0"', - 'enableoutput "0"', - #'find', - #'findadd "artist" "%(artist)s"', - #'idle', - #'kill', - #'list', - #'listall', - #'listallinfo', - #'listplaylist', - #'listplaylistinfo', - #'listplaylists', - #'lsinfo', - 'move "0:1" "2"', - 'move "0" "1"', - 'moveid "%(id)s" "1"', - 'next', - #'notcommands', - #'outputs', - #'password', - 'pause', - #'ping', - 'play', - 'playid "%(id)s"', - #'playlist', - 'playlistadd "foo" "%(url)s"', - 'playlistclear "foo"', - 'playlistadd "foo" "%(url)s"', - 'playlistdelete "foo" "0"', - #'playlistfind', - #'playlistid', - #'playlistinfo', - 'playlistadd "foo" "%(url)s"', - 'playlistadd "foo" "%(url)s"', - 'playlistmove "foo" "0" "1"', - #'playlistsearch', - #'plchanges', - #'plchangesposid', - 'previous', - 'random "1"', - 'random "0"', - 'rm "bar"', - 'rename "foo" "bar"', - 'repeat "0"', - 'rm "bar"', - 'save "bar"', - 'load "bar"', - #'search', - 'seek "1" "10"', - 'seekid "%(id)s" "10"', - #'setvol "10"', - 'shuffle', - 'shuffle "0:1"', - 'single "1"', - 'single "0"', - #'stats', - #'status', - 'stop', - 'swap "1" "2"', - 'swapid "%(id)s" "%(id2)s"', - #'tagtypes', - #'update', - #'urlhandlers', - #'volume', -] - - -def create_socketfile(): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, port)) - sock.settimeout(0.5) - fd = sock.makefile('rw', 1) # 1 = line buffered - fd.readline() # Read banner - return fd - - -def wait(fd, prefix=None, collect=None): - while True: - line = fd.readline().rstrip() - if prefix: - logging.debug('%s: %s', prefix, repr(line)) - if line.split()[0] in ('OK', 'ACK'): - break - - -def collect_ids(fd): - fd.write('playlistinfo\n') - - ids = [] - while True: - line = fd.readline() - if line.split()[0] == 'OK': - break - if line.split()[0] == 'Id:': - ids.append(line.split()[1]) - return ids - - -def main(): - subsystems = {} - - command = create_socketfile() - - for test in test_requests: - # Remove any old ids - del data['id'] - del data['id2'] - - # Run setup code to force MPD into known state - for setup in setup_requests: - command.write(setup % data + '\n') - wait(command) - - data['id'], data['id2'] = collect_ids(command)[:2] - - # This connection needs to be make after setup commands are done or - # else they will cause idle events. - idle = create_socketfile() - - # Wait for new idle events - idle.write('idle\n') - - test = test % data - - logging.debug('idle: %s', repr('idle')) - logging.debug('command: %s', repr(test)) - - command.write(test + '\n') - wait(command, prefix='command') - - while True: - try: - line = idle.readline().rstrip() - except socket.timeout: - # Abort try if we time out. - idle.write('noidle\n') - break - - logging.debug('idle: %s', repr(line)) - - if line == 'OK': - break - - request_type = test.split()[0] - subsystem = line.split()[1] - subsystems.setdefault(request_type, set()).add(subsystem) - - logging.debug('---') - - pprint.pprint(subsystems) - - -if __name__ == '__main__': - main() From 12d473ced6ac24e00e8f080513ca439e6b459e6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 29 Dec 2013 15:38:20 +0100 Subject: [PATCH 086/238] zeroconf: Make public API with docs --- docs/api/index.rst | 1 + docs/api/zeroconf.rst | 11 +++++++++++ mopidy/frontends/http/actor.py | 3 +-- mopidy/frontends/mpd/actor.py | 3 ++- mopidy/{utils => }/zeroconf.py | 27 +++++++++++++++++++++++++-- 5 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 docs/api/zeroconf.rst rename mopidy/{utils => }/zeroconf.py (76%) diff --git a/docs/api/index.rst b/docs/api/index.rst index f58552b7..a79f2bae 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -16,4 +16,5 @@ API reference commands ext config + zeroconf http diff --git a/docs/api/zeroconf.rst b/docs/api/zeroconf.rst new file mode 100644 index 00000000..7cdd93f0 --- /dev/null +++ b/docs/api/zeroconf.rst @@ -0,0 +1,11 @@ +.. _zeroconf-api: + +************ +Zeroconf API +************ + +.. module:: mopidy.zeroconf + :synopsis: Helper for publishing of services on Zeroconf + +.. autoclass:: Zeroconf + :members: diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 4e3493d4..5aef3506 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -9,9 +9,8 @@ import pykka from ws4py.messaging import TextMessage from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool -from mopidy import models +from mopidy import models, zeroconf from mopidy.core import CoreListener -from mopidy.utils import zeroconf from . import ws diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 9df7ba07..fb063f6c 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -5,9 +5,10 @@ import sys import pykka +from mopidy import zeroconf from mopidy.core import CoreListener from mopidy.frontends.mpd import session -from mopidy.utils import encoding, network, process, zeroconf +from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') diff --git a/mopidy/utils/zeroconf.py b/mopidy/zeroconf.py similarity index 76% rename from mopidy/utils/zeroconf.py rename to mopidy/zeroconf.py index acd25ef1..671bebc7 100644 --- a/mopidy/utils/zeroconf.py +++ b/mopidy/zeroconf.py @@ -4,7 +4,7 @@ import logging import socket import string -logger = logging.getLogger('mopidy.utils.zeroconf') +logger = logging.getLogger('mopidy.zeroconf') try: import dbus @@ -25,7 +25,20 @@ def _convert_text_to_dbus_bytes(text): class Zeroconf(object): - """Publish a network service with Zeroconf using Avahi.""" + """Publish a network service with Zeroconf. + + Currently, this only works on Linux using Avahi via D-Bus. + + :param str name: human readable name of the service, e.g. 'MPD on neptune' + :param int port: TCP port of the service, e.g. 6600 + :param str stype: service type, e.g. '_mpd._tcp' + :param str domain: local network domain name, defaults to '' + :param str host: interface to advertise the service on, defaults to all + interfaces + :param text: extra information depending on ``stype``, defaults to empty + list + :type text: list of str + """ def __init__(self, name, port, stype=None, domain=None, host=None, text=None): @@ -44,6 +57,11 @@ class Zeroconf(object): hostname=self.host or socket.getfqdn(), port=self.port) def publish(self): + """Publish the service. + + Call when your service starts. + """ + if _is_loopback_address(self.host): logger.info( 'Zeroconf publish on loopback interface is not supported.') @@ -83,6 +101,11 @@ class Zeroconf(object): return False def unpublish(self): + """Unpublish the service. + + Call when your service shuts down. + """ + if self.group: try: self.group.Reset() From 7bb533a6b5cb5b0da6e241031c9b3b0429a4c9f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 29 Dec 2013 15:38:41 +0100 Subject: [PATCH 087/238] docs: Add note about public APIs and API stability --- docs/api/index.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/api/index.rst b/docs/api/index.rst index a79f2bae..bede978b 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -4,6 +4,15 @@ API reference ************* +.. warning:: API stability + + Only APIs documented here are public and open for use by Mopidy + extensions. We will change these APIs, but will keep the changelog up to + date with all breaking changes. + + From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable. + + .. toctree:: :glob: From e87d5729e3c30cfe6771fe57b9712933bd72ea4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 29 Dec 2013 19:31:12 +0100 Subject: [PATCH 088/238] models: Add lightweight Ref model with URI, name, and type --- mopidy/models.py | 21 +++++++++++++++++++++ tests/models_test.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 04d71591..1aace8db 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -136,6 +136,27 @@ def model_json_decoder(dct): return dct +class Ref(ImmutableObject): + """ + :param uri: object URI + :type uri: string + :param name: object name + :type name: string + :param type: object type + :type name: string + """ + + #: The object URI. Read-only. + uri = None + + #: The object name. Read-only. + name = None + + #: The object type, e.g. "artist", "album", "track", "playlist", + #: "directory". Read-only. + type = None + + class Artist(ImmutableObject): """ :param uri: artist URI diff --git a/tests/models_test.py b/tests/models_test.py index 9f43e624..50faf89e 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -5,7 +5,7 @@ import json import unittest from mopidy.models import ( - Artist, Album, TlTrack, Track, Playlist, SearchResult, + Ref, Artist, Album, TlTrack, Track, Playlist, SearchResult, ModelJSONEncoder, model_json_decoder) @@ -54,6 +54,40 @@ class GenericCopyTest(unittest.TestCase): self.assertRaises(TypeError, test) +class RefTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + ref = Ref(uri=uri) + self.assertEqual(ref.uri, uri) + self.assertRaises(AttributeError, setattr, ref, 'uri', None) + + def test_name(self): + name = 'a name' + ref = Ref(name=name) + self.assertEqual(ref.name, name) + self.assertRaises(AttributeError, setattr, ref, 'name', None) + + def test_invalid_kwarg(self): + test = lambda: SearchResult(foo='baz') + self.assertRaises(TypeError, test) + + def test_repr_without_results(self): + self.assertEquals( + "Ref(name=u'foo', type=u'artist', uri=u'uri')", + repr(Ref(uri='uri', name='foo', type='artist'))) + + def test_serialize_without_results(self): + self.assertDictEqual( + {'__model__': 'Ref', 'uri': 'uri'}, + Ref(uri='uri').serialize()) + + def test_to_json_and_back(self): + ref1 = Ref(uri='uri') + serialized = json.dumps(ref1, cls=ModelJSONEncoder) + ref2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(ref1, ref2) + + class ArtistTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' From d3dcceefc52f7ca417e84a47a6951d1b7b543fad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 29 Dec 2013 21:08:46 +0100 Subject: [PATCH 089/238] models: Add description of Ref model --- mopidy/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index 1aace8db..0e40a8f6 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -138,6 +138,10 @@ def model_json_decoder(dct): class Ref(ImmutableObject): """ + Model to represent URI references with a human friendly name and type + attached. This is intended for use a lightweight object "free" of metadata + that can be passed around instead of using full blown models. + :param uri: object URI :type uri: string :param name: object name From 36e9b43e6c831c75830e34ba1349b6fb05374802 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Dec 2013 01:17:33 +0100 Subject: [PATCH 090/238] local: Update local library interface. Refactored interface to incorperate lessons learned so far trying to implemend a whoosh based local library. Search now has a limit and an offset to account for fact that we need to start doing pagination of results properly. Updates now have begin, flush and close calls. Additionally I've added clear method to allow for easily nuking the data store. --- mopidy/backends/local/__init__.py | 104 ++++++++++++++++++------------ mopidy/backends/local/commands.py | 7 +- mopidy/backends/local/json.py | 34 ++++++---- 3 files changed, 89 insertions(+), 56 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 436f43d4..4031c474 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -46,7 +46,17 @@ class Extension(ext.Extension): class Library(object): - #: Name of the local library implementation. + """ + Local library interface. + + Extensions that whish to provide an alternate local library storage backend + need to sub-class this class and install and confgure it with an extension. + Both scanning and library calls will use the active local library. + + :param config: Config dictionary + """ + + #: Name of the local library implementation, must be overriden. name = None def __init__(self, config): @@ -54,20 +64,53 @@ class Library(object): def load(self): """ - Initialize whatever resources are needed for this library. - - This is where you load the tracks into memory, setup a database - conection etc. + (Re)load any tracks stored in memory, if any, otherwise just return + number of available tracks currently available. Will be called at + startup for both library and update use cases, so if you plan to store + tracks in memory this is when the should be (re)loaded. :rtype: :class:`int` representing number of tracks in library. """ return 0 - def tracks(self): + def lookup(self, uri): """ - Iterator over all tracks. + Lookup the given URI. - :rtype: :class:`mopidy.models.Track` iterator + Unlike the core APIs, local tracks uris can only be resolved to a + single track. + + :param string uri: track URI + :rtype: :class:`~mopidy.models.Track` + """ + raise NotImplementedError + + # TODO: remove uris, replacing it with support in query language. + # TODO: remove exact, replacing it with support in query language. + def search(self, query=None, limit=100, offset=0, exact=False, uris=None): + """ + Search the library for tracks where ``field`` contains ``values``. + + :param dict query: one or more queries to search for + :param int limit: maximum number of results to return + :param int offset: offset into result set to use. + :param bool exact: whether to look for exact matches + :param uris: zero or more URI roots to limit the search to + :type uris: list of strings or :class:`None` + :rtype: :class:`~mopidy.models.SearchResult` + """ + raise NotImplementedError + + # TODO: add file browsing support. + + # Remaining methods are use for the update process. + def begin(self): + """ + Prepare library for accepting updates. Exactly what this means is + highly implementation depended. This must however return an iterator + that generates all tracks in the library for efficient scanning. + + :rtype: :class:`~mopidy.models.Track` iterator """ raise NotImplementedError @@ -75,8 +118,7 @@ class Library(object): """ Add the given track to library. - :param track: Track to add to the library/ - :type track: :class:`mopidy.models.Track` + :param :class:`~mopidy.models.Track` track: Track to add to the library """ raise NotImplementedError @@ -84,47 +126,27 @@ class Library(object): """ Remove the given track from the library. - :param uri: URI to remove from the library/ - :type uri: string + :param str uri: URI to remove from the library/ """ raise NotImplementedError - def commit(self): + def flush(self): """ - Persist any changes to the library. - - This is where you write your data file to disk, commit transactions - etc. depending on the requirements of your library implementation. + Called for every n-th track indicating that work should be commited, + implementors are free to ignore these hints. """ pass - def lookup(self, uri): + def close(self): """ - Lookup the given URI. - - If the URI expands to multiple tracks, the returned list will contain - them all. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` + Close any resources used for updating, commit outstanding work etc. """ - raise NotImplementedError + pass - # TODO: remove uris, replacing it with support in query language. - # TODO: remove exact, replacing it with support in query language. - def search(self, query=None, exact=False, uris=None): + def clear(self): """ - Search the library for tracks where ``field`` contains ``values``. + Clear out whatever data storage is used by this backend. - :param query: one or more queries to search for - :type query: dict - :param exact: whether to look for exact matches - :type query: boolean - :param uris: zero or more URI roots to limit the search to - :type uris: list of strings or :class:`None` - :rtype: :class:`mopidy.models.SearchResult` + :rtype: Boolean indicating if state was cleared. """ - raise NotImplementedError - - # TODO: add file browsing support. + return False diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 60bc8ad4..a2098078 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -46,7 +46,8 @@ class ScanCommand(commands.Command): num_tracks = library.load() logger.info('Checking %d tracks from library.', num_tracks) - for track in library.tracks(): + + for track in library.begin(): uri_path_mapping[track.uri] = translator.local_track_uri_to_path( track.uri, media_dir) try: @@ -90,10 +91,12 @@ class ScanCommand(commands.Command): except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) + # TODO: trigger this on batch size intervals instead and add + # flush progress.increment() logger.info('Commiting changes.') - library.commit() + library.close() return 0 diff --git a/mopidy/backends/local/json.py b/mopidy/backends/local/json.py index 20b922e6..d1cacd6d 100644 --- a/mopidy/backends/local/json.py +++ b/mopidy/backends/local/json.py @@ -56,7 +56,21 @@ class JsonLibrary(local.Library): self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) return len(self._tracks) - def tracks(self): + def lookup(self, uri): + try: + return self._tracks[uri] + except KeyError: + return None + + def search(self, query=None, limit=100, offset=0, uris=None, exact=False): + tracks = self._tracks.values() + # TODO: pass limit and offset into search helpers + if exact: + return search.find_exact(tracks, query=query, uris=uris) + else: + return search.search(tracks, query=query, uris=uris) + + def begin(self): return self._tracks.itervalues() def add(self, track): @@ -65,18 +79,12 @@ class JsonLibrary(local.Library): def remove(self, uri): self._tracks.pop(uri, None) - def commit(self): + def close(self): write_library(self._json_file, {'tracks': self._tracks.values()}) - def lookup(self, uri): + def clear(self): try: - return self._tracks[uri] - except KeyError: - return None - - def search(self, query=None, uris=None, exact=False): - tracks = self._tracks.values() - if exact: - return search.find_exact(tracks, query=query, uris=uris) - else: - return search.search(tracks, query=query, uris=uris) + os.remove(self._json_file) + return True + except OSError: + return False From 09c0ae2551b3ebb2639c773f0af114f7e43dcef4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Dec 2013 01:31:00 +0100 Subject: [PATCH 091/238] local: Add flush threshold to scanner. Instead of triggering every 1000th query, this is now configurable and also triggers the flush call to the library. --- docs/ext/local.rst | 5 +++++ mopidy/backends/local/__init__.py | 1 + mopidy/backends/local/commands.py | 33 +++++++++++-------------------- mopidy/backends/local/ext.conf | 1 + 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 5cf4b2d3..4484ab0d 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -58,6 +58,11 @@ Configuration values Number of milliseconds before giving up scanning a file and moving on to the next file. +.. confval:: local/scan_flush_threshold + + Number of tracks to wait before telling library it should try and store + it's progress so far. Some libraries might not respect this setting. + .. confval:: local/excluded_file_extensions File extensions to exclude when scanning the media directory. Values diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 4031c474..6697d91d 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -28,6 +28,7 @@ class Extension(ext.Extension): schema['tag_cache_file'] = config.Deprecated() schema['scan_timeout'] = config.Integer( minimum=1000, maximum=1000*60*60) + schema['scan_flush_threshold'] = config.Integer(minimum=0) schema['excluded_file_extensions'] = config.List(optional=True) return schema diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index a2098078..65bfd274 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -25,6 +25,7 @@ class ScanCommand(commands.Command): def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] + flush_threshold = config['local']['scan_flush_threshold'] excluded_file_extensions = config['local']['excluded_file_extensions'] excluded_file_extensions = set( file_ext.lower() for file_ext in excluded_file_extensions) @@ -80,7 +81,9 @@ class ScanCommand(commands.Command): logger.info('Scanning...') scanner = scan.Scanner(scan_timeout) - progress = Progress(len(uris_to_update)) + count = 0 + total = len(uris_to_update) + start = time.time() for uri in sorted(uris_to_update): try: @@ -91,26 +94,14 @@ class ScanCommand(commands.Command): except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) - # TODO: trigger this on batch size intervals instead and add - # flush - progress.increment() + count += 1 + if count % flush_threshold == 0 or count == total: + duration = time.time() - start + remainder = duration / count * (total - count) + logger.info('Scanned %d of %d files in %ds, ~%ds left.', + count, total, duration, remainder) + library.flush() - logger.info('Commiting changes.') library.close() + logger.info('Done scanning.') return 0 - - -# TODO: move to utils? -class Progress(object): - def __init__(self, total): - self.count = 0 - self.total = total - self.start = time.time() - - def increment(self): - self.count += 1 - if self.count % 1000 == 0 or self.count == self.total: - duration = time.time() - self.start - remainder = duration / self.count * (self.total - self.count) - logger.info('Scanned %d of %d files in %ds, ~%ds left.', - self.count, self.total, duration, remainder) diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index 5f83db05..8f1e860c 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -5,6 +5,7 @@ media_dir = $XDG_MUSIC_DIR data_dir = $XDG_DATA_DIR/mopidy/local playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists scan_timeout = 1000 +scan_flush_threshold = 1000 excluded_file_extensions = .html .jpeg From 82877ec60f8ae85761c25f9866b4d80b6ee52a2b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Dec 2013 01:38:44 +0100 Subject: [PATCH 092/238] local: Add --clear flag to scanner. --- mopidy/backends/local/commands.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 65bfd274..1c290705 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -22,6 +22,11 @@ class LocalCommand(commands.Command): class ScanCommand(commands.Command): help = "Scan local media files and populate the local library." + def __init__(self): + super(ScanCommand, self).__init__() + self.add_argument('--clear', action='store_true', dest='clear', + help='Clear out library storage') + def run(self, args, config): media_dir = config['local']['media_dir'] scan_timeout = config['local']['scan_timeout'] @@ -40,6 +45,13 @@ class ScanCommand(commands.Command): library = libraries[library_name](config) logger.debug('Using %s as the local library', library_name) + if args.clear: + if library.clear(): + logging.info('Library succesfully cleared.') + return 0 + logging.warning('Unable to clear library.') + return 1 + uri_path_mapping = {} uris_in_library = set() uris_to_update = set() From a462f132d3f9cdd54505211151eb3b4af6a00de1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Dec 2013 01:43:11 +0100 Subject: [PATCH 093/238] local: Add --limit to scanner. --- mopidy/backends/local/commands.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 1c290705..8eaf9d0d 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -26,6 +26,8 @@ class ScanCommand(commands.Command): super(ScanCommand, self).__init__() self.add_argument('--clear', action='store_true', dest='clear', help='Clear out library storage') + self.add_argument('--limit', action='store', type=int, dest='limit', + default=0, help='Maxmimum number of tracks to scan') def run(self, args, config): media_dir = config['local']['media_dir'] @@ -94,10 +96,10 @@ class ScanCommand(commands.Command): scanner = scan.Scanner(scan_timeout) count = 0 - total = len(uris_to_update) + total = args.limit or len(uris_to_update) start = time.time() - for uri in sorted(uris_to_update): + for uri in sorted(uris_to_update)[:args.limit or None]: try: data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) track = scan.audio_data_to_track(data).copy(uri=uri) From f5430f4a7f9a23cfb34686f8ab1cb5c04496c6f4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 30 Dec 2013 21:47:02 +0100 Subject: [PATCH 094/238] local: Move --clear to it's own sub-command. Split library setup out into a helper and move the clear option to a command. --- docs/commands/mopidy.rst | 4 +++ mopidy/backends/local/commands.py | 53 ++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/commands/mopidy.rst b/docs/commands/mopidy.rst index 44e961e6..49c7b5b9 100644 --- a/docs/commands/mopidy.rst +++ b/docs/commands/mopidy.rst @@ -83,6 +83,10 @@ Additionally, extensions can provide extra commands. Run `mopidy --help` for a list of what is available on your system and command-specific help. Commands for disabled extensions will be listed, but can not be run. +.. cmdoption:: local clear + + Clear local media files from the local library. + .. cmdoption:: local scan Scan local media files present in your library. diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 8eaf9d0d..e4075fc5 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -13,19 +13,49 @@ from . import translator logger = logging.getLogger('mopidy.backends.local.commands') +def _get_library(args, config): + libraries = dict((l.name, l) for l in args.registry['local:library']) + library_name = config['local']['library'] + + if library_name not in libraries: + logger.warning('Local library %s not found', library_name) + return 1 + + logger.debug('Using %s as the local library', library_name) + return libraries[library_name](config) + + class LocalCommand(commands.Command): def __init__(self): super(LocalCommand, self).__init__() self.add_child('scan', ScanCommand()) + self.add_child('clear', ClearCommand()) + + +class ClearCommand(commands.Command): + help = 'Clear local media files from the local library.' + + def run(self, args, config): + library = _get_library(args, config) + prompt = 'Are you sure you want to clear the library? [y/N] ' + + if raw_input(prompt).lower() != 'y': + logging.info('Clearing library aborted.') + return 0 + + if library.clear(): + logging.info('Library succesfully cleared.') + return 0 + + logging.warning('Unable to clear library.') + return 1 class ScanCommand(commands.Command): - help = "Scan local media files and populate the local library." + help = 'Scan local media files and populate the local library.' def __init__(self): super(ScanCommand, self).__init__() - self.add_argument('--clear', action='store_true', dest='clear', - help='Clear out library storage') self.add_argument('--limit', action='store', type=int, dest='limit', default=0, help='Maxmimum number of tracks to scan') @@ -37,22 +67,7 @@ class ScanCommand(commands.Command): excluded_file_extensions = set( file_ext.lower() for file_ext in excluded_file_extensions) - libraries = dict((l.name, l) for l in args.registry['local:library']) - library_name = config['local']['library'] - - if library_name not in libraries: - logger.warning('Local library %s not found', library_name) - return 1 - - library = libraries[library_name](config) - logger.debug('Using %s as the local library', library_name) - - if args.clear: - if library.clear(): - logging.info('Library succesfully cleared.') - return 0 - logging.warning('Unable to clear library.') - return 1 + library = _get_library(args, config) uri_path_mapping = {} uris_in_library = set() From 621aff22c9771a40f4c5f8de0560507c81068dee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 14:04:25 +0100 Subject: [PATCH 095/238] http: Move mopidy.{frontends => }.http --- MANIFEST.in | 2 +- docs/api/frontends.rst | 2 +- docs/api/http.rst | 4 ++-- docs/ext/http.rst | 8 +------- js/Gruntfile.js | 4 ++-- js/README.md | 6 +++--- mopidy/{frontends => }/http/__init__.py | 0 mopidy/{frontends => }/http/actor.py | 2 +- mopidy/{frontends => }/http/data/favicon.png | Bin mopidy/{frontends => }/http/data/index.html | 0 mopidy/{frontends => }/http/data/mopidy.css | 0 mopidy/{frontends => }/http/data/mopidy.html | 0 mopidy/{frontends => }/http/data/mopidy.js | 0 mopidy/{frontends => }/http/data/mopidy.min.js | 0 mopidy/{frontends => }/http/ext.conf | 0 mopidy/{frontends => }/http/ws.py | 2 +- setup.py | 2 +- tests/{frontends => }/http/__init__.py | 0 tests/{frontends => }/http/events_test.py | 2 +- 19 files changed, 14 insertions(+), 20 deletions(-) rename mopidy/{frontends => }/http/__init__.py (100%) rename mopidy/{frontends => }/http/actor.py (98%) rename mopidy/{frontends => }/http/data/favicon.png (100%) rename mopidy/{frontends => }/http/data/index.html (100%) rename mopidy/{frontends => }/http/data/mopidy.css (100%) rename mopidy/{frontends => }/http/data/mopidy.html (100%) rename mopidy/{frontends => }/http/data/mopidy.js (100%) rename mopidy/{frontends => }/http/data/mopidy.min.js (100%) rename mopidy/{frontends => }/http/ext.conf (100%) rename mopidy/{frontends => }/http/ws.py (97%) rename tests/{frontends => }/http/__init__.py (100%) rename tests/{frontends => }/http/events_test.py (97%) diff --git a/MANIFEST.in b/MANIFEST.in index cacaa924..b3a70f17 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,6 @@ recursive-include docs * prune docs/_build recursive-include mopidy *.conf -recursive-include mopidy/frontends/http/data * +recursive-include mopidy/http/data * recursive-include tests *.py recursive-include tests/data * diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 70bd73cf..add6871a 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -47,5 +47,5 @@ The following requirements applies to any frontend implementation: Frontend implementations ======================== -* :mod:`mopidy.frontends.http` +* :mod:`mopidy.http` * :mod:`mopidy.frontends.mpd` diff --git a/docs/api/http.rst b/docs/api/http.rst index 16546683..c57597c7 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -113,8 +113,8 @@ HTML file: If you don't use Mopidy to host your web client, you can find the JS files in the Git repo at: -- ``mopidy/frontends/http/data/mopidy.js`` -- ``mopidy/frontends/http/data/mopidy.min.js`` +- ``mopidy/http/data/mopidy.js`` +- ``mopidy/http/data/mopidy.min.js`` Getting the library for Node.js use diff --git a/docs/ext/http.rst b/docs/ext/http.rst index d011a4b9..83881292 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -9,12 +9,6 @@ from a web based client. See :ref:`http-api` for details on how to integrate with Mopidy over HTTP. -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend - - Dependencies ============ @@ -32,7 +26,7 @@ install Mopidy with the extra dependencies for required for Mopidy-HTTP:: Default configuration ===================== -.. literalinclude:: ../../mopidy/frontends/http/ext.conf +.. literalinclude:: ../../mopidy/http/ext.conf :language: ini diff --git a/js/Gruntfile.js b/js/Gruntfile.js index 43a4770b..c1e687c9 100644 --- a/js/Gruntfile.js +++ b/js/Gruntfile.js @@ -11,8 +11,8 @@ module.exports = function (grunt) { " * Licensed under the Apache License, Version 2.0 */\n", files: { own: ["Gruntfile.js", "src/**/*.js", "test/**/*-test.js"], - concat: "../mopidy/frontends/http/data/mopidy.js", - minified: "../mopidy/frontends/http/data/mopidy.min.js" + concat: "../mopidy/http/data/mopidy.js", + minified: "../mopidy/http/data/mopidy.min.js" } }, buster: { diff --git a/js/README.md b/js/README.md index eddfa99f..753e858a 100644 --- a/js/README.md +++ b/js/README.md @@ -21,8 +21,8 @@ You may need to adjust hostname and port for your local setup. In the source repo, you can find the files at: -- `mopidy/frontends/http/data/mopidy.js` -- `mopidy/frontends/http/data/mopidy.min.js` +- `mopidy/http/data/mopidy.js` +- `mopidy/http/data/mopidy.min.js` Getting it for Node.js use @@ -72,7 +72,7 @@ To run tests automatically when you save a file: npm start To run tests, concatenate, minify the source, and update the JavaScript files -in `mopidy/frontends/http/data/`: +in `mopidy/http/data/`: npm run-script build diff --git a/mopidy/frontends/http/__init__.py b/mopidy/http/__init__.py similarity index 100% rename from mopidy/frontends/http/__init__.py rename to mopidy/http/__init__.py diff --git a/mopidy/frontends/http/actor.py b/mopidy/http/actor.py similarity index 98% rename from mopidy/frontends/http/actor.py rename to mopidy/http/actor.py index 5aef3506..cc7c11a1 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/http/actor.py @@ -14,7 +14,7 @@ from mopidy.core import CoreListener from . import ws -logger = logging.getLogger('mopidy.frontends.http') +logger = logging.getLogger('mopidy.http') class HttpFrontend(pykka.ThreadingActor, CoreListener): diff --git a/mopidy/frontends/http/data/favicon.png b/mopidy/http/data/favicon.png similarity index 100% rename from mopidy/frontends/http/data/favicon.png rename to mopidy/http/data/favicon.png diff --git a/mopidy/frontends/http/data/index.html b/mopidy/http/data/index.html similarity index 100% rename from mopidy/frontends/http/data/index.html rename to mopidy/http/data/index.html diff --git a/mopidy/frontends/http/data/mopidy.css b/mopidy/http/data/mopidy.css similarity index 100% rename from mopidy/frontends/http/data/mopidy.css rename to mopidy/http/data/mopidy.css diff --git a/mopidy/frontends/http/data/mopidy.html b/mopidy/http/data/mopidy.html similarity index 100% rename from mopidy/frontends/http/data/mopidy.html rename to mopidy/http/data/mopidy.html diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/http/data/mopidy.js similarity index 100% rename from mopidy/frontends/http/data/mopidy.js rename to mopidy/http/data/mopidy.js diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/http/data/mopidy.min.js similarity index 100% rename from mopidy/frontends/http/data/mopidy.min.js rename to mopidy/http/data/mopidy.min.js diff --git a/mopidy/frontends/http/ext.conf b/mopidy/http/ext.conf similarity index 100% rename from mopidy/frontends/http/ext.conf rename to mopidy/http/ext.conf diff --git a/mopidy/frontends/http/ws.py b/mopidy/http/ws.py similarity index 97% rename from mopidy/frontends/http/ws.py rename to mopidy/http/ws.py index d773b422..5a0f2039 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/http/ws.py @@ -9,7 +9,7 @@ from mopidy import core, models from mopidy.utils import jsonrpc -logger = logging.getLogger('mopidy.frontends.http') +logger = logging.getLogger('mopidy.http') class WebSocketResource(object): diff --git a/setup.py b/setup.py index bc2fe222..5ad9ddf2 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ setup( 'mopidy-convert-config = mopidy.config.convert:main', ], 'mopidy.ext': [ - 'http = mopidy.frontends.http:Extension [http]', + 'http = mopidy.http:Extension [http]', 'local = mopidy.backends.local:Extension', 'local-json = mopidy.backends.local.json:Extension', 'mpd = mopidy.frontends.mpd:Extension', diff --git a/tests/frontends/http/__init__.py b/tests/http/__init__.py similarity index 100% rename from tests/frontends/http/__init__.py rename to tests/http/__init__.py diff --git a/tests/frontends/http/events_test.py b/tests/http/events_test.py similarity index 97% rename from tests/frontends/http/events_test.py rename to tests/http/events_test.py index 5150db9b..dbfa8413 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/http/events_test.py @@ -15,7 +15,7 @@ except ImportError: ws4py = False if cherrypy and ws4py: - from mopidy.frontends.http import actor + from mopidy.http import actor @unittest.skipUnless(cherrypy, 'cherrypy not found') From 1ee534126ed85f3d0f98f546a3a6add764b06d54 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 14:11:16 +0100 Subject: [PATCH 096/238] mpd: Move mopidy.{frontends => }.mpd --- docs/api/core.rst | 4 +-- docs/api/frontends.rst | 2 +- docs/ext/mpd.rst | 5 ++-- docs/modules/{frontends => }/mpd.rst | 30 +++++++++---------- mopidy/{frontends => }/mpd/__init__.py | 0 mopidy/{frontends => }/mpd/actor.py | 4 +-- mopidy/{frontends => }/mpd/dispatcher.py | 6 ++-- mopidy/{frontends => }/mpd/exceptions.py | 0 mopidy/{frontends => }/mpd/ext.conf | 0 .../{frontends => }/mpd/protocol/__init__.py | 0 .../mpd/protocol/audio_output.py | 4 +-- .../{frontends => }/mpd/protocol/channels.py | 4 +-- .../mpd/protocol/command_list.py | 4 +-- .../mpd/protocol/connection.py | 4 +-- .../mpd/protocol/current_playlist.py | 6 ++-- mopidy/{frontends => }/mpd/protocol/empty.py | 2 +- .../{frontends => }/mpd/protocol/music_db.py | 6 ++-- .../{frontends => }/mpd/protocol/playback.py | 4 +-- .../mpd/protocol/reflection.py | 4 +-- mopidy/{frontends => }/mpd/protocol/status.py | 6 ++-- .../{frontends => }/mpd/protocol/stickers.py | 4 +-- .../mpd/protocol/stored_playlists.py | 6 ++-- mopidy/{frontends => }/mpd/session.py | 4 +-- mopidy/{frontends => }/mpd/translator.py | 2 +- setup.py | 2 +- tests/{frontends => }/mpd/__init__.py | 0 tests/{frontends => }/mpd/dispatcher_test.py | 6 ++-- tests/{frontends => }/mpd/exception_test.py | 2 +- .../{frontends => }/mpd/protocol/__init__.py | 2 +- .../mpd/protocol/audio_output_test.py | 2 +- .../mpd/protocol/authentication_test.py | 2 +- .../mpd/protocol/channels_test.py | 2 +- .../mpd/protocol/command_list_test.py | 2 +- .../mpd/protocol/connection_test.py | 2 +- .../mpd/protocol/current_playlist_test.py | 2 +- .../{frontends => }/mpd/protocol/idle_test.py | 4 +-- .../mpd/protocol/music_db_test.py | 4 +-- .../mpd/protocol/playback_test.py | 2 +- .../mpd/protocol/reflection_test.py | 2 +- .../mpd/protocol/regression_test.py | 2 +- .../mpd/protocol/status_test.py | 2 +- .../mpd/protocol/stickers_test.py | 2 +- .../mpd/protocol/stored_playlists_test.py | 2 +- tests/{frontends => }/mpd/status_test.py | 4 +-- tests/{frontends => }/mpd/translator_test.py | 2 +- 45 files changed, 80 insertions(+), 81 deletions(-) rename docs/modules/{frontends => }/mpd.rst (61%) rename mopidy/{frontends => }/mpd/__init__.py (100%) rename mopidy/{frontends => }/mpd/actor.py (96%) rename mopidy/{frontends => }/mpd/dispatcher.py (98%) rename mopidy/{frontends => }/mpd/exceptions.py (100%) rename mopidy/{frontends => }/mpd/ext.conf (100%) rename mopidy/{frontends => }/mpd/protocol/__init__.py (100%) rename mopidy/{frontends => }/mpd/protocol/audio_output.py (90%) rename mopidy/{frontends => }/mpd/protocol/channels.py (93%) rename mopidy/{frontends => }/mpd/protocol/command_list.py (95%) rename mopidy/{frontends => }/mpd/protocol/connection.py (91%) rename mopidy/{frontends => }/mpd/protocol/current_playlist.py (98%) rename mopidy/{frontends => }/mpd/protocol/empty.py (74%) rename mopidy/{frontends => }/mpd/protocol/music_db.py (98%) rename mopidy/{frontends => }/mpd/protocol/playback.py (99%) rename mopidy/{frontends => }/mpd/protocol/reflection.py (95%) rename mopidy/{frontends => }/mpd/protocol/status.py (98%) rename mopidy/{frontends => }/mpd/protocol/stickers.py (94%) rename mopidy/{frontends => }/mpd/protocol/stored_playlists.py (96%) rename mopidy/{frontends => }/mpd/session.py (93%) rename mopidy/{frontends => }/mpd/translator.py (99%) rename tests/{frontends => }/mpd/__init__.py (100%) rename tests/{frontends => }/mpd/dispatcher_test.py (91%) rename tests/{frontends => }/mpd/exception_test.py (97%) rename tests/{frontends => }/mpd/protocol/__init__.py (98%) rename tests/{frontends => }/mpd/protocol/audio_output_test.py (97%) rename tests/{frontends => }/mpd/protocol/authentication_test.py (98%) rename tests/{frontends => }/mpd/protocol/channels_test.py (95%) rename tests/{frontends => }/mpd/protocol/command_list_test.py (98%) rename tests/{frontends => }/mpd/protocol/connection_test.py (95%) rename tests/{frontends => }/mpd/protocol/current_playlist_test.py (99%) rename tests/{frontends => }/mpd/protocol/idle_test.py (98%) rename tests/{frontends => }/mpd/protocol/music_db_test.py (99%) rename tests/{frontends => }/mpd/protocol/playback_test.py (99%) rename tests/{frontends => }/mpd/protocol/reflection_test.py (98%) rename tests/{frontends => }/mpd/protocol/regression_test.py (99%) rename tests/{frontends => }/mpd/protocol/status_test.py (96%) rename tests/{frontends => }/mpd/protocol/stickers_test.py (96%) rename tests/{frontends => }/mpd/protocol/stored_playlists_test.py (99%) rename tests/{frontends => }/mpd/status_test.py (98%) rename tests/{frontends => }/mpd/translator_test.py (99%) diff --git a/docs/api/core.rst b/docs/api/core.rst index 0fd3e0c8..38cc0f0a 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -8,8 +8,8 @@ Core API :synopsis: Core API for use by frontends The core API is the interface that is used by frontends like -:mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the -backends. +:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is inbetween the +frontends and the backends. .. autoclass:: mopidy.core.Core :members: diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index add6871a..7afafa74 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -48,4 +48,4 @@ Frontend implementations ======================== * :mod:`mopidy.http` -* :mod:`mopidy.frontends.mpd` +* :mod:`mopidy.mpd` diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index eb502221..46a01715 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -12,8 +12,7 @@ server project `_. Mopidy does not depend on the original MPD server, but implements the MPD protocol itself, and is thus compatible with clients for the original MPD server. -For more details on our MPD server implementation, see -:mod:`mopidy.frontends.mpd`. +For more details on our MPD server implementation, see :mod:`mopidy.mpd`. Known issues @@ -54,7 +53,7 @@ None. The extension just needs Mopidy. Default configuration ===================== -.. literalinclude:: ../../mopidy/frontends/mpd/ext.conf +.. literalinclude:: ../../mopidy/mpd/ext.conf :language: ini diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/mpd.rst similarity index 61% rename from docs/modules/frontends/mpd.rst rename to docs/modules/mpd.rst index 750d19bb..85cb2789 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/mpd.rst @@ -1,17 +1,17 @@ ***************************************** -:mod:`mopidy.frontends.mpd` -- MPD server +:mod:`mopidy.mpd` -- MPD server ***************************************** For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. -.. automodule:: mopidy.frontends.mpd +.. automodule:: mopidy.mpd :synopsis: MPD server frontend MPD dispatcher ============== -.. automodule:: mopidy.frontends.mpd.dispatcher +.. automodule:: mopidy.mpd.dispatcher :synopsis: MPD request dispatcher :members: @@ -19,7 +19,7 @@ MPD dispatcher MPD protocol ============ -.. automodule:: mopidy.frontends.mpd.protocol +.. automodule:: mopidy.mpd.protocol :synopsis: MPD protocol :members: @@ -27,7 +27,7 @@ MPD protocol Audio output ------------ -.. automodule:: mopidy.frontends.mpd.protocol.audio_output +.. automodule:: mopidy.mpd.protocol.audio_output :synopsis: MPD protocol: audio output :members: @@ -35,7 +35,7 @@ Audio output Channels -------- -.. automodule:: mopidy.frontends.mpd.protocol.channels +.. automodule:: mopidy.mpd.protocol.channels :synopsis: MPD protocol: channels -- client to client communication :members: @@ -43,7 +43,7 @@ Channels Command list ------------ -.. automodule:: mopidy.frontends.mpd.protocol.command_list +.. automodule:: mopidy.mpd.protocol.command_list :synopsis: MPD protocol: command list :members: @@ -51,7 +51,7 @@ Command list Connection ---------- -.. automodule:: mopidy.frontends.mpd.protocol.connection +.. automodule:: mopidy.mpd.protocol.connection :synopsis: MPD protocol: connection :members: @@ -59,7 +59,7 @@ Connection Current playlist ---------------- -.. automodule:: mopidy.frontends.mpd.protocol.current_playlist +.. automodule:: mopidy.mpd.protocol.current_playlist :synopsis: MPD protocol: current playlist :members: @@ -67,7 +67,7 @@ Current playlist Music database -------------- -.. automodule:: mopidy.frontends.mpd.protocol.music_db +.. automodule:: mopidy.mpd.protocol.music_db :synopsis: MPD protocol: music database :members: @@ -75,7 +75,7 @@ Music database Playback -------- -.. automodule:: mopidy.frontends.mpd.protocol.playback +.. automodule:: mopidy.mpd.protocol.playback :synopsis: MPD protocol: playback :members: @@ -83,7 +83,7 @@ Playback Reflection ---------- -.. automodule:: mopidy.frontends.mpd.protocol.reflection +.. automodule:: mopidy.mpd.protocol.reflection :synopsis: MPD protocol: reflection :members: @@ -91,7 +91,7 @@ Reflection Status ------ -.. automodule:: mopidy.frontends.mpd.protocol.status +.. automodule:: mopidy.mpd.protocol.status :synopsis: MPD protocol: status :members: @@ -99,7 +99,7 @@ Status Stickers -------- -.. automodule:: mopidy.frontends.mpd.protocol.stickers +.. automodule:: mopidy.mpd.protocol.stickers :synopsis: MPD protocol: stickers :members: @@ -107,6 +107,6 @@ Stickers Stored playlists ---------------- -.. automodule:: mopidy.frontends.mpd.protocol.stored_playlists +.. automodule:: mopidy.mpd.protocol.stored_playlists :synopsis: MPD protocol: stored playlists :members: diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/mpd/__init__.py similarity index 100% rename from mopidy/frontends/mpd/__init__.py rename to mopidy/mpd/__init__.py diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/mpd/actor.py similarity index 96% rename from mopidy/frontends/mpd/actor.py rename to mopidy/mpd/actor.py index fb063f6c..9c33faaa 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -7,10 +7,10 @@ import pykka from mopidy import zeroconf from mopidy.core import CoreListener -from mopidy.frontends.mpd import session +from mopidy.mpd import session from mopidy.utils import encoding, network, process -logger = logging.getLogger('mopidy.frontends.mpd') +logger = logging.getLogger('mopidy.mpd') class MpdFrontend(pykka.ThreadingActor, CoreListener): diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py similarity index 98% rename from mopidy/frontends/mpd/dispatcher.py rename to mopidy/mpd/dispatcher.py index ec3b71f8..c3881f6f 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -5,9 +5,9 @@ import re import pykka -from mopidy.frontends.mpd import exceptions, protocol +from mopidy.mpd import exceptions, protocol -logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') +logger = logging.getLogger('mopidy.mpd.dispatcher') protocol.load_protocol_modules() @@ -221,7 +221,7 @@ class MpdContext(object): #: The current :class:`MpdDispatcher`. dispatcher = None - #: The current :class:`mopidy.frontends.mpd.MpdSession`. + #: The current :class:`mopidy.mpd.MpdSession`. session = None #: The Mopidy configuration. diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/mpd/exceptions.py similarity index 100% rename from mopidy/frontends/mpd/exceptions.py rename to mopidy/mpd/exceptions.py diff --git a/mopidy/frontends/mpd/ext.conf b/mopidy/mpd/ext.conf similarity index 100% rename from mopidy/frontends/mpd/ext.conf rename to mopidy/mpd/ext.conf diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/mpd/protocol/__init__.py similarity index 100% rename from mopidy/frontends/mpd/protocol/__init__.py rename to mopidy/mpd/protocol/__init__.py diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py similarity index 90% rename from mopidy/frontends/mpd/protocol/audio_output.py rename to mopidy/mpd/protocol/audio_output.py index ee1782bd..606eb1d3 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.exceptions import MpdNoExistError -from mopidy.frontends.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNoExistError +from mopidy.mpd.protocol import handle_request @handle_request(r'disableoutput\ "(?P\d+)"$') diff --git a/mopidy/frontends/mpd/protocol/channels.py b/mopidy/mpd/protocol/channels.py similarity index 93% rename from mopidy/frontends/mpd/protocol/channels.py rename to mopidy/mpd/protocol/channels.py index 1f54a41b..e8efd2a0 100644 --- a/mopidy/frontends/mpd/protocol/channels.py +++ b/mopidy/mpd/protocol/channels.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNotImplemented @handle_request(r'subscribe\ "(?P[A-Za-z0-9:._-]+)"$') diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/mpd/protocol/command_list.py similarity index 95% rename from mopidy/frontends/mpd/protocol/command_list.py rename to mopidy/mpd/protocol/command_list.py index c85a594b..8268c55d 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/mpd/protocol/command_list.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdUnknownCommand +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdUnknownCommand @handle_request(r'command_list_begin$') diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py similarity index 91% rename from mopidy/frontends/mpd/protocol/connection.py rename to mopidy/mpd/protocol/connection.py index 734ed37a..2c615e65 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import ( MpdPasswordError, MpdPermissionError) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py similarity index 98% rename from mopidy/frontends/mpd/protocol/current_playlist.py rename to mopidy/mpd/protocol/current_playlist.py index d5bf267a..b4e22a61 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd import translator +from mopidy.mpd.exceptions import ( MpdArgError, MpdNoExistError, MpdNotImplemented) -from mopidy.frontends.mpd.protocol import handle_request +from mopidy.mpd.protocol import handle_request @handle_request(r'add\ "(?P[^"]*)"$') diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/mpd/protocol/empty.py similarity index 74% rename from mopidy/frontends/mpd/protocol/empty.py rename to mopidy/mpd/protocol/empty.py index b2bb9482..9b3d6883 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/mpd/protocol/empty.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request +from mopidy.mpd.protocol import handle_request @handle_request(r'[\ ]*$') diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py similarity index 98% rename from mopidy/frontends/mpd/protocol/music_db.py rename to mopidy/mpd/protocol/music_db.py index e1d718c0..6e7e3956 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -5,9 +5,9 @@ import itertools import re from mopidy.models import Track -from mopidy.frontends.mpd import translator -from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented -from mopidy.frontends.mpd.protocol import handle_request, stored_playlists +from mopidy.mpd import translator +from mopidy.mpd.exceptions import MpdArgError, MpdNotImplemented +from mopidy.mpd.protocol import handle_request, stored_playlists LIST_QUERY = r""" diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py similarity index 99% rename from mopidy/frontends/mpd/protocol/playback.py rename to mopidy/mpd/protocol/playback.py index 27ee6d4b..c09afde8 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals from mopidy.core import PlaybackState -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import ( MpdArgError, MpdNoExistError, MpdNotImplemented) diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/mpd/protocol/reflection.py similarity index 95% rename from mopidy/frontends/mpd/protocol/reflection.py rename to mopidy/mpd/protocol/reflection.py index d5206120..79aa1247 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/mpd/protocol/reflection.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.exceptions import MpdPermissionError -from mopidy.frontends.mpd.protocol import handle_request, mpd_commands +from mopidy.mpd.exceptions import MpdPermissionError +from mopidy.mpd.protocol import handle_request, mpd_commands @handle_request(r'config$', auth_required=False) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py similarity index 98% rename from mopidy/frontends/mpd/protocol/status.py rename to mopidy/mpd/protocol/status.py index 2fe3a402..96bca6d6 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -3,9 +3,9 @@ from __future__ import unicode_literals import pykka from mopidy.core import PlaybackState -from mopidy.frontends.mpd.exceptions import MpdNotImplemented -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import track_to_mpd_format +from mopidy.mpd.exceptions import MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.translator import track_to_mpd_format #: Subsystems that can be registered with idle command. SUBSYSTEMS = [ diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py similarity index 94% rename from mopidy/frontends/mpd/protocol/stickers.py rename to mopidy/mpd/protocol/stickers.py index 84417e51..1243d7a6 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNotImplemented @handle_request( diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py similarity index 96% rename from mopidy/frontends/mpd/protocol/stored_playlists.py rename to mopidy/mpd/protocol/stored_playlists.py index 974dbc7f..6564236e 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import datetime as dt -from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented -from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import playlist_to_mpd_format +from mopidy.mpd.exceptions import MpdNoExistError, MpdNotImplemented +from mopidy.mpd.protocol import handle_request +from mopidy.mpd.translator import playlist_to_mpd_format @handle_request(r'listplaylist\ ("?)(?P[^"]+)\1$') diff --git a/mopidy/frontends/mpd/session.py b/mopidy/mpd/session.py similarity index 93% rename from mopidy/frontends/mpd/session.py rename to mopidy/mpd/session.py index 14173308..5a531a79 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/mpd/session.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals import logging -from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.mpd import dispatcher, protocol from mopidy.utils import formatting, network -logger = logging.getLogger('mopidy.frontends.mpd') +logger = logging.getLogger('mopidy.mpd') class MpdSession(network.LineProtocol): diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/mpd/translator.py similarity index 99% rename from mopidy/frontends/mpd/translator.py rename to mopidy/mpd/translator.py index 671bfae7..49ebce35 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import shlex -from mopidy.frontends.mpd.exceptions import MpdArgError +from mopidy.mpd.exceptions import MpdArgError from mopidy.models import TlTrack # TODO: special handling of local:// uri scheme diff --git a/setup.py b/setup.py index 5ad9ddf2..b6857d4e 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( 'http = mopidy.http:Extension [http]', 'local = mopidy.backends.local:Extension', 'local-json = mopidy.backends.local.json:Extension', - 'mpd = mopidy.frontends.mpd:Extension', + 'mpd = mopidy.mpd:Extension', 'stream = mopidy.backends.stream:Extension', ], }, diff --git a/tests/frontends/mpd/__init__.py b/tests/mpd/__init__.py similarity index 100% rename from tests/frontends/mpd/__init__.py rename to tests/mpd/__init__.py diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/mpd/dispatcher_test.py similarity index 91% rename from tests/frontends/mpd/dispatcher_test.py rename to tests/mpd/dispatcher_test.py index 9ef88e44..13f2d7a5 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/mpd/dispatcher_test.py @@ -6,9 +6,9 @@ import pykka from mopidy import core from mopidy.backends import dummy -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.exceptions import MpdAckError -from mopidy.frontends.mpd.protocol import request_handlers, handle_request +from mopidy.mpd.dispatcher import MpdDispatcher +from mopidy.mpd.exceptions import MpdAckError +from mopidy.mpd.protocol import request_handlers, handle_request class MpdDispatcherTest(unittest.TestCase): diff --git a/tests/frontends/mpd/exception_test.py b/tests/mpd/exception_test.py similarity index 97% rename from tests/frontends/mpd/exception_test.py rename to tests/mpd/exception_test.py index 3b42f1b9..ae59253e 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/mpd/exception_test.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import unittest -from mopidy.frontends.mpd.exceptions import ( +from mopidy.mpd.exceptions import ( MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, MpdNotImplemented) diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py similarity index 98% rename from tests/frontends/mpd/protocol/__init__.py rename to tests/mpd/protocol/__init__.py index aa9a5a6d..9f3b58d6 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -7,7 +7,7 @@ import pykka from mopidy import core from mopidy.backends import dummy -from mopidy.frontends.mpd import session +from mopidy.mpd import session class MockConnection(mock.Mock): diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/mpd/protocol/audio_output_test.py similarity index 97% rename from tests/frontends/mpd/protocol/audio_output_test.py rename to tests/mpd/protocol/audio_output_test.py index 4871f169..643682ef 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/mpd/protocol/audio_output_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/mpd/protocol/authentication_test.py similarity index 98% rename from tests/frontends/mpd/protocol/authentication_test.py rename to tests/mpd/protocol/authentication_test.py index 2597ddef..6a39ba81 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/mpd/protocol/authentication_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class AuthenticationActiveTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/channels_test.py b/tests/mpd/protocol/channels_test.py similarity index 95% rename from tests/frontends/mpd/protocol/channels_test.py rename to tests/mpd/protocol/channels_test.py index 86cf8197..5d4ee670 100644 --- a/tests/frontends/mpd/protocol/channels_test.py +++ b/tests/mpd/protocol/channels_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/mpd/protocol/command_list_test.py similarity index 98% rename from tests/frontends/mpd/protocol/command_list_test.py rename to tests/mpd/protocol/command_list_test.py index 222dcb61..9d66bd5d 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/mpd/protocol/command_list_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class CommandListsTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/mpd/protocol/connection_test.py similarity index 95% rename from tests/frontends/mpd/protocol/connection_test.py rename to tests/mpd/protocol/connection_test.py index 01deb7a7..452a2147 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/mpd/protocol/connection_test.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from mock import patch -from tests.frontends.mpd import protocol +from tests.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/mpd/protocol/current_playlist_test.py similarity index 99% rename from tests/frontends/mpd/protocol/current_playlist_test.py rename to tests/mpd/protocol/current_playlist_test.py index fc4640b1..f94ec6a0 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/mpd/protocol/current_playlist_test.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class CurrentPlaylistHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/idle_test.py b/tests/mpd/protocol/idle_test.py similarity index 98% rename from tests/frontends/mpd/protocol/idle_test.py rename to tests/mpd/protocol/idle_test.py index e6910988..cc937119 100644 --- a/tests/frontends/mpd/protocol/idle_test.py +++ b/tests/mpd/protocol/idle_test.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals from mock import patch -from mopidy.frontends.mpd.protocol.status import SUBSYSTEMS +from mopidy.mpd.protocol.status import SUBSYSTEMS -from tests.frontends.mpd import protocol +from tests.mpd import protocol class IdleHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py similarity index 99% rename from tests/frontends/mpd/protocol/music_db_test.py rename to tests/mpd/protocol/music_db_test.py index 52a7a390..09db53ae 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals import unittest -from mopidy.frontends.mpd.protocol import music_db +from mopidy.mpd.protocol import music_db from mopidy.models import Album, Artist, SearchResult, Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class QueryFromMpdSearchFormatTest(unittest.TestCase): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/mpd/protocol/playback_test.py similarity index 99% rename from tests/frontends/mpd/protocol/playback_test.py rename to tests/mpd/protocol/playback_test.py index fc91c09c..a572aabe 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/mpd/protocol/playback_test.py @@ -5,7 +5,7 @@ import unittest from mopidy.core import PlaybackState from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol PAUSED = PlaybackState.PAUSED diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/mpd/protocol/reflection_test.py similarity index 98% rename from tests/frontends/mpd/protocol/reflection_test.py rename to tests/mpd/protocol/reflection_test.py index 16f4579f..160c9876 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/mpd/protocol/reflection_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/mpd/protocol/regression_test.py similarity index 99% rename from tests/frontends/mpd/protocol/regression_test.py rename to tests/mpd/protocol/regression_test.py index 0bc488fd..3389573f 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/mpd/protocol/regression_test.py @@ -4,7 +4,7 @@ import random from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class IssueGH17RegressionTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/mpd/protocol/status_test.py similarity index 96% rename from tests/frontends/mpd/protocol/status_test.py rename to tests/mpd/protocol/status_test.py index 1cf5f253..8ded6938 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/mpd/protocol/status_test.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from mopidy.models import Track -from tests.frontends.mpd import protocol +from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/stickers_test.py b/tests/mpd/protocol/stickers_test.py similarity index 96% rename from tests/frontends/mpd/protocol/stickers_test.py rename to tests/mpd/protocol/stickers_test.py index de610521..31fd5da0 100644 --- a/tests/frontends/mpd/protocol/stickers_test.py +++ b/tests/mpd/protocol/stickers_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from tests.frontends.mpd import protocol +from tests.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/mpd/protocol/stored_playlists_test.py similarity index 99% rename from tests/frontends/mpd/protocol/stored_playlists_test.py rename to tests/mpd/protocol/stored_playlists_test.py index d75944c4..a65b3ed7 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/mpd/protocol/stored_playlists_test.py @@ -4,7 +4,7 @@ import datetime from mopidy.models import Track, Playlist -from tests.frontends.mpd import protocol +from tests.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/status_test.py b/tests/mpd/status_test.py similarity index 98% rename from tests/frontends/mpd/status_test.py rename to tests/mpd/status_test.py index d86f7dcd..dea0c479 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/mpd/status_test.py @@ -7,8 +7,8 @@ import pykka from mopidy import core from mopidy.backends import dummy from mopidy.core import PlaybackState -from mopidy.frontends.mpd import dispatcher -from mopidy.frontends.mpd.protocol import status +from mopidy.mpd import dispatcher +from mopidy.mpd.protocol import status from mopidy.models import Track diff --git a/tests/frontends/mpd/translator_test.py b/tests/mpd/translator_test.py similarity index 99% rename from tests/frontends/mpd/translator_test.py rename to tests/mpd/translator_test.py index 1db10ab9..c2648311 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/mpd/translator_test.py @@ -4,7 +4,7 @@ import datetime import unittest from mopidy.utils.path import mtime -from mopidy.frontends.mpd import translator +from mopidy.mpd import translator from mopidy.models import Album, Artist, TlTrack, Playlist, Track From 4b9ab5fcbc665c685a33ecaf936007c7856791da Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 14:14:13 +0100 Subject: [PATCH 097/238] Remove empty "frontends" packages --- mopidy/frontends/__init__.py | 1 - tests/frontends/__init__.py | 1 - 2 files changed, 2 deletions(-) delete mode 100644 mopidy/frontends/__init__.py delete mode 100644 tests/frontends/__init__.py diff --git a/mopidy/frontends/__init__.py b/mopidy/frontends/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/mopidy/frontends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/frontends/__init__.py b/tests/frontends/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/frontends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals From 9eab3cc8ceef924d7ad906f7c4e168afaf1dbcf9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 14:25:43 +0100 Subject: [PATCH 098/238] docs: Add Ref model to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 601de39e..56177441 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,9 @@ v0.18.0 (UNRELEASED) - Expose :meth:`mopidy.core.Core.version` for HTTP clients to manage compatibility between API versions. (Fixes: :issue:`597`) +- Add :class:`mopidy.models.Ref` class for use as a lightweight reference to + other model types, containing just an URI, a name, and an object type. + **Pluggable local libraries** Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes From e82ce6256d6b1dc2c1306b34d65e5c966e74561d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Dec 2013 14:45:57 +0100 Subject: [PATCH 099/238] core: Re-add one uri scheme per backend constraint. --- mopidy/core/actor.py | 29 ++++++++++++++--------------- tests/core/actor_test.py | 24 +++--------------------- 2 files changed, 17 insertions(+), 36 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4924cca2..dba8d76d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -91,25 +91,24 @@ class Backends(list): self.with_playback = collections.OrderedDict() self.with_playlists = collections.OrderedDict() + backends_by_scheme = {} + name = lambda backend: backend.actor_ref.actor_class.__name__ + for backend in backends: has_library = backend.has_library().get() has_playback = backend.has_playback().get() has_playlists = backend.has_playlists().get() for scheme in backend.uri_schemes.get(): - self.add(self.with_library, has_library, scheme, backend) - self.add(self.with_playback, has_playback, scheme, backend) - self.add(self.with_playlists, has_playlists, scheme, backend) + assert scheme not in backends_by_scheme, ( + 'Cannot add URI scheme %s for %s, ' + 'it is already handled by %s' + ) % (scheme, name(backend), name(backends_by_scheme[scheme])) + backends_by_scheme[scheme] = backend - def add(self, registry, supported, uri_scheme, backend): - if not supported: - return - - if uri_scheme not in registry: - registry[uri_scheme] = backend - return - - get_name = lambda actor: actor.actor_ref.actor_class.__name__ - raise AssertionError( - 'Cannot add URI scheme %s for %s, it is already handled by %s' % - (uri_scheme, get_name(backend), get_name(registry[uri_scheme]))) + if has_library: + self.with_library[scheme] = backend + if has_playback: + self.with_playback[scheme] = backend + if has_playlists: + self.with_playlists[scheme] = backend diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index e9e5f396..4a808cad 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -13,9 +13,11 @@ class CoreActorTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.backend1.actor_ref.actor_class.__name__ = b'B1' self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.backend2.actor_ref.actor_class.__name__ = b'B2' self.core = Core(audio=None, backends=[self.backend1, self.backend2]) @@ -29,32 +31,12 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): - self.backend1.actor_ref.actor_class.__name__ = b'B1' - self.backend2.actor_ref.actor_class.__name__ = b'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', Core, audio=None, backends=[self.backend1, self.backend2]) - def test_backends_with_colliding_uri_schemes_passes(self): - """ - Checks that backends with overlapping schemes, but distinct sub parts - provided can co-exist. - """ - - self.backend1.has_library().get.return_value = False - self.backend1.has_playlists().get.return_value = False - - self.backend2.uri_schemes.get.return_value = ['dummy1'] - self.backend2.has_playback().get.return_value = False - self.backend2.has_playlists().get.return_value = False - - core = Core(audio=None, backends=[self.backend1, self.backend2]) - self.assertEqual(core.backends.with_playback, - {'dummy1': self.backend1}) - self.assertEqual(core.backends.with_library, - {'dummy1': self.backend2}) - def test_version(self): self.assertEqual(self.core.version, versioning.get_version()) From 63a83754294de8f4d0ac4d5c8a862bdbf8555818 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Dec 2013 14:57:54 +0100 Subject: [PATCH 100/238] local: Review comments and library interface update. Added return value to flush so we can log what is being done. --- docs/ext/local.rst | 2 +- mopidy/backends/local/__init__.py | 15 +++++++++------ mopidy/backends/local/commands.py | 9 ++++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 4484ab0d..135a486b 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -61,7 +61,7 @@ Configuration values .. confval:: local/scan_flush_threshold Number of tracks to wait before telling library it should try and store - it's progress so far. Some libraries might not respect this setting. + its progress so far. Some libraries might not respect this setting. .. confval:: local/excluded_file_extensions diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 6697d91d..d16eddfb 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -50,9 +50,10 @@ class Library(object): """ Local library interface. - Extensions that whish to provide an alternate local library storage backend - need to sub-class this class and install and confgure it with an extension. - Both scanning and library calls will use the active local library. + Extensions that wish to provide an alternate local library storage backend + need to sub-class this class and install and configure it with an + extension. Both scanning and library calls will use the active local + library. :param config: Config dictionary """ @@ -133,10 +134,12 @@ class Library(object): def flush(self): """ - Called for every n-th track indicating that work should be commited, - implementors are free to ignore these hints. + Called for every n-th track indicating that work should be comitted. + Sub-classes are free to ignore these hints. + + :rtype: Boolean indicating if state was flushed. """ - pass + return False def close(self): """ diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index e4075fc5..9dff43d5 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -56,8 +56,9 @@ class ScanCommand(commands.Command): def __init__(self): super(ScanCommand, self).__init__() - self.add_argument('--limit', action='store', type=int, dest='limit', - default=0, help='Maxmimum number of tracks to scan') + self.add_argument('--limit', + action='store', type=int, dest='limit', default=None, + help='Maxmimum number of tracks to scan') def run(self, args, config): media_dir = config['local']['media_dir'] @@ -114,7 +115,7 @@ class ScanCommand(commands.Command): total = args.limit or len(uris_to_update) start = time.time() - for uri in sorted(uris_to_update)[:args.limit or None]: + for uri in sorted(uris_to_update)[:args.limit]: try: data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) track = scan.audio_data_to_track(data).copy(uri=uri) @@ -129,6 +130,8 @@ class ScanCommand(commands.Command): remainder = duration / count * (total - count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', count, total, duration, remainder) + # TODO: log if flush succeeded + # TODO: don't flush when count == total library.flush() library.close() From e880cb56b56bbffeac2ea4681009694f82c987ee Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Dec 2013 15:09:54 +0100 Subject: [PATCH 101/238] local: Re-add progress helper and log flushes. Refactored the progress helper to be used in batching flushes in addition to logging. --- mopidy/backends/local/commands.py | 42 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 9dff43d5..08d6a942 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -110,12 +110,12 @@ class ScanCommand(commands.Command): logger.info('Found %d unknown tracks.', len(uris_to_update)) logger.info('Scanning...') - scanner = scan.Scanner(scan_timeout) - count = 0 - total = args.limit or len(uris_to_update) - start = time.time() + uris_to_update = sorted(uris_to_update)[:args.limit] - for uri in sorted(uris_to_update)[:args.limit]: + scanner = scan.Scanner(scan_timeout) + progress = _Progress(flush_threshold, len(uris_to_update)) + + for uri in uris_to_update: try: data = scanner.scan(path.path_to_uri(uri_path_mapping[uri])) track = scan.audio_data_to_track(data).copy(uri=uri) @@ -124,16 +124,30 @@ class ScanCommand(commands.Command): except exceptions.ScannerError as error: logger.warning('Failed %s: %s', uri, error) - count += 1 - if count % flush_threshold == 0 or count == total: - duration = time.time() - start - remainder = duration / count * (total - count) - logger.info('Scanned %d of %d files in %ds, ~%ds left.', - count, total, duration, remainder) - # TODO: log if flush succeeded - # TODO: don't flush when count == total - library.flush() + if progress.increment(): + progress.log() + if library.flush(): + logger.debug('Progress flushed.') + progress.log() library.close() logger.info('Done scanning.') return 0 + + +class _Progress(object): + def __init__(self, batch_size, total): + self.count = 0 + self.batch_size = batch_size + self.total = total + self.start = time.time() + + def increment(self): + self.count += 1 + return self.count % self.batch_size == 0 + + def log(self): + duration = time.time() - self.start + remainder = duration / self.count * (self.total - self.count) + logger.info('Scanned %d of %d files in %ds, ~%ds left.', + self.count, self.total, duration, remainder) From 29bb2d52b62c8a4d54978c392130f84f77357766 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 31 Dec 2013 15:31:09 +0100 Subject: [PATCH 102/238] local: Re-add test for #500 fix. --- mopidy/backends/local/json.py | 1 + tests/backends/local/library_test.py | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/mopidy/backends/local/json.py b/mopidy/backends/local/json.py index d1cacd6d..e4779ffe 100644 --- a/mopidy/backends/local/json.py +++ b/mopidy/backends/local/json.py @@ -52,6 +52,7 @@ class JsonLibrary(local.Library): config['local']['data_dir'], b'library.json.gz') def load(self): + logger.debug('Loading json library from %s', self._json_file) library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) return len(self._tracks) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 40bece7d..4ca5abf0 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals -import copy +import os +import shutil import tempfile import unittest @@ -89,31 +90,30 @@ class LocalLibraryProviderTest(unittest.TestCase): # Verifies that https://github.com/mopidy/mopidy/issues/500 # has been fixed. - # TODO: re-add something that tests this in a more sane way - return + tmpdir = tempfile.mkdtemp() + try: + tmplib = os.path.join(tmpdir, 'library.json.gz') + shutil.copy(path_to_data_dir('library.json.gz'), tmplib) - with tempfile.NamedTemporaryFile() as library: - with open(self.config['local-json']['json_file']) as fh: - library.write(fh.read()) - library.flush() - - config = copy.deepcopy(self.config) - config['local-json']['json_file'] = library.name + config = {'local': self.config['local'].copy()} + config['local']['data_dir'] = tmpdir backend = actor.LocalBackend(config=config, audio=None) # Sanity check that value is in the library result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, self.tracks[0:1]) - # Clear library and refresh - library.seek(0) - library.truncate() + # Clear and refresh. + open(tmplib, 'w').close() backend.library.refresh() # Now it should be gone. result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, []) + finally: + shutil.rmtree(tmpdir) + def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) self.assertEqual(tracks, self.tracks[0:1]) From 79e0fe6d87e7e586e0d814bac8e6b65578561a7a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 16:31:45 +0100 Subject: [PATCH 103/238] docs: Align header lines --- docs/modules/mpd.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/modules/mpd.rst b/docs/modules/mpd.rst index 85cb2789..4a9eb7e8 100644 --- a/docs/modules/mpd.rst +++ b/docs/modules/mpd.rst @@ -1,6 +1,6 @@ -***************************************** +******************************* :mod:`mopidy.mpd` -- MPD server -***************************************** +******************************* For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. From a9ab02737c48420841630e8b9db1c98ad7264276 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 16:32:58 +0100 Subject: [PATCH 104/238] docs: Remove outdated links to issue labels --- docs/ext/local.rst | 6 ------ docs/ext/mpd.rst | 6 ------ docs/ext/stream.rst | 6 ------ 3 files changed, 18 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index cbde826f..0c16142c 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -9,12 +9,6 @@ Extension for playing music from a local music archive. This backend handles URIs starting with ``local:``. -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=Local+backend - - Dependencies ============ diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 46a01715..fa91f6a2 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -15,12 +15,6 @@ compatible with clients for the original MPD server. For more details on our MPD server implementation, see :mod:`mopidy.mpd`. -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=MPD+frontend - - Limitations =========== diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index 30bc22ab..22e7d99e 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -11,12 +11,6 @@ The stream backend will handle streaming of URIs matching the are installed. -Known issues -============ - -https://github.com/mopidy/mopidy/issues?labels=Stream+backend - - Dependencies ============ From 9eb6307607f94d7bb0980eb605f21cfe0f140a5f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 31 Dec 2013 16:36:04 +0100 Subject: [PATCH 105/238] docs: Fix broken label reference --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 56177441..3898e5f3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1701,8 +1701,8 @@ to this problem. - Local backend: - Add :command:`mopidy-scan` command to generate ``tag_cache`` files without - any help from the original MPD server. See :ref:`generating-a-tag-cache` - for instructions on how to use it. + any help from the original MPD server. See + :ref:`generating-a-local-library` for instructions on how to use it. - Fix support for UTF-8 encoding in tag caches. From 9e905e2e4bc7e1c42d079b17b960da2c66203551 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 1 Jan 2014 13:15:47 +0100 Subject: [PATCH 106/238] docs: Bump copyright year to 2014 --- docs/authors.rst | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 54ba95af..7c00e2ac 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,7 +4,7 @@ Authors ******* -Mopidy is copyright 2009-2013 Stein Magnus Jodal and contributors. Mopidy is +Mopidy is copyright 2009-2014 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. diff --git a/docs/conf.py b/docs/conf.py index 5417a55c..bb9b7c2f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,7 +97,7 @@ source_suffix = '.rst' master_doc = 'index' project = 'Mopidy' -copyright = '2009-2013, Stein Magnus Jodal and contributors' +copyright = '2009-2014, Stein Magnus Jodal and contributors' from mopidy.utils.versioning import get_version release = get_version() From 0fb7c795242bb2b6018041bb07b914b6b0aa604b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 1 Jan 2014 13:31:20 +0100 Subject: [PATCH 107/238] log: Use loggers named after __name__ --- mopidy/__main__.py | 2 +- mopidy/audio/actor.py | 2 +- mopidy/audio/mixers/auto.py | 2 +- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/local/actor.py | 2 +- mopidy/backends/local/commands.py | 2 +- mopidy/backends/local/json/actor.py | 2 +- mopidy/backends/local/json/library.py | 2 +- mopidy/backends/local/playback.py | 2 +- mopidy/backends/local/playlists.py | 2 +- mopidy/backends/local/translator.py | 2 +- mopidy/backends/stream/actor.py | 2 +- mopidy/commands.py | 2 +- mopidy/config/__init__.py | 2 +- mopidy/config/keyring.py | 2 +- mopidy/core/playback.py | 2 +- mopidy/core/tracklist.py | 2 +- mopidy/ext.py | 2 +- mopidy/http/actor.py | 2 +- mopidy/http/ws.py | 2 +- mopidy/listener.py | 2 +- mopidy/mpd/actor.py | 2 +- mopidy/mpd/dispatcher.py | 2 +- mopidy/mpd/session.py | 2 +- mopidy/utils/network.py | 2 +- mopidy/utils/path.py | 2 +- mopidy/utils/process.py | 2 +- mopidy/zeroconf.py | 2 +- 28 files changed, 28 insertions(+), 28 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 6a6c0eb8..2e6a6cc5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -29,7 +29,7 @@ from mopidy import commands, ext from mopidy import config as config_lib from mopidy.utils import log, path, process, versioning -logger = logging.getLogger('mopidy.main') +logger = logging.getLogger(__name__) def main(): diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5c931865..feeee820 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -15,7 +15,7 @@ from . import mixers, playlists, utils from .constants import PlaybackState from .listener import AudioListener -logger = logging.getLogger('mopidy.audio') +logger = logging.getLogger(__name__) mixers.register_mixers() diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 023674bf..7e59b602 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -12,7 +12,7 @@ import gst import logging -logger = logging.getLogger('mopidy.audio.mixers.auto') +logger = logging.getLogger(__name__) # TODO: we might want to add some ranking to the mixers we know about? diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index dedc868c..e9204338 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,7 +7,7 @@ import mopidy from mopidy import config, ext from mopidy.utils import encoding, path -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) class Extension(ext.Extension): diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index a73f627e..a9902c8b 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -11,7 +11,7 @@ from mopidy.utils import encoding, path from .playlists import LocalPlaylistsProvider from .playback import LocalPlaybackProvider -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) class LocalBackend(pykka.ThreadingActor, base.Backend): diff --git a/mopidy/backends/local/commands.py b/mopidy/backends/local/commands.py index 5e9b42e6..e9eb807c 100644 --- a/mopidy/backends/local/commands.py +++ b/mopidy/backends/local/commands.py @@ -10,7 +10,7 @@ from mopidy.utils import path from . import translator -logger = logging.getLogger('mopidy.backends.local.commands') +logger = logging.getLogger(__name__) class LocalCommand(commands.Command): diff --git a/mopidy/backends/local/json/actor.py b/mopidy/backends/local/json/actor.py index 66a6fbd5..4fc46417 100644 --- a/mopidy/backends/local/json/actor.py +++ b/mopidy/backends/local/json/actor.py @@ -10,7 +10,7 @@ from mopidy.utils import encoding from . import library -logger = logging.getLogger('mopidy.backends.local.json') +logger = logging.getLogger(__name__) class LocalJsonBackend(pykka.ThreadingActor, base.Backend): diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py index 33427231..99640543 100644 --- a/mopidy/backends/local/json/library.py +++ b/mopidy/backends/local/json/library.py @@ -11,7 +11,7 @@ from mopidy import models from mopidy.backends import base from mopidy.backends.local import search -logger = logging.getLogger('mopidy.backends.local.json') +logger = logging.getLogger(__name__) def load_library(json_file): diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py index ae8eeb82..6ef7b410 100644 --- a/mopidy/backends/local/playback.py +++ b/mopidy/backends/local/playback.py @@ -6,7 +6,7 @@ from mopidy.backends import base from . import translator -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) class LocalPlaybackProvider(base.BasePlaybackProvider): diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index e8996b51..9e2459c6 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -12,7 +12,7 @@ from mopidy.utils import formatting, path from .translator import parse_m3u -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) class LocalPlaylistsProvider(base.BasePlaylistsProvider): diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 243eb314..8cc3df81 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -8,7 +8,7 @@ import urllib from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri, uri_to_path -logger = logging.getLogger('mopidy.backends.local') +logger = logging.getLogger(__name__) def local_track_uri_to_file_uri(uri, media_dir): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index c807e09d..a5b2a539 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -10,7 +10,7 @@ from mopidy.audio import scan from mopidy.backends import base from mopidy.models import Track -logger = logging.getLogger('mopidy.backends.stream') +logger = logging.getLogger(__name__) class StreamBackend(pykka.ThreadingActor, base.Backend): diff --git a/mopidy/commands.py b/mopidy/commands.py index ba5a42f1..46989c9c 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -14,7 +14,7 @@ from mopidy.audio import Audio from mopidy.core import Core from mopidy.utils import deps, process, versioning -logger = logging.getLogger('mopidy.commands') +logger = logging.getLogger(__name__) _default_config = [] for base in glib.get_system_config_dirs() + (glib.get_user_config_dir(),): diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index f68567e7..d6400fad 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -12,7 +12,7 @@ from mopidy.config.schemas import * # noqa from mopidy.config.types import * # noqa from mopidy.utils import path, versioning -logger = logging.getLogger('mopidy.config') +logger = logging.getLogger(__name__) _logging_schema = ConfigSchema('logging') _logging_schema['console_format'] = String() diff --git a/mopidy/config/keyring.py b/mopidy/config/keyring.py index 169ffdd1..6800d2c4 100644 --- a/mopidy/config/keyring.py +++ b/mopidy/config/keyring.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import logging -logger = logging.getLogger('mopidy.config.keyring') +logger = logging.getLogger(__name__) try: import dbus diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3c0e43fa..96d13017 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -8,7 +8,7 @@ from mopidy.audio import PlaybackState from . import listener -logger = logging.getLogger('mopidy.core') +logger = logging.getLogger(__name__) class PlaybackController(object): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index d3cc0d75..816e7b65 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -9,7 +9,7 @@ from mopidy.models import TlTrack from . import listener -logger = logging.getLogger('mopidy.core') +logger = logging.getLogger(__name__) class TracklistController(object): diff --git a/mopidy/ext.py b/mopidy/ext.py index feadc99f..33b9497d 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -7,7 +7,7 @@ from mopidy import exceptions from mopidy import config as config_lib -logger = logging.getLogger('mopidy.ext') +logger = logging.getLogger(__name__) class Extension(object): diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index cc7c11a1..037fe1ea 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -14,7 +14,7 @@ from mopidy.core import CoreListener from . import ws -logger = logging.getLogger('mopidy.http') +logger = logging.getLogger(__name__) class HttpFrontend(pykka.ThreadingActor, CoreListener): diff --git a/mopidy/http/ws.py b/mopidy/http/ws.py index 5a0f2039..4d7aa9a2 100644 --- a/mopidy/http/ws.py +++ b/mopidy/http/ws.py @@ -9,7 +9,7 @@ from mopidy import core, models from mopidy.utils import jsonrpc -logger = logging.getLogger('mopidy.http') +logger = logging.getLogger(__name__) class WebSocketResource(object): diff --git a/mopidy/listener.py b/mopidy/listener.py index 715beb03..cce5556d 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -5,7 +5,7 @@ import logging import gobject import pykka -logger = logging.getLogger('mopidy.listener') +logger = logging.getLogger(__name__) def send_async(cls, event, **kwargs): diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 9c33faaa..144b09d5 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -10,7 +10,7 @@ from mopidy.core import CoreListener from mopidy.mpd import session from mopidy.utils import encoding, network, process -logger = logging.getLogger('mopidy.mpd') +logger = logging.getLogger(__name__) class MpdFrontend(pykka.ThreadingActor, CoreListener): diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index c3881f6f..4ddb4025 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -7,7 +7,7 @@ import pykka from mopidy.mpd import exceptions, protocol -logger = logging.getLogger('mopidy.mpd.dispatcher') +logger = logging.getLogger(__name__) protocol.load_protocol_modules() diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py index 5a531a79..2c0bd840 100644 --- a/mopidy/mpd/session.py +++ b/mopidy/mpd/session.py @@ -5,7 +5,7 @@ import logging from mopidy.mpd import dispatcher, protocol from mopidy.utils import formatting, network -logger = logging.getLogger('mopidy.mpd') +logger = logging.getLogger(__name__) class MpdSession(network.LineProtocol): diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 1ffb12d6..bb1edbc4 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -12,7 +12,7 @@ import pykka from mopidy.utils import encoding -logger = logging.getLogger('mopidy.utils.server') +logger = logging.getLogger(__name__) class ShouldRetrySocketCall(Exception): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index b8dcc589..29e8077e 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -9,7 +9,7 @@ import urlparse import glib -logger = logging.getLogger('mopidy.utils.path') +logger = logging.getLogger(__name__) XDG_DIRS = { diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c8e3e558..0660efe0 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -8,7 +8,7 @@ import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry -logger = logging.getLogger('mopidy.utils.process') +logger = logging.getLogger(__name__) SIGNALS = dict( (k, v) for v, k in signal.__dict__.iteritems() diff --git a/mopidy/zeroconf.py b/mopidy/zeroconf.py index 671bebc7..e95b1792 100644 --- a/mopidy/zeroconf.py +++ b/mopidy/zeroconf.py @@ -4,7 +4,7 @@ import logging import socket import string -logger = logging.getLogger('mopidy.zeroconf') +logger = logging.getLogger(__name__) try: import dbus From a3731c8187c46ba851083e80d0d7bad59e793d49 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Jan 2014 22:06:32 +0100 Subject: [PATCH 108/238] backend: Add library.browse() --- mopidy/backends/base.py | 19 +++++++++++++++++++ mopidy/backends/dummy.py | 6 ++++++ mopidy/backends/local/json/library.py | 2 ++ tests/backends/local/library_test.py | 4 ++++ 4 files changed, 31 insertions(+) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 6b980f06..3dc644ee 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -50,9 +50,28 @@ class BaseLibraryProvider(object): pykka_traversable = True + name = None + """ + Name of the library. + + Used as the library directory name in Mopidy's virtual file system. + + *MUST be set by any class that implements :meth:`browse`.* + """ + def __init__(self, backend): self.backend = backend + def browse(self, path): + """ + See :meth:`mopidy.core.LibraryController.browse`. + + If you implement this method, make sure to also set :attr:`name`. + + *MAY be implemented by subclass.* + """ + return [] + # TODO: replace with search(query, exact=True, ...) def find_exact(self, query=None, uris=None): """ diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 65477ea2..f16c457a 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -38,12 +38,18 @@ class DummyBackend(pykka.ThreadingActor, base.Backend): class DummyLibraryProvider(base.BaseLibraryProvider): + name = 'dummy' + def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] + self.dummy_browse_result = [] self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() + def browse(self, path): + return self.dummy_browse_result + def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py index 99640543..c3dacec4 100644 --- a/mopidy/backends/local/json/library.py +++ b/mopidy/backends/local/json/library.py @@ -42,6 +42,8 @@ def write_library(json_file, data): class LocalJsonLibraryProvider(base.BaseLibraryProvider): + name = 'local' + def __init__(self, *args, **kwargs): super(LocalJsonLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index e4c00570..92d615ba 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -110,6 +110,10 @@ class LocalLibraryProviderTest(unittest.TestCase): result = backend.library.lookup(self.tracks[0].uri) self.assertEqual(result, []) + @unittest.SkipTest + def test_browse(self): + pass # TODO + def test_lookup(self): tracks = self.library.lookup(self.tracks[0].uri) self.assertEqual(tracks, self.tracks[0:1]) From 6027ed1fac7002b0fc2b971f1512202227c689fe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Jan 2014 22:06:33 +0100 Subject: [PATCH 109/238] core: Add library.browse() --- mopidy/core/library.py | 59 ++++++++++++++++++++++++++++++++ tests/core/library_test.py | 70 +++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2e73e0db..d1eb430a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -5,6 +5,8 @@ import urlparse import pykka +from mopidy.models import Ref + class LibraryController(object): pykka_traversable = True @@ -29,6 +31,63 @@ class LibraryController(object): (b, None) for b in self.backends.with_library.values()]) return backends_to_uris + def browse(self, path): + """ + Browse directories and tracks at the given ``path``. + + ``path`` is a string that always starts with "/". It points to a + directory in Mopidy's virtual file system. + + Returns a list of :class:`mopidy.models.Ref` objects for the + directories and tracks at the given ``path``. + + The :class:`~mopidy.models.Ref` objects representing tracks keeps the + track's original URI. A matching pair of objects can look like this:: + + Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) + Ref(uri='dummy:/foo.mp3', name='foo', type='track') + + The :class:`~mopidy.models.Ref` objects representing directories has + plain paths, not including any URI schema. For example, the dummy + library's ``/bar`` directory is returned like this:: + + Ref(uri='/dummy/bar', name='bar', type='directory') + + Note to backend implementors: The ``/dummy`` part of the URI is added + by Mopidy core, not the individual backends. + + :param path: path to browse + :type path: string + :rtype: list of :class:`mopidy.models.Ref` + """ + if not path.startswith('/'): + return [] + if path == '/': + library_names = [ + backend.library.name.get() + for backend in self.backends.with_library.values() + if backend.library.browse('/').get()] + return [ + Ref(uri='/%s' % name, name=name, type='directory') + for name in library_names] + uri_scheme = path.split('/', 2)[1] + backend = self.backends.with_library.get(uri_scheme, None) + if backend: + backend_path = path.replace('/%s' % uri_scheme, '') + if not backend_path.startswith('/'): + backend_path = '/%s' % backend_path + refs = backend.library.browse(backend_path).get() + result = [] + for ref in refs: + if ref.type == 'directory': + result.append( + ref.copy(uri='/%s%s' % (uri_scheme, ref.uri))) + else: + result.append(ref) + return result + else: + return [] + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. diff --git a/tests/core/library_test.py b/tests/core/library_test.py index f4028d2f..3734af41 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -5,7 +5,7 @@ import unittest from mopidy.backends import base from mopidy.core import Core -from mopidy.models import SearchResult, Track +from mopidy.models import Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): @@ -13,11 +13,13 @@ class CoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.library1.name.get.return_value = 'dummy1' self.backend1.library = self.library1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.library2.name.get.return_value = 'dummy2' self.backend2.library = self.library2 # A backend without the optional library provider @@ -28,6 +30,72 @@ class CoreLibraryTest(unittest.TestCase): self.core = Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) + def test_browse_root_returns_dir_ref_for_each_library_with_content(self): + result1 = [ + Ref(uri='/foo/bar', name='bar', type='directory'), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + ] + self.library1.browse().get.return_value = result1 + self.library1.browse.reset_mock() + self.library2.browse().get.return_value = [] + self.library2.browse.reset_mock() + + result = self.core.library.browse('/') + + self.assertEqual(result, [ + Ref(uri='/dummy1', name='dummy1', type='directory'), + ]) + self.assertTrue(self.library1.browse.called) + self.assertTrue(self.library2.browse.called) + self.assertFalse(self.backend3.library.browse.called) + + def test_browse_empty_string_returns_nothing(self): + result = self.core.library.browse('') + + self.assertEqual(result, []) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) + + def test_browse_dummy1_selects_dummy1_backend(self): + self.library1.browse().get.return_value = [] + self.library1.browse.reset_mock() + + self.core.library.browse('/dummy1/foo') + + self.library1.browse.assert_called_once_with('/foo') + self.assertFalse(self.library2.browse.called) + + def test_browse_dummy2_selects_dummy2_backend(self): + self.library2.browse().get.return_value = [] + self.library2.browse.reset_mock() + + self.core.library.browse('/dummy2/bar') + + self.assertFalse(self.library1.browse.called) + self.library2.browse.assert_called_once_with('/bar') + + def test_browse_dummy3_returns_nothing(self): + result = self.core.library.browse('/dummy3') + + self.assertEqual(result, []) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) + + def test_browse_dir_returns_subdirs_and_tracks(self): + result1 = [ + Ref(uri='/foo/bar', name='bar', type='directory'), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + ] + self.library1.browse().get.return_value = result1 + self.library1.browse.reset_mock() + + result = self.core.library.browse('/dummy1/foo') + + self.assertEqual(result, [ + Ref(uri='/dummy1/foo/bar', name='bar', type='directory'), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + ]) + def test_lookup_selects_dummy1_backend(self): self.core.library.lookup('dummy1:a') From 7dba0dafa519d172fd4225aa50eadbed57f427ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Jan 2014 22:06:33 +0100 Subject: [PATCH 110/238] mpd: Include dirs and files in lsinfo response --- mopidy/mpd/protocol/music_db.py | 18 ++++++- tests/mpd/protocol/music_db_test.py | 83 ++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 6e7e3956..ae18957d 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -452,9 +452,23 @@ def lsinfo(context, uri=None): directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ + result = [] if uri is None or uri == '/' or uri == '': - return stored_playlists.listplaylists(context) - raise MpdNotImplemented # TODO + result.extend(stored_playlists.listplaylists(context)) + uri = '/' + if not uri.startswith('/'): + uri = '/%s' % uri + for ref in context.core.library.browse(uri).get(): + if ref.type == 'directory': + assert ref.uri.startswith('/'), ( + 'Directory URIs must start with /: %r' % ref) + result.append(('directory', ref.uri[1:])) + elif ref.type == 'track': + # TODO Lookup tracks in batch for better performance + tracks = context.core.library.lookup(ref.uri).get() + if tracks: + result.extend(translator.track_to_mpd_format(tracks[0])) + return result @handle_request(r'rescan$') diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py index 09db53ae..eabc2064 100644 --- a/tests/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals +import datetime import unittest from mopidy.mpd.protocol import music_db -from mopidy.models import Album, Artist, SearchResult, Track +from mopidy.models import Album, Artist, Playlist, Ref, SearchResult, Track from tests.mpd import protocol @@ -137,20 +138,76 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.sendRequest('listallinfo "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + def test_lsinfo_without_path_returns_same_as_for_root(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo ""') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + response1 = self.sendRequest('lsinfo') + response2 = self.sendRequest('lsinfo "/"') + self.assertEqual(response1, response2) - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest('lsinfo "/"') - listplaylists_response = self.sendRequest('listplaylists') - self.assertEqual(lsinfo_response, listplaylists_response) + def test_lsinfo_with_empty_path_returns_same_as_for_root(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + response1 = self.sendRequest('lsinfo ""') + response2 = self.sendRequest('lsinfo "/"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_root_includes_playlists(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='a', uri='dummy:/a', last_modified=last_modified)] + + self.sendRequest('lsinfo "/"') + self.assertInResponse('playlist: a') + # Date without microseconds and with time zone information + self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') + self.assertInResponse('OK') + + def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): + self.backend.library.dummy_browse_result = [ + Ref(uri='dummy:/a', name='a', type='track'), + Ref(uri='/foo', name='foo', type='directory'), + ] + + self.sendRequest('lsinfo "/"') + self.assertInResponse('directory: dummy') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): + self.backend.library.dummy_browse_result = [ + Ref(uri='dummy:/a', name='a', type='track'), + Ref(uri='/foo', name='foo', type='directory'), + ] + + response1 = self.sendRequest('lsinfo "dummy"') + response2 = self.sendRequest('lsinfo "/dummy"') + self.assertEqual(response1, response2) + + def test_lsinfo_for_dir_includes_tracks(self): + self.backend.library.dummy_library = [ + Track(uri='dummy:/a', name='a'), + ] + self.backend.library.dummy_browse_result = [ + Ref(uri='dummy:/a', name='a', type='track'), + ] + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('file: dummy:/a') + self.assertInResponse('Title: a') + self.assertInResponse('OK') + + def test_lsinfo_for_dir_includes_subdirs(self): + self.backend.library.dummy_browse_result = [ + Ref(uri='/foo', name='foo', type='directory'), + ] + + self.sendRequest('lsinfo "/dummy"') + self.assertInResponse('directory: dummy/foo') + self.assertInResponse('OK') def test_update_without_uri(self): self.sendRequest('update') From 048c3bb544f19bcba4a62c27a2896f62b38362aa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Jan 2014 23:10:09 +0100 Subject: [PATCH 111/238] core: Use library name instead of URI scheme in browse() --- mopidy/core/library.py | 48 +++++++++++++++++++++----------------- tests/core/library_test.py | 27 +++++++++++++-------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index d1eb430a..015661d3 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import collections +import re import urlparse import pykka @@ -62,32 +63,37 @@ class LibraryController(object): """ if not path.startswith('/'): return [] + + backends = { + backend.library.name.get(): backend + for backend in self.backends.with_library.values() + if backend.library.browse('/').get()} + if path == '/': - library_names = [ - backend.library.name.get() - for backend in self.backends.with_library.values() - if backend.library.browse('/').get()] return [ Ref(uri='/%s' % name, name=name, type='directory') - for name in library_names] - uri_scheme = path.split('/', 2)[1] - backend = self.backends.with_library.get(uri_scheme, None) - if backend: - backend_path = path.replace('/%s' % uri_scheme, '') - if not backend_path.startswith('/'): - backend_path = '/%s' % backend_path - refs = backend.library.browse(backend_path).get() - result = [] - for ref in refs: - if ref.type == 'directory': - result.append( - ref.copy(uri='/%s%s' % (uri_scheme, ref.uri))) - else: - result.append(ref) - return result - else: + for name in backends.keys()] + + groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() + library_name = groups['library'] + backend_path = groups['path'] + if not backend_path.startswith('/'): + backend_path = '/%s' % backend_path + + backend = backends.get(library_name, None) + if not backend: return [] + refs = backend.library.browse(backend_path).get() + result = [] + for ref in refs: + if ref.type == 'directory': + result.append( + ref.copy(uri='/%s%s' % (library_name, ref.uri))) + else: + result.append(ref) + return result + def find_exact(self, query=None, uris=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 3734af41..26bef60a 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -31,11 +31,10 @@ class CoreLibraryTest(unittest.TestCase): self.backend1, self.backend2, self.backend3]) def test_browse_root_returns_dir_ref_for_each_library_with_content(self): - result1 = [ + self.library1.browse().get.return_value = [ Ref(uri='/foo/bar', name='bar', type='directory'), Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), ] - self.library1.browse().get.return_value = result1 self.library1.browse.reset_mock() self.library2.browse().get.return_value = [] self.library2.browse.reset_mock() @@ -57,29 +56,37 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library2.browse.called) def test_browse_dummy1_selects_dummy1_backend(self): - self.library1.browse().get.return_value = [] + self.library1.browse().get.return_value = [ + Ref(uri='/foo/bar', name='bar', type='directory'), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + ] self.library1.browse.reset_mock() self.core.library.browse('/dummy1/foo') - self.library1.browse.assert_called_once_with('/foo') - self.assertFalse(self.library2.browse.called) + self.assertEqual(self.library1.browse.call_count, 2) + self.assertEqual(self.library2.browse.call_count, 1) + self.library1.browse.assert_called_with('/foo') def test_browse_dummy2_selects_dummy2_backend(self): - self.library2.browse().get.return_value = [] + self.library2.browse().get.return_value = [ + Ref(uri='/bar/quux', name='quux', type='directory'), + Ref(uri='dummy2:/foo/baz.mp3', name='Baz', type='track'), + ] self.library2.browse.reset_mock() self.core.library.browse('/dummy2/bar') - self.assertFalse(self.library1.browse.called) - self.library2.browse.assert_called_once_with('/bar') + self.assertEqual(self.library1.browse.call_count, 1) + self.assertEqual(self.library2.browse.call_count, 2) + self.library2.browse.assert_called_with('/bar') def test_browse_dummy3_returns_nothing(self): result = self.core.library.browse('/dummy3') self.assertEqual(result, []) - self.assertFalse(self.library1.browse.called) - self.assertFalse(self.library2.browse.called) + self.assertEqual(self.library1.browse.call_count, 1) + self.assertEqual(self.library2.browse.call_count, 1) def test_browse_dir_returns_subdirs_and_tracks(self): result1 = [ From 7c8a63e7be97b015d9aa59c78e70ac5b081a8bae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 2 Jan 2014 23:21:54 +0100 Subject: [PATCH 112/238] docs: Update authors --- .mailmap | 3 +++ AUTHORS | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.mailmap b/.mailmap index 2cc42b4c..a427c69c 100644 --- a/.mailmap +++ b/.mailmap @@ -11,3 +11,6 @@ Alexandre Petitjean Javier Domingo Cansino Lasse Bigum Nick Steel +Janez Troha +Luke Giuliani +Colin Montgomerie diff --git a/AUTHORS b/AUTHORS index d3e86ef1..cba5edc2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -30,3 +30,5 @@ - Lasse Bigum - David Eisner - PÃ¥l Ruud +- Luke Giuliani +- Colin Montgomerie From 32252cb10053c4ac31e6089b0116c29cfe9a6584 Mon Sep 17 00:00:00 2001 From: Paul Connolley Date: Fri, 3 Jan 2014 11:26:13 +0000 Subject: [PATCH 113/238] Updated npm version to 0.2.0 After using this in our live mopidy system for the last 2 weeks, I am quite happy about the stability of the release. I've updated the package.json file as it makes sense to incorporate this in to my final commit for issue #609 --- js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/package.json b/js/package.json index 6d6c8a89..b278c08b 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "mopidy", - "version": "0.1.1", + "version": "0.2.0", "description": "Client lib for controlling a Mopidy music server over a WebSocket", "homepage": "http://www.mopidy.com/", "author": { From af3b0e40fd00263babcfc0aa0ce6c1175442cb81 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jan 2014 22:16:22 +0100 Subject: [PATCH 114/238] models: Add Ref.type constants --- mopidy/core/library.py | 8 ++++---- mopidy/models.py | 15 +++++++++++++++ tests/core/library_test.py | 22 +++++++++++----------- tests/models_test.py | 7 +++++++ tests/mpd/protocol/music_db_test.py | 6 +++--- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 015661d3..9c1b13a1 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -46,13 +46,13 @@ class LibraryController(object): track's original URI. A matching pair of objects can look like this:: Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) - Ref(uri='dummy:/foo.mp3', name='foo', type='track') + Ref(uri='dummy:/foo.mp3', name='foo', type=Ref.TRACK) The :class:`~mopidy.models.Ref` objects representing directories has plain paths, not including any URI schema. For example, the dummy library's ``/bar`` directory is returned like this:: - Ref(uri='/dummy/bar', name='bar', type='directory') + Ref(uri='/dummy/bar', name='bar', type=Ref.DIRECTORY) Note to backend implementors: The ``/dummy`` part of the URI is added by Mopidy core, not the individual backends. @@ -71,7 +71,7 @@ class LibraryController(object): if path == '/': return [ - Ref(uri='/%s' % name, name=name, type='directory') + Ref(uri='/%s' % name, name=name, type=Ref.DIRECTORY) for name in backends.keys()] groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() @@ -87,7 +87,7 @@ class LibraryController(object): refs = backend.library.browse(backend_path).get() result = [] for ref in refs: - if ref.type == 'directory': + if ref.type == Ref.DIRECTORY: result.append( ref.copy(uri='/%s%s' % (library_name, ref.uri))) else: diff --git a/mopidy/models.py b/mopidy/models.py index 0e40a8f6..b3d2a1b8 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -160,6 +160,21 @@ class Ref(ImmutableObject): #: "directory". Read-only. type = None + #: Constant used for comparision with the :attr:`type` field. + ALBUM = 'album' + + #: Constant used for comparision with the :attr:`type` field. + ARTIST = 'artist' + + #: Constant used for comparision with the :attr:`type` field. + DIRECTORY = 'directory' + + #: Constant used for comparision with the :attr:`type` field. + PLAYLIST = 'playlist' + + #: Constant used for comparision with the :attr:`type` field. + TRACK = 'track' + class Artist(ImmutableObject): """ diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 26bef60a..460811ac 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -32,8 +32,8 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_root_returns_dir_ref_for_each_library_with_content(self): self.library1.browse().get.return_value = [ - Ref(uri='/foo/bar', name='bar', type='directory'), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), ] self.library1.browse.reset_mock() self.library2.browse().get.return_value = [] @@ -42,7 +42,7 @@ class CoreLibraryTest(unittest.TestCase): result = self.core.library.browse('/') self.assertEqual(result, [ - Ref(uri='/dummy1', name='dummy1', type='directory'), + Ref(uri='/dummy1', name='dummy1', type=Ref.DIRECTORY), ]) self.assertTrue(self.library1.browse.called) self.assertTrue(self.library2.browse.called) @@ -57,8 +57,8 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dummy1_selects_dummy1_backend(self): self.library1.browse().get.return_value = [ - Ref(uri='/foo/bar', name='bar', type='directory'), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), ] self.library1.browse.reset_mock() @@ -70,8 +70,8 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dummy2_selects_dummy2_backend(self): self.library2.browse().get.return_value = [ - Ref(uri='/bar/quux', name='quux', type='directory'), - Ref(uri='dummy2:/foo/baz.mp3', name='Baz', type='track'), + Ref(uri='/bar/quux', name='quux', type=Ref.DIRECTORY), + Ref(uri='dummy2:/foo/baz.mp3', name='Baz', type=Ref.TRACK), ] self.library2.browse.reset_mock() @@ -90,8 +90,8 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dir_returns_subdirs_and_tracks(self): result1 = [ - Ref(uri='/foo/bar', name='bar', type='directory'), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), ] self.library1.browse().get.return_value = result1 self.library1.browse.reset_mock() @@ -99,8 +99,8 @@ class CoreLibraryTest(unittest.TestCase): result = self.core.library.browse('/dummy1/foo') self.assertEqual(result, [ - Ref(uri='/dummy1/foo/bar', name='bar', type='directory'), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type='track'), + Ref(uri='/dummy1/foo/bar', name='bar', type=Ref.DIRECTORY), + Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), ]) def test_lookup_selects_dummy1_backend(self): diff --git a/tests/models_test.py b/tests/models_test.py index 50faf89e..b2b72ea4 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -87,6 +87,13 @@ class RefTest(unittest.TestCase): ref2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(ref1, ref2) + def test_type_constants(self): + self.assertEqual(Ref.ALBUM, 'album') + self.assertEqual(Ref.ARTIST, 'artist') + self.assertEqual(Ref.DIRECTORY, 'directory') + self.assertEqual(Ref.PLAYLIST, 'playlist') + self.assertEqual(Ref.TRACK, 'track') + class ArtistTest(unittest.TestCase): def test_uri(self): diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py index eabc2064..36e36a0a 100644 --- a/tests/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -170,7 +170,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): self.backend.library.dummy_browse_result = [ Ref(uri='dummy:/a', name='a', type='track'), - Ref(uri='/foo', name='foo', type='directory'), + Ref(uri='/foo', name='foo', type=Ref.DIRECTORY), ] self.sendRequest('lsinfo "/"') @@ -180,7 +180,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): self.backend.library.dummy_browse_result = [ Ref(uri='dummy:/a', name='a', type='track'), - Ref(uri='/foo', name='foo', type='directory'), + Ref(uri='/foo', name='foo', type=Ref.DIRECTORY), ] response1 = self.sendRequest('lsinfo "dummy"') @@ -202,7 +202,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = [ - Ref(uri='/foo', name='foo', type='directory'), + Ref(uri='/foo', name='foo', type=Ref.DIRECTORY), ] self.sendRequest('lsinfo "/dummy"') From 69836d2e16c47873e4b2943764a4b9614b7d9e02 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jan 2014 23:07:31 +0100 Subject: [PATCH 115/238] backend: Rename library.name to library.root_directory_name --- mopidy/backends/base.py | 9 ++++----- mopidy/backends/dummy.py | 2 +- mopidy/core/library.py | 2 +- tests/core/library_test.py | 7 +++---- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 3dc644ee..5a8a23bb 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -50,11 +50,9 @@ class BaseLibraryProvider(object): pykka_traversable = True - name = None + root_directory_name = None """ - Name of the library. - - Used as the library directory name in Mopidy's virtual file system. + Name of the library's root directory in Mopidy's virtual file system. *MUST be set by any class that implements :meth:`browse`.* """ @@ -66,7 +64,8 @@ class BaseLibraryProvider(object): """ See :meth:`mopidy.core.LibraryController.browse`. - If you implement this method, make sure to also set :attr:`name`. + If you implement this method, make sure to also set + :attr:`root_directory_name`. *MAY be implemented by subclass.* """ diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index f16c457a..b3be0889 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -38,7 +38,7 @@ class DummyBackend(pykka.ThreadingActor, base.Backend): class DummyLibraryProvider(base.BaseLibraryProvider): - name = 'dummy' + root_directory_name = 'dummy' def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 9c1b13a1..59993db4 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -65,7 +65,7 @@ class LibraryController(object): return [] backends = { - backend.library.name.get(): backend + backend.library.root_directory_name.get(): backend for backend in self.backends.with_library.values() if backend.library.browse('/').get()} diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 460811ac..dc7ab778 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -13,13 +13,13 @@ class CoreLibraryTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=base.BaseLibraryProvider) - self.library1.name.get.return_value = 'dummy1' + self.library1.root_directory_name.get.return_value = 'dummy1' self.backend1.library = self.library1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.library2 = mock.Mock(spec=base.BaseLibraryProvider) - self.library2.name.get.return_value = 'dummy2' + self.library2.root_directory_name.get.return_value = 'dummy2' self.backend2.library = self.library2 # A backend without the optional library provider @@ -89,11 +89,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertEqual(self.library2.browse.call_count, 1) def test_browse_dir_returns_subdirs_and_tracks(self): - result1 = [ + self.library1.browse().get.return_value = [ Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), ] - self.library1.browse().get.return_value = result1 self.library1.browse.reset_mock() result = self.core.library.browse('/dummy1/foo') From 252f4792a027a5272b54ed7098e742be4d24ced9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jan 2014 23:12:03 +0100 Subject: [PATCH 116/238] core: Check if library is browsable at startup --- mopidy/core/actor.py | 8 ++++++++ mopidy/core/library.py | 9 ++------- tests/core/library_test.py | 27 ++++++++++----------------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4924cca2..2055340e 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -88,6 +88,7 @@ class Backends(list): super(Backends, self).__init__(backends) self.with_library = collections.OrderedDict() + self.with_browsable_library = collections.OrderedDict() self.with_playback = collections.OrderedDict() self.with_playlists = collections.OrderedDict() @@ -101,6 +102,13 @@ class Backends(list): self.add(self.with_playback, has_playback, scheme, backend) self.add(self.with_playlists, has_playlists, scheme, backend) + if has_library: + root_dir_name = backend.library.root_directory_name.get() + has_browsable_library = root_dir_name is not None + self.add( + self.with_browsable_library, has_browsable_library, + root_dir_name, backend) + def add(self, registry, supported, uri_scheme, backend): if not supported: return diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 59993db4..6ea3041c 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -64,15 +64,10 @@ class LibraryController(object): if not path.startswith('/'): return [] - backends = { - backend.library.root_directory_name.get(): backend - for backend in self.backends.with_library.values() - if backend.library.browse('/').get()} - if path == '/': return [ Ref(uri='/%s' % name, name=name, type=Ref.DIRECTORY) - for name in backends.keys()] + for name in self.backends.with_browsable_library.keys()] groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() library_name = groups['library'] @@ -80,7 +75,7 @@ class LibraryController(object): if not backend_path.startswith('/'): backend_path = '/%s' % backend_path - backend = backends.get(library_name, None) + backend = self.backends.with_browsable_library.get(library_name, None) if not backend: return [] diff --git a/tests/core/library_test.py b/tests/core/library_test.py index dc7ab778..59f3faf7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -30,22 +30,15 @@ class CoreLibraryTest(unittest.TestCase): self.core = Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) - def test_browse_root_returns_dir_ref_for_each_library_with_content(self): - self.library1.browse().get.return_value = [ - Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), - ] - self.library1.browse.reset_mock() - self.library2.browse().get.return_value = [] - self.library2.browse.reset_mock() - + def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): result = self.core.library.browse('/') self.assertEqual(result, [ Ref(uri='/dummy1', name='dummy1', type=Ref.DIRECTORY), + Ref(uri='/dummy2', name='dummy2', type=Ref.DIRECTORY), ]) - self.assertTrue(self.library1.browse.called) - self.assertTrue(self.library2.browse.called) + self.assertFalse(self.library1.browse.called) + self.assertFalse(self.library2.browse.called) self.assertFalse(self.backend3.library.browse.called) def test_browse_empty_string_returns_nothing(self): @@ -64,8 +57,8 @@ class CoreLibraryTest(unittest.TestCase): self.core.library.browse('/dummy1/foo') - self.assertEqual(self.library1.browse.call_count, 2) - self.assertEqual(self.library2.browse.call_count, 1) + self.assertEqual(self.library1.browse.call_count, 1) + self.assertEqual(self.library2.browse.call_count, 0) self.library1.browse.assert_called_with('/foo') def test_browse_dummy2_selects_dummy2_backend(self): @@ -77,16 +70,16 @@ class CoreLibraryTest(unittest.TestCase): self.core.library.browse('/dummy2/bar') - self.assertEqual(self.library1.browse.call_count, 1) - self.assertEqual(self.library2.browse.call_count, 2) + self.assertEqual(self.library1.browse.call_count, 0) + self.assertEqual(self.library2.browse.call_count, 1) self.library2.browse.assert_called_with('/bar') def test_browse_dummy3_returns_nothing(self): result = self.core.library.browse('/dummy3') self.assertEqual(result, []) - self.assertEqual(self.library1.browse.call_count, 1) - self.assertEqual(self.library2.browse.call_count, 1) + self.assertEqual(self.library1.browse.call_count, 0) + self.assertEqual(self.library2.browse.call_count, 0) def test_browse_dir_returns_subdirs_and_tracks(self): self.library1.browse().get.return_value = [ From dcd912580f6dc349390ae07a2cd8180f5d83e884 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jan 2014 23:14:01 +0100 Subject: [PATCH 117/238] local: Change library.{name => root_directory_name} --- mopidy/backends/local/json/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/json/library.py b/mopidy/backends/local/json/library.py index c3dacec4..5aa0d647 100644 --- a/mopidy/backends/local/json/library.py +++ b/mopidy/backends/local/json/library.py @@ -42,7 +42,7 @@ def write_library(json_file, data): class LocalJsonLibraryProvider(base.BaseLibraryProvider): - name = 'local' + root_directory_name = 'local' def __init__(self, *args, **kwargs): super(LocalJsonLibraryProvider, self).__init__(*args, **kwargs) From 67fafd67d22612f2c630a8813282d13ea46d456d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jan 2014 23:30:15 +0100 Subject: [PATCH 118/238] docs: Add hint about installing extension packages (fixes #627) --- docs/installation/index.rst | 13 +++++++++++++ docs/installation/raspberrypi.rst | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 456ae73a..fb3de75b 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -42,6 +42,19 @@ in the same way as you get updates to the rest of your distribution. sudo apt-get update sudo apt-get install mopidy + Note that this will only install the main Mopidy package. For e.g. Spotify + or SoundCloud support you need to install the respective extension packages. + To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + + To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + + For a full list of available Mopidy extensions, including those not + installable from apt.mopidy.com, see :ref:`ext`. + #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 4bc17a26..4eb25072 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -62,6 +62,19 @@ you a lot better performance. sudo apt-get update sudo apt-get install mopidy + Note that this will only install the main Mopidy package. For e.g. Spotify + or SoundCloud support you need to install the respective extension packages. + To list all the extensions available from apt.mopidy.com, you can run:: + + apt-cache search mopidy + + To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: + + sudo apt-get install mopidy-spotify + + For a full list of available Mopidy extensions, including those not + installable from apt.mopidy.com, see :ref:`ext`. + #. Since I have a HDMI cable connected, but want the sound on the analog sound connector, I have to run:: From ac7ff2744ddb982c72562d6260d67b140c9cb95d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 3 Jan 2014 23:59:39 +0100 Subject: [PATCH 119/238] js: Update Node instructions, add changelog --- js/README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index 753e858a..8c582feb 100644 --- a/js/README.md +++ b/js/README.md @@ -35,7 +35,7 @@ Mopidy.js using npm: After npm completes, you can import Mopidy.js using ``require()``: - var Mopidy = require("mopidy").Mopidy; + var Mopidy = require("mopidy"); Using the library @@ -80,3 +80,26 @@ To run other [grunt](http://gruntjs.com/) targets which isn't predefined in `package.json` and thus isn't available through `npm run-script`: PATH=./node_modules/.bin:$PATH grunt foo + + +Changelog +--------- + +### 0.2.0 (UNRELEASED) + +- **Backwards incompatible change for Node.js users:** + `var Mopidy = require('mopidy').Mopidy;` must be changed to + `var Mopidy = require('mopidy');` + +- Add support for [Browserify](http://browserify.org/). + +- Upgrade dependencies. + +### 0.1.1 (2013-09-17) + +- Upgrade dependencies. + +### 0.1.0 (2013-03-31) + +- Initial release as a Node.js module to the + [npm registry](https://npmjs.org/). From c7e96cf992b8fee7be8eb2249f64f190b10a2ec6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 00:05:31 +0100 Subject: [PATCH 120/238] js: Update all dependencies --- js/package.json | 18 +++++++++--------- js/test/mopidy-test.js | 5 ++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/js/package.json b/js/package.json index b278c08b..d16cfaa9 100644 --- a/js/package.json +++ b/js/package.json @@ -14,19 +14,19 @@ }, "main": "src/mopidy.js", "dependencies": { - "bane": "~1.0.0", - "faye-websocket": "~0.7.0", - "when": "~2.7.0" + "bane": "~1.1.0", + "faye-websocket": "~0.7.2", + "when": "~2.7.1" }, "devDependencies": { - "buster": "~0.6.13", - "grunt": "~0.4.1", - "grunt-buster": "~0.2.1", + "buster": "~0.7.8", + "grunt": "~0.4.2", + "grunt-buster": "~0.3.1", "grunt-browserify": "~1.3.0", - "grunt-contrib-jshint": "~0.6.4", - "grunt-contrib-uglify": "~0.2.4", + "grunt-contrib-jshint": "~0.8.0", + "grunt-contrib-uglify": "~0.2.7", "grunt-contrib-watch": "~0.5.3", - "phantomjs": "~1.9.2-0" + "phantomjs": "~1.9.2-6" }, "scripts": { "test": "grunt test", diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index ee34d845..9f2509fc 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -1,4 +1,4 @@ -/*global require:false, assert:false, refute:false*/ +/*global require:false */ if (typeof module === "object" && typeof require === "function") { var buster = require("buster"); @@ -6,6 +6,9 @@ if (typeof module === "object" && typeof require === "function") { var when = require("when"); } +var assert = buster.assert; +var refute = buster.refute; + buster.testCase("Mopidy", { setUp: function () { // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, From edc27135fd4d56bcdb09ed248486ba35beb783cf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 00:07:12 +0100 Subject: [PATCH 121/238] js: Build updated mopidy.js --- mopidy/http/data/mopidy.js | 422 ++++++++++++++++----------------- mopidy/http/data/mopidy.min.js | 6 +- 2 files changed, 207 insertions(+), 221 deletions(-) diff --git a/mopidy/http/data/mopidy.js b/mopidy/http/data/mopidy.js index 857d826b..cc72e3e6 100644 --- a/mopidy/http/data/mopidy.js +++ b/mopidy/http/data/mopidy.js @@ -1,8 +1,8 @@ -/*! Mopidy.js - built 2013-12-15 +/*! Mopidy.js - built 2014-01-04 * http://www.mopidy.com/ - * Copyright (c) 2013 Stein Magnus Jodal and contributors + * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -!function(e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):"undefined"!=typeof window?window.Mopidy=e():"undefined"!=typeof global?global.Mopidy=e():"undefined"!=typeof self&&(self.Mopidy=e())}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 0) { var fn = queue.shift(); @@ -236,7 +248,7 @@ var process=require("__browserify_process");/** @license MIT License (c) copyrig * * @author Brian Cavalier * @author John Hann - * @version 2.7.0 + * @version 2.7.1 */ (function(define) { 'use strict'; define(function (require) { @@ -282,8 +294,14 @@ define(function (require) { return cast(promiseOrValue).then(onFulfilled, onRejected, onProgress); } - function cast(x) { - return x instanceof Promise ? x : resolve(x); + /** + * Creates a new promise whose fate is determined by resolver. + * @param {function} resolver function(resolve, reject, notify) + * @returns {Promise} promise whose fate is determine by resolver + */ + function promise(resolver) { + return new Promise(resolver, + monitorApi.PromiseStatus && monitorApi.PromiseStatus()); } /** @@ -291,16 +309,90 @@ define(function (require) { * a trusted when.js promise. Any other duck-typed promise is considered * untrusted. * @constructor - * @param {function} sendMessage function to deliver messages to the promise's handler - * @param {function?} inspect function that reports the promise's state + * @returns {Promise} promise whose fate is determine by resolver * @name Promise */ - function Promise(sendMessage, inspect) { - this._message = sendMessage; + function Promise(resolver, status) { + var self, value, consumers = []; + + self = this; + this._status = status; this.inspect = inspect; + this._when = _when; + + // Call the provider resolver to seal the promise's fate + try { + resolver(promiseResolve, promiseReject, promiseNotify); + } catch(e) { + promiseReject(e); + } + + /** + * Returns a snapshot of this promise's current status at the instant of call + * @returns {{state:String}} + */ + function inspect() { + return value ? value.inspect() : toPendingState(); + } + + /** + * Private message delivery. Queues and delivers messages to + * the promise's ultimate fulfillment value or rejection reason. + * @private + */ + function _when(resolve, notify, onFulfilled, onRejected, onProgress) { + consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); + + function deliver(p) { + p._when(resolve, notify, onFulfilled, onRejected, onProgress); + } + } + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the ultimate fulfillment or rejection + * @param {*} val resolution value + */ + function promiseResolve(val) { + if(!consumers) { + return; + } + + var queue = consumers; + consumers = undef; + + enqueue(function () { + value = coerce(self, val); + if(status) { + updateStatus(value, status); + } + runHandlers(queue, value); + }); + } + + /** + * Reject this promise with the supplied reason, which will be used verbatim. + * @param {*} reason reason for the rejection + */ + function promiseReject(reason) { + promiseResolve(new RejectedPromise(reason)); + } + + /** + * Issue a progress event, notifying all progress listeners + * @param {*} update progress event payload to pass to all listeners + */ + function promiseNotify(update) { + if(consumers) { + var queue = consumers; + enqueue(function () { + runHandlers(queue, new ProgressingPromise(update)); + }); + } + } } - var promisePrototype = Promise.prototype; + promisePrototype = Promise.prototype; /** * Register handlers for this promise. @@ -310,14 +402,10 @@ define(function (require) { * @return {Promise} new Promise */ promisePrototype.then = function(onFulfilled, onRejected, onProgress) { - /*jshint unused:false*/ - var args, sendMessage; + var self = this; - args = arguments; - sendMessage = this._message; - - return _promise(function(resolve, reject, notify) { - sendMessage('when', args, resolve, notify); + return new Promise(function(resolve, reject, notify) { + self._when(resolve, notify, onFulfilled, onRejected, onProgress); }, this._status && this._status.observed()); }; @@ -361,7 +449,7 @@ define(function (require) { * @returns {undefined} */ promisePrototype.done = function(handleResult, handleError) { - this.then(handleResult, handleError).otherwise(crash); + this.then(handleResult, handleError)['catch'](crash); }; /** @@ -412,12 +500,23 @@ define(function (require) { return this.then(onFulfilledOrRejected, onFulfilledOrRejected, onProgress); }; + /** + * Casts x to a trusted promise. If x is already a trusted promise, it is + * returned, otherwise a new trusted Promise which follows x is returned. + * @param {*} x + * @returns {Promise} + */ + function cast(x) { + return x instanceof Promise ? x : resolve(x); + } + /** * Returns a resolved promise. The returned promise will be * - fulfilled with promiseOrValue if it is a value, or * - if promiseOrValue is a promise * - fulfilled with promiseOrValue's value after it is fulfilled * - rejected with promiseOrValue's reason after it is rejected + * In contract to cast(x), this always creates a new Promise * @param {*} value * @return {Promise} */ @@ -438,7 +537,9 @@ define(function (require) { * @return {Promise} rejected {@link Promise} */ function reject(promiseOrValue) { - return when(promiseOrValue, rejected); + return when(promiseOrValue, function(e) { + return new RejectedPromise(e); + }); } /** @@ -483,7 +584,7 @@ define(function (require) { deferred.reject = deferred.resolver.reject = function(reason) { if(resolved) { - return resolve(rejected(reason)); + return resolve(new RejectedPromise(reason)); } resolved = true; rejectPending(reason); @@ -497,112 +598,6 @@ define(function (require) { } } - /** - * Creates a new promise whose fate is determined by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @returns {Promise} promise whose fate is determine by resolver - */ - function promise(resolver) { - return _promise(resolver, monitorApi.PromiseStatus && monitorApi.PromiseStatus()); - } - - /** - * Creates a new promise, linked to parent, whose fate is determined - * by resolver. - * @param {function} resolver function(resolve, reject, notify) - * @param {Promise?} status promise from which the new promise is begotten - * @returns {Promise} promise whose fate is determine by resolver - * @private - */ - function _promise(resolver, status) { - var self, value, consumers = []; - - self = new Promise(_message, inspect); - self._status = status; - - // Call the provider resolver to seal the promise's fate - try { - resolver(promiseResolve, promiseReject, promiseNotify); - } catch(e) { - promiseReject(e); - } - - // Return the promise - return self; - - /** - * Private message delivery. Queues and delivers messages to - * the promise's ultimate fulfillment value or rejection reason. - * @private - * @param {String} type - * @param {Array} args - * @param {Function} resolve - * @param {Function} notify - */ - function _message(type, args, resolve, notify) { - consumers ? consumers.push(deliver) : enqueue(function() { deliver(value); }); - - function deliver(p) { - p._message(type, args, resolve, notify); - } - } - - /** - * Returns a snapshot of the promise's state at the instant inspect() - * is called. The returned object is not live and will not update as - * the promise's state changes. - * @returns {{ state:String, value?:*, reason?:* }} status snapshot - * of the promise. - */ - function inspect() { - return value ? value.inspect() : toPendingState(); - } - - /** - * Transition from pre-resolution state to post-resolution state, notifying - * all listeners of the ultimate fulfillment or rejection - * @param {*|Promise} val resolution value - */ - function promiseResolve(val) { - if(!consumers) { - return; - } - - var queue = consumers; - consumers = undef; - - enqueue(function () { - value = coerce(self, val); - if(status) { - updateStatus(value, status); - } - runHandlers(queue, value); - }); - - } - - /** - * Reject this promise with the supplied reason, which will be used verbatim. - * @param {*} reason reason for the rejection - */ - function promiseReject(reason) { - promiseResolve(rejected(reason)); - } - - /** - * Issue a progress event, notifying all progress listeners - * @param {*} update progress event payload to pass to all listeners - */ - function promiseNotify(update) { - if(consumers) { - var queue = consumers; - enqueue(function () { - runHandlers(queue, progressed(update)); - }); - } - } - } - /** * Run a queue of functions as quickly as possible, passing * value to each. @@ -613,67 +608,6 @@ define(function (require) { } } - /** - * Creates a fulfilled, local promise as a proxy for a value - * NOTE: must never be exposed - * @param {*} value fulfillment value - * @returns {Promise} - */ - function fulfilled(value) { - return near( - new NearFulfilledProxy(value), - function() { return toFulfilledState(value); } - ); - } - - /** - * Creates a rejected, local promise with the supplied reason - * NOTE: must never be exposed - * @param {*} reason rejection reason - * @returns {Promise} - */ - function rejected(reason) { - return near( - new NearRejectedProxy(reason), - function() { return toRejectedState(reason); } - ); - } - - /** - * Creates a near promise using the provided proxy - * NOTE: must never be exposed - * @param {object} proxy proxy for the promise's ultimate value or reason - * @param {function} inspect function that returns a snapshot of the - * returned near promise's state - * @returns {Promise} - */ - function near(proxy, inspect) { - return new Promise(function (type, args, resolve) { - try { - resolve(proxy[type].apply(proxy, args)); - } catch(e) { - resolve(rejected(e)); - } - }, inspect); - } - - /** - * Create a progress promise with the supplied update. - * @private - * @param {*} update - * @return {Promise} progress promise - */ - function progressed(update) { - return new Promise(function (type, args, _, notify) { - var onProgress = args[2]; - try { - notify(typeof onProgress === 'function' ? onProgress(update) : update); - } catch(e) { - notify(e); - } - }); - } - /** * Coerces x to a trusted Promise * @param {*} x thing to coerce @@ -685,7 +619,7 @@ define(function (require) { */ function coerce(self, x) { if (x === self) { - return rejected(new TypeError()); + return new RejectedPromise(new TypeError()); } if (x instanceof Promise) { @@ -697,9 +631,9 @@ define(function (require) { return typeof untrustedThen === 'function' ? assimilate(untrustedThen, x) - : fulfilled(x); + : new FulfilledPromise(x); } catch(e) { - return rejected(e); + return new RejectedPromise(e); } } @@ -715,36 +649,89 @@ define(function (require) { }); } + makePromisePrototype = Object.create || + function(o) { + function PromisePrototype() {} + PromisePrototype.prototype = o; + return new PromisePrototype(); + }; + /** - * Proxy for a near, fulfilled value - * @param {*} value - * @constructor + * Creates a fulfilled, local promise as a proxy for a value + * NOTE: must never be exposed + * @private + * @param {*} value fulfillment value + * @returns {Promise} */ - function NearFulfilledProxy(value) { + function FulfilledPromise(value) { this.value = value; } - NearFulfilledProxy.prototype.when = function(onResult) { - return typeof onResult === 'function' ? onResult(this.value) : this.value; + FulfilledPromise.prototype = makePromisePrototype(promisePrototype); + + FulfilledPromise.prototype.inspect = function() { + return toFulfilledState(this.value); }; - /** - * Proxy for a near rejection - * @param {*} reason - * @constructor - */ - function NearRejectedProxy(reason) { - this.reason = reason; - } - - NearRejectedProxy.prototype.when = function(_, onError) { - if(typeof onError === 'function') { - return onError(this.reason); - } else { - throw this.reason; + FulfilledPromise.prototype._when = function(resolve, _, onFulfilled) { + try { + resolve(typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value); + } catch(e) { + resolve(new RejectedPromise(e)); } }; + /** + * Creates a rejected, local promise as a proxy for a value + * NOTE: must never be exposed + * @private + * @param {*} reason rejection reason + * @returns {Promise} + */ + function RejectedPromise(reason) { + this.value = reason; + } + + RejectedPromise.prototype = makePromisePrototype(promisePrototype); + + RejectedPromise.prototype.inspect = function() { + return toRejectedState(this.value); + }; + + RejectedPromise.prototype._when = function(resolve, _, __, onRejected) { + try { + resolve(typeof onRejected === 'function' ? onRejected(this.value) : this); + } catch(e) { + resolve(new RejectedPromise(e)); + } + }; + + /** + * Create a progress promise with the supplied update. + * @private + * @param {*} value progress update value + * @return {Promise} progress promise + */ + function ProgressingPromise(value) { + this.value = value; + } + + ProgressingPromise.prototype = makePromisePrototype(promisePrototype); + + ProgressingPromise.prototype._when = function(_, notify, f, r, u) { + try { + notify(typeof u === 'function' ? u(this.value) : this.value); + } catch(e) { + notify(e); + } + }; + + /** + * Update a PromiseStatus monitor object with the outcome + * of the supplied value promise. + * @param {Promise} value + * @param {PromiseStatus} status + */ function updateStatus(value, status) { value.then(statusFulfilled, statusRejected); @@ -921,7 +908,7 @@ define(function (require) { function _map(array, mapFunc, fallback) { return when(array, function(array) { - return _promise(resolveMap); + return new Promise(resolveMap); function resolveMap(resolve, reject, notify) { var results, len, toResolve, i; @@ -1028,7 +1015,7 @@ define(function (require) { // Internals, utilities, etc. // - var reduceArray, slice, fcall, nextTick, handlerQueue, + var promisePrototype, makePromisePrototype, reduceArray, slice, fcall, nextTick, handlerQueue, funcProto, call, arrayProto, monitorApi, capturedSetTimeout, cjsRequire, MutationObs, undef; @@ -1475,5 +1462,4 @@ module.exports = Mopidy; },{"../lib/websocket/":1,"bane":2,"when":4}]},{},[5]) (5) -}); -; \ No newline at end of file +}); \ No newline at end of file diff --git a/mopidy/http/data/mopidy.min.js b/mopidy/http/data/mopidy.min.js index 5e61a3f6..450911bd 100644 --- a/mopidy/http/data/mopidy.min.js +++ b/mopidy/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! Mopidy.js - built 2013-12-15 +/*! Mopidy.js - built 2014-01-04 * http://www.mopidy.com/ - * Copyright (c) 2013 Stein Magnus Jodal and contributors + * Copyright (c) 2014 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -!function(a){"object"==typeof exports?module.exports=a():"function"==typeof define&&define.amd?define(a):"undefined"!=typeof window?window.Mopidy=a():"undefined"!=typeof global?global.Mopidy=a():"undefined"!=typeof self&&(self.Mopidy=a())}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f}})},{}],3:[function(a,b){var c=b.exports={};c.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){if(a.source===window&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var b=c.shift();b()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),c.title="browser",c.browser=!0,c.env={},c.argv=[],c.binding=function(){throw new Error("process.binding is not supported")},c.cwd=function(){return"/"},c.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){var d=b("__browserify_process");!function(a){"use strict";a(function(a){function b(a,b,d,e){return c(a).then(b,d,e)}function c(a){return a instanceof e?a:f(a)}function e(a,b){this._message=a,this.inspect=b}function f(a){return i(function(b){b(a)})}function g(a){return b(a,m)}function h(){function a(a,e,g){b.resolve=b.resolver.resolve=function(b){return d?f(b):(d=!0,a(b),c)},b.reject=b.resolver.reject=function(a){return d?f(m(a)):(d=!0,e(a),c)},b.notify=b.resolver.notify=function(a){return g(a),a}}var b,c,d;return b={promise:X,resolve:X,reject:X,notify:X,resolver:{resolve:X,reject:X,notify:X}},b.promise=c=i(a),b}function i(a){return j(a,T.PromiseStatus&&T.PromiseStatus())}function j(a,b){function c(a,b,c,d){function e(e){e._message(a,b,c,d)}l?l.push(e):G(function(){e(j)})}function d(){return j?j.inspect():F()}function f(a){if(l){var c=l;l=X,G(function(){j=p(i,a),b&&t(j,b),k(c,j)})}}function g(a){f(m(a))}function h(a){if(l){var b=l;G(function(){k(b,o(a))})}}var i,j,l=[];i=new e(c,d),i._status=b;try{a(f,g,h)}catch(n){g(n)}return i}function k(a,b){for(var c=0;c>>0,i=Math.max(0,Math.min(c,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=I,e(l))},m=function(a){k.push(a),--i||(m=n=I,d(k))},p=0;o>p;++p)p in a&&b(a[p],h,g,f);else d(k)}return i(g).then(d,e,f)})}function w(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return v(a,1,e,c,d)}function x(a,b,c,d){return B(a,I).then(b,c,d)}function y(){return B(arguments,I)}function z(a){return B(a,D,E)}function A(a,b){return B(a,b)}function B(a,c,d){return b(a,function(a){function e(e,f,g){function h(a,h){b(a,c,d).then(function(a){i[h]=a,--k||e(i)},f,g)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return j(e)})}function C(a,c){var d=N(M,arguments,1);return b(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return b(a,function(a){return b(d,function(b){return c(a,b,f,e)})})},L.apply(a,d)})}function D(a){return{state:"fulfilled",value:a}}function E(a){return{state:"rejected",reason:a}}function F(){return{state:"pending"}}function G(a){1===P.push(a)&&O(H)}function H(){k(P),P=[]}function I(a){return a}function J(a){throw"function"==typeof T.reportUnhandled?T.reportUnhandled():G(function(){throw a}),a}b.promise=i,b.resolve=f,b.reject=g,b.defer=h,b.join=y,b.all=x,b.map=A,b.reduce=C,b.settle=z,b.any=w,b.some=v,b.isPromise=u,b.isPromiseLike=u;var K=e.prototype;K.then=function(){var a,b;return a=arguments,b=this._message,j(function(c,d,e){b("when",a,c,e)},this._status&&this._status.observed())},K["catch"]=K.otherwise=function(a){return this.then(X,a)},K["finally"]=K.ensure=function(a){function b(){return f(a())}return"function"==typeof a?this.then(b,b).yield(this):this},K.done=function(a,b){this.then(a,b).otherwise(J)},K.yield=function(a){return this.then(function(){return a})},K.tap=function(a){return this.then(a).yield(this)},K.spread=function(a){return this.then(function(b){return x(b,function(b){return a.apply(X,b)})})},K.always=function(a,b){return this.then(a,a,b)},r.prototype.when=function(a){return"function"==typeof a?a(this.value):this.value},s.prototype.when=function(a,b){if("function"==typeof b)return b(this.reason);throw this.reason};var L,M,N,O,P,Q,R,S,T,U,V,W,X;if(V=a,P=[],T="undefined"!=typeof console?console:b,"object"==typeof d&&d.nextTick)O=d.nextTick;else if(W="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)O=function(a,b,c){var d=a.createElement("div");return new b(c).observe(d,{attributes:!0}),function(){d.setAttribute("x","x")}}(document,W,H);else try{O=V("vertx").runOnLoop||V("vertx").runOnContext}catch(Y){U=setTimeout,O=function(a){U(a,0)}}return Q=Function.prototype,R=Q.call,N=Q.bind?R.bind(R):function(a,b){return a.apply(b,M.call(arguments,2))},S=[],M=S.slice,L=S.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{__browserify_process:3}],5:[function(a,b){function c(a){return this instanceof c?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.WebSocket=e.Client,c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},c.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){var b=f.defer();switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case c.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case c.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},c.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:4}]},{},[5])(5)}); \ No newline at end of file +!function(a){if("object"==typeof exports)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else{var b;"undefined"!=typeof window?b=window:"undefined"!=typeof global?b=global:"undefined"!=typeof self&&(b=self),b.Mopidy=a()}}(function(){var a;return function b(a,c,d){function e(g,h){if(!c[g]){if(!a[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);throw new Error("Cannot find module '"+g+"'")}var j=c[g]={exports:{}};a[g][0].call(j.exports,function(b){var c=a[g][1][b];return e(c?c:b)},j,j.exports,b,a,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g0)for(d=0;e>d;++d)c[d](a,b);else setTimeout(function(){throw b.message=a+" listener threw error: "+b.message,b},0)}function b(a){if("function"!=typeof a)throw new TypeError("Listener is not function");return a}function c(a){return a.supervisors||(a.supervisors=[]),a.supervisors}function d(a,b){return a.listeners||(a.listeners={}),b&&!a.listeners[b]&&(a.listeners[b]=[]),b?a.listeners[b]:a.listeners}function e(a){return a.errbacks||(a.errbacks=[]),a.errbacks}function f(f){function h(b,c,d){try{c.listener.apply(c.thisp||f,d)}catch(g){a(b,g,e(f))}}return f=f||{},f.on=function(a,e,f){return"function"==typeof a?c(this).push({listener:a,thisp:e}):(d(this,a).push({listener:b(e),thisp:f}),void 0)},f.off=function(a,b){var f,g,h,i;if(!a){f=c(this),f.splice(0,f.length),g=d(this);for(h in g)g.hasOwnProperty(h)&&(f=d(this,h),f.splice(0,f.length));return f=e(this),f.splice(0,f.length),void 0}if("function"==typeof a?(f=c(this),b=a):f=d(this,a),!b)return f.splice(0,f.length),void 0;for(h=0,i=f.length;i>h;++h)if(f[h].listener===b)return f.splice(h,1),void 0},f.once=function(a,b,c){var d=function(){f.off(a,d),b.apply(this,arguments)};f.on(a,d,c)},f.bind=function(a,b){var c,d,e;if(b)for(d=0,e=b.length;e>d;++d){if("function"!=typeof a[b[d]])throw new Error("No such method "+b[d]);this.on(b[d],a[b[d]],a)}else for(c in a)"function"==typeof a[c]&&this.on(c,a[c],a);return a},f.emit=function(a){var b,e,f=c(this),i=g.call(arguments);for(b=0,e=f.length;e>b;++b)h(a,f[b],i);for(f=d(this,a).slice(),i=g.call(arguments,1),b=0,e=f.length;e>b;++b)h(a,f[b],i)},f.errback=function(a){this.errbacks||(this.errbacks=[]),this.errbacks.push(b(a))},f}var g=Array.prototype.slice;return{createEventEmitter:f,aggregate:function(a){var b=f();return a.forEach(function(a){a.on(function(a,c){b.emit(a,c)})}),b}}})},{}],3:[function(a,b){var c=b.exports={};c.nextTick=function(){var a="undefined"!=typeof window&&window.setImmediate,b="undefined"!=typeof window&&window.postMessage&&window.addEventListener;if(a)return function(a){return window.setImmediate(a)};if(b){var c=[];return window.addEventListener("message",function(a){var b=a.source;if((b===window||null===b)&&"process-tick"===a.data&&(a.stopPropagation(),c.length>0)){var d=c.shift();d()}},!0),function(a){c.push(a),window.postMessage("process-tick","*")}}return function(a){setTimeout(a,0)}}(),c.title="browser",c.browser=!0,c.env={},c.argv=[],c.binding=function(){throw new Error("process.binding is not supported")},c.cwd=function(){return"/"},c.chdir=function(){throw new Error("process.chdir is not supported")}},{}],4:[function(b,c){var d=b("__browserify_process");!function(a){"use strict";a(function(a){function b(a,b,c,d){return f(a).then(b,c,d)}function c(a){return new e(a,Q.PromiseStatus&&Q.PromiseStatus())}function e(a,b){function c(){return i?i.inspect():B()}function d(a,b,c,d,e){function f(f){f._when(a,b,c,d,e)}l?l.push(f):C(function(){f(i)})}function e(a){if(l){var c=l;l=U,C(function(){i=k(h,a),b&&p(i,b),j(c,i)})}}function f(a){e(new n(a))}function g(a){if(l){var b=l;C(function(){j(b,new o(a))})}}var h,i,l=[];h=this,this._status=b,this.inspect=c,this._when=d;try{a(e,f,g)}catch(m){f(m)}}function f(a){return a instanceof e?a:g(a)}function g(a){return c(function(b){b(a)})}function h(a){return b(a,function(a){return new n(a)})}function i(){function a(a,c,f){b.resolve=b.resolver.resolve=function(b){return e?g(b):(e=!0,a(b),d)},b.reject=b.resolver.reject=function(a){return e?g(new n(a)):(e=!0,c(a),d)},b.notify=b.resolver.notify=function(a){return f(a),a}}var b,d,e;return b={promise:U,resolve:U,reject:U,notify:U,resolver:{resolve:U,reject:U,notify:U}},b.promise=d=c(a),b}function j(a,b){for(var c=0;c>>0,i=Math.max(0,Math.min(d,o)),k=[],j=o-i+1,l=[],i)for(n=function(a){l.push(a),--j||(m=n=E,e(l))},m=function(a){k.push(a),--i||(m=n=E,c(k))},p=0;o>p;++p)p in a&&b(a[p],h,g,f);else c(k)}return c(h).then(e,f,g)})}function s(a,b,c,d){function e(a){return b?b(a[0]):a[0]}return r(a,1,e,c,d)}function t(a,b,c,d){return x(a,E).then(b,c,d)}function u(){return x(arguments,E)}function v(a){return x(a,z,A)}function w(a,b){return x(a,b)}function x(a,c,d){return b(a,function(a){function f(e,f,g){function h(a,h){b(a,c,d).then(function(a){i[h]=a,--k||e(i)},f,g)}var i,j,k,l;if(k=j=a.length>>>0,i=[],!k)return e(i),void 0;for(l=0;j>l;l++)l in a?h(a[l],l):--k}return new e(f)})}function y(a,c){var d=K(J,arguments,1);return b(a,function(a){var e;return e=a.length,d[0]=function(a,d,f){return b(a,function(a){return b(d,function(b){return c(a,b,f,e)})})},I.apply(a,d)})}function z(a){return{state:"fulfilled",value:a}}function A(a){return{state:"rejected",reason:a}}function B(){return{state:"pending"}}function C(a){1===M.push(a)&&L(D)}function D(){j(M),M=[]}function E(a){return a}function F(a){throw"function"==typeof Q.reportUnhandled?Q.reportUnhandled():C(function(){throw a}),a}b.promise=c,b.resolve=g,b.reject=h,b.defer=i,b.join=u,b.all=t,b.map=w,b.reduce=y,b.settle=v,b.any=s,b.some=r,b.isPromise=q,b.isPromiseLike=q,G=e.prototype,G.then=function(a,b,c){var d=this;return new e(function(e,f,g){d._when(e,g,a,b,c)},this._status&&this._status.observed())},G["catch"]=G.otherwise=function(a){return this.then(U,a)},G["finally"]=G.ensure=function(a){function b(){return g(a())}return"function"==typeof a?this.then(b,b).yield(this):this},G.done=function(a,b){this.then(a,b)["catch"](F)},G.yield=function(a){return this.then(function(){return a})},G.tap=function(a){return this.then(a).yield(this)},G.spread=function(a){return this.then(function(b){return t(b,function(b){return a.apply(U,b)})})},G.always=function(a,b){return this.then(a,a,b)},H=Object.create||function(a){function b(){}return b.prototype=a,new b},m.prototype=H(G),m.prototype.inspect=function(){return z(this.value)},m.prototype._when=function(a,b,c){try{a("function"==typeof c?c(this.value):this.value)}catch(d){a(new n(d))}},n.prototype=H(G),n.prototype.inspect=function(){return A(this.value)},n.prototype._when=function(a,b,c,d){try{a("function"==typeof d?d(this.value):this)}catch(e){a(new n(e))}},o.prototype=H(G),o.prototype._when=function(a,b,c,d,e){try{b("function"==typeof e?e(this.value):this.value)}catch(f){b(f)}};var G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U;if(S=a,M=[],Q="undefined"!=typeof console?console:b,"object"==typeof d&&d.nextTick)L=d.nextTick;else if(T="function"==typeof MutationObserver&&MutationObserver||"function"==typeof WebKitMutationObserver&&WebKitMutationObserver)L=function(a,b,c){var d=a.createElement("div");return new b(c).observe(d,{attributes:!0}),function(){d.setAttribute("x","x")}}(document,T,D);else try{L=S("vertx").runOnLoop||S("vertx").runOnContext}catch(V){R=setTimeout,L=function(a){R(a,0)}}return N=Function.prototype,O=N.call,K=N.bind?O.bind(O):function(a,b){return a.apply(b,J.call(arguments,2))},P=[],J=P.slice,I=P.reduce||function(a){var b,c,d,e,f;if(f=0,b=Object(this),e=b.length>>>0,c=arguments,c.length<=1)for(;;){if(f in b){d=b[f++];break}if(++f>=e)throw new TypeError}else d=c[1];for(;e>f;++f)f in b&&(d=a(d,b[f],f,b));return d},b})}("function"==typeof a&&a.amd?a:function(a){c.exports=a(b)})},{__browserify_process:3}],5:[function(a,b){function c(a){return this instanceof c?(this._settings=this._configure(a||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,d.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect(),void 0):new c(a)}var d=a("bane"),e=a("../lib/websocket/"),f=a("when");c.WebSocket=e.Client,c.prototype._configure=function(a){var b="undefined"!=typeof document&&document.location.host||"localhost";return a.webSocketUrl=a.webSocketUrl||"ws://"+b+"/mopidy/ws/",a.autoConnect!==!1&&(a.autoConnect=!0),a.backoffDelayMin=a.backoffDelayMin||1e3,a.backoffDelayMax=a.backoffDelayMax||64e3,a},c.prototype._getConsole=function(){var a="undefined"!=typeof a&&a||{};return a.log=a.log||function(){},a.warn=a.warn||function(){},a.error=a.error||function(){},a},c.prototype._delegateEvents=function(){this.off("websocket:close"),this.off("websocket:error"),this.off("websocket:incomingMessage"),this.off("websocket:open"),this.off("state:offline"),this.on("websocket:close",this._cleanup),this.on("websocket:error",this._handleWebSocketError),this.on("websocket:incomingMessage",this._handleMessage),this.on("websocket:open",this._resetBackoffDelay),this.on("websocket:open",this._getApiSpec),this.on("state:offline",this._reconnect)},c.prototype.connect=function(){if(this._webSocket){if(this._webSocket.readyState===c.WebSocket.OPEN)return;this._webSocket.close()}this._webSocket=this._settings.webSocket||new c.WebSocket(this._settings.webSocketUrl),this._webSocket.onclose=function(a){this.emit("websocket:close",a)}.bind(this),this._webSocket.onerror=function(a){this.emit("websocket:error",a)}.bind(this),this._webSocket.onopen=function(){this.emit("websocket:open")}.bind(this),this._webSocket.onmessage=function(a){this.emit("websocket:incomingMessage",a)}.bind(this)},c.prototype._cleanup=function(a){Object.keys(this._pendingRequests).forEach(function(b){var c=this._pendingRequests[b];delete this._pendingRequests[b],c.reject({message:"WebSocket closed",closeEvent:a})}.bind(this)),this.emit("state:offline")},c.prototype._reconnect=function(){this.emit("reconnectionPending",{timeToAttempt:this._backoffDelay}),setTimeout(function(){this.emit("reconnecting"),this.connect()}.bind(this),this._backoffDelay),this._backoffDelay=2*this._backoffDelay,this._backoffDelay>this._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},c.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},c.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},c.prototype._handleWebSocketError=function(a){this._console.warn("WebSocket error:",a.stack||a)},c.prototype._send=function(a){var b=f.defer();switch(this._webSocket.readyState){case c.WebSocket.CONNECTING:b.resolver.reject({message:"WebSocket is still connecting"});break;case c.WebSocket.CLOSING:b.resolver.reject({message:"WebSocket is closing"});break;case c.WebSocket.CLOSED:b.resolver.reject({message:"WebSocket is closed"});break;default:a.jsonrpc="2.0",a.id=this._nextRequestId(),this._pendingRequests[a.id]=b.resolver,this._webSocket.send(JSON.stringify(a)),this.emit("websocket:outgoingMessage",a)}return b.promise},c.prototype._nextRequestId=function(){var a=-1;return function(){return a+=1}}(),c.prototype._handleMessage=function(a){try{var b=JSON.parse(a.data);b.hasOwnProperty("id")?this._handleResponse(b):b.hasOwnProperty("event")?this._handleEvent(b):this._console.warn("Unknown message type received. Message was: "+a.data)}catch(c){if(!(c instanceof SyntaxError))throw c;this._console.warn("WebSocket message parsing failed. Message was: "+a.data)}},c.prototype._handleResponse=function(a){if(!this._pendingRequests.hasOwnProperty(a.id))return this._console.warn("Unexpected response received. Message was:",a),void 0;var b=this._pendingRequests[a.id];delete this._pendingRequests[a.id],a.hasOwnProperty("result")?b.resolve(a.result):a.hasOwnProperty("error")?(b.reject(a.error),this._console.warn("Server returned error:",a.error)):(b.reject({message:"Response without 'result' or 'error' received",data:{response:a}}),this._console.warn("Response without 'result' or 'error' received. Message was:",a))},c.prototype._handleEvent=function(a){var b=a.event,c=a;delete c.event,this.emit("event:"+this._snakeToCamel(b),c)},c.prototype._getApiSpec=function(){return this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},c.prototype._createApi=function(a){var b=function(a){return function(){var b=Array.prototype.slice.call(arguments);return this._send({method:a,params:b})}.bind(this)}.bind(this),c=function(a){var b=a.split(".");return b.length>=1&&"core"===b[0]&&(b=b.slice(1)),b},d=function(a){var b=this;return a.forEach(function(a){a=this._snakeToCamel(a),b[a]=b[a]||{},b=b[a]}.bind(this)),b}.bind(this),e=function(e){var f=c(e),g=this._snakeToCamel(f.slice(-1)[0]),h=d(f.slice(0,-1));h[g]=b(e),h[g].description=a[e].description,h[g].params=a[e].params}.bind(this);Object.keys(a).forEach(e),this.emit("state:online")},c.prototype._snakeToCamel=function(a){return a.replace(/(_[a-z])/g,function(a){return a.toUpperCase().replace("_","")})},b.exports=c},{"../lib/websocket/":1,bane:2,when:4}]},{},[5])(5)}); \ No newline at end of file From aa8406e3097ec0b5b9cb7ca60db6361c0f3654d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 00:10:48 +0100 Subject: [PATCH 122/238] js: Remove unused when-define-shim.js --- js/lib/when-define-shim.js | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 js/lib/when-define-shim.js diff --git a/js/lib/when-define-shim.js b/js/lib/when-define-shim.js deleted file mode 100644 index ad135517..00000000 --- a/js/lib/when-define-shim.js +++ /dev/null @@ -1,11 +0,0 @@ -if (typeof window !== "undefined") { - window.define = function (factory) { - try { - delete window.define; - } catch (e) { - window.define = void 0; // IE - } - window.when = factory(); - }; - window.define.amd = {}; -} From a7d38df853af7980d45981dc1533c36682a1eb84 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 00:13:19 +0100 Subject: [PATCH 123/238] js: Release Mopidy.js 0.2.0 to npm --- js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index 8c582feb..5a04cd66 100644 --- a/js/README.md +++ b/js/README.md @@ -85,7 +85,7 @@ To run other [grunt](http://gruntjs.com/) targets which isn't predefined in Changelog --------- -### 0.2.0 (UNRELEASED) +### 0.2.0 (2014-01-04) - **Backwards incompatible change for Node.js users:** `var Mopidy = require('mopidy').Mopidy;` must be changed to From 7f467802f3b6aba39a056d68386e6dbd7304185a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 00:14:36 +0100 Subject: [PATCH 124/238] docs: Update changelog --- docs/changelog.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3898e5f3..b0e9fa45 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,12 @@ a temporary regression of :issue:`527`. - Live lookup of URI metadata has been added. (Fixes :issue:`540`) +**HTTP frontend** + +- Upgrade Mopidy.js dependencies and add support for using Mopidy.js with + Browserify. This version has been released to npm as Mopidy.js v0.2.0. + (Fixes: :issue:`609`) + **Internal changes** - Events from the audio actor, backends, and core actor are now emitted @@ -414,7 +420,7 @@ A release with a number of small and medium fixes, with no specific focus. objects with ``tlid`` set to ``0`` to be sent to the HTTP client without the ``tlid`` field. (Fixes: :issue:`501`) -- Upgrade Mopidy.js dependencies. This version has been released to NPM as +- Upgrade Mopidy.js dependencies. This version has been released to npm as Mopidy.js v0.1.1. **Extension support** From 789610c85ab325761f77afedbb23340c8d417777 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 00:39:57 +0100 Subject: [PATCH 125/238] docs: Update authors --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index cba5edc2..c048b83e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -30,5 +30,6 @@ - Lasse Bigum - David Eisner - PÃ¥l Ruud +- Paul Connolley - Luke Giuliani - Colin Montgomerie From 3f665ec29ac33a07cb01e913bc223321816c6443 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 14:10:19 +0100 Subject: [PATCH 126/238] Include all files not ignored by Git in PyPI releases --- MANIFEST.in | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index b3a70f17..51ba5919 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,12 +1,23 @@ +include *.py include *.rst +include .coveragerc +include .mailmap +include .travis.yml +include AUTHORS include LICENSE include MANIFEST.in -include data/mopidy.desktop + +recursive-include data * recursive-include docs * prune docs/_build +recursive-include js * +prune js/node_modules +prune js/test/lib + recursive-include mopidy *.conf recursive-include mopidy/http/data * + recursive-include tests *.py recursive-include tests/data * From 26b3d268f7735d9ae71c552c61bd2e1b889c1bdc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 4 Jan 2014 16:16:25 +0100 Subject: [PATCH 127/238] docs: Change how to require() Mopidy.js --- docs/api/http.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/http.rst b/docs/api/http.rst index c57597c7..5561955d 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -129,7 +129,7 @@ After npm completes, you can import Mopidy.js using ``require()``: .. code-block:: js - var Mopidy = require("mopidy").Mopidy; + var Mopidy = require("mopidy"); Getting the library for development on the library From 0d74be0b1e676b350f836270a3b309afa5c2133e Mon Sep 17 00:00:00 2001 From: kingosticks Date: Mon, 6 Jan 2014 11:54:28 +0000 Subject: [PATCH 128/238] Empty MPD commands should return an error instead of OK, just like the original MPD server. Includes tests. --- docs/changelog.rst | 5 +++++ mopidy/mpd/exceptions.py | 6 ++++++ mopidy/mpd/protocol/empty.py | 5 +++-- tests/mpd/exception_test.py | 10 +++++++++- tests/mpd/protocol/connection_test.py | 4 ++-- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b0e9fa45..e3b4826f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,11 @@ This changelog is used to track all major changes to Mopidy. v0.18.0 (UNRELEASED) ==================== +**MPD frontend** + +- Empty commands now return a ``ACK [5@0] {} No command given`` error instead + of ``OK``. This is consistent with the original MPD server implementation. + **Core API** - Expose :meth:`mopidy.core.Core.version` for HTTP clients to manage diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index db3212d8..07e3a421 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -62,6 +62,12 @@ class MpdUnknownCommand(MpdAckError): self.command = '' +class MpdNoCommand(MpdUnknownCommand): + def __init__(self, *args, **kwargs): + super(MpdNoCommand, self).__init__(*args, **kwargs) + self.message = 'No command given' + + class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST diff --git a/mopidy/mpd/protocol/empty.py b/mopidy/mpd/protocol/empty.py index 9b3d6883..9cb0aa6b 100644 --- a/mopidy/mpd/protocol/empty.py +++ b/mopidy/mpd/protocol/empty.py @@ -1,9 +1,10 @@ from __future__ import unicode_literals from mopidy.mpd.protocol import handle_request +from mopidy.mpd.exceptions import MpdNoCommand @handle_request(r'[\ ]*$') def empty(context): - """The original MPD server returns ``OK`` on an empty request.""" - pass + """The original MPD server returns an error on an empty request.""" + raise MpdNoCommand diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py index ae59253e..df815cfa 100644 --- a/tests/mpd/exception_test.py +++ b/tests/mpd/exception_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, MpdSystemError, MpdNotImplemented) @@ -41,6 +41,14 @@ class MpdExceptionsTest(unittest.TestCase): e.get_mpd_ack(), 'ACK [5@0] {} unknown command "play"') + def test_mpd_no_command(self): + try: + raise MpdNoCommand + except MpdAckError as e: + self.assertEqual( + e.get_mpd_ack(), + 'ACK [5@0] {} No command given') + def test_mpd_system_error(self): try: raise MpdSystemError('foo') diff --git a/tests/mpd/protocol/connection_test.py b/tests/mpd/protocol/connection_test.py index 452a2147..34cce6a0 100644 --- a/tests/mpd/protocol/connection_test.py +++ b/tests/mpd/protocol/connection_test.py @@ -14,10 +14,10 @@ class ConnectionHandlerTest(protocol.BaseTestCase): def test_empty_request(self): self.sendRequest('') - self.assertEqualResponse('OK') + self.assertEqualResponse('ACK [5@0] {} No command given') self.sendRequest(' ') - self.assertEqualResponse('OK') + self.assertEqualResponse('ACK [5@0] {} No command given') def test_kill(self): self.sendRequest('kill') From 08961cfcdd7e0005e9c5353058b6feb620e988b8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 8 Jan 2014 23:47:24 +0100 Subject: [PATCH 129/238] docs: Update change log and local library info --- docs/changelog.rst | 15 +++++++++++++++ docs/ext/local.rst | 6 ++++++ mopidy/backends/local/__init__.py | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 56177441..4c4792e1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,12 @@ v0.18.0 (UNRELEASED) - Add :class:`mopidy.models.Ref` class for use as a lightweight reference to other model types, containing just an URI, a name, and an object type. +**Extension registry** + +- Switched to using a registry model for classes provided by extension. This + allows extensions to be extended as needed for plugable local libraries. + (Fixes :issue:`601`) + **Pluggable local libraries** Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes @@ -33,6 +39,15 @@ a temporary regression of :issue:`527`. - Added support for deprecated config values in order to allow for graceful removal of :confval:`local/tag_cache_file`. +- Added :confval:`local/library` to select which library to use. + +- Added :confval:`local/data_dir` to have a common setting for where to store + local library data. This is intended to avoid every single local library + provider having to have it's own setting for this. + +- Added :confval:`local/scan_flush_threshold` to control how often to tell + local libraries to store changes. + **Streaming backend** - Live lookup of URI metadata has been added. (Fixes :issue:`540`) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 135a486b..4659678a 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -109,3 +109,9 @@ disable the current default library ``json``, replacing it with a third party one. When running :command:`mopidy local scan` mopidy will populate whatever the current active library is with data. Only one library may be active at a time. + +To create a new library provider you must create class that implements the +:class:`~mopidy.backends.local.Libary` interface and install it in the +extension registry under ``local:library``. Any data that the library needs +to store on disc should be stored in :confval:`local/data_dir` using the +library name as part of the filename or directory to avoid any conflicts. diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index d16eddfb..f34592b7 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -134,7 +134,7 @@ class Library(object): def flush(self): """ - Called for every n-th track indicating that work should be comitted. + Called for every n-th track indicating that work should be committed. Sub-classes are free to ignore these hints. :rtype: Boolean indicating if state was flushed. From f2a26ef246e4ac92063b06aa4ccd43be3dc0f86e Mon Sep 17 00:00:00 2001 From: kingosticks Date: Wed, 8 Jan 2014 23:04:38 +0000 Subject: [PATCH 130/238] Fixed line length for flake --- tests/mpd/exception_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py index df815cfa..88a8cdda 100644 --- a/tests/mpd/exception_test.py +++ b/tests/mpd/exception_test.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, MpdSystemError, - MpdNotImplemented) + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, + MpdSystemError, MpdNotImplemented) class MpdExceptionsTest(unittest.TestCase): From 96eb9a87c6984cfba415b20cbdc95d9900304a16 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 9 Jan 2014 00:41:46 +0100 Subject: [PATCH 131/238] mpd: Whitespace fix to make travis happy. --- tests/mpd/exception_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py index 88a8cdda..b470ed44 100644 --- a/tests/mpd/exception_test.py +++ b/tests/mpd/exception_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdNoCommand, MpdSystemError, MpdNotImplemented) From 9da5ccbb79a05cec6e1dcd4f6058e85451630488 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jan 2014 08:50:26 +0100 Subject: [PATCH 132/238] models: Add type specific constructors to Ref --- mopidy/models.py | 30 ++++++++++++++++++++++++++++++ tests/models_test.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/mopidy/models.py b/mopidy/models.py index b3d2a1b8..53083eb7 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -175,6 +175,36 @@ class Ref(ImmutableObject): #: Constant used for comparision with the :attr:`type` field. TRACK = 'track' + @classmethod + def album(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" + kwargs['type'] = Ref.ALBUM + return cls(**kwargs) + + @classmethod + def artist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" + kwargs['type'] = Ref.ARTIST + return cls(**kwargs) + + @classmethod + def directory(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" + kwargs['type'] = Ref.DIRECTORY + return cls(**kwargs) + + @classmethod + def playlist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" + kwargs['type'] = Ref.PLAYLIST + return cls(**kwargs) + + @classmethod + def track(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" + kwargs['type'] = Ref.TRACK + return cls(**kwargs) + class Artist(ImmutableObject): """ diff --git a/tests/models_test.py b/tests/models_test.py index b2b72ea4..02cba8f4 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -94,6 +94,36 @@ class RefTest(unittest.TestCase): self.assertEqual(Ref.PLAYLIST, 'playlist') self.assertEqual(Ref.TRACK, 'track') + def test_album_constructor(self): + ref = Ref.album(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.ALBUM) + + def test_artist_constructor(self): + ref = Ref.artist(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.ARTIST) + + def test_directory_constructor(self): + ref = Ref.directory(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.DIRECTORY) + + def test_playlist_constructor(self): + ref = Ref.playlist(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.PLAYLIST) + + def test_track_constructor(self): + ref = Ref.track(uri='foo', name='bar') + self.assertEqual(ref.uri, 'foo') + self.assertEqual(ref.name, 'bar') + self.assertEqual(ref.type, Ref.TRACK) + class ArtistTest(unittest.TestCase): def test_uri(self): From fe283113248b17bcca28d86d6f879602a06bed68 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jan 2014 08:54:03 +0100 Subject: [PATCH 133/238] models: Use new Ref constructors --- mopidy/core/library.py | 6 +++--- tests/core/library_test.py | 20 ++++++++++---------- tests/mpd/protocol/music_db_test.py | 12 ++++++------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 6ea3041c..ac1aad14 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -46,13 +46,13 @@ class LibraryController(object): track's original URI. A matching pair of objects can look like this:: Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) - Ref(uri='dummy:/foo.mp3', name='foo', type=Ref.TRACK) + Ref.track(uri='dummy:/foo.mp3', name='foo') The :class:`~mopidy.models.Ref` objects representing directories has plain paths, not including any URI schema. For example, the dummy library's ``/bar`` directory is returned like this:: - Ref(uri='/dummy/bar', name='bar', type=Ref.DIRECTORY) + Ref.directory(uri='/dummy/bar', name='bar') Note to backend implementors: The ``/dummy`` part of the URI is added by Mopidy core, not the individual backends. @@ -66,7 +66,7 @@ class LibraryController(object): if path == '/': return [ - Ref(uri='/%s' % name, name=name, type=Ref.DIRECTORY) + Ref.directory(uri='/%s' % name, name=name) for name in self.backends.with_browsable_library.keys()] groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 59f3faf7..44c5e3f1 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -34,8 +34,8 @@ class CoreLibraryTest(unittest.TestCase): result = self.core.library.browse('/') self.assertEqual(result, [ - Ref(uri='/dummy1', name='dummy1', type=Ref.DIRECTORY), - Ref(uri='/dummy2', name='dummy2', type=Ref.DIRECTORY), + Ref.directory(uri='/dummy1', name='dummy1'), + Ref.directory(uri='/dummy2', name='dummy2'), ]) self.assertFalse(self.library1.browse.called) self.assertFalse(self.library2.browse.called) @@ -50,8 +50,8 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dummy1_selects_dummy1_backend(self): self.library1.browse().get.return_value = [ - Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), + Ref.directory(uri='/foo/bar', name='bar'), + Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), ] self.library1.browse.reset_mock() @@ -63,8 +63,8 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dummy2_selects_dummy2_backend(self): self.library2.browse().get.return_value = [ - Ref(uri='/bar/quux', name='quux', type=Ref.DIRECTORY), - Ref(uri='dummy2:/foo/baz.mp3', name='Baz', type=Ref.TRACK), + Ref.directory(uri='/bar/quux', name='quux'), + Ref.track(uri='dummy2:/foo/baz.mp3', name='Baz'), ] self.library2.browse.reset_mock() @@ -83,16 +83,16 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dir_returns_subdirs_and_tracks(self): self.library1.browse().get.return_value = [ - Ref(uri='/foo/bar', name='bar', type=Ref.DIRECTORY), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), + Ref.directory(uri='/foo/bar', name='bar'), + Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), ] self.library1.browse.reset_mock() result = self.core.library.browse('/dummy1/foo') self.assertEqual(result, [ - Ref(uri='/dummy1/foo/bar', name='bar', type=Ref.DIRECTORY), - Ref(uri='dummy1:/foo/baz.mp3', name='Baz', type=Ref.TRACK), + Ref.directory(uri='/dummy1/foo/bar', name='bar'), + Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), ]) def test_lookup_selects_dummy1_backend(self): diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py index 36e36a0a..25feab27 100644 --- a/tests/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -169,8 +169,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): self.backend.library.dummy_browse_result = [ - Ref(uri='dummy:/a', name='a', type='track'), - Ref(uri='/foo', name='foo', type=Ref.DIRECTORY), + Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo', name='foo'), ] self.sendRequest('lsinfo "/"') @@ -179,8 +179,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): self.backend.library.dummy_browse_result = [ - Ref(uri='dummy:/a', name='a', type='track'), - Ref(uri='/foo', name='foo', type=Ref.DIRECTORY), + Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo', name='foo'), ] response1 = self.sendRequest('lsinfo "dummy"') @@ -192,7 +192,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Track(uri='dummy:/a', name='a'), ] self.backend.library.dummy_browse_result = [ - Ref(uri='dummy:/a', name='a', type='track'), + Ref.track(uri='dummy:/a', name='a'), ] self.sendRequest('lsinfo "/dummy"') @@ -202,7 +202,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = [ - Ref(uri='/foo', name='foo', type=Ref.DIRECTORY), + Ref.directory(uri='/foo', name='foo'), ] self.sendRequest('lsinfo "/dummy"') From 8d6f9ee8079fa6c60d0243ce31a3aeb943fb5e46 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jan 2014 08:57:32 +0100 Subject: [PATCH 134/238] mpd: Use Ref type constants --- mopidy/mpd/protocol/music_db.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index ae18957d..b31d295b 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -4,7 +4,7 @@ import functools import itertools import re -from mopidy.models import Track +from mopidy.models import Ref, Track from mopidy.mpd import translator from mopidy.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.mpd.protocol import handle_request, stored_playlists @@ -459,11 +459,11 @@ def lsinfo(context, uri=None): if not uri.startswith('/'): uri = '/%s' % uri for ref in context.core.library.browse(uri).get(): - if ref.type == 'directory': + if ref.type == Ref.DIRECTORY: assert ref.uri.startswith('/'), ( 'Directory URIs must start with /: %r' % ref) result.append(('directory', ref.uri[1:])) - elif ref.type == 'track': + elif ref.type == Ref.TRACK: # TODO Lookup tracks in batch for better performance tracks = context.core.library.lookup(ref.uri).get() if tracks: From 0980af25905a5c5a317100a667218e050a32b885 Mon Sep 17 00:00:00 2001 From: Simon de Bakker Date: Thu, 9 Jan 2014 18:47:26 +0100 Subject: [PATCH 135/238] Set initial mixer volume for all mixers --- mopidy/audio/actor.py | 10 ++++++++++ mopidy/config/__init__.py | 1 + mopidy/config/convert.py | 1 + mopidy/config/default.conf | 1 + 4 files changed, 13 insertions(+) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index feeee820..5a023be8 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -184,6 +184,7 @@ class Audio(pykka.ThreadingActor): def _setup_mixer(self): mixer_desc = self._config['audio']['mixer'] track_desc = self._config['audio']['mixer_track'] + volume = self._config['audio']['mixer_volume'] if mixer_desc is None: logger.info('Not setting up audio mixer') @@ -192,6 +193,9 @@ class Audio(pykka.ThreadingActor): if mixer_desc == 'software': self._software_mixing = True logger.info('Audio mixer is using software mixing') + if volume is not None: + self.set_volume(volume) + logger.info('Audio mixer volume set to %d', volume) return try: @@ -223,11 +227,17 @@ class Audio(pykka.ThreadingActor): 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"', str(mixer.get_factory().get_name()).decode('utf-8'), str(track.label).decode('utf-8')) + if volume is not None: + self.set_volume(volume) + logger.info('Audio mixer volume set to %d', volume) + + def _select_mixer_track(self, mixer, track_label): # Ignore tracks without volumes, then look for track with # label equal to the audio/mixer_track config value, otherwise fallback diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index d6400fad..2b740549 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -25,6 +25,7 @@ _loglevels_schema = LogLevelConfigSchema('loglevels') _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = String(optional=True) +_audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() _audio_schema['visualizer'] = String(optional=True) diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py index 7012b56e..a3ae5273 100644 --- a/mopidy/config/convert.py +++ b/mopidy/config/convert.py @@ -36,6 +36,7 @@ def convert(settings): helper('audio/mixer', 'MIXER') helper('audio/mixer_track', 'MIXER_TRACK') + helper('audio/mixer_volume', 'MIXER_VOLUME') helper('audio/output', 'OUTPUT') helper('proxy/hostname', 'SPOTIFY_PROXY_HOST') diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 26b9f2e7..ae690de3 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -10,6 +10,7 @@ pykka = info [audio] mixer = software mixer_track = +mixer_volume = output = autoaudiosink visualizer = From bdb4f9e2a517e2f6677e60fa0b516e782b376ef0 Mon Sep 17 00:00:00 2001 From: Simon de Bakker Date: Thu, 9 Jan 2014 18:48:27 +0100 Subject: [PATCH 136/238] Added mixer_volume to config documentation --- docs/config.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/config.rst b/docs/config.rst index 5099f04d..50369e78 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -78,6 +78,17 @@ Core configuration values Setting the config value to blank turns off volume control. + +.. confval:: audio/mixer_volume + + Audio mixer initial volume. + + Expects an Integer between 0 and 100. + + Sets the initial volume of the audio mixer. Setting the config value to blank + sets the initial volume for the software mixer to 100. + + .. confval:: audio/mixer_track Audio mixer track to use. From 1d93691296b8726f81ea66752411a7c6b8c68259 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 9 Jan 2014 22:15:48 +0100 Subject: [PATCH 137/238] docs: Add extend M3U to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 48c7d822..77e7837d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,9 @@ a temporary regression of :issue:`527`. - Live lookup of URI metadata has been added. (Fixes :issue:`540`) +- Support for extended M3U added, meaning that basic track metadata stored in + playlists will be provided for playlist tracks. + **HTTP frontend** - Upgrade Mopidy.js dependencies and add support for using Mopidy.js with From 58e3cb8fafd86ac58d502e1537bf595daeafe929 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 9 Jan 2014 22:33:15 +0100 Subject: [PATCH 138/238] docs: Update changelog with library browsing --- docs/changelog.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 77e7837d..cc79cdcd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,6 +20,10 @@ v0.18.0 (UNRELEASED) - Add :class:`mopidy.models.Ref` class for use as a lightweight reference to other model types, containing just an URI, a name, and an object type. +- Add :meth:`mopidy.core.LibraryController.browse` method for browsing a + virtual file system of tracks. Backends can implement support for this by + implementing :meth:`mopidy.backends.base.BaseLibraryController.browse`. + **Extension registry** - Switched to using a registry model for classes provided by extension. This @@ -66,6 +70,13 @@ a temporary regression of :issue:`527`. Browserify. This version has been released to npm as Mopidy.js v0.2.0. (Fixes: :issue:`609`) +**MPD frontend** + +- Make the ``lsinfo`` command support browsing of Mopidy's virtual file + system. Note that the related ``listall`` and ``listallinfo`` commands are + still not implemented. Also note that this adds very little until e.g. the + local backend is extended with support for library browsing. + **Internal changes** - Events from the audio actor, backends, and core actor are now emitted From 6afc85ec72a6720e59937f8fa02cc3c6f38ba031 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 9 Jan 2014 22:35:54 +0100 Subject: [PATCH 139/238] ext: Switch rest of bundled extensions to registry --- mopidy/backends/stream/__init__.py | 4 ++-- mopidy/http/__init__.py | 4 ++-- mopidy/mpd/__init__.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 47dd6151..e4c2bad7 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -26,6 +26,6 @@ class Extension(ext.Extension): def validate_environment(self): pass - def get_backend_classes(self): + def setup(self, registry): from .actor import StreamBackend - return [StreamBackend] + registry.add('backend', StreamBackend) diff --git a/mopidy/http/__init__.py b/mopidy/http/__init__.py index 64cb88f9..25e2dd46 100644 --- a/mopidy/http/__init__.py +++ b/mopidy/http/__init__.py @@ -35,6 +35,6 @@ class Extension(ext.Extension): except ImportError as e: raise exceptions.ExtensionError('ws4py library not found', e) - def get_frontend_classes(self): + def setup(self, registry): from .actor import HttpFrontend - return [HttpFrontend] + registry.add('frontend', HttpFrontend) diff --git a/mopidy/mpd/__init__.py b/mopidy/mpd/__init__.py index 571d6455..77aaf83f 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -29,6 +29,6 @@ class Extension(ext.Extension): def validate_environment(self): pass - def get_frontend_classes(self): + def setup(self, registry): from .actor import MpdFrontend - return [MpdFrontend] + registry.add('frontend', MpdFrontend) From 7897fe7bac685a8238635c8ea60c925c2a940cca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jan 2014 00:04:06 +0100 Subject: [PATCH 140/238] Fix typos --- mopidy/core/library.py | 4 ++-- mopidy/models.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ac1aad14..0e25ec4f 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -42,13 +42,13 @@ class LibraryController(object): Returns a list of :class:`mopidy.models.Ref` objects for the directories and tracks at the given ``path``. - The :class:`~mopidy.models.Ref` objects representing tracks keeps the + The :class:`~mopidy.models.Ref` objects representing tracks keep the track's original URI. A matching pair of objects can look like this:: Track(uri='dummy:/foo.mp3', name='foo', artists=..., album=...) Ref.track(uri='dummy:/foo.mp3', name='foo') - The :class:`~mopidy.models.Ref` objects representing directories has + The :class:`~mopidy.models.Ref` objects representing directories have plain paths, not including any URI schema. For example, the dummy library's ``/bar`` directory is returned like this:: diff --git a/mopidy/models.py b/mopidy/models.py index 53083eb7..ed371f23 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -160,19 +160,19 @@ class Ref(ImmutableObject): #: "directory". Read-only. type = None - #: Constant used for comparision with the :attr:`type` field. + #: Constant used for comparison with the :attr:`type` field. ALBUM = 'album' - #: Constant used for comparision with the :attr:`type` field. + #: Constant used for comparison with the :attr:`type` field. ARTIST = 'artist' - #: Constant used for comparision with the :attr:`type` field. + #: Constant used for comparison with the :attr:`type` field. DIRECTORY = 'directory' - #: Constant used for comparision with the :attr:`type` field. + #: Constant used for comparison with the :attr:`type` field. PLAYLIST = 'playlist' - #: Constant used for comparision with the :attr:`type` field. + #: Constant used for comparison with the :attr:`type` field. TRACK = 'track' @classmethod From 9321ae5f78d5023b23dc6af44a9cc9f68e4aefaa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 10 Jan 2014 08:29:00 +0100 Subject: [PATCH 141/238] docs: Remove visits counter We got stats from GitHub now. --- README.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.rst b/README.rst index c2026d14..0b003815 100644 --- a/README.rst +++ b/README.rst @@ -40,7 +40,3 @@ To get started with Mopidy, check out `the docs `_. .. image:: https://coveralls.io/repos/mopidy/mopidy/badge.png?branch=develop :target: https://coveralls.io/r/mopidy/mopidy?branch=develop :alt: Test coverage - -.. image:: https://sourcegraph.com/api/repos/github.com/mopidy/mopidy/counters/views-24h.png - :target: https://sourcegraph.com/github.com/mopidy/mopidy - :alt: Mopidy stats From d8270ec153749c70c0caaca0c47912dc36f6154b Mon Sep 17 00:00:00 2001 From: Simon de Bakker Date: Fri, 10 Jan 2014 23:38:27 +0100 Subject: [PATCH 142/238] Removed empty line (caught by flake8) --- mopidy/audio/actor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5a023be8..ca023125 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -237,7 +237,6 @@ class Audio(pykka.ThreadingActor): self.set_volume(volume) logger.info('Audio mixer volume set to %d', volume) - def _select_mixer_track(self, mixer, track_label): # Ignore tracks without volumes, then look for track with # label equal to the audio/mixer_track config value, otherwise fallback From ca35094554cfc6cb315c1f871f47d717f62a2507 Mon Sep 17 00:00:00 2001 From: Simon de Bakker Date: Fri, 10 Jan 2014 23:38:59 +0100 Subject: [PATCH 143/238] Forgot to adapt tests for audio actor --- tests/audio/actor_test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index eac299cf..3f7e56ce 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -23,6 +23,7 @@ class AudioTest(unittest.TestCase): 'audio': { 'mixer': 'fakemixer track_max_volume=65536', 'mixer_track': None, + 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, } @@ -73,6 +74,7 @@ class AudioTest(unittest.TestCase): 'audio': { 'mixer': 'fakemixer track_max_volume=40', 'mixer_track': None, + 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, } @@ -88,6 +90,7 @@ class AudioTest(unittest.TestCase): 'audio': { 'mixer': 'fakemixer track_max_volume=0', 'mixer_track': None, + 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, } From 95ef4c0193e94a9882dea7154a381be1d32e8137 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:05:54 +0100 Subject: [PATCH 144/238] ext: Remove get_library_updaters() --- docs/extensiondev.rst | 25 ------------------------- mopidy/ext.py | 9 --------- 2 files changed, 34 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 82144d0a..7fa19f7a 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -309,10 +309,6 @@ This is ``mopidy_soundspot/__init__.py``:: from .commands import SoundspotCommand return SoundspotCommand() - def get_library_updaters(self): - from .library import SoundspotLibraryUpdateProvider - return [SoundspotLibraryUpdateProvider] - def register_gstreamer_elements(self): from .mixer import SoundspotMixer gobject.type_register(SoundspotMixer) @@ -410,27 +406,6 @@ more details. return 0 -Example library provider -======================== - -Currently library providers are only really relevant for people who want to -replace the default local library. Providing this in addition to a backend that -exposes a library for the `local` uri scheme lets you plug in whatever storage -solution you happen to prefer. - -:: - - from mopidy.backends import base - - - class SoundspotLibraryUpdateProvider(base.BaseLibraryProvider): - def __init__(self, config): - super(SoundspotLibraryUpdateProvider, self).__init__(config) - self.config = config - - # Your library provider implementation here. - - Example GStreamer element ========================= diff --git a/mopidy/ext.py b/mopidy/ext.py index b29523a7..d4833275 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -89,15 +89,6 @@ class Extension(object): """ return [] - # TODO: remove - def get_library_updaters(self): - """List of library updater classes - - :returns: list of - :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` subclasses - """ - return [] - def get_command(self): """Command to expose to command line users running mopidy. From bc4434a8cb3c181a2f596f569b0fe6fb938cf1e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:05:55 +0100 Subject: [PATCH 145/238] ext: Doc Registry and setup(), deprecate old methods --- mopidy/ext.py | 89 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index d4833275..2900af4b 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -51,18 +51,54 @@ class Extension(object): schema['enabled'] = config_lib.Boolean() return schema + def get_command(self): + """Command to expose to command line users running mopidy. + + :returns: + Instance of a :class:`~mopidy.commands.Command` class. + """ + pass + def validate_environment(self): """Checks if the extension can run in the current environment For example, this method can be used to check if all dependencies that - are needed are installed. + are needed are installed. If a problem is found, raise + :exc:`~mopidy.exceptions.ExtensionError` with a message explaining the + issue. - :raises: :class:`~mopidy.exceptions.ExtensionError` + :raises: :exc:`~mopidy.exceptions.ExtensionError` :returns: :class:`None` """ pass def setup(self, registry): + """ + Register the extension's components in the extension :class:`Registry`. + + For example, to register a backend:: + + def setup(self, registry): + from .backend import SoundspotBackend + registry.add('backend', SoundspotBackend) + + See :meth:`Registry.add` for a list of registry keys with a special + meaning. Mopidy will instantiate and start any classes registered + under the ``frontend`` and ``backend`` registry keys. + + This method can also be used for other setup tasks not involving the + extension registry. For example, to register custom GStreamer + elements:: + + def setup(self, registry): + from .mixer import SoundspotMixer + gobject.type_register(SoundspotMixer) + gst.element_register( + SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) + + :param registry: the extension registry + :type registry: :class:`Registry` + """ for backend_class in self.get_backend_classes(): registry.add('backend', backend_class) @@ -74,7 +110,8 @@ class Extension(object): def get_frontend_classes(self): """List of frontend actor classes - Mopidy will take care of starting the actors. + .. deprecated:: 0.18 + Use :meth:`setup` instead. :returns: list of :class:`pykka.Actor` subclasses """ @@ -83,43 +120,51 @@ class Extension(object): def get_backend_classes(self): """List of backend actor classes - Mopidy will take care of starting the actors. + .. deprecated:: 0.18 + Use :meth:`setup` instead. :returns: list of :class:`~mopidy.backends.base.Backend` subclasses """ return [] - def get_command(self): - """Command to expose to command line users running mopidy. - - :returns: - Instance of a :class:`~mopidy.commands.Command` class. - """ - pass - def register_gstreamer_elements(self): - """Hook for registering custom GStreamer elements + """Hook for registering custom GStreamer elements. - Register custom GStreamer elements by implementing this method. - Example:: - - def register_gstreamer_elements(self): - from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) - gst.element_register( - SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) + .. deprecated:: 0.18 + Use :meth:`setup` instead. :returns: :class:`None` """ pass -# TODO: document class Registry(collections.Mapping): + """Registry of components provided by Mopidy extensions. + + Passed to the :meth:`~Extension.setup` method of all extensions. The + registry can be used like a dict of string keys and lists. + + Some keys have a special meaning, including, but not limited to: + + - ``backend`` is used for Mopidy backend classes. + - ``frontend`` is used for Mopidy frontend classes. + - ``local:library`` is used for Mopidy-Local libraries. + + Extensions can use the registry for allow other to extend the extension + itself. For example the ``Mopidy-Local`` use the ``local:library`` key to + allow other extensions to register library providers for ``Mopidy-Local`` + to use. Extensions should namespace custom keys with the extension's + :attr:`~Extension.ext_name`, e.g. ``local:foo`` or ``http:bar``. + """ + def __init__(self): self._registry = {} def add(self, name, cls): + """Add a component to the registry. + + Multiple classes can be registered to the same name. + """ self._registry.setdefault(name, []).append(cls) def __getitem__(self, name): From 99dff7515cf90dfb0bc45df91ccdecf5bc12a031 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:19:37 +0100 Subject: [PATCH 146/238] docs: Add check-manifest tips --- docs/extensiondev.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 7fa19f7a..75dcc32c 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -222,8 +222,10 @@ file:: include README.rst include mopidy_soundspot/ext.conf -For details on the ``MANIFEST.in`` file format, check out the `distuitls docs +For details on the ``MANIFEST.in`` file format, check out the `distutils docs `_. +`check-manifest `_ is a very +useful tool to check your ``MANIFEST.in`` file for completeness. Example __init__.py From b6288259642a1cdd356517af8463603095f494cb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:24:10 +0100 Subject: [PATCH 147/238] docs: Minor tweaks --- docs/extensiondev.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 75dcc32c..dcd1681a 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -239,7 +239,7 @@ The root of your Python package should have an ``__version__`` attribute with a class named ``Extension`` which inherits from Mopidy's extension base class, :class:`mopidy.ext.Extension`. This is the class referred to in the ``entry_points`` part of ``setup.py``. Any imports of other files in your -extension should be kept inside methods. This ensures that this file can be +extension should be kept inside methods. This ensures that this file can be imported without raising :exc:`ImportError` exceptions for missing dependencies, etc. @@ -436,7 +436,7 @@ Use of Mopidy APIs When writing an extension, you should only use APIs documented at :ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at -any time, and is not something extensions should rely on being stable. +any time, and is not something extensions should use. Logging in extensions From 3c7c0ae46c6b5e039dc7a4f1c9b247eff6dc0ff2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:24:32 +0100 Subject: [PATCH 148/238] docs: Use Extension.setup() in extensiondev guide --- docs/extensiondev.rst | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index dcd1681a..8c2cd16c 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -290,28 +290,29 @@ This is ``mopidy_soundspot/__init__.py``:: schema['password'] = config.Secret() return schema + def get_command(self): + from .commands import SoundspotCommand + return SoundspotCommand() + def validate_environment(self): try: import pysoundspot except ImportError as e: raise exceptions.ExtensionError('pysoundspot library not found', e) - # You will typically only implement one of the next three methods - # in a single extension. + def setup(self, registry): + # You will typically only do one of the following things in a + # single extension. - def get_frontend_classes(self): + # Register a frontend from .frontend import SoundspotFrontend - return [SoundspotFrontend] + registry.add('frontend', SoundspotFrontend) - def get_backend_classes(self): + # Register a backend from .backend import SoundspotBackend - return [SoundspotBackend] + registry.add('backend', SoundspotBackend) - def get_command(self): - from .commands import SoundspotCommand - return SoundspotCommand() - - def register_gstreamer_elements(self): + # Register a custom GStreamer element from .mixer import SoundspotMixer gobject.type_register(SoundspotMixer) gst.element_register( @@ -415,8 +416,8 @@ If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer elements, you'll need to register them in GStreamer before they can be used. Basically, you just implement your GStreamer element in Python and then make -your :meth:`~mopidy.ext.Extension.register_gstreamer_elements` method register -all your custom GStreamer elements. +your :meth:`~mopidy.ext.Extension.setup` method register all your custom +GStreamer elements. For examples of custom GStreamer elements implemented in Python, see :mod:`mopidy.audio.mixers`. From cde87d8c3e1834550c7b32ceec0dc8d3784dd471 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:40:42 +0100 Subject: [PATCH 149/238] docs: Update/rewrite changelog --- docs/changelog.rst | 82 +++++++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cc79cdcd..8bd5b15e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,62 +7,72 @@ This changelog is used to track all major changes to Mopidy. v0.18.0 (UNRELEASED) ==================== -**MPD frontend** - -- Empty commands now return a ``ACK [5@0] {} No command given`` error instead - of ``OK``. This is consistent with the original MPD server implementation. - **Core API** -- Expose :meth:`mopidy.core.Core.version` for HTTP clients to manage - compatibility between API versions. (Fixes: :issue:`597`) +- Add :meth:`mopidy.core.Core.version` for HTTP clients to manage compatibility + between API versions. (Fixes: :issue:`597`) - Add :class:`mopidy.models.Ref` class for use as a lightweight reference to - other model types, containing just an URI, a name, and an object type. + other model types, containing just an URI, a name, and an object type. It is + barely used for now, but its use will be extended over time. - Add :meth:`mopidy.core.LibraryController.browse` method for browsing a virtual file system of tracks. Backends can implement support for this by implementing :meth:`mopidy.backends.base.BaseLibraryController.browse`. -**Extension registry** +**Configuration** + +- The default for the :option:`mopidy --config` option has been updated to + include ``$XDG_CONFIG_DIRS`` in addition to ``$XDG_CONFIG_DIR``. (Fixes + :issue:`431`) + +- Added support for deprecating config values in order to allow for graceful + removal of the no longer used config value :confval:`local/tag_cache_file`. + +**Extension support** - Switched to using a registry model for classes provided by extension. This - allows extensions to be extended as needed for plugable local libraries. - (Fixes :issue:`601`) + allows extensions to be extended by other extensions, as needed by for + example pluggable libraries for the local backend. See + :class:`mopidy.ext.Registry` for details. (Fixes :issue:`601`) -**Pluggable local libraries** +- Added the new method :meth:`mopidy.ext.Extension.setup`. This method + replaces the now deprecated + :meth:`~mopidy.ext.Extension.get_backend_classes`, + :meth:`~mopidy.ext.Extension.get_frontend_classes`, and + :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. -Fixes issues :issue:`44`, partially resolves :issue:`397`, and causes -a temporary regression of :issue:`527`. +**Local backend** - Finished the work on creating pluggable libraries. Users can now - reconfigure Mopidy to use alternate library providers of their choosing - for local files. + reconfigure Mopidy to use alternate library providers of their choosing for + local files. (Fixes issue :issue:`44`, partially resolves :issue:`397`, and + causes a temporary regression of :issue:`527`.) -- Switched default local library provider from "tag cache" to JSON. This - greatly simplifies our library code and reuses our existing serialization - code. +- Switched default local library provider from a "tag cache" file that closely + resembled the one used by the original MPD server to a compressed JSON file. + This greatly simplifies our library code and reuses our existing model + serialization code, as used by the HTTP API and web clients. -- Killed our outdated and bug-ridden "tag cache" implementation. +- Removed our outdated and bug-ridden "tag cache" local library implementation. -- Added support for deprecated config values in order to allow for - graceful removal of :confval:`local/tag_cache_file`. +- Added the config value :confval:`local/library` to select which library to + use. It defaults to ``json``, which is the only local library bundled with + Mopidy. -- Added :confval:`local/library` to select which library to use. +- Added the config value :confval:`local/data_dir` to have a common config for + where to store local library data. This is intended to avoid every single + local library provider having to have it's own config value for this. -- Added :confval:`local/data_dir` to have a common setting for where to store - local library data. This is intended to avoid every single local library - provider having to have it's own setting for this. - -- Added :confval:`local/scan_flush_threshold` to control how often to tell - local libraries to store changes. +- Added the config value :confval:`local/scan_flush_threshold` to control how + often to tell local libraries to store changes when scanning local music. **Streaming backend** -- Live lookup of URI metadata has been added. (Fixes :issue:`540`) +- Add live lookup of URI metadata. (Fixes :issue:`540`) -- Support for extended M3U added, meaning that basic track metadata stored in - playlists will be provided for playlist tracks. +- Add support for extended M3U playlist, meaning that basic track metadata + stored in playlists will be used by Mopidy. **HTTP frontend** @@ -77,17 +87,15 @@ a temporary regression of :issue:`527`. still not implemented. Also note that this adds very little until e.g. the local backend is extended with support for library browsing. +- Empty commands now return a ``ACK [5@0] {} No command given`` error instead + of ``OK``. This is consistent with the original MPD server implementation. + **Internal changes** - Events from the audio actor, backends, and core actor are now emitted asyncronously through the GObject event loop. This should resolve the issue that has blocked the merge of the EOT-vs-EOS fix for a long time. -**Config file loading** - -- The default for the config flag has been updated to include - ``$XDG_CONFIG_DIRS`` in addition to ``$XDG_CONFIG_DIR``. (Fixes :issue:`431`) - v0.17.0 (2013-11-23) ==================== From 7ab8fd886140ed6681d241007d4dcb981d966587 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 14:41:53 +0100 Subject: [PATCH 150/238] docs: Reference the correct element --- mopidy/ext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 2900af4b..58e6caab 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -82,9 +82,9 @@ class Extension(object): from .backend import SoundspotBackend registry.add('backend', SoundspotBackend) - See :meth:`Registry.add` for a list of registry keys with a special - meaning. Mopidy will instantiate and start any classes registered - under the ``frontend`` and ``backend`` registry keys. + See :class:`Registry` for a list of registry keys with a special + meaning. Mopidy will instantiate and start any classes registered under + the ``frontend`` and ``backend`` registry keys. This method can also be used for other setup tasks not involving the extension registry. For example, to register custom GStreamer From 120c3812855580dfbdee30091bacaebc1e9643ba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 15:10:37 +0100 Subject: [PATCH 151/238] stream: Move mopidy.{backends => }.stream --- docs/api/backends.rst | 2 +- docs/changelog.rst | 6 +++--- docs/ext/stream.rst | 2 +- mopidy/{backends => }/stream/__init__.py | 0 mopidy/{backends => }/stream/actor.py | 0 mopidy/{backends => }/stream/ext.conf | 0 setup.py | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename mopidy/{backends => }/stream/__init__.py (100%) rename mopidy/{backends => }/stream/actor.py (100%) rename mopidy/{backends => }/stream/ext.conf (100%) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index ec78f250..2c4ad6a6 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -54,4 +54,4 @@ Backend implementations * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.local` -* :mod:`mopidy.backends.stream` +* :mod:`mopidy.stream` diff --git a/docs/changelog.rst b/docs/changelog.rst index 8bd5b15e..97be243b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -708,9 +708,9 @@ throughout Mopidy. **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` setting. +`. 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` setting. Current limitations: diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index 22e7d99e..6c6ab21c 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -20,7 +20,7 @@ None. The extension just needs Mopidy. Default configuration ===================== -.. literalinclude:: ../../mopidy/backends/stream/ext.conf +.. literalinclude:: ../../mopidy/stream/ext.conf :language: ini diff --git a/mopidy/backends/stream/__init__.py b/mopidy/stream/__init__.py similarity index 100% rename from mopidy/backends/stream/__init__.py rename to mopidy/stream/__init__.py diff --git a/mopidy/backends/stream/actor.py b/mopidy/stream/actor.py similarity index 100% rename from mopidy/backends/stream/actor.py rename to mopidy/stream/actor.py diff --git a/mopidy/backends/stream/ext.conf b/mopidy/stream/ext.conf similarity index 100% rename from mopidy/backends/stream/ext.conf rename to mopidy/stream/ext.conf diff --git a/setup.py b/setup.py index 607496b7..52a1f2a8 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( 'http = mopidy.http:Extension [http]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.mpd:Extension', - 'stream = mopidy.backends.stream:Extension', + 'stream = mopidy.stream:Extension', ], }, classifiers=[ From 2731d2357865590da1b12c3e95116425b249321b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 15:16:33 +0100 Subject: [PATCH 152/238] local: Move mopidy.{backends => }.local --- docs/api/backends.rst | 2 +- docs/ext/local.rst | 10 +++++----- mopidy/{backends => }/local/__init__.py | 0 mopidy/{backends => }/local/actor.py | 0 mopidy/{backends => }/local/commands.py | 0 mopidy/{backends => }/local/ext.conf | 0 mopidy/{backends => }/local/json.py | 5 ++--- mopidy/{backends => }/local/library.py | 0 mopidy/{backends => }/local/playback.py | 0 mopidy/{backends => }/local/playlists.py | 0 mopidy/{backends => }/local/search.py | 0 mopidy/{backends => }/local/translator.py | 0 setup.py | 2 +- tests/{backends => }/local/__init__.py | 0 tests/{backends => }/local/events_test.py | 4 ++-- tests/{backends => }/local/library_test.py | 2 +- tests/{backends => }/local/playback_test.py | 4 ++-- tests/{backends => }/local/playlists_test.py | 4 ++-- tests/{backends => }/local/tracklist_test.py | 4 ++-- tests/{backends => }/local/translator_test.py | 2 +- 20 files changed, 19 insertions(+), 20 deletions(-) rename mopidy/{backends => }/local/__init__.py (100%) rename mopidy/{backends => }/local/actor.py (100%) rename mopidy/{backends => }/local/commands.py (100%) rename mopidy/{backends => }/local/ext.conf (100%) rename mopidy/{backends => }/local/json.py (96%) rename mopidy/{backends => }/local/library.py (100%) rename mopidy/{backends => }/local/playback.py (100%) rename mopidy/{backends => }/local/playlists.py (100%) rename mopidy/{backends => }/local/search.py (100%) rename mopidy/{backends => }/local/translator.py (100%) rename tests/{backends => }/local/__init__.py (100%) rename tests/{backends => }/local/events_test.py (93%) rename tests/{backends => }/local/library_test.py (99%) rename tests/{backends => }/local/playback_test.py (99%) rename tests/{backends => }/local/playlists_test.py (98%) rename tests/{backends => }/local/tracklist_test.py (99%) rename tests/{backends => }/local/translator_test.py (98%) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 2c4ad6a6..ee9ef406 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -53,5 +53,5 @@ Backend implementations ======================= * :mod:`mopidy.backends.dummy` -* :mod:`mopidy.backends.local` +* :mod:`mopidy.local` * :mod:`mopidy.stream` diff --git a/docs/ext/local.rst b/docs/ext/local.rst index aaaeb8e4..c7d6487d 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -18,7 +18,7 @@ None. The extension just needs Mopidy. Default configuration ===================== -.. literalinclude:: ../../mopidy/backends/local/ext.conf +.. literalinclude:: ../../mopidy/local/ext.conf :language: ini @@ -105,7 +105,7 @@ whatever the current active library is with data. Only one library may be active at a time. To create a new library provider you must create class that implements the -:class:`~mopidy.backends.local.Libary` interface and install it in the -extension registry under ``local:library``. Any data that the library needs -to store on disc should be stored in :confval:`local/data_dir` using the -library name as part of the filename or directory to avoid any conflicts. +:class:`~mopidy.local.Library` interface and install it in the extension +registry under ``local:library``. Any data that the library needs to store on +disc should be stored in :confval:`local/data_dir` using the library name as +part of the filename or directory to avoid any conflicts. diff --git a/mopidy/backends/local/__init__.py b/mopidy/local/__init__.py similarity index 100% rename from mopidy/backends/local/__init__.py rename to mopidy/local/__init__.py diff --git a/mopidy/backends/local/actor.py b/mopidy/local/actor.py similarity index 100% rename from mopidy/backends/local/actor.py rename to mopidy/local/actor.py diff --git a/mopidy/backends/local/commands.py b/mopidy/local/commands.py similarity index 100% rename from mopidy/backends/local/commands.py rename to mopidy/local/commands.py diff --git a/mopidy/backends/local/ext.conf b/mopidy/local/ext.conf similarity index 100% rename from mopidy/backends/local/ext.conf rename to mopidy/local/ext.conf diff --git a/mopidy/backends/local/json.py b/mopidy/local/json.py similarity index 96% rename from mopidy/backends/local/json.py rename to mopidy/local/json.py index 7bccf101..f81d6915 100644 --- a/mopidy/backends/local/json.py +++ b/mopidy/local/json.py @@ -7,9 +7,8 @@ import os import tempfile import mopidy -from mopidy import models -from mopidy.backends import local -from mopidy.backends.local import search +from mopidy import local, models +from mopidy.local import search logger = logging.getLogger(__name__) diff --git a/mopidy/backends/local/library.py b/mopidy/local/library.py similarity index 100% rename from mopidy/backends/local/library.py rename to mopidy/local/library.py diff --git a/mopidy/backends/local/playback.py b/mopidy/local/playback.py similarity index 100% rename from mopidy/backends/local/playback.py rename to mopidy/local/playback.py diff --git a/mopidy/backends/local/playlists.py b/mopidy/local/playlists.py similarity index 100% rename from mopidy/backends/local/playlists.py rename to mopidy/local/playlists.py diff --git a/mopidy/backends/local/search.py b/mopidy/local/search.py similarity index 100% rename from mopidy/backends/local/search.py rename to mopidy/local/search.py diff --git a/mopidy/backends/local/translator.py b/mopidy/local/translator.py similarity index 100% rename from mopidy/backends/local/translator.py rename to mopidy/local/translator.py diff --git a/setup.py b/setup.py index 52a1f2a8..f3e20f4a 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ setup( ], 'mopidy.ext': [ 'http = mopidy.http:Extension [http]', - 'local = mopidy.backends.local:Extension', + 'local = mopidy.local:Extension', 'mpd = mopidy.mpd:Extension', 'stream = mopidy.stream:Extension', ], diff --git a/tests/backends/local/__init__.py b/tests/local/__init__.py similarity index 100% rename from tests/backends/local/__init__.py rename to tests/local/__init__.py diff --git a/tests/backends/local/events_test.py b/tests/local/events_test.py similarity index 93% rename from tests/backends/local/events_test.py rename to tests/local/events_test.py index 967d4cdb..60c0b146 100644 --- a/tests/backends/local/events_test.py +++ b/tests/local/events_test.py @@ -5,9 +5,9 @@ import unittest import mock import pykka -from mopidy import core, audio +from mopidy import audio, core from mopidy.backends import listener -from mopidy.backends.local import actor +from mopidy.local import actor from tests import path_to_data_dir diff --git a/tests/backends/local/library_test.py b/tests/local/library_test.py similarity index 99% rename from tests/backends/local/library_test.py rename to tests/local/library_test.py index b9292f1f..3a0ed090 100644 --- a/tests/backends/local/library_test.py +++ b/tests/local/library_test.py @@ -8,7 +8,7 @@ import unittest import pykka from mopidy import core -from mopidy.backends.local import actor, json +from mopidy.local import actor, json from mopidy.models import Track, Album, Artist from tests import path_to_data_dir diff --git a/tests/backends/local/playback_test.py b/tests/local/playback_test.py similarity index 99% rename from tests/backends/local/playback_test.py rename to tests/local/playback_test.py index 7d48cfea..4aae8b04 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/local/playback_test.py @@ -7,12 +7,12 @@ import unittest import pykka from mopidy import audio, core -from mopidy.backends.local import actor from mopidy.core import PlaybackState +from mopidy.local import actor from mopidy.models import Track from tests import path_to_data_dir -from tests.backends.local import generate_song, populate_tracklist +from tests.local import generate_song, populate_tracklist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 diff --git a/tests/backends/local/playlists_test.py b/tests/local/playlists_test.py similarity index 98% rename from tests/backends/local/playlists_test.py rename to tests/local/playlists_test.py index 38827526..f054ffc9 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/local/playlists_test.py @@ -8,11 +8,11 @@ import unittest import pykka from mopidy import audio, core -from mopidy.backends.local import actor +from mopidy.local import actor from mopidy.models import Playlist, Track from tests import path_to_data_dir -from tests.backends.local import generate_song +from tests.local import generate_song class LocalPlaylistsProviderTest(unittest.TestCase): diff --git a/tests/backends/local/tracklist_test.py b/tests/local/tracklist_test.py similarity index 99% rename from tests/backends/local/tracklist_test.py rename to tests/local/tracklist_test.py index 28def50c..7717f1a5 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/local/tracklist_test.py @@ -6,12 +6,12 @@ import unittest import pykka from mopidy import audio, core -from mopidy.backends.local import actor from mopidy.core import PlaybackState +from mopidy.local import actor from mopidy.models import Playlist, TlTrack, Track from tests import path_to_data_dir -from tests.backends.local import generate_song, populate_tracklist +from tests.local import generate_song, populate_tracklist class LocalTracklistProviderTest(unittest.TestCase): diff --git a/tests/backends/local/translator_test.py b/tests/local/translator_test.py similarity index 98% rename from tests/backends/local/translator_test.py rename to tests/local/translator_test.py index 407a7860..b7ffd5cf 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/local/translator_test.py @@ -6,7 +6,7 @@ import os import tempfile import unittest -from mopidy.backends.local.translator import parse_m3u +from mopidy.local.translator import parse_m3u from mopidy.models import Track from mopidy.utils.path import path_to_uri From d724001f5b692e7ddc28563d0629a0d992d35452 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 15:49:22 +0100 Subject: [PATCH 153/238] tests: Move mopidy.backends.dummy to tests.dummy_backend --- tests/core/events_test.py | 5 +++-- mopidy/backends/dummy.py => tests/dummy_backend.py | 10 ---------- tests/mpd/dispatcher_test.py | 5 +++-- tests/mpd/protocol/__init__.py | 5 +++-- tests/mpd/status_test.py | 6 +++--- tests/utils/jsonrpc_test.py | 5 +++-- 6 files changed, 15 insertions(+), 21 deletions(-) rename mopidy/backends/dummy.py => tests/dummy_backend.py (96%) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 5d646840..17d2eb84 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -6,14 +6,15 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy from mopidy.models import Track +from tests import dummy_backend + @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): diff --git a/mopidy/backends/dummy.py b/tests/dummy_backend.py similarity index 96% rename from mopidy/backends/dummy.py rename to tests/dummy_backend.py index b3be0889..9fdedaa6 100644 --- a/mopidy/backends/dummy.py +++ b/tests/dummy_backend.py @@ -2,16 +2,6 @@ This backend implements the backend API in the simplest way possible. It is used in tests of the frontends. - -The backend handles URIs starting with ``dummy:``. - -**Dependencies** - -None - -**Default config** - -None """ from __future__ import unicode_literals diff --git a/tests/mpd/dispatcher_test.py b/tests/mpd/dispatcher_test.py index 13f2d7a5..36f2f5e1 100644 --- a/tests/mpd/dispatcher_test.py +++ b/tests/mpd/dispatcher_test.py @@ -5,11 +5,12 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError from mopidy.mpd.protocol import request_handlers, handle_request +from tests import dummy_backend + class MpdDispatcherTest(unittest.TestCase): def setUp(self): @@ -18,7 +19,7 @@ class MpdDispatcherTest(unittest.TestCase): 'password': None, } } - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 9f3b58d6..216afe33 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,9 +6,10 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy from mopidy.mpd import session +from tests import dummy_backend + class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): @@ -31,7 +32,7 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() diff --git a/tests/mpd/status_test.py b/tests/mpd/status_test.py index dea0c479..5c22be36 100644 --- a/tests/mpd/status_test.py +++ b/tests/mpd/status_test.py @@ -5,12 +5,12 @@ import unittest import pykka from mopidy import core -from mopidy.backends import dummy from mopidy.core import PlaybackState +from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status -from mopidy.models import Track +from tests import dummy_backend PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING @@ -22,7 +22,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index c6f516bb..f562f113 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -7,9 +7,10 @@ import unittest import pykka from mopidy import core, models -from mopidy.backends import dummy from mopidy.utils import jsonrpc +from tests import dummy_backend + class Calculator(object): def model(self): @@ -40,7 +41,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): def setUp(self): - self.backend = dummy.create_dummy_backend_proxy() + self.backend = dummy_backend.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.jrw = jsonrpc.JsonRpcWrapper( From 4a8ab668df01b7aaf402610df98067b046c78aa9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 16:48:59 +0100 Subject: [PATCH 154/238] backend: Remove unused BaseLibraryUpdateProvider --- mopidy/backends/base.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 5a8a23bb..b9c67ad5 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -105,40 +105,6 @@ class BaseLibraryProvider(object): pass -class BaseLibraryUpdateProvider(object): - uri_schemes = [] - - def load(self): - """Loads the library and returns all tracks in it. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def add(self, track): - """Adds given track to library. - - Overwrites any existing track with same URI. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def remove(self, uri): - """Removes given track from library. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def commit(self): - """Persist changes to library. - - *MAY be implemented by subclass.* - """ - pass - - class BasePlaybackProvider(object): """ :param audio: the audio actor From c962bdffcf4dcc535600afec287090c1bfc4d5f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 16:49:34 +0100 Subject: [PATCH 155/238] docs: Remove reference to dummy backend --- docs/api/backends.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index ee9ef406..40d65f30 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -52,6 +52,5 @@ Backend listener Backend implementations ======================= -* :mod:`mopidy.backends.dummy` * :mod:`mopidy.local` * :mod:`mopidy.stream` From b6b542a60fe722f42a1ce97b3ac57da10c1e685e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 16:51:25 +0100 Subject: [PATCH 156/238] backend: Move backend API to mopidy.backend Keep imports from old locations working until extensions have been updated to use the new location. --- docs/api/backends.rst | 16 +- docs/changelog.rst | 22 +++ mopidy/backend.py | 294 ++++++++++++++++++++++++++++++++++++ mopidy/backends/base.py | 278 ++-------------------------------- mopidy/backends/listener.py | 30 +--- 5 files changed, 343 insertions(+), 297 deletions(-) create mode 100644 mopidy/backend.py diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 40d65f30..fa6d8410 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -4,46 +4,46 @@ Backend API *********** -.. module:: mopidy.backends.base +.. module:: mopidy.backend :synopsis: The API implemented by backends The backend API is the interface that must be implemented when you create a -backend. If you are working on a frontend and need to access the backend, see -the :ref:`core-api`. +backend. If you are working on a frontend and need to access the backends, see +the :ref:`core-api` instead. Backend class ============= -.. autoclass:: mopidy.backends.base.Backend +.. autoclass:: mopidy.backend.Backend :members: Playback provider ================= -.. autoclass:: mopidy.backends.base.BasePlaybackProvider +.. autoclass:: mopidy.backend.PlaybackProvider :members: Playlists provider ================== -.. autoclass:: mopidy.backends.base.BasePlaylistsProvider +.. autoclass:: mopidy.backend.PlaylistsProvider :members: Library provider ================ -.. autoclass:: mopidy.backends.base.BaseLibraryProvider +.. autoclass:: mopidy.backend.LibraryProvider :members: Backend listener ================ -.. autoclass:: mopidy.backends.listener.BackendListener +.. autoclass:: mopidy.backend.BackendListener :members: diff --git a/docs/changelog.rst b/docs/changelog.rst index 97be243b..fa20f791 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,6 +20,28 @@ v0.18.0 (UNRELEASED) virtual file system of tracks. Backends can implement support for this by implementing :meth:`mopidy.backends.base.BaseLibraryController.browse`. +**Backend API** + +- Move the backend API classes from :mod:`mopidy.backends.base` to + :mod:`mopidy.backend` and remove the ``Base`` prefix from the class names: + + - From :class:`mopidy.backends.base.Backend` + to :class:`mopidy.backend.Backend` + + - From :class:`mopidy.backends.base.BaseLibraryProvider` + to :class:`mopidy.backend.LibraryProvider` + + - From :class:`mopidy.backends.base.BasePlaybackProvider` + to :class:`mopidy.backend.PlaybackProvider` + + - From :class:`mopidy.backends.base.BasePlaylistsProvider` + to :class:`mopidy.backend.PlaylistsProvider` + + - From :class:`mopidy.backends.listener.BackendListener` + to :class:`mopidy.backend.BackendListener` + + Imports from the old locations still works, but are deprecated. + **Configuration** - The default for the :option:`mopidy --config` option has been updated to diff --git a/mopidy/backend.py b/mopidy/backend.py new file mode 100644 index 00000000..5db013e0 --- /dev/null +++ b/mopidy/backend.py @@ -0,0 +1,294 @@ +from __future__ import unicode_literals + +import copy + +from mopidy import listener + + +class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + + #: The library provider. An instance of + #: :class:`~mopidy.backend.LibraryProvider`, or :class:`None` if + #: the backend doesn't provide a library. + library = None + + #: The playback provider. An instance of + #: :class:`~mopidy.backend.PlaybackProvider`, or :class:`None` if + #: the backend doesn't provide playback. + playback = None + + #: The playlists provider. An instance of + #: :class:`~mopidy.backend.PlaylistsProvider`, or class:`None` if + #: the backend doesn't provide playlists. + playlists = None + + #: List of URI schemes this backend can handle. + uri_schemes = [] + + # Because the providers is marked as pykka_traversible, we can't get() them + # from another actor, and need helper methods to check if the providers are + # set or None. + + def has_library(self): + return self.library is not None + + def has_playback(self): + return self.playback is not None + + def has_playlists(self): + return self.playlists is not None + + +class LibraryProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backend.Backend` + """ + + pykka_traversable = True + + root_directory_name = None + """ + Name of the library's root directory in Mopidy's virtual file system. + + *MUST be set by any class that implements :meth:`browse`.* + """ + + def __init__(self, backend): + self.backend = backend + + def browse(self, path): + """ + See :meth:`mopidy.core.LibraryController.browse`. + + If you implement this method, make sure to also set + :attr:`root_directory_name`. + + *MAY be implemented by subclass.* + """ + return [] + + # TODO: replace with search(query, exact=True, ...) + def find_exact(self, query=None, uris=None): + """ + See :meth:`mopidy.core.LibraryController.find_exact`. + + *MAY be implemented by subclass.* + """ + pass + + def lookup(self, uri): + """ + See :meth:`mopidy.core.LibraryController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self, uri=None): + """ + See :meth:`mopidy.core.LibraryController.refresh`. + + *MAY be implemented by subclass.* + """ + pass + + def search(self, query=None, uris=None): + """ + See :meth:`mopidy.core.LibraryController.search`. + + *MAY be implemented by subclass.* + """ + pass + + +class PlaybackProvider(object): + """ + :param audio: the audio actor + :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` + :param backend: the backend + :type backend: :class:`mopidy.backend.Backend` + """ + + pykka_traversable = True + + def __init__(self, audio, backend): + self.audio = audio + self.backend = backend + + def pause(self): + """ + Pause playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.pause_playback().get() + + def play(self, track): + """ + Play given track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.prepare_change() + self.change_track(track) + return self.audio.start_playback().get() + + def change_track(self, track): + """ + Swith to provided track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.set_uri(track.uri).get() + return True + + def resume(self): + """ + Resume playback at the same time position playback was paused. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.start_playback().get() + + def seek(self, time_position): + """ + Seek to a given time position. + + *MAY be reimplemented by subclass.* + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.set_position(time_position).get() + + def stop(self): + """ + Stop playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.stop_playback().get() + + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.audio.get_position().get() + + +class PlaylistsProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backend.Backend` instance + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + """ + Currently available playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return copy.copy(self._playlists) + + @playlists.setter # noqa + def playlists(self, playlists): + self._playlists = playlists + + def create(self, name): + """ + See :meth:`mopidy.core.PlaylistsController.create`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def delete(self, uri): + """ + See :meth:`mopidy.core.PlaylistsController.delete`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.core.PlaylistsController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self): + """ + See :meth:`mopidy.core.PlaylistsController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def save(self, playlist): + """ + See :meth:`mopidy.core.PlaylistsController.save`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + +class BackendListener(listener.Listener): + """ + Marker interface for recipients of events sent by the backend actors. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the core actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + + Normally, only the Core actor should mix in this class. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of backend listener events""" + listener.send_async(BackendListener, event, **kwargs) + + def playlists_loaded(self): + """ + Called when playlists are loaded or refreshed. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index b9c67ad5..aed6ce3e 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -1,265 +1,17 @@ from __future__ import unicode_literals -import copy - - -class Backend(object): - #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. - #: - #: Should be passed to the backend constructor as the kwarg ``audio``, - #: which will then set this field. - audio = None - - #: The library provider. An instance of - #: :class:`~mopidy.backends.base.BaseLibraryProvider`, or :class:`None` if - #: the backend doesn't provide a library. - library = None - - #: The playback provider. An instance of - #: :class:`~mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if - #: the backend doesn't provide playback. - playback = None - - #: The playlists provider. An instance of - #: :class:`~mopidy.backends.base.BasePlaylistsProvider`, or class:`None` if - #: the backend doesn't provide playlists. - playlists = None - - #: List of URI schemes this backend can handle. - uri_schemes = [] - - # Because the providers is marked as pykka_traversible, we can't get() them - # from another actor, and need helper methods to check if the providers are - # set or None. - - def has_library(self): - return self.library is not None - - def has_playback(self): - return self.playback is not None - - def has_playlists(self): - return self.playlists is not None - - -class BaseLibraryProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - root_directory_name = None - """ - Name of the library's root directory in Mopidy's virtual file system. - - *MUST be set by any class that implements :meth:`browse`.* - """ - - def __init__(self, backend): - self.backend = backend - - def browse(self, path): - """ - See :meth:`mopidy.core.LibraryController.browse`. - - If you implement this method, make sure to also set - :attr:`root_directory_name`. - - *MAY be implemented by subclass.* - """ - return [] - - # TODO: replace with search(query, exact=True, ...) - def find_exact(self, query=None, uris=None): - """ - See :meth:`mopidy.core.LibraryController.find_exact`. - - *MAY be implemented by subclass.* - """ - pass - - def lookup(self, uri): - """ - See :meth:`mopidy.core.LibraryController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self, uri=None): - """ - See :meth:`mopidy.core.LibraryController.refresh`. - - *MAY be implemented by subclass.* - """ - pass - - def search(self, query=None, uris=None): - """ - See :meth:`mopidy.core.LibraryController.search`. - - *MAY be implemented by subclass.* - """ - pass - - -class BasePlaybackProvider(object): - """ - :param audio: the audio actor - :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, audio, backend): - self.audio = audio - self.backend = backend - - def pause(self): - """ - Pause playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.pause_playback().get() - - def play(self, track): - """ - Play given track. - - *MAY be reimplemented by subclass.* - - :param track: the track to play - :type track: :class:`mopidy.models.Track` - :rtype: :class:`True` if successful, else :class:`False` - """ - self.audio.prepare_change() - self.change_track(track) - return self.audio.start_playback().get() - - def change_track(self, track): - """ - Swith to provided track. - - *MAY be reimplemented by subclass.* - - :param track: the track to play - :type track: :class:`mopidy.models.Track` - :rtype: :class:`True` if successful, else :class:`False` - """ - self.audio.set_uri(track.uri).get() - return True - - def resume(self): - """ - Resume playback at the same time position playback was paused. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.start_playback().get() - - def seek(self, time_position): - """ - Seek to a given time position. - - *MAY be reimplemented by subclass.* - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.set_position(time_position).get() - - def stop(self): - """ - Stop playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.stop_playback().get() - - def get_time_position(self): - """ - Get the current time position in milliseconds. - - *MAY be reimplemented by subclass.* - - :rtype: int - """ - return self.audio.get_position().get() - - -class BasePlaylistsProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - self._playlists = [] - - @property - def playlists(self): - """ - Currently available playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return copy.copy(self._playlists) - - @playlists.setter # noqa - def playlists(self, playlists): - self._playlists = playlists - - def create(self, name): - """ - See :meth:`mopidy.core.PlaylistsController.create`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def delete(self, uri): - """ - See :meth:`mopidy.core.PlaylistsController.delete`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.core.PlaylistsController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self): - """ - See :meth:`mopidy.core.PlaylistsController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def save(self, playlist): - """ - See :meth:`mopidy.core.PlaylistsController.save`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError +from mopidy.backend import ( + Backend, + LibraryProvider as BaseLibraryProvider, + PlaybackProvider as BasePlaybackProvider, + PlaylistsProvider as BasePlaylistsProvider) + + +# Make classes previously residing here available in the old location for +# backwards compatibility with extensions targeting Mopidy < 0.18. +__all__ = [ + 'Backend', + 'BaseLibraryProvider', + 'BasePlaybackProvider', + 'BasePlaylistsProvider', +] diff --git a/mopidy/backends/listener.py b/mopidy/backends/listener.py index ee4735e7..0b551f26 100644 --- a/mopidy/backends/listener.py +++ b/mopidy/backends/listener.py @@ -1,30 +1,8 @@ from __future__ import unicode_literals -from mopidy import listener +from mopidy.backend import BackendListener -class BackendListener(listener.Listener): - """ - Marker interface for recipients of events sent by the backend actors. - - Any Pykka actor that mixes in this class will receive calls to the methods - defined here when the corresponding events happen in the core actor. This - interface is used both for looking up what actors to notify of the events, - and for providing default implementations for those listeners that are not - interested in all events. - - Normally, only the Core actor should mix in this class. - """ - - @staticmethod - def send(event, **kwargs): - """Helper to allow calling of backend listener events""" - listener.send_async(BackendListener, event, **kwargs) - - def playlists_loaded(self): - """ - Called when playlists are loaded or refreshed. - - *MAY* be implemented by actor. - """ - pass +# Make classes previously residing here available in the old location for +# backwards compatibility with extensions targeting Mopidy < 0.18. +__all__ = ['BackendListener'] From 05632c3b8bb50ae02e2db51b0f010059b30766be Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 17:52:34 +0100 Subject: [PATCH 157/238] backend: Update backend API imports --- mopidy/core/actor.py | 6 +++--- mopidy/ext.py | 2 +- mopidy/local/actor.py | 4 ++-- mopidy/local/library.py | 4 ++-- mopidy/local/playback.py | 4 ++-- mopidy/local/playlists.py | 6 +++--- mopidy/stream/actor.py | 9 ++++----- tests/{backends => backend}/__init__.py | 0 tests/{backends => backend}/listener_test.py | 4 ++-- tests/core/library_test.py | 9 ++++----- tests/core/playback_test.py | 21 ++++++++++---------- tests/core/playlists_test.py | 9 ++++----- tests/core/tracklist_test.py | 9 ++++----- tests/dummy_backend.py | 10 +++++----- tests/local/events_test.py | 5 ++--- 15 files changed, 48 insertions(+), 54 deletions(-) rename tests/{backends => backend}/__init__.py (100%) rename tests/{backends => backend}/listener_test.py (83%) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 26350f16..0f152436 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,8 +5,8 @@ import itertools import pykka -from mopidy.audio import AudioListener, PlaybackState -from mopidy.backends.listener import BackendListener +from mopidy import audio, backend +from mopidy.audio import PlaybackState from mopidy.utils import versioning from .library import LibraryController @@ -16,7 +16,7 @@ from .playlists import PlaylistsController from .tracklist import TracklistController -class Core(pykka.ThreadingActor, AudioListener, BackendListener): +class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): library = None """The library controller. An instance of :class:`mopidy.core.LibraryController`.""" diff --git a/mopidy/ext.py b/mopidy/ext.py index 58e6caab..a58090cc 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -123,7 +123,7 @@ class Extension(object): .. deprecated:: 0.18 Use :meth:`setup` instead. - :returns: list of :class:`~mopidy.backends.base.Backend` subclasses + :returns: list of :class:`~mopidy.backend.Backend` subclasses """ return [] diff --git a/mopidy/local/actor.py b/mopidy/local/actor.py index c29a5dbe..61becc72 100644 --- a/mopidy/local/actor.py +++ b/mopidy/local/actor.py @@ -5,7 +5,7 @@ import os import pykka -from mopidy.backends import base +from mopidy import backend from mopidy.utils import encoding, path from .library import LocalLibraryProvider @@ -15,7 +15,7 @@ from .playlists import LocalPlaylistsProvider logger = logging.getLogger(__name__) -class LocalBackend(pykka.ThreadingActor, base.Backend): +class LocalBackend(pykka.ThreadingActor, backend.Backend): uri_schemes = ['local'] libraries = [] diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 2d0478ab..13d46979 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -2,12 +2,12 @@ from __future__ import unicode_literals import logging -from mopidy.backends import base +from mopidy import backend logger = logging.getLogger(__name__) -class LocalLibraryProvider(base.BaseLibraryProvider): +class LocalLibraryProvider(backend.LibraryProvider): """Proxy library that delegates work to our active local library.""" root_directory_name = 'local' diff --git a/mopidy/local/playback.py b/mopidy/local/playback.py index 6ef7b410..bd798589 100644 --- a/mopidy/local/playback.py +++ b/mopidy/local/playback.py @@ -2,14 +2,14 @@ from __future__ import unicode_literals import logging -from mopidy.backends import base +from mopidy import backend from . import translator logger = logging.getLogger(__name__) -class LocalPlaybackProvider(base.BasePlaybackProvider): +class LocalPlaybackProvider(backend.PlaybackProvider): def change_track(self, track): track = track.copy(uri=translator.local_track_uri_to_file_uri( track.uri, self.backend.config['local']['media_dir'])) diff --git a/mopidy/local/playlists.py b/mopidy/local/playlists.py index 64019d08..f22c6fde 100644 --- a/mopidy/local/playlists.py +++ b/mopidy/local/playlists.py @@ -5,7 +5,7 @@ import logging import os import shutil -from mopidy.backends import base, listener +from mopidy import backend from mopidy.models import Playlist from mopidy.utils import formatting, path @@ -15,7 +15,7 @@ from .translator import parse_m3u logger = logging.getLogger(__name__) -class LocalPlaylistsProvider(base.BasePlaylistsProvider): +class LocalPlaylistsProvider(backend.PlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) self._media_dir = self.backend.config['local']['media_dir'] @@ -58,7 +58,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): self.playlists = playlists # TODO: send what scheme we loaded them for? - listener.BackendListener.send('playlists_loaded') + backend.BackendListener.send('playlists_loaded') logger.info( 'Loaded %d local playlists from %s', diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index a5b2a539..aecc4e42 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -5,28 +5,27 @@ import urlparse import pykka -from mopidy import audio as audio_lib, exceptions +from mopidy import audio as audio_lib, backend, exceptions from mopidy.audio import scan -from mopidy.backends import base from mopidy.models import Track logger = logging.getLogger(__name__) -class StreamBackend(pykka.ThreadingActor, base.Backend): +class StreamBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider( backend=self, timeout=config['stream']['timeout']) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playback = backend.PlaybackProvider(audio=audio, backend=self) self.playlists = None self.uri_schemes = audio_lib.supported_uri_schemes( config['stream']['protocols']) -class StreamLibraryProvider(base.BaseLibraryProvider): +class StreamLibraryProvider(backend.LibraryProvider): def __init__(self, backend, timeout): super(StreamLibraryProvider, self).__init__(backend) self._scanner = scan.Scanner(min_duration=None, timeout=timeout) diff --git a/tests/backends/__init__.py b/tests/backend/__init__.py similarity index 100% rename from tests/backends/__init__.py rename to tests/backend/__init__.py diff --git a/tests/backends/listener_test.py b/tests/backend/listener_test.py similarity index 83% rename from tests/backends/listener_test.py rename to tests/backend/listener_test.py index ae2eb997..fd861e4f 100644 --- a/tests/backends/listener_test.py +++ b/tests/backend/listener_test.py @@ -3,12 +3,12 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends.listener import BackendListener +from mopidy import backend class BackendListenerTest(unittest.TestCase): def setUp(self): - self.listener = BackendListener() + self.listener = backend.BackendListener() def test_on_event_forwards_to_specific_handler(self): self.listener.playlists_loaded = mock.Mock() diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 44c5e3f1..836a434e 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -3,8 +3,7 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core +from mopidy import backend, core from mopidy.models import Ref, SearchResult, Track @@ -12,13 +11,13 @@ class CoreLibraryTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.library1 = mock.Mock(spec=backend.LibraryProvider) self.library1.root_directory_name.get.return_value = 'dummy1' self.backend1.library = self.library1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.library2 = mock.Mock(spec=backend.LibraryProvider) self.library2.root_directory_name.get.return_value = 'dummy2' self.backend2.library = self.library2 @@ -27,7 +26,7 @@ class CoreLibraryTest(unittest.TestCase): self.backend3.uri_schemes.get.return_value = ['dummy3'] self.backend3.has_library().get.return_value = False - self.core = Core(audio=None, backends=[ + self.core = core.Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index f3374547..806de40e 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -3,8 +3,7 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core, PlaybackState +from mopidy import backend, core from mopidy.models import Track @@ -12,12 +11,12 @@ class CorePlaybackTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.playback1 = mock.Mock(spec=base.BasePlaybackProvider) + self.playback1 = mock.Mock(spec=backend.PlaybackProvider) self.backend1.playback = self.playback1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) + self.playback2 = mock.Mock(spec=backend.PlaybackProvider) self.backend2.playback = self.playback2 # A backend without the optional playback provider @@ -32,7 +31,7 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy1:b', length=40000), ] - self.core = Core(audio=None, backends=[ + self.core = core.Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) self.core.tracklist.add(self.tracks) @@ -78,7 +77,7 @@ class CorePlaybackTest(unittest.TestCase): self.core.playback.current_tl_track = self.unplayable_tl_track self.core.playback.pause() - self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) self.assertFalse(self.playback1.pause.called) self.assertFalse(self.playback2.pause.called) @@ -100,10 +99,10 @@ class CorePlaybackTest(unittest.TestCase): def test_resume_does_nothing_if_track_is_unplayable(self): self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.state = PlaybackState.PAUSED + self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.resume() - self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) + self.assertEqual(self.core.playback.state, core.PlaybackState.PAUSED) self.assertFalse(self.playback1.resume.called) self.assertFalse(self.playback2.resume.called) @@ -123,10 +122,10 @@ class CorePlaybackTest(unittest.TestCase): def test_stop_changes_state_even_if_track_is_unplayable(self): self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.state = PlaybackState.PAUSED + self.core.playback.state = core.PlaybackState.PAUSED self.core.playback.stop() - self.assertEqual(self.core.playback.state, PlaybackState.STOPPED) + self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED) self.assertFalse(self.playback1.stop.called) self.assertFalse(self.playback2.stop.called) @@ -146,7 +145,7 @@ class CorePlaybackTest(unittest.TestCase): def test_seek_fails_for_unplayable_track(self): self.core.playback.current_tl_track = self.unplayable_tl_track - self.core.playback.state = PlaybackState.PLAYING + self.core.playback.state = core.PlaybackState.PLAYING success = self.core.playback.seek(1000) self.assertFalse(success) diff --git a/tests/core/playlists_test.py b/tests/core/playlists_test.py index 01c2b881..ac1787fa 100644 --- a/tests/core/playlists_test.py +++ b/tests/core/playlists_test.py @@ -3,8 +3,7 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core +from mopidy import backend, core from mopidy.models import Playlist, Track @@ -12,12 +11,12 @@ class PlaylistsTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.sp1 = mock.Mock(spec=base.BasePlaylistsProvider) + self.sp1 = mock.Mock(spec=backend.PlaylistsProvider) self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.sp2 = mock.Mock(spec=base.BasePlaylistsProvider) + self.sp2 = mock.Mock(spec=backend.PlaylistsProvider) self.backend2.playlists = self.sp2 # A backend without the optional playlists provider @@ -34,7 +33,7 @@ class PlaylistsTest(unittest.TestCase): 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=[ + self.core = core.Core(audio=None, backends=[ self.backend3, self.backend1, self.backend2]) def test_get_playlists_combines_result_from_backends(self): diff --git a/tests/core/tracklist_test.py b/tests/core/tracklist_test.py index 596a20a6..80b4dd23 100644 --- a/tests/core/tracklist_test.py +++ b/tests/core/tracklist_test.py @@ -3,8 +3,7 @@ from __future__ import unicode_literals import mock import unittest -from mopidy.backends import base -from mopidy.core import Core +from mopidy import backend, core from mopidy.models import Track @@ -18,10 +17,10 @@ class TracklistTest(unittest.TestCase): self.backend = mock.Mock() self.backend.uri_schemes.get.return_value = ['dummy1'] - self.library = mock.Mock(spec=base.BaseLibraryProvider) + self.library = mock.Mock(spec=backend.LibraryProvider) self.backend.library = self.library - self.core = Core(audio=None, backends=[self.backend]) + self.core = core.Core(audio=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(self.tracks) def test_add_by_uri_looks_up_uri_in_library(self): @@ -72,4 +71,4 @@ class TracklistTest(unittest.TestCase): def test_filter_fails_if_values_is_a_string(self): self.assertRaises(ValueError, self.core.tracklist.filter, uri='a') - # TODO Extract tracklist tests from the base backend tests + # TODO Extract tracklist tests from the local backend tests diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 9fdedaa6..0b8e3858 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -8,7 +8,7 @@ from __future__ import unicode_literals import pykka -from mopidy.backends import base +from mopidy import backend from mopidy.models import Playlist, SearchResult @@ -16,7 +16,7 @@ def create_dummy_backend_proxy(config=None, audio=None): return DummyBackend.start(config=config, audio=audio).proxy() -class DummyBackend(pykka.ThreadingActor, base.Backend): +class DummyBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super(DummyBackend, self).__init__() @@ -27,7 +27,7 @@ class DummyBackend(pykka.ThreadingActor, base.Backend): self.uri_schemes = ['dummy'] -class DummyLibraryProvider(base.BaseLibraryProvider): +class DummyLibraryProvider(backend.LibraryProvider): root_directory_name = 'dummy' def __init__(self, *args, **kwargs): @@ -53,7 +53,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): return self.dummy_search_result -class DummyPlaybackProvider(base.BasePlaybackProvider): +class DummyPlaybackProvider(backend.PlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._time_position = 0 @@ -80,7 +80,7 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): return self._time_position -class DummyPlaylistsProvider(base.BasePlaylistsProvider): +class DummyPlaylistsProvider(backend.PlaylistsProvider): def create(self, name): playlist = Playlist(name=name, uri='dummy:%s' % name) self._playlists.append(playlist) diff --git a/tests/local/events_test.py b/tests/local/events_test.py index 60c0b146..f0fd0959 100644 --- a/tests/local/events_test.py +++ b/tests/local/events_test.py @@ -5,14 +5,13 @@ import unittest import mock import pykka -from mopidy import audio, core -from mopidy.backends import listener +from mopidy import audio, backend, core from mopidy.local import actor from tests import path_to_data_dir -@mock.patch.object(listener.BackendListener, 'send') +@mock.patch.object(backend.BackendListener, 'send') class LocalBackendEventsTest(unittest.TestCase): config = { 'local': { From 81b3f21af0655ae3817644d2a57cda7093b667ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 18:28:16 +0100 Subject: [PATCH 158/238] docs: Update backend import --- docs/extensiondev.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 8c2cd16c..7368e396 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -344,10 +344,10 @@ passed a reference to the core API when it's created. See the import pykka - from mopidy.core import CoreListener + from mopidy import core - class SoundspotFrontend(pykka.ThreadingActor, CoreListener): + class SoundspotFrontend(pykka.ThreadingActor, core.CoreListener): def __init__(self, core): super(SoundspotFrontend, self).__init__() self.core = core @@ -370,10 +370,10 @@ details. import pykka - from mopidy.backends import base + from mopidy import backend - class SoundspotBackend(pykka.ThreadingActor, base.BaseBackend): + class SoundspotBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, audio): super(SoundspotBackend, self).__init__() self.audio = audio From f7407b621399b3ff0e514f6656cd2a850c378a3b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 19:47:49 +0100 Subject: [PATCH 159/238] docs: Add mopidy.local.Library docs --- docs/ext/local.rst | 2 +- docs/modules/local.rst | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 docs/modules/local.rst diff --git a/docs/ext/local.rst b/docs/ext/local.rst index c7d6487d..9fed4292 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -105,7 +105,7 @@ whatever the current active library is with data. Only one library may be active at a time. To create a new library provider you must create class that implements the -:class:`~mopidy.local.Library` interface and install it in the extension +:class:`mopidy.local.Library` interface and install it in the extension registry under ``local:library``. Any data that the library needs to store on disc should be stored in :confval:`local/data_dir` using the library name as part of the filename or directory to avoid any conflicts. diff --git a/docs/modules/local.rst b/docs/modules/local.rst new file mode 100644 index 00000000..0d7db26a --- /dev/null +++ b/docs/modules/local.rst @@ -0,0 +1,8 @@ +************************************ +:mod:`mopidy.local` -- Local backend +************************************ + +For details on how to use Mopidy's local backend, see :ref:`ext-local`. + +.. automodule:: mopidy.local + :synopsis: Local backend From 6b8331c9d58d2f5f4be1c27b058de69df69651d5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 19:51:31 +0100 Subject: [PATCH 160/238] docs: Actually include the Library class --- docs/modules/local.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/modules/local.rst b/docs/modules/local.rst index 0d7db26a..31ca6498 100644 --- a/docs/modules/local.rst +++ b/docs/modules/local.rst @@ -6,3 +6,4 @@ For details on how to use Mopidy's local backend, see :ref:`ext-local`. .. automodule:: mopidy.local :synopsis: Local backend + :members: From b1e587851885471861b3b61fe4fce7fab752b460 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 19:55:00 +0100 Subject: [PATCH 161/238] docs: Fix param rendering --- mopidy/local/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 7cb2f0d5..5a46283d 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -120,7 +120,8 @@ class Library(object): """ Add the given track to library. - :param :class:`~mopidy.models.Track` track: Track to add to the library + :param track: Track to add to the library + :type track: :class:`~mopidy.models.Track` """ raise NotImplementedError From d43a944d4dd319cdf2dec0103fc25dda314ff002 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 19:59:19 +0100 Subject: [PATCH 162/238] docs: Point Raspi users to the config and run sections Ref. user not configuring or trying to run Mopidy at http://www.raspberrypi.org/phpBB3/viewtopic.php?t=66074&p=484932 --- docs/installation/raspberrypi.rst | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 4eb25072..fe958e81 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -54,27 +54,6 @@ you a lot better performance. echo ipv6 | sudo tee -a /etc/modules -#. Installing Mopidy and its dependencies from `apt.mopidy.com - `_, as described in :ref:`installation`. In short:: - - wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - - sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list - sudo apt-get update - sudo apt-get install mopidy - - Note that this will only install the main Mopidy package. For e.g. Spotify - or SoundCloud support you need to install the respective extension packages. - To list all the extensions available from apt.mopidy.com, you can run:: - - apt-cache search mopidy - - To install one of the listed packages, e.g. ``mopidy-spotify``, simply run:: - - sudo apt-get install mopidy-spotify - - For a full list of available Mopidy extensions, including those not - installable from apt.mopidy.com, see :ref:`ext`. - #. Since I have a HDMI cable connected, but want the sound on the analog sound connector, I have to run:: @@ -92,9 +71,15 @@ you a lot better performance. command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. +#. Install Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. -Fixing audio quality issues -=========================== +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. + + +Appendix: Fixing audio quality issues +===================================== As of about April 2013 the following steps should resolve any audio issues for HDMI and analog without the use of an external USB sound From d9e602c128ca20088828fc092477b1d2c3bf4a1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 20:19:49 +0100 Subject: [PATCH 163/238] docs: Make extensions and clients more prominent on the front page --- docs/clients/index.rst | 8 ---- docs/ext/{index.rst => external.rst} | 65 +++++++++++----------------- docs/index.rst | 29 ++++++++++++- 3 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 docs/clients/index.rst rename docs/ext/{index.rst => external.rst} (71%) diff --git a/docs/clients/index.rst b/docs/clients/index.rst deleted file mode 100644 index 6ebfd948..00000000 --- a/docs/clients/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -******* -Clients -******* - -.. toctree:: - :glob: - - ** diff --git a/docs/ext/index.rst b/docs/ext/external.rst similarity index 71% rename from docs/ext/index.rst rename to docs/ext/external.rst index 27fe3b45..b804ef75 100644 --- a/docs/ext/index.rst +++ b/docs/ext/external.rst @@ -1,37 +1,22 @@ -.. _ext: - -********** -Extensions -********** - -Here you can find a list of packages that extend Mopidy with additional -functionality. This list is moderated and updated on a regular basis. If you -want your package to show up here, follow the :ref:`guide on creating -extensions `. - - -Bundled with Mopidy -=================== - -These extensions are maintained by Mopidy's core developers. They are installed -together with Mopidy and are enabled by default. - -.. toctree:: - :maxdepth: 1 - :glob: - - ** - - +******************* External extensions -=================== +******************* -These extensions are maintained outside Mopidy's core, often by other -developers. +Here you can find a list of external packages that extend Mopidy with +additional functionality. This list is moderated and updated on a regular +basis. If you want your package to show up here, follow the :ref:`guide on +creating extensions `. + +Mopidy also bundles some extensions: + +- :ref:`ext-local` +- :ref:`ext-stream` +- :ref:`ext-http` +- :ref:`ext-mpd` Mopidy-Arcam ------------- +============ https://github.com/TooDizzy/mopidy-arcam @@ -40,7 +25,7 @@ and tested with an Arcam AVR-300. Mopidy-Beets ------------- +============ https://github.com/mopidy/mopidy-beets @@ -49,7 +34,7 @@ Provides a backend for playing music from your `Beets Mopidy-GMusic -------------- +============= https://github.com/hechtus/mopidy-gmusic @@ -58,7 +43,7 @@ Provides a backend for playing music from `Google Play Music Mopidy-MPRIS ------------- +============ https://github.com/mopidy/mopidy-mpris @@ -67,7 +52,7 @@ D-Bus interface, for example using the Ubuntu Sound Menu. Mopidy-NAD ----------- +========== https://github.com/mopidy/mopidy-nad @@ -75,7 +60,7 @@ Extension for controlling volume using an external NAD amplifier. Mopidy-Notifier ---------------- +=============== https://github.com/sauberfred/mopidy-notifier @@ -83,7 +68,7 @@ Extension for displaying track info as User Notifications in Mac OS X. Mopidy-radio-de ---------------- +=============== https://github.com/hechtus/mopidy-radio-de @@ -93,7 +78,7 @@ Extension for listening to Internet radio stations and podcasts listed at Mopidy-Scrobbler ----------------- +================ https://github.com/mopidy/mopidy-scrobbler @@ -101,7 +86,7 @@ Extension for scrobbling played tracks to Last.fm. Mopidy-SomaFM -------------- +============= https://github.com/AlexandrePTJ/mopidy-somafm @@ -110,7 +95,7 @@ service. Mopidy-SoundCloud ------------------ +================= https://github.com/mopidy/mopidy-soundcloud @@ -119,7 +104,7 @@ Provides a backend for playing music from the `SoundCloud Mopidy-Spotify --------------- +============== https://github.com/mopidy/mopidy-spotify @@ -128,7 +113,7 @@ streaming service. Mopidy-Subsonic ---------------- +=============== https://github.com/rattboi/mopidy-subsonic diff --git a/docs/index.rst b/docs/index.rst index dc1f7c4f..e5f98a3a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,12 +35,37 @@ Usage installation/index installation/raspberrypi config - ext/index running - clients/index troubleshooting +.. _ext: + +Extensions +========== + +.. toctree:: + :maxdepth: 2 + + ext/local + ext/stream + ext/http + ext/mpd + ext/external + + +Clients +======= + +.. toctree:: + :maxdepth: 2 + + clients/http + clients/mpd + clients/mpris + clients/upnp + + About ===== From cc8bf676412d7ccc8b1f722cb8f0a6f2e8635cfc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 21:05:21 +0100 Subject: [PATCH 164/238] docs: More prose and less headers in ext docs --- docs/config.rst | 2 + docs/ext/http.rst | 105 ++++++++++++++++++++++---------------------- docs/ext/local.rst | 102 +++++++++++++++++++----------------------- docs/ext/mpd.rst | 55 ++++++++--------------- docs/ext/stream.rst | 47 ++++++++------------ 5 files changed, 134 insertions(+), 177 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 5099f04d..63e62622 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,3 +1,5 @@ +.. _config: + ************* Configuration ************* diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 83881292..1b5b0119 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -4,35 +4,77 @@ Mopidy-HTTP *********** -The HTTP extension lets you control Mopidy through HTTP and WebSockets, e.g. -from a web based client. See :ref:`http-api` for details on how to integrate -with Mopidy over HTTP. +Mopidy-HTTP is an extension that lets you control Mopidy through HTTP and +WebSockets, for example from a web client. It is bundled with Mopidy and +enabled by default if all dependencies are available. + +When it is enabled it starts a web server at the port specified by the +:confval:`http/port` config value. + +.. warning:: + + As a simple security measure, the web server is by default only available + from localhost. To make it available from other computers, change the + :confval:`http/hostname` config value. Before you do so, note that the HTTP + extension does not feature any form of user authentication or + authorization. Anyone able to access the web server can use the full core + API of Mopidy. Thus, you probably only want to make the web server + available from your local network or place it behind a web proxy which + takes care or user authentication. You have been warned. + + +Using a web based Mopidy client +=============================== + +Mopidy-HTTP's web server can also host any static files, for example the HTML, +CSS, JavaScript, and images needed for a web based Mopidy client. To host +static files, change the :confval:`http/static_dir` config value to point to +the root directory of your web client, for example:: + + [http] + static_dir = /home/alice/dev/the-client + +If the directory includes a file named ``index.html``, it will be served on the +root of Mopidy's web server. + +If you're making a web based client and wants to do server side development as +well, you are of course free to run your own web server and just use Mopidy's +web server to host the API end points. But, for clients implemented purely in +JavaScript, letting Mopidy host the files is a simpler solution. + +See :ref:`http-api` for details on how to integrate with Mopidy over HTTP. If +you're looking for a web based client for Mopidy, go check out +:ref:`http-clients`. Dependencies ============ +In addition to Mopidy's dependencies, Mopidy-HTTP requires the following: + - cherrypy >= 3.2.2. Available as python-cherrypy3 in Debian/Ubuntu. - ws4py >= 0.2.3. Available as python-ws4py in newer Debian/Ubuntu and from - apt.mopidy.com for older releases of Debian/Ubuntu. + `apt.mopidy.com `__ for older releases of + Debian/Ubuntu. If you're installing Mopidy with pip, you can run the following command to install Mopidy with the extra dependencies for required for Mopidy-HTTP:: pip install --upgrade Mopidy[http] +If you're installing Mopidy from APT, the additional dependencies needed for +Mopidy-HTTP are always included. -Default configuration -===================== + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/http/ext.conf :language: ini - -Configuration values -==================== - .. confval:: http/enabled If the HTTP extension should be enabled or not. @@ -67,46 +109,3 @@ Configuration values ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for HTTP. - - -Usage -===== - -The extension is enabled by default if all dependencies are available. - -When it is enabled it starts a web server at the port specified by the -:confval:`http/port` config value. - -.. warning:: Security - - As a simple security measure, the web server is by default only available - from localhost. To make it available from other computers, change the - :confval:`http/hostname` config value. Before you do so, note that the HTTP - extension does not feature any form of user authentication or - authorization. Anyone able to access the web server can use the full core - API of Mopidy. Thus, you probably only want to make the web server - available from your local network or place it behind a web proxy which - takes care or user authentication. You have been warned. - - -Using a web based Mopidy client -------------------------------- - -The web server can also host any static files, for example the HTML, CSS, -JavaScript, and images needed for a web based Mopidy client. To host static -files, change the ``http/static_dir`` to point to the root directory of your -web client, e.g.:: - - [http] - static_dir = /home/alice/dev/the-client - -If the directory includes a file named ``index.html``, it will be served on the -root of Mopidy's web server. - -If you're making a web based client and wants to do server side development as -well, you are of course free to run your own web server and just use Mopidy's -web server for the APIs. But, for clients implemented purely in JavaScript, -letting Mopidy host the files is a simpler solution. - -If you're looking for a web based client for Mopidy, go check out -:ref:`http-clients`. diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 9fed4292..5d3562a9 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -4,27 +4,61 @@ Mopidy-Local ************ -Extension for playing music from a local music archive. +Mopidy-Local is an extension for playing music from your local music archive. +It is bundled with Mopidy and enabled by default. Though, you'll have to scan +your music collection to build a cache of metadata before the Mopidy-Local +will be able to play your music. This backend handles URIs starting with ``local:``. -Dependencies -============ +.. _generating-a-local-library: -None. The extension just needs Mopidy. +Generating a local library +========================== + +The command :command:`mopidy local scan` will scan the path set in the +:confval:`local/media_dir` config value for any audio files and build a +library of metadata. + +To make a local library for your music available for Mopidy: + +#. Ensure that the :confval:`local/media_dir` config value points to where your + music is located. Check the current setting by running:: + + mopidy config + +#. Scan your media library.:: + + mopidy local scan + +#. Start Mopidy, find the music library in a client, and play some local music! -Default configuration -===================== +Pluggable library support +========================= + +Local libraries are fully pluggable. What this means is that users may opt to +disable the current default library ``json``, replacing it with a third +party one. When running :command:`mopidy local scan` Mopidy will populate +whatever the current active library is with data. Only one library may be +active at a time. + +To create a new library provider you must create class that implements the +:class:`mopidy.local.Library` interface and install it in the extension +registry under ``local:library``. Any data that the library needs to store on +disc should be stored in :confval:`local/data_dir` using the library name as +part of the filename or directory to avoid any conflicts. + + +Configuration +============= + +See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/local/ext.conf :language: ini - -Configuration values -==================== - .. confval:: local/enabled If the local extension should be enabled or not. @@ -61,51 +95,3 @@ Configuration values File extensions to exclude when scanning the media directory. Values should be separated by either comma or newline. - - -Usage -===== - -If you want use Mopidy to play music you have locally at your machine, you need -to review and maybe change some of the local extension config values. See above -for a complete list. Then you need to generate a local library for your local -music... - - -.. _generating-a-local-library: - -Generating a local library --------------------------- - -The command :command:`mopidy local scan` will scan the path set in the -:confval:`local/media_dir` config value for any audio files and build a -library. - -To make a local library for your music available for Mopidy: - -#. Ensure that the :confval:`local/media_dir` config value points to where your - music is located. Check the current setting by running:: - - mopidy config - -#. Scan your media library.:: - - mopidy local scan - -#. Start Mopidy, find the music library in a client, and play some local music! - - -Pluggable library support -------------------------- - -Local libraries are fully pluggable. What this means is that users may opt to -disable the current default library ``json``, replacing it with a third -party one. When running :command:`mopidy local scan` mopidy will populate -whatever the current active library is with data. Only one library may be -active at a time. - -To create a new library provider you must create class that implements the -:class:`mopidy.local.Library` interface and install it in the extension -registry under ``local:library``. Any data that the library needs to store on -disc should be stored in :confval:`local/data_dir` using the library name as -part of the filename or directory to avoid any conflicts. diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index fa91f6a2..5b82dce2 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -4,8 +4,20 @@ Mopidy-MPD ********** -This extension implements an MPD server to make Mopidy available to :ref:`MPD -clients `. +Mopidy-MPD is an extension that provides a full MPD server implementation to +make Mopidy available to :ref:`MPD clients `. It is bundled with +Mopidy and enabled by default. + +.. warning:: + + As a simple security measure, the HTTP server is by default only available + from localhost. To make it available from other computers, change the + :confval:`mpd/hostname` config value. Before you do so, note that the MPD + server does not support any form of encryption and only a single clear + text password (see :confval:`mpd/password`) for weak authentication. Anyone + able to access the MPD server can control music playback on your computer. + Thus, you probably only want to make the MPD server available from your + local network. You have been warned. MPD stands for Music Player Daemon, which is also the name of the `original MPD server project `_. Mopidy does not depend on the @@ -21,6 +33,7 @@ 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. +- Only a single password is supported. It gives all-or-nothing access. - Toggling of audio outputs is not supported - Channels for client-to-client communication are not supported - Stickers are not supported @@ -38,22 +51,14 @@ near future: - Live update of the music database is not supported -Dependencies -============ +Configuration +============= -None. The extension just needs Mopidy. - - -Default configuration -===================== +See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/mpd/ext.conf :language: ini - -Configuration values -==================== - .. confval:: mpd/enabled If the MPD extension should be enabled or not. @@ -95,27 +100,3 @@ Configuration values ``$hostname`` and ``$port`` can be used in the name. Set to an empty string to disable Zeroconf for MPD. - - -Usage -===== - -The extension is enabled by default. To connect to the server, use an :ref:`MPD -client `. - - -.. _use-mpd-on-a-network: - -Connecting from other machines on the network ---------------------------------------------- - -As a secure default, Mopidy only accepts connections from ``localhost``. If you -want to open it for connections from other machines on your network, see -the documentation for the :confval:`mpd/hostname` config value. - -If you open up Mopidy for your local network, you should consider turning on -MPD password authentication by setting the :confval:`mpd/password` config value -to the password you want to use. If the password is set, Mopidy will require -MPD clients to provide the password before they can do anything else. Mopidy -only supports a single password, and do not support different permission -schemes like the original MPD server. diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index 6c6ab21c..88dc5ade 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -4,29 +4,32 @@ Mopidy-Stream ************* -Extension for playing streaming music. +Mopidy-Stream is an extension for playing streaming music. It is bundled with +Mopidy and enabled by default. -The stream backend will handle streaming of URIs matching the -:confval:`stream/protocols` config value, assuming the needed GStreamer plugins -are installed. +This backend does not provide a library or playlist storage. It simply accepts +any URI added to Mopidy's tracklist that matches any of the protocols in the +:confval:`stream/protocols` config value. It then tries to retrieve metadata +and play back the URI using GStreamer. For example, if you're using an MPD +client, you'll just have to find your clients "add URI" interface, and provide +it with the URI of a stream. + +In addition to playing streams, the extension also understands how to extract +streams from a lot of playlist formats. This is convenient as most Internet +radio stations links to playlists instead of directly to the radio streams. + +If you're having trouble playing back a stream, run the ``mopidy deps`` +command to check if you have all relevant GStreamer plugins installed. -Dependencies -============ +Configuration +============= -None. The extension just needs Mopidy. - - -Default configuration -===================== +See :ref:`config` for general help on configuring Mopidy. .. literalinclude:: ../../mopidy/stream/ext.conf :language: ini - -Configuration values -==================== - .. confval:: stream/enabled If the stream extension should be enabled or not. @@ -39,17 +42,3 @@ Configuration values .. confval:: stream/timeout Number of milliseconds before giving up looking up stream metadata. - - -Usage -===== - -This backend does not provide a library or similar. It simply takes any URI -added to Mopidy's tracklist that matches any of the protocols in the -:confval:`stream/protocols` setting and tries to play back the URI using -GStreamer. E.g. if you're using an MPD client, you'll just have to find your -clients "add URI" interface, and provide it with the direct URI of the stream. - -Currently the stream backend can only work with URIs pointing direcly at -streams, and not intermediate playlists which is often used. See :issue:`303` -to track the development of playlist expansion support. From feb1cd51bc07804c52d6f6ed50f519e380acdcab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 21:10:25 +0100 Subject: [PATCH 165/238] docs: Add Mopidy-VKontakte --- docs/ext/external.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/external.rst b/docs/ext/external.rst index b804ef75..e6b61596 100644 --- a/docs/ext/external.rst +++ b/docs/ext/external.rst @@ -119,3 +119,12 @@ https://github.com/rattboi/mopidy-subsonic Provides a backend for playing music from a `Subsonic Music Streamer `_ library. + + +Mopidy-VKontakte +================ + +https://github.com/sibuser/mopidy-vkontakte + +Provides a backend for playing music from the `VKontakte social network +`_. From bc31a42c49a96879f0564d06fa37e1297176eb8e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 21:21:55 +0100 Subject: [PATCH 166/238] Ignore INFO level log messages from requests --- mopidy/config/default.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 26b9f2e7..08cfe9cd 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -6,6 +6,7 @@ config_file = [loglevels] pykka = info +requests = warning [audio] mixer = software From 74d04d4e2f48452211f7aab08cfc8a13d829cdbf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 21:40:17 +0100 Subject: [PATCH 167/238] docs: Use pkill instead of line noise --- docs/running.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/running.rst b/docs/running.rst index 266545b2..488a0959 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -20,8 +20,7 @@ Stopping Mopidy To stop Mopidy, press ``CTRL+C`` in the terminal where you started Mopidy. Mopidy will also shut down properly if you send it the TERM signal, e.g. by -using ``kill``:: +using ``pkill``:: - kill `ps ax | grep mopidy | grep -v grep | cut -d' ' -f1` + pkill mopidy -This can be useful e.g. if you create init script for managing Mopidy. From 068b63b714621096add1f5974cd2b0fdd1f52690 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 11 Jan 2014 21:40:38 +0100 Subject: [PATCH 168/238] docs: Add info on init scripts (fixes: #266) --- docs/running.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/running.rst b/docs/running.rst index 488a0959..d357afe6 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -24,3 +24,21 @@ using ``pkill``:: pkill mopidy + +Init scripts +============ + +- The ``mopidy`` package at `apt.mopidy.com `__ comes + with an `sysvinit init script + `_. + +- The ``mopidy`` package in `Arch Linux AUR + `__ comes with a systemd init + script. + +- A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch + It at Login on OS X + `_. + +- Issue :issue:`266` contains a bunch of init scripts for Mopidy, including + Upstart init scripts. From 550f7a971b579b6f87c4d59ea9cf0cbe16c2fdf6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 01:42:41 +0100 Subject: [PATCH 169/238] log: Add verbosity_level 2 and 3 Reduces the amount of dependency logging on level 1, and increases the amount on level 2 and 3. Fixes #593. --- mopidy/__main__.py | 3 +- mopidy/commands.py | 2 +- mopidy/utils/log.py | 67 +++++++++++++++++++++++++++++++-------------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1ddd76a4..ac5e2102 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -70,7 +70,8 @@ def main(): if args.verbosity_level: verbosity_level += args.verbosity_level - log.setup_logging(config, verbosity_level, args.save_debug_log) + log.setup_logging( + config, installed_extensions, verbosity_level, args.save_debug_log) enabled_extensions = [] for extension in installed_extensions: diff --git a/mopidy/commands.py b/mopidy/commands.py index e73f9373..ad20c47f 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -241,7 +241,7 @@ class RootCommand(Command): self.add_argument( '-v', '--verbose', action='count', dest='verbosity_level', default=0, - help='more output (debug level)') + help='more output (repeat up to 3 times for even more)') self.add_argument( '--save-debug-log', action='store_true', dest='save_debug_log', diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 896fd707..9c88b368 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -31,12 +31,13 @@ def bootstrap_delayed_logging(): root.addHandler(_delayed_handler) -def setup_logging(config, verbosity_level, save_debug_log): - setup_console_logging(config, verbosity_level) +def setup_logging(config, extensions, verbosity_level, save_debug_log): setup_log_levels(config) + setup_console_logging(config, extensions, verbosity_level) + if save_debug_log: - setup_debug_logging_to_file(config) + setup_debug_logging_to_file(config, extensions) logging.captureWarnings(True) @@ -51,29 +52,55 @@ def setup_log_levels(config): logging.getLogger(name).setLevel(level) -def setup_console_logging(config, verbosity_level): - if verbosity_level < 0: - log_level = logging.WARNING +LOG_LEVELS = { + -1: dict(root=logging.ERROR, mopidy=logging.WARNING), + 0: dict(root=logging.ERROR, mopidy=logging.INFO), + 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), + 2: dict(root=logging.INFO, mopidy=logging.DEBUG), + 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), +} + + +def setup_console_logging(config, extensions, verbosity_level): + if verbosity_level < min(LOG_LEVELS.keys()): + verbosity_level = min(LOG_LEVELS.keys()) + if verbosity_level > max(LOG_LEVELS.keys()): + verbosity_level = max(LOG_LEVELS.keys()) + + if verbosity_level < 1: log_format = config['logging']['console_format'] - elif verbosity_level >= 1: - log_level = logging.DEBUG - log_format = config['logging']['debug_format'] else: - log_level = logging.INFO - log_format = config['logging']['console_format'] + log_format = config['logging']['debug_format'] formatter = logging.Formatter(log_format) - handler = logging.StreamHandler() - handler.setFormatter(formatter) - handler.setLevel(log_level) - root = logging.getLogger('') - root.addHandler(handler) + + root_handler = logging.StreamHandler() + root_handler.setFormatter(formatter) + root_handler.setLevel(LOG_LEVELS[verbosity_level]['root']) + logging.getLogger('').addHandler(root_handler) + + mopidy_handler = logging.StreamHandler() + mopidy_handler.setFormatter(formatter) + mopidy_handler.setLevel(LOG_LEVELS[verbosity_level]['mopidy']) + add_mopidy_handler(extensions, mopidy_handler) -def setup_debug_logging_to_file(config): +def setup_debug_logging_to_file(config, extensions): formatter = logging.Formatter(config['logging']['debug_format']) handler = logging.handlers.RotatingFileHandler( config['logging']['debug_file'], maxBytes=10485760, backupCount=3) handler.setFormatter(formatter) - handler.setLevel(logging.DEBUG) - root = logging.getLogger('') - root.addHandler(handler) + + logging.getLogger('').addHandler(handler) + + # We must add our handler explicitly, since the mopidy* handlers don't + # propagate to the root handler. + add_mopidy_handler(extensions, handler) + + +def add_mopidy_handler(extensions, handler): + names = ['mopidy_%s' % ext.ext_name for ext in extensions] + names.append('mopidy') + for name in names: + logger = logging.getLogger(name) + logger.propagate = False + logger.addHandler(handler) From c7240141c2afecdc18d1658ebdd5cfb85e2fab8e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 01:44:03 +0100 Subject: [PATCH 170/238] log: Remove default config that has no effect for verbosity_level < 2 --- mopidy/config/default.conf | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 08cfe9cd..0ca9597d 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -4,10 +4,6 @@ debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s debug_file = mopidy.log config_file = -[loglevels] -pykka = info -requests = warning - [audio] mixer = software mixer_track = From 94c904815d64768f7d070e39b1c6ebcadabba97e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 01:44:34 +0100 Subject: [PATCH 171/238] docs: Explain -v/-vv/-vvv differences --- docs/troubleshooting.rst | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index f344b1cf..9e065ed7 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -25,8 +25,8 @@ mailing list or when reporting an issue, somewhat longer text dumps are accepted, but large logs should still be shared through a pastebin. -Effective configuration -======================= +Show effective configuration +============================ The command ``mopidy config`` will print your full effective configuration the way Mopidy sees it after all defaults and all config files @@ -35,8 +35,8 @@ passwords are masked out, so the output of the command should be safe to share with others for debugging. -Installed dependencies -====================== +Show installed dependencies +=========================== The command ``mopidy deps`` will list the paths to and versions of any dependency Mopidy or the extensions might need to work. This is very useful @@ -48,11 +48,16 @@ your system. Debug logging ============= -If you run :option:`mopidy -v`, Mopidy will output debug log to stdout. If you -run :option:`mopidy --save-debug-log`, it will save the debug log to the file -``mopidy.log`` in the directory you ran the command from. +If you run :option:`mopidy -v` or ``mopidy -vv`` or ``mopidy -vvv`` Mopidy will +print more and more debug log to stdout. All three options will give you debug +level output from Mopidy and extensions, while ``-vv`` and ``-vvv`` will give +you more log output from their dependencies as well. -If you want to turn on more or less logging for some component, see the +If you run :option:`mopidy --save-debug-log`, it will save the log equivalent +with ``-vvv`` to the file ``mopidy.log`` in the directory you ran the command +from. + +If you want to reduce the logging for some component, see the docs for the :confval:`loglevels/*` config section. From d127bc89311868442dddcdbcd2c8a6627f1fff2a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 01:46:36 +0100 Subject: [PATCH 172/238] docs: Update changelog --- docs/changelog.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index fa20f791..8bf8340c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -42,6 +42,15 @@ v0.18.0 (UNRELEASED) Imports from the old locations still works, but are deprecated. +**Commands** + +- Reduce amount of logging from dependencies when using :option:`mopidy -v`. + (Fixes: :issue:`593`) + +- Add support for additional logging verbosity levels with ``mopidy -vv`` and + ``mopidy -vvv`` which increases the amount of logging from dependencies. + (Fixes: :issue:`593`) + **Configuration** - The default for the :option:`mopidy --config` option has been updated to From cc72ce8da9987cbf68e046b3ab6d3ffa764cfa70 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 11:37:11 +0100 Subject: [PATCH 173/238] docs: Complete lists of frontend/backend impls --- docs/api/backends.rst | 21 +++++++++++++++++++-- docs/api/frontends.rst | 11 +++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index fa6d8410..98502b5e 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -52,5 +52,22 @@ Backend listener Backend implementations ======================= -* :mod:`mopidy.local` -* :mod:`mopidy.stream` +- `Mopidy-Beets `_ + +- `Mopidy-GMusic `_ + +- :ref:`ext-local` + +- `Mopidy-radio-de `_ + +- `Mopidy-SomaFM `_ + +- `Mopidy-SoundCloud `_ + +- `Mopidy-Spotify `_ + +- :ref:`ext-stream` + +- `Mopidy-Subsonic `_ + +- `Mopidy-VKontakte `_ diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 7afafa74..5e2f8d6c 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -47,5 +47,12 @@ The following requirements applies to any frontend implementation: Frontend implementations ======================== -* :mod:`mopidy.http` -* :mod:`mopidy.mpd` +- :ref:`ext-http` + +- :ref:`ext-mpd` + +- `Mopidy-MPRIS `_ + +- `Mopidy-Notifier `_ + +- `Mopidy-Scrobbler `_ From 7c3f7dfcea0b19a27718a47323f166c5f317e6ff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 12:19:04 +0100 Subject: [PATCH 174/238] The harmless Zeroconf warning is confusing users --- mopidy/http/actor.py | 2 +- mopidy/mpd/actor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index 037fe1ea..e7b5cb66 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -103,7 +103,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): logger.info('Registered HTTP with Zeroconf as "%s"', self.zeroconf_service.name) else: - logger.warning('Registering HTTP with Zeroconf failed.') + logger.info('Registering HTTP with Zeroconf failed.') def on_stop(self): if self.zeroconf_service: diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 144b09d5..20417a4d 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -51,7 +51,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): logger.info('Registered MPD with Zeroconf as "%s"', self.zeroconf_service.name) else: - logger.warning('Registering MPD with Zeroconf failed.') + logger.info('Registering MPD with Zeroconf failed.') def on_stop(self): if self.zeroconf_service: From e1ec5b2217b3222b99b6531af7f821d596ea61cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 13:26:42 +0100 Subject: [PATCH 175/238] docs: How to use backend URI schemes (fixes #429) --- docs/api/backends.rst | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 98502b5e..71124166 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -12,6 +12,43 @@ backend. If you are working on a frontend and need to access the backends, see the :ref:`core-api` instead. +URIs and routing of requests to the backend +=========================================== + +When Mopidy's core layer is processing a client request, it routes the request +to one or more appropriate backends based on the URIs of the objects the +request touches on. The objects' URIs are compared with the backends' +:attr:`~mopidy.backend.Backend.uri_scheme` to select the relevant backends. + +An often used pattern when implementing Mopidy backends is to create your own +URI scheme which you use for all tracks, playlists, etc. related to your +backend. For example: + +- Spotify already got an URI scheme (``spotify:track:...``, + ``spotify:playlist:...``, etc.) used throughout their applications, and thus + Mopidy-Spotify simply use the same URI scheme. + +- Mopidy-Soundcloud created it's own URI scheme, after the model of Spotify, + and use URIs of the following forms: ``soundcloud:search``, + ``soundcloud:user-...``, ``soundcloud:exp-...``, and ``soundcloud:set-...``. + +- Mopidy differentiates between ``file://...`` URIs handled by + :ref:`ext-stream` and ``local:...`` URIs handled by :ref:`ext-local`. + :ref:`ext-stream` can play ``file://...`` URIs to tracks and playlists + located anywhere on your system, but it doesn't know a thing about the + object before you play it. On the other hand, :ref:`ext-local` scans a + predefined :confval:`local/media_dir` to build a metadata library of all + known tracks. It is thus limited to playing tracks residing in the media + library, but can provide additional features like directory browsing and + search. In other words, we got two different ways of playing local music, + handled by two different backends, and have thus created to different URI + schemes to separate their handling. + +If there isn't an existing URI scheme that fits for your backend's purpose, +you should create your own, and name it after your extension's +:attr:`~mopidy.ext.Extension.ext_name`. + + Backend class ============= From 8aaf98e2d0362a3f84e54bd78fc65b9f2f3f42bd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 12 Jan 2014 13:35:52 +0100 Subject: [PATCH 176/238] docs: Language fixes and added some more URI details --- docs/api/backends.rst | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 71124166..63c8e055 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -22,9 +22,10 @@ request touches on. The objects' URIs are compared with the backends' An often used pattern when implementing Mopidy backends is to create your own URI scheme which you use for all tracks, playlists, etc. related to your -backend. For example: +backend. In most cases the Mopidy URI is translated to an actuall URI right +before playback. For example: -- Spotify already got an URI scheme (``spotify:track:...``, +- Spotify already has it's own URI scheme (``spotify:track:...``, ``spotify:playlist:...``, etc.) used throughout their applications, and thus Mopidy-Spotify simply use the same URI scheme. @@ -40,13 +41,15 @@ backend. For example: predefined :confval:`local/media_dir` to build a metadata library of all known tracks. It is thus limited to playing tracks residing in the media library, but can provide additional features like directory browsing and - search. In other words, we got two different ways of playing local music, + search. In other words, we have two different ways of playing local music, handled by two different backends, and have thus created to different URI schemes to separate their handling. If there isn't an existing URI scheme that fits for your backend's purpose, you should create your own, and name it after your extension's -:attr:`~mopidy.ext.Extension.ext_name`. +:attr:`~mopidy.ext.Extension.ext_name`. Care should be taken not to conflict +with already in use URI schemes. It is also recomended to design the format +such that tracks, playlists and other entities can be distingished easily. Backend class From cee7cc28ab1aabab7c3868b053b9593af8bec3ba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 13:42:09 +0100 Subject: [PATCH 177/238] docs: Fix more typos --- docs/api/backends.rst | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 63c8e055..77e780e3 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -22,34 +22,34 @@ request touches on. The objects' URIs are compared with the backends' An often used pattern when implementing Mopidy backends is to create your own URI scheme which you use for all tracks, playlists, etc. related to your -backend. In most cases the Mopidy URI is translated to an actuall URI right +backend. In most cases the Mopidy URI is translated to an actual URI right before playback. For example: -- Spotify already has it's own URI scheme (``spotify:track:...``, +- Spotify already has its own URI scheme (``spotify:track:...``, ``spotify:playlist:...``, etc.) used throughout their applications, and thus - Mopidy-Spotify simply use the same URI scheme. + Mopidy-Spotify simply uses the same URI scheme. -- Mopidy-Soundcloud created it's own URI scheme, after the model of Spotify, +- Mopidy-SoundCloud created it's own URI scheme, after the model of Spotify, and use URIs of the following forms: ``soundcloud:search``, ``soundcloud:user-...``, ``soundcloud:exp-...``, and ``soundcloud:set-...``. - Mopidy differentiates between ``file://...`` URIs handled by :ref:`ext-stream` and ``local:...`` URIs handled by :ref:`ext-local`. - :ref:`ext-stream` can play ``file://...`` URIs to tracks and playlists - located anywhere on your system, but it doesn't know a thing about the - object before you play it. On the other hand, :ref:`ext-local` scans a - predefined :confval:`local/media_dir` to build a metadata library of all + :ref:`ext-stream` can play ``file://...`` URIs pointing to tracks and + playlists located anywhere on your system, but it doesn't know a thing about + the object before you play it. On the other hand, :ref:`ext-local` scans a + predefined :confval:`local/media_dir` to build a meta data library of all known tracks. It is thus limited to playing tracks residing in the media library, but can provide additional features like directory browsing and search. In other words, we have two different ways of playing local music, - handled by two different backends, and have thus created to different URI + handled by two different backends, and have thus created two different URI schemes to separate their handling. If there isn't an existing URI scheme that fits for your backend's purpose, you should create your own, and name it after your extension's :attr:`~mopidy.ext.Extension.ext_name`. Care should be taken not to conflict -with already in use URI schemes. It is also recomended to design the format -such that tracks, playlists and other entities can be distingished easily. +with already in use URI schemes. It is also recommended to design the format +such that tracks, playlists and other entities can be distinguished easily. Backend class From 351eaefbafd51438da1d3b6ce5e27fabcdba4232 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 13:47:58 +0100 Subject: [PATCH 178/238] docs: Fix link, add examples of URI conversion before playback --- docs/api/backends.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 77e780e3..b1ba3128 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -18,20 +18,24 @@ URIs and routing of requests to the backend When Mopidy's core layer is processing a client request, it routes the request to one or more appropriate backends based on the URIs of the objects the request touches on. The objects' URIs are compared with the backends' -:attr:`~mopidy.backend.Backend.uri_scheme` to select the relevant backends. +:attr:`~mopidy.backend.Backend.uri_schemes` to select the relevant backends. An often used pattern when implementing Mopidy backends is to create your own URI scheme which you use for all tracks, playlists, etc. related to your -backend. In most cases the Mopidy URI is translated to an actual URI right -before playback. For example: +backend. In most cases the Mopidy URI is translated to an actual URI that +GStreamer knows how to play right before playback. For example: - Spotify already has its own URI scheme (``spotify:track:...``, ``spotify:playlist:...``, etc.) used throughout their applications, and thus - Mopidy-Spotify simply uses the same URI scheme. + Mopidy-Spotify simply uses the same URI scheme. Playback is handled by + pushing raw audio data into a GStreamer ``appsrc`` element. - Mopidy-SoundCloud created it's own URI scheme, after the model of Spotify, and use URIs of the following forms: ``soundcloud:search``, ``soundcloud:user-...``, ``soundcloud:exp-...``, and ``soundcloud:set-...``. + Playback is handled by converting the custom ``soundcloud:..`` URIs to + ``http://`` URIs immediately before they are passed on to GStreamer for + playback. - Mopidy differentiates between ``file://...`` URIs handled by :ref:`ext-stream` and ``local:...`` URIs handled by :ref:`ext-local`. @@ -43,7 +47,9 @@ before playback. For example: library, but can provide additional features like directory browsing and search. In other words, we have two different ways of playing local music, handled by two different backends, and have thus created two different URI - schemes to separate their handling. + schemes to separate their handling. The ``local:...`` URIs are converted to + ``file://...`` URIs immediately before they are passed on to GStreamer for + playback. If there isn't an existing URI scheme that fits for your backend's purpose, you should create your own, and name it after your extension's From bba1f0a84070c9a2d26dc324534d683e4510af83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 12 Jan 2014 23:31:47 +0100 Subject: [PATCH 179/238] docs: Update install docs for OS X and source install --- docs/installation/index.rst | 159 +++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 77 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index fb3de75b..73c2f08f 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -105,11 +105,11 @@ package found in AUR. then you're ready to :doc:`run Mopidy `. -OS X: Install from Homebrew and Pip +OS X: Install from Homebrew and pip =================================== If you are running OS X, you can install everything needed with Homebrew and -Pip. +pip. #. Install `Homebrew `_. @@ -127,7 +127,7 @@ Pip. #. Install the required packages from Homebrew:: - brew install gst-python010 gst-plugins-good010 gst-plugins-ugly010 libspotify + brew install gst-python010 gst-plugins-good010 gst-plugins-ugly010 #. Make sure to include Homebrew's Python ``site-packages`` directory in your ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer @@ -142,32 +142,48 @@ Pip. PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy -#. Next up, you need to install some Python packages. To do so, we use Pip. If +#. Next up, you need to install some Python packages. To do so, we use pip. If you don't have the ``pip`` command, you can install it now:: sudo easy_install pip -#. Then get, build, and install the latest release of pyspotify, pylast, - and Mopidy using Pip:: +#. Then, install the latest release of Mopidy using pip:: - sudo pip install -U pyspotify pylast cherrypy ws4py mopidy + sudo pip install -U mopidy + +#. Optionally, install additional extensions to Mopidy. + + For HTTP frontend support, so you can run Mopidy web clients:: + + sudo pip install -U mopidy[http] + + For playing music from Spotify:: + + brew install libspotify + sudo pip install -U mopidy-spotify + + For scrobbling to Last.fm:: + + sudo pip install -U mopidy-scrobbler + + For more extensions, see :ref:`ext`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. -Otherwise: Install from source using Pip +Otherwise: Install from source using pip ======================================== If you are on on Linux, but can't install from the APT archive or from AUR, you -can install Mopidy from PyPI using Pip. +can install Mopidy from PyPI using pip. #. First of all, you need Python 2.7. Check if you have Python and what version by running:: python --version -#. When you install using Pip, you need to make sure you have Pip. You'll also +#. When you install using pip, you need to make sure you have pip. You'll also need a C compiler and the Python development headers to build pyspotify later. @@ -183,49 +199,63 @@ can install Mopidy from PyPI using Pip. sudo yum install -y gcc python-devel python-pip -#. Then you'll need to install all of Mopidy's hard non-Python dependencies: + .. note:: - - GStreamer 0.10 (>= 0.10.31, < 0.11), with Python bindings. GStreamer is - packaged for most popular Linux distributions. Search for GStreamer in - your package manager, and make sure to install the Python bindings, and - the "good" and "ugly" plugin sets. + On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the + following steps. - If you use Debian/Ubuntu you can install GStreamer like this:: +#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python + bindings. GStreamer is packaged for most popular Linux distributions. Search + for GStreamer in your package manager, and make sure to install the Python + bindings, and the "good" and "ugly" plugin sets. - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools + If you use Debian/Ubuntu you can install GStreamer like this:: - If you use Arch Linux, install the following packages from the official - repository:: + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins + If you use Arch Linux, install the following packages from the official + repository:: - If you use Fedora you can install GStreamer like this:: + sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ + gstreamer0.10-ugly-plugins - sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools + If you use Fedora you can install GStreamer like this:: - If you use Gentoo you need to be careful because GStreamer 0.10 is in - a different lower slot than 1.0, the default. Your emerge commands will - need to include the slot:: + sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools - emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ - gst-plugins-ugly:0.10 gst-plugins-meta:0.10 + If you use Gentoo you need to be careful because GStreamer 0.10 is in a + different lower slot than 1.0, the default. Your emerge commands will need + to include the slot:: - gst-plugins-meta:0.10 is the one that actually pulls in the plugins - you want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. + emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ + gst-plugins-ugly:0.10 gst-plugins-meta:0.10 + + ``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you + want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. + +#. Install the latest release of Mopidy:: + + sudo pip install -U mopidy + + To upgrade Mopidy to future releases, just rerun this command. + + Alternatively, if you want to track Mopidy development closer, you may + install a snapshot of Mopidy's ``develop`` Git branch using pip:: + + sudo pip install mopidy==dev + +#. Optional: If you want to use the HTTP frontend and web clients, you need + some additional dependencies:: + + sudo pip install -U mopidy[http] #. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Python bindings, pyspotify. + libspotify and the Mopidy-Spotify extension. - #. First, check `pyspotify's changelog `_ to - see what's the latest version of libspotify which it supports. The - versions of libspotify and pyspotify are tightly coupled, so you'll need - to get this right. - - #. Download and install the appropriate version of libspotify for your OS - and CPU architecture from `Spotify + #. Download and install the latest version of libspotify for your OS and CPU + architecture from `Spotify `_. For libspotify 12.1.51 for 64-bit Linux the process is as follows:: @@ -234,7 +264,6 @@ can install Mopidy from PyPI using Pip. tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz cd libspotify-12.1.51-Linux-x86_64-release/ sudo make install prefix=/usr/local - sudo ldconfig Remember to adjust the above example for the latest libspotify version supported by pyspotify, your OS, and your CPU architecture. @@ -245,55 +274,31 @@ can install Mopidy from PyPI using Pip. su -c 'echo "/usr/local/lib" > /etc/ld.so.conf.d/libspotify.conf' sudo ldconfig - #. Then get, build, and install the latest release of pyspotify using Pip:: + #. Then install the latest release of Mopidy-Spotify using pip:: - sudo pip install -U pyspotify - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U pyspotify + sudo pip install -U mopidy-spotify #. Optional: If you want to scrobble your played tracks to Last.fm, you need - pylast:: + to install Mopidy-Scrobbler:: - sudo pip install -U pylast - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U pylast - -#. Optional: If you want to use the HTTP frontend and web clients, you need - cherrypy and ws4py:: - - sudo pip install -U cherrypy ws4py - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U cherrypy ws4py + sudo pip install -U mopidy-scrobbler #. Optional: To use Mopidy-MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound Menu or from an UPnP client via Rygel, you need some additional - dependencies: the Python bindings for libindicate, and the Python bindings - for libdbus, the reference D-Bus library. + dependencies and the Mopidy-MPRIS extension. - On Debian/Ubuntu:: + #. Install the Python bindings for libindicate, and the Python bindings for + libdbus, the reference D-Bus library. - sudo apt-get install python-dbus python-indicate + On Debian/Ubuntu:: -#. Then, to install the latest release of Mopidy:: + sudo apt-get install python-dbus python-indicate - sudo pip install -U mopidy + #. Then install the latest release of Mopidy-MPRIS using pip:: - On Fedora the binary is called ``pip-python``:: + sudo pip install -U mopidy-mpris - sudo pip-python install -U mopidy - - To upgrade Mopidy to future releases, just rerun this command. - - Alternatively, if you want to track Mopidy development closer, you may - install a snapshot of Mopidy's ``develop`` Git branch using Pip:: - - sudo pip install mopidy==dev +#. For more Mopidy extensions, see :ref:`ext`. #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. From 3e3f59c1d0aa837781a32cb128c93fe3e1864d1b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Jan 2014 13:16:28 +0100 Subject: [PATCH 180/238] docs: Fix reference --- mopidy/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 5db013e0..9ada95c5 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -56,7 +56,7 @@ class LibraryProvider(object): """ Name of the library's root directory in Mopidy's virtual file system. - *MUST be set by any class that implements :meth:`browse`.* + *MUST be set by any class that implements :meth:`LibraryProvider.browse`.* """ def __init__(self, backend): From 816d047a0999cda44155d4d1a0d999f208f358f2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 9 Jan 2014 22:58:13 +0100 Subject: [PATCH 181/238] local: Add browse support to local library interface --- mopidy/local/__init__.py | 9 +++++++++ mopidy/local/json.py | 3 +++ mopidy/local/library.py | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 5a46283d..8b4a8b1f 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -64,6 +64,15 @@ class Library(object): def __init__(self, config): self._config = config + def browse(self, path): + """ + Browse directories and tracks at the given path. + + :param string path: path to browse or None for root. + :rtype: List of :class:`~mopidy.models.Ref` tracks and directories. + """ + raise NotImplementedError + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/json.py b/mopidy/local/json.py index f81d6915..8404f0a4 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -50,6 +50,9 @@ class JsonLibrary(local.Library): self._json_file = os.path.join( config['local']['data_dir'], b'library.json.gz') + def browse(self, path): + return [] + def load(self): logger.debug('Loading json library from %s', self._json_file) library = load_library(self._json_file) diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 13d46979..dc068457 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -17,6 +17,11 @@ class LocalLibraryProvider(backend.LibraryProvider): self._library = library self.refresh() + def browse(self, path): + if not self._library: + return [] + return self._library.browse(path) + def refresh(self, uri=None): if not self._library: return 0 From 25e4bc0e3072d1291c03dab403f8f5d8e7cda021 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Jan 2014 22:27:33 +0100 Subject: [PATCH 182/238] docs: Recommend using Python module name as logger name --- docs/extensiondev.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 7368e396..8f66faf6 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -452,6 +452,9 @@ as this will be visible in Mopidy's debug log:: logger = logging.getLogger('mopidy_soundspot') + # Or even better, use the Python module name as the logger name: + logger = logging.getLogger(__name__) + When logging at logging level ``info`` or higher (i.e. ``warning``, ``error``, and ``critical``, but not ``debug``) the log message will be displayed to all Mopidy users. Thus, the log messages at those levels should be well written and From a8eaaedb71988ee26005791f860af71bda2ac22a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Jan 2014 22:40:35 +0100 Subject: [PATCH 183/238] docs: Tweak audio/mixer_volume docs, update changelog --- docs/changelog.rst | 6 ++++++ docs/config.rst | 10 ++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8bf8340c..64f4848d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -73,6 +73,12 @@ v0.18.0 (UNRELEASED) :meth:`~mopidy.ext.Extension.get_frontend_classes`, and :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. +*Audio** + +- Added :confval:`audio/mixer_volume` to set the initial volume of mixers. + This is especially useful for setting the software mixer volume to something + else than the default 100%. (Fixes: :issue:`633`) + **Local backend** - Finished the work on creating pluggable libraries. Users can now diff --git a/docs/config.rst b/docs/config.rst index 3872ecd3..d1752ba5 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -80,16 +80,14 @@ Core configuration values Setting the config value to blank turns off volume control. - .. confval:: audio/mixer_volume - Audio mixer initial volume. - - Expects an Integer between 0 and 100. + Initial volume for the audio mixer. - Sets the initial volume of the audio mixer. Setting the config value to blank - sets the initial volume for the software mixer to 100. + Expects an integer between 0 and 100. + Setting the config value to blank leaves the audio mixer volume unchanged. + For the software mixer blank means 100. .. confval:: audio/mixer_track From d59e1367134410f927131ccba32d5bb457c885ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 13 Jan 2014 22:40:42 +0100 Subject: [PATCH 184/238] docs: Update author list --- AUTHORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index c048b83e..e51a1966 100644 --- a/AUTHORS +++ b/AUTHORS @@ -30,6 +30,8 @@ - Lasse Bigum - David Eisner - PÃ¥l Ruud +- Thomas Kemmer - Paul Connolley - Luke Giuliani - Colin Montgomerie +- Simon de Bakker From 82584eb21a14c97b4c9ab4e93bbe9c3e5ac5a94f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 13 Jan 2014 23:43:55 +0100 Subject: [PATCH 185/238] local: Add browser support for json library. --- mopidy/local/json.py | 52 ++++++++++++++++++++++++++++++++++++++-- tests/local/json_test.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/local/json_test.py diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 8404f0a4..5fbdb01a 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -4,11 +4,13 @@ import gzip import json import logging import os +import re +import sys import tempfile import mopidy from mopidy import local, models -from mopidy.local import search +from mopidy.local import search, translator logger = logging.getLogger(__name__) @@ -41,22 +43,68 @@ def write_library(json_file, data): os.remove(tmp.name) +class _BrowseCache(object): + encoding = sys.getfilesystemencoding() + + def __init__(self, uris): + """Create a dictionary tree for quick browsing. + + {'foo': {'bar': {None: [ref1, ref2]}, + 'baz': {}, + None: [ref3]}} + """ + self._root = {} + + for uri in uris: + path = translator.local_track_uri_to_path(uri, b'/') + parts = self.split(path.decode(self.encoding)) + filename = parts.pop() + node = self._root + for part in parts: + node = node.setdefault(part, {}) + ref = models.Ref.track(uri=uri, name=filename) + node.setdefault(None, set()).add(ref) + + def split(self, path): + return re.findall(r'([^/]+)', path) + + def lookup(self, path): + results = [] + node = self._root + + for part in self.split(path): + node = node.get(part, {}) + + for key, value in node.items(): + if key is None: + results.extend(value) + else: + uri = os.path.join(path, key) + results.append(models.Ref.directory(uri=uri, name=key)) + + return results + + class JsonLibrary(local.Library): name = b'json' def __init__(self, config): self._tracks = {} + self._browse_cache = None self._media_dir = config['local']['media_dir'] self._json_file = os.path.join( config['local']['data_dir'], b'library.json.gz') def browse(self, path): - return [] + if not self._browse_cache: + return + return self._browse_cache.lookup(path) def load(self): logger.debug('Loading json library from %s', self._json_file) library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) + self._browse_cache = _BrowseCache(self._tracks.keys()) return len(self._tracks) def lookup(self, uri): diff --git a/tests/local/json_test.py b/tests/local/json_test.py new file mode 100644 index 00000000..09f84ab7 --- /dev/null +++ b/tests/local/json_test.py @@ -0,0 +1,36 @@ +from __future__ import unicode_literals + +import unittest + +from mopidy.local import json +from mopidy.models import Ref + + +class BrowseCacheTest(unittest.TestCase): + def setUp(self): + self.uris = [b'local:track:foo/bar/song1', + b'local:track:foo/bar/song2', + b'local:track:foo/song3'] + self.cache = json._BrowseCache(self.uris) + + def test_lookup_root(self): + expected = [Ref.directory(uri='/foo', name='foo')] + self.assertEqual(expected, self.cache.lookup('/')) + + def test_lookup_foo(self): + expected = [Ref.directory(uri='/foo/bar', name='bar'), + Ref.track(uri=self.uris[2], name='song3')] + self.assertEqual(expected, self.cache.lookup('/foo')) + + def test_lookup_foo_bar(self): + expected = [Ref.track(uri=self.uris[0], name='song1'), + Ref.track(uri=self.uris[1], name='song2')] + self.assertEqual(expected, self.cache.lookup('/foo/bar')) + + def test_lookup_foo_baz(self): + self.assertEqual([], self.cache.lookup('/foo/baz')) + + def test_lookup_normalise_slashes(self): + expected = [Ref.track(uri=self.uris[0], name='song1'), + Ref.track(uri=self.uris[1], name='song2')] + self.assertEqual(expected, self.cache.lookup('/foo//bar/')) From 62ad6d1de29f841e9808f3b46280d794edea9d0e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Jan 2014 00:24:17 +0100 Subject: [PATCH 186/238] core: Switch to neater handling of paths --- mopidy/core/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 0e25ec4f..cea21b10 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -83,8 +83,8 @@ class LibraryController(object): result = [] for ref in refs: if ref.type == Ref.DIRECTORY: - result.append( - ref.copy(uri='/%s%s' % (library_name, ref.uri))) + uri = '/'.join(['', library_name, ref.uri.lstrip('/')]) + result.append(ref.copy(uri=uri)) else: result.append(ref) return result From 1b32b56cf0cf420b66e3d1dcf07a0068fe14c64d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Jan 2014 00:31:45 +0100 Subject: [PATCH 187/238] mpd: Add support for VFS in add-commands --- mopidy/mpd/protocol/current_playlist.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index b4e22a61..0f7ba901 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -22,10 +22,32 @@ def add(context, uri): """ if not uri: return + tl_tracks = context.core.tracklist.add(uri=uri).get() - if not tl_tracks: + if tl_tracks: + return + + if not uri.startswith('/'): + uri = '/%s' % uri + + browse_futures = [context.core.library.browse(uri)] + lookup_futures = [] + while browse_futures: + for ref in browse_futures.pop().get(): + if ref.type == ref.DIRECTORY: + browse_futures.append(context.core.library.browse(ref.uri)) + else: + lookup_futures.append(context.core.library.lookup(ref.uri)) + + tracks = [] + for future in lookup_futures: + tracks.extend(future.get()) + + if not tracks: raise MpdNoExistError('directory or file not found', command='add') + context.core.tracklist.add(tracks=tracks) + @handle_request(r'addid\ "(?P[^"]*)"(\ "(?P\d+)")*$') def addid(context, uri, songpos=None): From d95b07f73789dac0e32744609afea7cdba8c702f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Jan 2014 00:46:07 +0100 Subject: [PATCH 188/238] local: Make ordering of json browse stable --- mopidy/local/json.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 5fbdb01a..626dbbb5 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import collections import gzip import json import logging @@ -53,7 +54,7 @@ class _BrowseCache(object): 'baz': {}, None: [ref3]}} """ - self._root = {} + self._root = collections.OrderedDict() for uri in uris: path = translator.local_track_uri_to_path(uri, b'/') @@ -61,9 +62,9 @@ class _BrowseCache(object): filename = parts.pop() node = self._root for part in parts: - node = node.setdefault(part, {}) + node = node.setdefault(part, collections.OrderedDict()) ref = models.Ref.track(uri=uri, name=filename) - node.setdefault(None, set()).add(ref) + node.setdefault(None, []).append(ref) def split(self, path): return re.findall(r'([^/]+)', path) @@ -76,12 +77,13 @@ class _BrowseCache(object): node = node.get(part, {}) for key, value in node.items(): - if key is None: - results.extend(value) - else: + if key is not None: uri = os.path.join(path, key) results.append(models.Ref.directory(uri=uri, name=key)) + # Get tracks afterwards to ensure ordering. + results.extend(node.get(None, [])) + return results @@ -104,7 +106,7 @@ class JsonLibrary(local.Library): logger.debug('Loading json library from %s', self._json_file) library = load_library(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) - self._browse_cache = _BrowseCache(self._tracks.keys()) + self._browse_cache = _BrowseCache(sorted(self._tracks)) return len(self._tracks) def lookup(self, uri): From 1d108752f64fc31ba96e75b415cda5ffb7a23df8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jan 2014 01:06:10 +0100 Subject: [PATCH 189/238] core: Make events emitted on playback consistent (fixes #629) This commit does not try to make the events correct/perfect with regard to GStreamer states, end-of-stream signalling, etc. It only tries to make the events work consistently across all the methods on the playback controller. * play(track) while already playing has changed from: - playback_state_changed(old_state='playing', new_state='playing') - track_playback_started(track=...) to: - playback_state_changed(old_state='playing', new_state='stopped') - track_playback_ended(track=..., time_position=...) - playback_state_changed(old_state='stopped', new_state='playing') - track_playback_started(track=...) * next() has changed from: - track_playback_ended(track=..., time_position=...) - playback_state_changed(old_state='playing', new_state='stopped') - track_playback_ended(track=..., time_position=0) - playback_state_changed(old_state='stopped', new_state='playing') - track_playback_started(track=...) to same as play() above. * previous() has changed in the same way as next(). * on_end_of_track() has changed from: - track_playback_ended(track=..., time_position=...) - playback_state_changed(old_state='playing', new_state='playing') - track_playback_started(track=...) to same as play() above. * stop() has reordered its events from: - track_playback_ended(track=..., time_position=...) - playback_state_changed(old_state='playing', new_state='stopped') to: - playback_state_changed(old_state='playing', new_state='stopped') - track_playback_ended(track=..., time_position=...) --- mopidy/core/playback.py | 10 +- tests/core/events_test.py | 62 ----------- tests/core/playback_test.py | 210 ++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 67 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 96d13017..b2acb35a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -160,8 +160,7 @@ class PlaybackController(object): next_tl_track = self.core.tracklist.eot_track(original_tl_track) if next_tl_track: - self._trigger_track_playback_ended() - self.play(next_tl_track) + self.change_track(next_tl_track) else: self.stop(clear_current_track=True) @@ -185,7 +184,6 @@ class PlaybackController(object): """ tl_track = self.core.tracklist.next_track(self.current_tl_track) if tl_track: - self._trigger_track_playback_ended() self.change_track(tl_track) else: self.stop(clear_current_track=True) @@ -228,6 +226,9 @@ class PlaybackController(object): assert tl_track in self.core.tracklist.tl_tracks + if self.state == PlaybackState.PLAYING: + self.stop() + self.current_tl_track = tl_track self.state = PlaybackState.PLAYING backend = self._get_backend() @@ -251,7 +252,6 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - self._trigger_track_playback_ended() tl_track = self.current_tl_track self.change_track( self.core.tracklist.previous_track(tl_track), on_error_step=-1) @@ -307,8 +307,8 @@ class PlaybackController(object): if self.state != PlaybackState.STOPPED: backend = self._get_backend() if not backend or backend.playback.stop().get(): - self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED + self._trigger_track_playback_ended() if clear_current_track: self.current_tl_track = None diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 17d2eb84..d975ae29 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -25,59 +25,6 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'playlists_loaded') - def test_pause_sends_track_playback_paused_event(self, send): - 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): - tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() - 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') - 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): - 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): - 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)]) - self.core.playback.play().get() - send.reset_mock() - - 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() @@ -153,12 +100,3 @@ class BackendEventsTest(unittest.TestCase): 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) diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index 806de40e..6796cfe7 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -12,11 +12,15 @@ class CorePlaybackTest(unittest.TestCase): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.playback1 = mock.Mock(spec=backend.PlaybackProvider) + self.playback1.get_time_position().get.return_value = 1000 + self.playback1.reset_mock() self.backend1.playback = self.playback1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.playback2 = mock.Mock(spec=backend.PlaybackProvider) + self.playback2.get_time_position().get.return_value = 2000 + self.playback2.reset_mock() self.backend2.playback = self.playback2 # A backend without the optional playback provider @@ -38,6 +42,12 @@ class CorePlaybackTest(unittest.TestCase): self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] + # TODO Test get_current_tl_track + + # TODO Test get_current_track + + # TODO Test state + def test_play_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) @@ -59,6 +69,45 @@ class CorePlaybackTest(unittest.TestCase): self.assertEqual( self.core.playback.current_tl_track, self.tl_tracks[3]) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_stopped_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[0]), + ]) + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_play_when_playing_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.play(self.tl_tracks[3]) + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=1000), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[3]), + ]) + def test_pause_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.pause() @@ -81,6 +130,25 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.pause.called) self.assertFalse(self.playback2.pause.called) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_pause_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.pause() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='paused'), + mock.call( + 'track_playback_paused', + tl_track=self.tl_tracks[0], time_position=1000), + ]) + def test_resume_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.pause() @@ -106,6 +174,26 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.resume.called) self.assertFalse(self.playback2.resume.called) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_resume_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + self.core.playback.pause() + listener_mock.reset_mock() + + self.core.playback.resume() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='paused', new_state='playing'), + mock.call( + 'track_playback_resumed', + tl_track=self.tl_tracks[0], time_position=1000), + ]) + def test_stop_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.stop() @@ -129,6 +217,103 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.stop.called) self.assertFalse(self.playback2.stop.called) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_stop_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.stop() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=1000), + ]) + + # TODO Test next() more + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_next_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.next() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + + # TODO Test previous() more + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_previous_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[1]) + listener_mock.reset_mock() + + self.core.playback.previous() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[1], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[0]), + ]) + + # TODO Test on_end_of_track() more + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_on_end_of_track_emits_events(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.on_end_of_track() + + self.assertListEqual( + listener_mock.send.mock_calls, + [ + mock.call( + 'playback_state_changed', + old_state='playing', new_state='stopped'), + mock.call( + 'track_playback_ended', + tl_track=self.tl_tracks[0], time_position=mock.ANY), + mock.call( + 'playback_state_changed', + old_state='stopped', new_state='playing'), + mock.call( + 'track_playback_started', tl_track=self.tl_tracks[1]), + ]) + def test_seek_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.seek(10000) @@ -152,6 +337,17 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.seek.called) self.assertFalse(self.playback2.seek.called) + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_seek_emits_seeked_event(self, listener_mock): + self.core.playback.play(self.tl_tracks[0]) + listener_mock.reset_mock() + + self.core.playback.seek(1000) + + listener_mock.send.assert_called_once_with( + 'seeked', time_position=1000) + def test_time_position_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) self.core.playback.seek(10000) @@ -177,6 +373,20 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback1.get_time_position.called) self.assertFalse(self.playback2.get_time_position.called) + # TODO Test on_tracklist_change + + # TODO Test volume + + @mock.patch( + 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) + def test_set_volume_emits_volume_changed_event(self, listener_mock): + self.core.playback.set_volume(10) + listener_mock.reset_mock() + + self.core.playback.set_volume(20) + + listener_mock.send.assert_called_once_with('volume_changed', volume=20) + def test_mute(self): self.assertEqual(self.core.playback.mute, False) From d1630e00f1b5fb341181550c9c63c3ddeae74e2b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jan 2014 01:24:26 +0100 Subject: [PATCH 190/238] docs: Update changelog --- docs/changelog.rst | 4 ++++ docs/conf.py | 1 + 2 files changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 64f4848d..fb7fa12a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -20,6 +20,10 @@ v0.18.0 (UNRELEASED) virtual file system of tracks. Backends can implement support for this by implementing :meth:`mopidy.backends.base.BaseLibraryController.browse`. +- Events emitted on play/stop, pause/resume, next/previous and on end of track + has been cleaned up to work consistenly. See the message of + :commit:`1d108752f6` for the full details. + **Backend API** - Move the backend API classes from :mod:`mopidy.backends.base` to diff --git a/docs/conf.py b/docs/conf.py index bb9b7c2f..737fb07a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -161,6 +161,7 @@ man_pages = [ extlinks = { 'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'), + 'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '), 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), } From d96ea03ac168f1fa1221acde91fd7f48e821a54e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jan 2014 10:07:54 +0100 Subject: [PATCH 191/238] docs: Add issue ref to changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fb7fa12a..3ca2eb6a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,7 +22,7 @@ v0.18.0 (UNRELEASED) - Events emitted on play/stop, pause/resume, next/previous and on end of track has been cleaned up to work consistenly. See the message of - :commit:`1d108752f6` for the full details. + :commit:`1d108752f6` for the full details. (Fixes: :issue:`629`) **Backend API** From a8458720ee83775e546e05449504e1f5cf365539 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Jan 2014 22:07:20 +0100 Subject: [PATCH 192/238] local: Review fixes --- mopidy/local/json.py | 10 ++++------ tests/local/json_test.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 626dbbb5..9a7d02f8 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -46,6 +46,7 @@ def write_library(json_file, data): class _BrowseCache(object): encoding = sys.getfilesystemencoding() + splitpath_re = re.compile(r'([^/]+)') def __init__(self, uris): """Create a dictionary tree for quick browsing. @@ -58,7 +59,7 @@ class _BrowseCache(object): for uri in uris: path = translator.local_track_uri_to_path(uri, b'/') - parts = self.split(path.decode(self.encoding)) + parts = self.splitpath_re.findall(path.decode(self.encoding)) filename = parts.pop() node = self._root for part in parts: @@ -66,14 +67,11 @@ class _BrowseCache(object): ref = models.Ref.track(uri=uri, name=filename) node.setdefault(None, []).append(ref) - def split(self, path): - return re.findall(r'([^/]+)', path) - def lookup(self, path): results = [] node = self._root - for part in self.split(path): + for part in self.splitpath_re.findall(path): node = node.get(part, {}) for key, value in node.items(): @@ -99,7 +97,7 @@ class JsonLibrary(local.Library): def browse(self, path): if not self._browse_cache: - return + return [] return self._browse_cache.lookup(path) def load(self): diff --git a/tests/local/json_test.py b/tests/local/json_test.py index 09f84ab7..af606c05 100644 --- a/tests/local/json_test.py +++ b/tests/local/json_test.py @@ -30,7 +30,7 @@ class BrowseCacheTest(unittest.TestCase): def test_lookup_foo_baz(self): self.assertEqual([], self.cache.lookup('/foo/baz')) - def test_lookup_normalise_slashes(self): + def test_lookup_normalize_slashes(self): expected = [Ref.track(uri=self.uris[0], name='song1'), Ref.track(uri=self.uris[1], name='song2')] self.assertEqual(expected, self.cache.lookup('/foo//bar/')) From 03838a19687f76846461e75f4df6e044026d5037 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 14 Jan 2014 23:29:52 +0100 Subject: [PATCH 193/238] mpd: Add tests for adding VFS folders --- mopidy/mpd/protocol/current_playlist.py | 2 +- tests/dummy_backend.py | 4 +-- tests/mpd/protocol/current_playlist_test.py | 33 +++++++++++++++++++-- tests/mpd/protocol/music_db_test.py | 24 +++++++-------- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index 0f7ba901..ab799fb4 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -20,7 +20,7 @@ def add(context, uri): - ``add ""`` should add all tracks in the library to the current playlist. """ - if not uri: + if not uri.strip('/'): return tl_tracks = context.core.tracklist.add(uri=uri).get() diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 0b8e3858..258340b9 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -33,12 +33,12 @@ class DummyLibraryProvider(backend.LibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_browse_result = [] + self.dummy_browse_result = {} self.dummy_find_exact_result = SearchResult() self.dummy_search_result = SearchResult() def browse(self, path): - return self.dummy_browse_result + return self.dummy_browse_result.get(path, []) def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/tests/mpd/protocol/current_playlist_test.py b/tests/mpd/protocol/current_playlist_test.py index f94ec6a0..34221fcd 100644 --- a/tests/mpd/protocol/current_playlist_test.py +++ b/tests/mpd/protocol/current_playlist_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.models import Track +from mopidy.models import Ref, Track from tests.mpd import protocol @@ -24,9 +24,36 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [50@0] {add} directory or file not found') - def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): + def test_add_with_empty_uri_should_not_add_anything_and_ok(self): + self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a')]} + self.sendRequest('add ""') - # TODO check that we add all tracks (we currently don't) + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) + self.assertInResponse('OK') + + def test_add_with_library_should_recurse(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/b', name='b')] + + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo')], + '/foo': [Ref.track(uri='dummy:/b', name='b')]} + + self.sendRequest('add "/dummy"') + self.assertEqual(self.core.tracklist.tracks.get(), tracks) + self.assertInResponse('OK') + + def test_add_root_should_not_add_anything_and_ok(self): + self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a')]} + + self.sendRequest('add "/"') + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertInResponse('OK') def test_addid_without_songpos(self): diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/music_db_test.py index 25feab27..163ccf88 100644 --- a/tests/mpd/protocol/music_db_test.py +++ b/tests/mpd/protocol/music_db_test.py @@ -168,20 +168,18 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): - self.backend.library.dummy_browse_result = [ - Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo', name='foo'), - ] + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo', name='foo')]} self.sendRequest('lsinfo "/"') self.assertInResponse('directory: dummy') self.assertInResponse('OK') def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): - self.backend.library.dummy_browse_result = [ - Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo', name='foo'), - ] + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo', name='foo')]} response1 = self.sendRequest('lsinfo "dummy"') response2 = self.sendRequest('lsinfo "/dummy"') @@ -191,9 +189,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.backend.library.dummy_library = [ Track(uri='dummy:/a', name='a'), ] - self.backend.library.dummy_browse_result = [ - Ref.track(uri='dummy:/a', name='a'), - ] + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('file: dummy:/a') @@ -201,9 +198,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_lsinfo_for_dir_includes_subdirs(self): - self.backend.library.dummy_browse_result = [ - Ref.directory(uri='/foo', name='foo'), - ] + self.backend.library.dummy_browse_result = { + '/': [Ref.directory(uri='/foo', name='foo')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo') From eeb5a1f13ad65fa0a3a106713f83e6580e69ba41 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jan 2014 00:01:11 +0100 Subject: [PATCH 194/238] docs: Update changelog --- docs/changelog.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3ca2eb6a..3e86ab9e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -85,6 +85,8 @@ v0.18.0 (UNRELEASED) **Local backend** +- Added support for browsing local directories. + - Finished the work on creating pluggable libraries. Users can now reconfigure Mopidy to use alternate library providers of their choosing for local files. (Fixes issue :issue:`44`, partially resolves :issue:`397`, and @@ -125,8 +127,7 @@ v0.18.0 (UNRELEASED) - Make the ``lsinfo`` command support browsing of Mopidy's virtual file system. Note that the related ``listall`` and ``listallinfo`` commands are - still not implemented. Also note that this adds very little until e.g. the - local backend is extended with support for library browsing. + still not implemented. - Empty commands now return a ``ACK [5@0] {} No command given`` error instead of ``OK``. This is consistent with the original MPD server implementation. From c781f77ef3dd961c5d0e41cd8652068338692565 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 14 Jan 2014 23:56:03 +0100 Subject: [PATCH 195/238] Rename test files to pattern expected by test runners --- tests/audio/{actor_test.py => test_actor.py} | 0 tests/audio/{listener_test.py => test_listener.py} | 0 tests/audio/{playlists_test.py => test_playlists.py} | 0 tests/audio/{scan_test.py => test_scan.py} | 0 tests/backend/{listener_test.py => test_listener.py} | 0 tests/config/{config_test.py => test_config.py} | 0 tests/config/{schemas_test.py => test_schemas.py} | 0 tests/config/{types_test.py => test_types.py} | 0 tests/config/{validator_tests.py => test_validator.py} | 0 tests/core/{actor_test.py => test_actor.py} | 0 tests/core/{events_test.py => test_events.py} | 0 tests/core/{library_test.py => test_library.py} | 0 tests/core/{listener_test.py => test_listener.py} | 0 tests/core/{playback_test.py => test_playback.py} | 0 tests/core/{playlists_test.py => test_playlists.py} | 0 tests/core/{tracklist_test.py => test_tracklist.py} | 0 tests/http/{events_test.py => test_events.py} | 0 tests/local/{events_test.py => test_events.py} | 0 tests/local/{library_test.py => test_library.py} | 0 tests/local/{playback_test.py => test_playback.py} | 0 tests/local/{playlists_test.py => test_playlists.py} | 0 tests/local/{tracklist_test.py => test_tracklist.py} | 0 tests/local/{translator_test.py => test_translator.py} | 0 .../mpd/protocol/{audio_output_test.py => test_audio_output.py} | 0 .../protocol/{authentication_test.py => test_authentication.py} | 0 tests/mpd/protocol/{channels_test.py => test_channels.py} | 0 .../mpd/protocol/{command_list_test.py => test_command_list.py} | 0 tests/mpd/protocol/{connection_test.py => test_connection.py} | 0 .../{current_playlist_test.py => test_current_playlist.py} | 0 tests/mpd/protocol/{idle_test.py => test_idle.py} | 0 tests/mpd/protocol/{music_db_test.py => test_music_db.py} | 0 tests/mpd/protocol/{playback_test.py => test_playback.py} | 0 tests/mpd/protocol/{reflection_test.py => test_reflection.py} | 0 tests/mpd/protocol/{regression_test.py => test_regression.py} | 0 tests/mpd/protocol/{status_test.py => test_status.py} | 0 tests/mpd/protocol/{stickers_test.py => test_stickers.py} | 0 .../{stored_playlists_test.py => test_stored_playlists.py} | 0 tests/mpd/{dispatcher_test.py => test_dispatcher.py} | 0 tests/mpd/{exception_test.py => test_exceptions.py} | 0 tests/mpd/{status_test.py => test_status.py} | 0 tests/mpd/{translator_test.py => test_translator.py} | 0 tests/outputs/__init__.py | 1 - tests/{commands_test.py => test_commands.py} | 0 tests/{exceptions_test.py => test_exceptions.py} | 0 tests/{ext_test.py => test_ext.py} | 0 tests/{help_test.py => test_help.py} | 0 tests/{models_test.py => test_models.py} | 0 tests/{version_test.py => test_version.py} | 0 tests/utils/network/{connection_test.py => test_connection.py} | 0 .../utils/network/{lineprotocol_test.py => test_lineprotocol.py} | 0 tests/utils/network/{server_test.py => test_server.py} | 0 tests/utils/network/{utils_test.py => test_utils.py} | 0 tests/utils/{deps_test.py => test_deps.py} | 0 tests/utils/{encoding_test.py => test_encoding.py} | 0 tests/utils/{jsonrpc_test.py => test_jsonrpc.py} | 0 tests/utils/{path_test.py => test_path.py} | 0 56 files changed, 1 deletion(-) rename tests/audio/{actor_test.py => test_actor.py} (100%) rename tests/audio/{listener_test.py => test_listener.py} (100%) rename tests/audio/{playlists_test.py => test_playlists.py} (100%) rename tests/audio/{scan_test.py => test_scan.py} (100%) rename tests/backend/{listener_test.py => test_listener.py} (100%) rename tests/config/{config_test.py => test_config.py} (100%) rename tests/config/{schemas_test.py => test_schemas.py} (100%) rename tests/config/{types_test.py => test_types.py} (100%) rename tests/config/{validator_tests.py => test_validator.py} (100%) rename tests/core/{actor_test.py => test_actor.py} (100%) rename tests/core/{events_test.py => test_events.py} (100%) rename tests/core/{library_test.py => test_library.py} (100%) rename tests/core/{listener_test.py => test_listener.py} (100%) rename tests/core/{playback_test.py => test_playback.py} (100%) rename tests/core/{playlists_test.py => test_playlists.py} (100%) rename tests/core/{tracklist_test.py => test_tracklist.py} (100%) rename tests/http/{events_test.py => test_events.py} (100%) rename tests/local/{events_test.py => test_events.py} (100%) rename tests/local/{library_test.py => test_library.py} (100%) rename tests/local/{playback_test.py => test_playback.py} (100%) rename tests/local/{playlists_test.py => test_playlists.py} (100%) rename tests/local/{tracklist_test.py => test_tracklist.py} (100%) rename tests/local/{translator_test.py => test_translator.py} (100%) rename tests/mpd/protocol/{audio_output_test.py => test_audio_output.py} (100%) rename tests/mpd/protocol/{authentication_test.py => test_authentication.py} (100%) rename tests/mpd/protocol/{channels_test.py => test_channels.py} (100%) rename tests/mpd/protocol/{command_list_test.py => test_command_list.py} (100%) rename tests/mpd/protocol/{connection_test.py => test_connection.py} (100%) rename tests/mpd/protocol/{current_playlist_test.py => test_current_playlist.py} (100%) rename tests/mpd/protocol/{idle_test.py => test_idle.py} (100%) rename tests/mpd/protocol/{music_db_test.py => test_music_db.py} (100%) rename tests/mpd/protocol/{playback_test.py => test_playback.py} (100%) rename tests/mpd/protocol/{reflection_test.py => test_reflection.py} (100%) rename tests/mpd/protocol/{regression_test.py => test_regression.py} (100%) rename tests/mpd/protocol/{status_test.py => test_status.py} (100%) rename tests/mpd/protocol/{stickers_test.py => test_stickers.py} (100%) rename tests/mpd/protocol/{stored_playlists_test.py => test_stored_playlists.py} (100%) rename tests/mpd/{dispatcher_test.py => test_dispatcher.py} (100%) rename tests/mpd/{exception_test.py => test_exceptions.py} (100%) rename tests/mpd/{status_test.py => test_status.py} (100%) rename tests/mpd/{translator_test.py => test_translator.py} (100%) delete mode 100644 tests/outputs/__init__.py rename tests/{commands_test.py => test_commands.py} (100%) rename tests/{exceptions_test.py => test_exceptions.py} (100%) rename tests/{ext_test.py => test_ext.py} (100%) rename tests/{help_test.py => test_help.py} (100%) rename tests/{models_test.py => test_models.py} (100%) rename tests/{version_test.py => test_version.py} (100%) rename tests/utils/network/{connection_test.py => test_connection.py} (100%) rename tests/utils/network/{lineprotocol_test.py => test_lineprotocol.py} (100%) rename tests/utils/network/{server_test.py => test_server.py} (100%) rename tests/utils/network/{utils_test.py => test_utils.py} (100%) rename tests/utils/{deps_test.py => test_deps.py} (100%) rename tests/utils/{encoding_test.py => test_encoding.py} (100%) rename tests/utils/{jsonrpc_test.py => test_jsonrpc.py} (100%) rename tests/utils/{path_test.py => test_path.py} (100%) diff --git a/tests/audio/actor_test.py b/tests/audio/test_actor.py similarity index 100% rename from tests/audio/actor_test.py rename to tests/audio/test_actor.py diff --git a/tests/audio/listener_test.py b/tests/audio/test_listener.py similarity index 100% rename from tests/audio/listener_test.py rename to tests/audio/test_listener.py diff --git a/tests/audio/playlists_test.py b/tests/audio/test_playlists.py similarity index 100% rename from tests/audio/playlists_test.py rename to tests/audio/test_playlists.py diff --git a/tests/audio/scan_test.py b/tests/audio/test_scan.py similarity index 100% rename from tests/audio/scan_test.py rename to tests/audio/test_scan.py diff --git a/tests/backend/listener_test.py b/tests/backend/test_listener.py similarity index 100% rename from tests/backend/listener_test.py rename to tests/backend/test_listener.py diff --git a/tests/config/config_test.py b/tests/config/test_config.py similarity index 100% rename from tests/config/config_test.py rename to tests/config/test_config.py diff --git a/tests/config/schemas_test.py b/tests/config/test_schemas.py similarity index 100% rename from tests/config/schemas_test.py rename to tests/config/test_schemas.py diff --git a/tests/config/types_test.py b/tests/config/test_types.py similarity index 100% rename from tests/config/types_test.py rename to tests/config/test_types.py diff --git a/tests/config/validator_tests.py b/tests/config/test_validator.py similarity index 100% rename from tests/config/validator_tests.py rename to tests/config/test_validator.py diff --git a/tests/core/actor_test.py b/tests/core/test_actor.py similarity index 100% rename from tests/core/actor_test.py rename to tests/core/test_actor.py diff --git a/tests/core/events_test.py b/tests/core/test_events.py similarity index 100% rename from tests/core/events_test.py rename to tests/core/test_events.py diff --git a/tests/core/library_test.py b/tests/core/test_library.py similarity index 100% rename from tests/core/library_test.py rename to tests/core/test_library.py diff --git a/tests/core/listener_test.py b/tests/core/test_listener.py similarity index 100% rename from tests/core/listener_test.py rename to tests/core/test_listener.py diff --git a/tests/core/playback_test.py b/tests/core/test_playback.py similarity index 100% rename from tests/core/playback_test.py rename to tests/core/test_playback.py diff --git a/tests/core/playlists_test.py b/tests/core/test_playlists.py similarity index 100% rename from tests/core/playlists_test.py rename to tests/core/test_playlists.py diff --git a/tests/core/tracklist_test.py b/tests/core/test_tracklist.py similarity index 100% rename from tests/core/tracklist_test.py rename to tests/core/test_tracklist.py diff --git a/tests/http/events_test.py b/tests/http/test_events.py similarity index 100% rename from tests/http/events_test.py rename to tests/http/test_events.py diff --git a/tests/local/events_test.py b/tests/local/test_events.py similarity index 100% rename from tests/local/events_test.py rename to tests/local/test_events.py diff --git a/tests/local/library_test.py b/tests/local/test_library.py similarity index 100% rename from tests/local/library_test.py rename to tests/local/test_library.py diff --git a/tests/local/playback_test.py b/tests/local/test_playback.py similarity index 100% rename from tests/local/playback_test.py rename to tests/local/test_playback.py diff --git a/tests/local/playlists_test.py b/tests/local/test_playlists.py similarity index 100% rename from tests/local/playlists_test.py rename to tests/local/test_playlists.py diff --git a/tests/local/tracklist_test.py b/tests/local/test_tracklist.py similarity index 100% rename from tests/local/tracklist_test.py rename to tests/local/test_tracklist.py diff --git a/tests/local/translator_test.py b/tests/local/test_translator.py similarity index 100% rename from tests/local/translator_test.py rename to tests/local/test_translator.py diff --git a/tests/mpd/protocol/audio_output_test.py b/tests/mpd/protocol/test_audio_output.py similarity index 100% rename from tests/mpd/protocol/audio_output_test.py rename to tests/mpd/protocol/test_audio_output.py diff --git a/tests/mpd/protocol/authentication_test.py b/tests/mpd/protocol/test_authentication.py similarity index 100% rename from tests/mpd/protocol/authentication_test.py rename to tests/mpd/protocol/test_authentication.py diff --git a/tests/mpd/protocol/channels_test.py b/tests/mpd/protocol/test_channels.py similarity index 100% rename from tests/mpd/protocol/channels_test.py rename to tests/mpd/protocol/test_channels.py diff --git a/tests/mpd/protocol/command_list_test.py b/tests/mpd/protocol/test_command_list.py similarity index 100% rename from tests/mpd/protocol/command_list_test.py rename to tests/mpd/protocol/test_command_list.py diff --git a/tests/mpd/protocol/connection_test.py b/tests/mpd/protocol/test_connection.py similarity index 100% rename from tests/mpd/protocol/connection_test.py rename to tests/mpd/protocol/test_connection.py diff --git a/tests/mpd/protocol/current_playlist_test.py b/tests/mpd/protocol/test_current_playlist.py similarity index 100% rename from tests/mpd/protocol/current_playlist_test.py rename to tests/mpd/protocol/test_current_playlist.py diff --git a/tests/mpd/protocol/idle_test.py b/tests/mpd/protocol/test_idle.py similarity index 100% rename from tests/mpd/protocol/idle_test.py rename to tests/mpd/protocol/test_idle.py diff --git a/tests/mpd/protocol/music_db_test.py b/tests/mpd/protocol/test_music_db.py similarity index 100% rename from tests/mpd/protocol/music_db_test.py rename to tests/mpd/protocol/test_music_db.py diff --git a/tests/mpd/protocol/playback_test.py b/tests/mpd/protocol/test_playback.py similarity index 100% rename from tests/mpd/protocol/playback_test.py rename to tests/mpd/protocol/test_playback.py diff --git a/tests/mpd/protocol/reflection_test.py b/tests/mpd/protocol/test_reflection.py similarity index 100% rename from tests/mpd/protocol/reflection_test.py rename to tests/mpd/protocol/test_reflection.py diff --git a/tests/mpd/protocol/regression_test.py b/tests/mpd/protocol/test_regression.py similarity index 100% rename from tests/mpd/protocol/regression_test.py rename to tests/mpd/protocol/test_regression.py diff --git a/tests/mpd/protocol/status_test.py b/tests/mpd/protocol/test_status.py similarity index 100% rename from tests/mpd/protocol/status_test.py rename to tests/mpd/protocol/test_status.py diff --git a/tests/mpd/protocol/stickers_test.py b/tests/mpd/protocol/test_stickers.py similarity index 100% rename from tests/mpd/protocol/stickers_test.py rename to tests/mpd/protocol/test_stickers.py diff --git a/tests/mpd/protocol/stored_playlists_test.py b/tests/mpd/protocol/test_stored_playlists.py similarity index 100% rename from tests/mpd/protocol/stored_playlists_test.py rename to tests/mpd/protocol/test_stored_playlists.py diff --git a/tests/mpd/dispatcher_test.py b/tests/mpd/test_dispatcher.py similarity index 100% rename from tests/mpd/dispatcher_test.py rename to tests/mpd/test_dispatcher.py diff --git a/tests/mpd/exception_test.py b/tests/mpd/test_exceptions.py similarity index 100% rename from tests/mpd/exception_test.py rename to tests/mpd/test_exceptions.py diff --git a/tests/mpd/status_test.py b/tests/mpd/test_status.py similarity index 100% rename from tests/mpd/status_test.py rename to tests/mpd/test_status.py diff --git a/tests/mpd/translator_test.py b/tests/mpd/test_translator.py similarity index 100% rename from tests/mpd/translator_test.py rename to tests/mpd/test_translator.py diff --git a/tests/outputs/__init__.py b/tests/outputs/__init__.py deleted file mode 100644 index baffc488..00000000 --- a/tests/outputs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import unicode_literals diff --git a/tests/commands_test.py b/tests/test_commands.py similarity index 100% rename from tests/commands_test.py rename to tests/test_commands.py diff --git a/tests/exceptions_test.py b/tests/test_exceptions.py similarity index 100% rename from tests/exceptions_test.py rename to tests/test_exceptions.py diff --git a/tests/ext_test.py b/tests/test_ext.py similarity index 100% rename from tests/ext_test.py rename to tests/test_ext.py diff --git a/tests/help_test.py b/tests/test_help.py similarity index 100% rename from tests/help_test.py rename to tests/test_help.py diff --git a/tests/models_test.py b/tests/test_models.py similarity index 100% rename from tests/models_test.py rename to tests/test_models.py diff --git a/tests/version_test.py b/tests/test_version.py similarity index 100% rename from tests/version_test.py rename to tests/test_version.py diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/test_connection.py similarity index 100% rename from tests/utils/network/connection_test.py rename to tests/utils/network/test_connection.py diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/test_lineprotocol.py similarity index 100% rename from tests/utils/network/lineprotocol_test.py rename to tests/utils/network/test_lineprotocol.py diff --git a/tests/utils/network/server_test.py b/tests/utils/network/test_server.py similarity index 100% rename from tests/utils/network/server_test.py rename to tests/utils/network/test_server.py diff --git a/tests/utils/network/utils_test.py b/tests/utils/network/test_utils.py similarity index 100% rename from tests/utils/network/utils_test.py rename to tests/utils/network/test_utils.py diff --git a/tests/utils/deps_test.py b/tests/utils/test_deps.py similarity index 100% rename from tests/utils/deps_test.py rename to tests/utils/test_deps.py diff --git a/tests/utils/encoding_test.py b/tests/utils/test_encoding.py similarity index 100% rename from tests/utils/encoding_test.py rename to tests/utils/test_encoding.py diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/test_jsonrpc.py similarity index 100% rename from tests/utils/jsonrpc_test.py rename to tests/utils/test_jsonrpc.py diff --git a/tests/utils/path_test.py b/tests/utils/test_path.py similarity index 100% rename from tests/utils/path_test.py rename to tests/utils/test_path.py From afbff12ccc62dadce4e4d552361e056bc4b66f5c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jan 2014 00:51:56 +0100 Subject: [PATCH 196/238] mpd: Implement "listall" command --- mopidy/mpd/protocol/music_db.py | 23 +++++++++++++++++-- tests/mpd/protocol/test_music_db.py | 35 ++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index b31d295b..302d37eb 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -6,7 +6,8 @@ import re from mopidy.models import Ref, Track from mopidy.mpd import translator -from mopidy.mpd.exceptions import MpdArgError, MpdNotImplemented +from mopidy.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) from mopidy.mpd.protocol import handle_request, stored_playlists @@ -417,7 +418,25 @@ def listall(context, uri=None): Lists all songs and directories in ``URI``. """ - raise MpdNotImplemented # TODO + if uri is None: + uri = '/' + if not uri.startswith('/'): + uri = '/%s' % uri + + result = [] + browse_futures = [context.core.library.browse(uri)] + while browse_futures: + for ref in browse_futures.pop().get(): + if ref.type == Ref.DIRECTORY: + result.append(('directory', ref.uri)) + browse_futures.append(context.core.library.browse(ref.uri)) + elif ref.type == Ref.TRACK: + result.append(('file', ref.uri)) + + if not result: + raise MpdNoExistError('Not found', command='listall') + + return [('directory', uri)] + result @handle_request(r'listallinfo$') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 163ccf88..78a94a78 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -123,12 +123,41 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listall_without_uri(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo')], + '/foo': [Ref.track(uri='dummy:/b', name='b')]} + self.sendRequest('listall') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + + self.assertInResponse('file: dummy:/a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/b') + self.assertInResponse('OK') def test_listall_with_uri(self): - self.sendRequest('listall "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo')], + '/foo': [Ref.track(uri='dummy:/b', name='b')]} + + self.sendRequest('listall "/dummy/foo"') + + self.assertNotInResponse('file: dummy:/a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/b') + self.assertInResponse('OK') + + def test_listall_with_unknown_uri(self): + self.sendRequest('listall "/unknown"') + + self.assertEqualResponse('ACK [50@0] {listall} Not found') def test_listallinfo_without_uri(self): self.sendRequest('listallinfo') From d698653d83b0be1cd3272ebd0e433e5cf8e40002 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jan 2014 01:08:17 +0100 Subject: [PATCH 197/238] mpd: Implement "listallinfo" command (fixes #145) --- mopidy/mpd/protocol/music_db.py | 32 ++++++++++++++++++++--- tests/mpd/protocol/test_music_db.py | 39 ++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 302d37eb..7ef11111 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -6,8 +6,7 @@ import re from mopidy.models import Ref, Track from mopidy.mpd import translator -from mopidy.mpd.exceptions import ( - MpdArgError, MpdNoExistError, MpdNotImplemented) +from mopidy.mpd.exceptions import MpdArgError, MpdNoExistError from mopidy.mpd.protocol import handle_request, stored_playlists @@ -450,7 +449,34 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - raise MpdNotImplemented # TODO + if uri is None: + uri = '/' + if not uri.startswith('/'): + uri = '/%s' % uri + + dirs_and_futures = [] + browse_futures = [context.core.library.browse(uri)] + while browse_futures: + for ref in browse_futures.pop().get(): + if ref.type == Ref.DIRECTORY: + dirs_and_futures.append(('directory', ref.uri)) + browse_futures.append(context.core.library.browse(ref.uri)) + elif ref.type == Ref.TRACK: + # TODO Lookup tracks in batch for better performance + dirs_and_futures.append(context.core.library.lookup(ref.uri)) + + result = [] + for obj in dirs_and_futures: + if hasattr(obj, 'get'): + for track in obj.get(): + result.extend(translator.track_to_mpd_format(track)) + else: + result.append(obj) + + if not result: + raise MpdNoExistError('Not found', command='listallinfo') + + return [('directory', uri)] + result @handle_request(r'lsinfo$') diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index 78a94a78..d2ecd66c 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -160,12 +160,45 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {listall} Not found') def test_listallinfo_without_uri(self): + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo')], + '/foo': [Ref.track(uri='dummy:/b', name='b')]} + self.sendRequest('listallinfo') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + + self.assertInResponse('file: dummy:/a') + self.assertInResponse('Title: a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/b') + self.assertInResponse('Title: b') + self.assertInResponse('OK') def test_listallinfo_with_uri(self): - self.sendRequest('listallinfo "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + tracks = [Track(uri='dummy:/a', name='a'), + Track(uri='dummy:/b', name='b')] + self.backend.library.dummy_library = tracks + self.backend.library.dummy_browse_result = { + '/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='/foo')], + '/foo': [Ref.track(uri='dummy:/b', name='b')]} + + self.sendRequest('listallinfo "/dummy/foo"') + + self.assertNotInResponse('file: dummy:/a') + self.assertNotInResponse('Title: a') + self.assertInResponse('directory: /dummy/foo') + self.assertInResponse('file: dummy:/b') + self.assertInResponse('Title: b') + self.assertInResponse('OK') + + def test_listallinfo_with_unknown_uri(self): + self.sendRequest('listallinfo "/unknown"') + + self.assertEqualResponse('ACK [50@0] {listallinfo} Not found') def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) From 07d9a15ff36c98d1c28ac586321807fbce65221e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jan 2014 01:10:44 +0100 Subject: [PATCH 198/238] docs: Update changelog --- docs/changelog.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3e86ab9e..a3f127b9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,7 +18,7 @@ v0.18.0 (UNRELEASED) - Add :meth:`mopidy.core.LibraryController.browse` method for browsing a virtual file system of tracks. Backends can implement support for this by - implementing :meth:`mopidy.backends.base.BaseLibraryController.browse`. + implementing :meth:`mopidy.backend.LibraryProvider.browse`. - Events emitted on play/stop, pause/resume, next/previous and on end of track has been cleaned up to work consistenly. See the message of @@ -46,6 +46,10 @@ v0.18.0 (UNRELEASED) Imports from the old locations still works, but are deprecated. +- Add :meth:`mopidy.backend.LibraryProvider.browse`, which can be implemented + by backends that wants to expose directories of tracks in Mopidy's virtual + file system. + **Commands** - Reduce amount of logging from dependencies when using :option:`mopidy -v`. @@ -85,7 +89,7 @@ v0.18.0 (UNRELEASED) **Local backend** -- Added support for browsing local directories. +- Added support for browsing local directories in Mopidy's virtual file system. - Finished the work on creating pluggable libraries. Users can now reconfigure Mopidy to use alternate library providers of their choosing for @@ -125,9 +129,8 @@ v0.18.0 (UNRELEASED) **MPD frontend** -- Make the ``lsinfo`` command support browsing of Mopidy's virtual file - system. Note that the related ``listall`` and ``listallinfo`` commands are - still not implemented. +- Make the ``lsinfo``, ``listall``, and ``listallinfo`` commands support + browsing of Mopidy's virtual file system. (Fixes: :issue:`145`) - Empty commands now return a ``ACK [5@0] {} No command given`` error instead of ``OK``. This is consistent with the original MPD server implementation. From c4ab4b150af383e59409d247fd692eb75d5cdef7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jan 2014 01:34:43 +0100 Subject: [PATCH 199/238] docs: Remove browsing from list of MPD limitations --- docs/ext/mpd.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 5b82dce2..10ecdb24 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -47,7 +47,6 @@ 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 57798a27572bf2dc26b8278f65966011a5cb4096 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 00:09:57 +0100 Subject: [PATCH 200/238] audio: Ensure tests can be run on their own --- tests/audio/test_scan.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index ed3f8e01..5753ecf3 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -3,6 +3,9 @@ from __future__ import unicode_literals import os import unittest +import gobject +gobject.threads_init() + from mopidy import exceptions from mopidy.audio import scan from mopidy.models import Track, Artist, Album From 7209b38aa639371670f95f6632c7df2c821db35e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 00:26:30 +0100 Subject: [PATCH 201/238] audio: Nest tags in scan data return value --- mopidy/audio/scan.py | 19 ++++----- tests/audio/test_scan.py | 92 +++++++++++++++++++++------------------- 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 0c8e3478..46ab6f8f 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -52,18 +52,17 @@ class Scanner(object): :return: Dictionary of tags, duration, mtime and uri information. """ try: + data = {'uri': uri} self._setup(uri) - data = self._collect() - # Make sure uri, mtime and duration does not come from tags. - data[b'uri'] = uri - data[b'mtime'] = self._query_mtime(uri) - data[gst.TAG_DURATION] = self._query_duration() + data['tags'] = self._collect() + data['mtime'] = self._query_mtime(uri) + data['duration'] = self._query_duration() finally: self._reset() if self._min_duration_ms is None: return data - elif data[gst.TAG_DURATION] >= self._min_duration_ms * gst.MSECOND: + elif data['duration'] >= self._min_duration_ms * gst.MSECOND: return data raise exceptions.ScannerError('Rejecting file with less than %dms ' @@ -131,8 +130,8 @@ def audio_data_to_track(data): track_kwargs = {} def _retrieve(source_key, target_key, target): - if source_key in data: - target.setdefault(target_key, data[source_key]) + if source_key in data['tags']: + target.setdefault(target_key, data['tags'][source_key]) _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) @@ -160,8 +159,8 @@ def audio_data_to_track(data): _retrieve(gst.TAG_LOCATION, 'comment', track_kwargs) _retrieve(gst.TAG_COPYRIGHT, 'comment', track_kwargs) - if gst.TAG_DATE in data and data[gst.TAG_DATE]: - date = data[gst.TAG_DATE] + if gst.TAG_DATE in data['tags'] and data['tags'][gst.TAG_DATE]: + date = data['tags'][gst.TAG_DATE] try: date = datetime.date(date.year, date.month, date.day) except ValueError: diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 5753ecf3..c3ee568c 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -25,26 +25,28 @@ class TranslatorTest(unittest.TestCase): def setUp(self): self.data = { 'uri': 'uri', - 'album': 'albumname', - 'track-number': 1, - 'artist': 'name', - 'composer': 'composer', - 'performer': 'performer', - 'album-artist': 'albumartistname', - 'title': 'trackname', - 'track-count': 2, - 'album-disc-number': 2, - 'album-disc-count': 3, - 'date': FakeGstDate(2006, 1, 1,), - 'container-format': 'ID3 tag', - 'genre': 'genre', 'duration': 4531000000, - 'comment': 'comment', - 'musicbrainz-trackid': 'mbtrackid', - 'musicbrainz-albumid': 'mbalbumid', - 'musicbrainz-artistid': 'mbartistid', - 'musicbrainz-albumartistid': 'mbalbumartistid', 'mtime': 1234, + 'tags': { + 'album': 'albumname', + 'track-number': 1, + 'artist': 'name', + 'composer': 'composer', + 'performer': 'performer', + 'album-artist': 'albumartistname', + 'title': 'trackname', + 'track-count': 2, + 'album-disc-number': 2, + 'album-disc-count': 3, + 'date': FakeGstDate(2006, 1, 1,), + 'container-format': 'ID3 tag', + 'genre': 'genre', + 'comment': 'comment', + 'musicbrainz-trackid': 'mbtrackid', + 'musicbrainz-albumid': 'mbalbumid', + 'musicbrainz-artistid': 'mbartistid', + 'musicbrainz-albumartistid': 'mbalbumartistid', + }, } self.album = { @@ -143,98 +145,98 @@ class TranslatorTest(unittest.TestCase): self.check() def test_missing_track_number(self): - del self.data['track-number'] + del self.data['tags']['track-number'] del self.track['track_no'] self.check() def test_missing_track_count(self): - del self.data['track-count'] + del self.data['tags']['track-count'] del self.album['num_tracks'] self.check() def test_missing_track_name(self): - del self.data['title'] + del self.data['tags']['title'] del self.track['name'] self.check() def test_missing_track_musicbrainz_id(self): - del self.data['musicbrainz-trackid'] + del self.data['tags']['musicbrainz-trackid'] del self.track['musicbrainz_id'] self.check() def test_missing_album_name(self): - del self.data['album'] + del self.data['tags']['album'] del self.album['name'] self.check() def test_missing_album_musicbrainz_id(self): - del self.data['musicbrainz-albumid'] + del self.data['tags']['musicbrainz-albumid'] del self.album['musicbrainz_id'] self.check() def test_missing_artist_name(self): - del self.data['artist'] + del self.data['tags']['artist'] del self.artist['name'] self.check() def test_missing_composer_name(self): - del self.data['composer'] + del self.data['tags']['composer'] del self.composer['name'] self.check() def test_multiple_track_composers(self): - self.data['composer'] = ['composer1', 'composer2'] + self.data['tags']['composer'] = ['composer1', 'composer2'] self.composer = self.composer_multiple self.check() def test_multiple_track_performers(self): - self.data['performer'] = ['performer1', 'performer2'] + self.data['tags']['performer'] = ['performer1', 'performer2'] self.performer = self.performer_multiple self.check() def test_missing_performer_name(self): - del self.data['performer'] + del self.data['tags']['performer'] del self.performer['name'] self.check() def test_missing_artist_musicbrainz_id(self): - del self.data['musicbrainz-artistid'] + del self.data['tags']['musicbrainz-artistid'] del self.artist['musicbrainz_id'] self.check() def test_multiple_track_artists(self): - self.data['artist'] = ['name1', 'name2'] + self.data['tags']['artist'] = ['name1', 'name2'] self.data['musicbrainz-artistid'] = 'mbartistid' self.artist = self.artist_multiple self.check() def test_missing_album_artist(self): - del self.data['album-artist'] + del self.data['tags']['album-artist'] del self.albumartist['name'] self.check() def test_missing_album_artist_musicbrainz_id(self): - del self.data['musicbrainz-albumartistid'] + del self.data['tags']['musicbrainz-albumartistid'] del self.albumartist['musicbrainz_id'] self.check() def test_missing_genre(self): - del self.data['genre'] + del self.data['tags']['genre'] del self.track['genre'] self.check() def test_missing_date(self): - del self.data['date'] + del self.data['tags']['date'] del self.track['date'] self.check() def test_invalid_date(self): - self.data['date'] = FakeGstDate(65535, 1, 1) + self.data['tags']['date'] = FakeGstDate(65535, 1, 1) del self.track['date'] self.check() def test_missing_comment(self): - del self.data['comment'] + del self.data['tags']['comment'] del self.track['comment'] self.check() @@ -263,6 +265,10 @@ class ScannerTest(unittest.TestCase): name = path_to_data_dir(name) self.assertEqual(self.data[name][key], value) + def check_tag(self, name, key, value): + name = path_to_data_dir(name) + self.assertEqual(self.data[name]['tags'][key], value) + def test_data_is_set(self): self.scan(self.find('scanner/simple')) self.assert_(self.data) @@ -287,18 +293,18 @@ class ScannerTest(unittest.TestCase): def test_artist_is_set(self): self.scan(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'artist', 'name') - self.check('scanner/simple/song1.ogg', 'artist', 'name') + self.check_tag('scanner/simple/song1.mp3', 'artist', 'name') + self.check_tag('scanner/simple/song1.ogg', 'artist', 'name') def test_album_is_set(self): self.scan(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'album', 'albumname') - self.check('scanner/simple/song1.ogg', 'album', 'albumname') + self.check_tag('scanner/simple/song1.mp3', 'album', 'albumname') + self.check_tag('scanner/simple/song1.ogg', 'album', 'albumname') def test_track_is_set(self): self.scan(self.find('scanner/simple')) - self.check('scanner/simple/song1.mp3', 'title', 'trackname') - self.check('scanner/simple/song1.ogg', 'title', 'trackname') + self.check_tag('scanner/simple/song1.mp3', 'title', 'trackname') + self.check_tag('scanner/simple/song1.ogg', 'title', 'trackname') def test_nonexistant_dir_does_not_fail(self): self.scan(self.find('scanner/does-not-exist')) From 807bedad851b0c79decd14b5b4b9521feb708ddc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 01:14:08 +0100 Subject: [PATCH 202/238] audio: Change audio data convert to operate on taglists --- mopidy/audio/scan.py | 114 +++++++++++++++++---------------------- tests/audio/test_scan.py | 53 +++++++++--------- 2 files changed, 75 insertions(+), 92 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 46ab6f8f..0bee4a9c 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -82,7 +82,7 @@ class Scanner(object): """Polls for messages to collect data.""" start = time.time() timeout_s = self._timeout_ms / float(1000) - data = {} + tags = {} while time.time() - start < timeout_s: if not self._bus.have_pending(): @@ -92,14 +92,21 @@ class Scanner(object): if message.type == gst.MESSAGE_ERROR: raise exceptions.ScannerError(message.parse_error()[0]) elif message.type == gst.MESSAGE_EOS: - return data + return tags elif message.type == gst.MESSAGE_ASYNC_DONE: if message.src == self._pipe: - return data + return tags elif message.type == gst.MESSAGE_TAG: + # Taglists are not really dicts, hence the key usage. + # Beyond that, we only keep the last tag for each key, + # as we assume this is the best, and force everything + # to lists. taglist = message.parse_tag() for key in taglist.keys(): - data[key] = taglist[key] + value = taglist[key] + if not isinstance(value, list): + value = [value] + tags[key] = value raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) @@ -122,45 +129,45 @@ class Scanner(object): def audio_data_to_track(data): """Convert taglist data + our extras to a track.""" - albumartist_kwargs = {} + tags = data['tags'] album_kwargs = {} - artist_kwargs = {} - composer_kwargs = {} - performer_kwargs = {} track_kwargs = {} - def _retrieve(source_key, target_key, target): - if source_key in data['tags']: - target.setdefault(target_key, data['tags'][source_key]) + def _retrieve(source_key, target_key, target, convert): + if tags.get(source_key, None): + result = convert(tags[source_key]) + target.setdefault(target_key, result) - _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) - _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) - _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs) - _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) - _retrieve(gst.TAG_COMPOSER, 'name', composer_kwargs) - _retrieve(gst.TAG_PERFORMER, 'name', performer_kwargs) - _retrieve(gst.TAG_ALBUM_ARTIST, 'name', albumartist_kwargs) - _retrieve(gst.TAG_TITLE, 'name', track_kwargs) - _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) - _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs) - _retrieve(gst.TAG_GENRE, 'genre', track_kwargs) - _retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs) + first = lambda values: values[0] + join = lambda values: ', '.join(values) + artists = lambda values: [Artist(name=v) for v in values] + + _retrieve(gst.TAG_ARTIST, 'artists', track_kwargs, artists) + _retrieve(gst.TAG_COMPOSER, 'composers', track_kwargs, artists) + _retrieve(gst.TAG_PERFORMER, 'performers', track_kwargs, artists) + _retrieve(gst.TAG_TITLE, 'name', track_kwargs, join) + _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs, first) + _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs, first) + _retrieve(gst.TAG_GENRE, 'genre', track_kwargs, join) + _retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs, first) + + _retrieve(gst.TAG_ALBUM, 'name', album_kwargs, join) + _retrieve(gst.TAG_ALBUM_ARTIST, 'artists', album_kwargs, artists) + _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs, first) + _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs, first) # Following keys don't seem to have TAG_* constant. - _retrieve('comment', 'comment', track_kwargs) - _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) - _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) - _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) - _retrieve( - 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) + _retrieve('comment', 'comment', track_kwargs, join) + _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs, first) + _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs, first) # For streams, will not override if a better value has already been set. - _retrieve(gst.TAG_ORGANIZATION, 'name', track_kwargs) - _retrieve(gst.TAG_LOCATION, 'comment', track_kwargs) - _retrieve(gst.TAG_COPYRIGHT, 'comment', track_kwargs) + _retrieve(gst.TAG_ORGANIZATION, 'name', track_kwargs, join) + _retrieve(gst.TAG_LOCATION, 'comment', track_kwargs, join) + _retrieve(gst.TAG_COPYRIGHT, 'comment', track_kwargs, join) - if gst.TAG_DATE in data['tags'] and data['tags'][gst.TAG_DATE]: - date = data['tags'][gst.TAG_DATE] + if tags.get(gst.TAG_DATE, None): + date = tags[gst.TAG_DATE][0] try: date = datetime.date(date.year, date.month, date.day) except ValueError: @@ -168,8 +175,13 @@ def audio_data_to_track(data): else: track_kwargs['date'] = date.isoformat() - if albumartist_kwargs: - album_kwargs['artists'] = [Artist(**albumartist_kwargs)] + def _retrive_mb_artistid(source_key, target): + if source_key in tags and len(target.get('artists', [])) == 1: + target['artists'][0] = target['artists'][0].copy( + musicbrainz_id=tags[source_key][0]) + + _retrive_mb_artistid('musicbrainz-artistid', track_kwargs) + _retrive_mb_artistid('musicbrainz-albumartistid', album_kwargs) if data['mtime']: track_kwargs['last_modified'] = int(data['mtime']) @@ -179,34 +191,4 @@ def audio_data_to_track(data): track_kwargs['uri'] = data['uri'] track_kwargs['album'] = Album(**album_kwargs) - - # TODO: this feels like a half assed workaround. we need to be sure that we - # don't suddenly have lists in our models where we expect strings etc - if ('genre' in track_kwargs and - not isinstance(track_kwargs['genre'], basestring)): - track_kwargs['genre'] = ', '.join(track_kwargs['genre']) - - if ('name' in artist_kwargs - and not isinstance(artist_kwargs['name'], basestring)): - track_kwargs['artists'] = [Artist(name=artist) - for artist in artist_kwargs['name']] - else: - track_kwargs['artists'] = [Artist(**artist_kwargs)] - - if ('name' in composer_kwargs - and not isinstance(composer_kwargs['name'], basestring)): - track_kwargs['composers'] = [Artist(name=artist) - for artist in composer_kwargs['name']] - else: - track_kwargs['composers'] = \ - [Artist(**composer_kwargs)] if composer_kwargs else '' - - if ('name' in performer_kwargs - and not isinstance(performer_kwargs['name'], basestring)): - track_kwargs['performers'] = [Artist(name=artist) - for artist in performer_kwargs['name']] - else: - track_kwargs['performers'] = \ - [Artist(**performer_kwargs)] if performer_kwargs else '' - return Track(**track_kwargs) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index c3ee568c..e3db53ef 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -28,24 +28,24 @@ class TranslatorTest(unittest.TestCase): 'duration': 4531000000, 'mtime': 1234, 'tags': { - 'album': 'albumname', - 'track-number': 1, - 'artist': 'name', - 'composer': 'composer', - 'performer': 'performer', - 'album-artist': 'albumartistname', - 'title': 'trackname', - 'track-count': 2, - 'album-disc-number': 2, - 'album-disc-count': 3, - 'date': FakeGstDate(2006, 1, 1,), - 'container-format': 'ID3 tag', - 'genre': 'genre', - 'comment': 'comment', - 'musicbrainz-trackid': 'mbtrackid', - 'musicbrainz-albumid': 'mbalbumid', - 'musicbrainz-artistid': 'mbartistid', - 'musicbrainz-albumartistid': 'mbalbumartistid', + 'album': ['albumname'], + 'track-number': [1], + 'artist': ['name'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartistname'], + 'title': ['trackname'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': [FakeGstDate(2006, 1, 1,)], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['mbtrackid'], + 'musicbrainz-albumid': ['mbalbumid'], + 'musicbrainz-artistid': ['mbartistid'], + 'musicbrainz-albumartistid': ['mbalbumartistid'], }, } @@ -115,7 +115,7 @@ class TranslatorTest(unittest.TestCase): and not isinstance(self.artist['name'], basestring)): self.track['artists'] = [Artist(name=artist) for artist in self.artist['name']] - else: + elif 'name' in self.artist: self.track['artists'] = [Artist(**self.artist)] if ('name' in self.composer @@ -213,6 +213,7 @@ class TranslatorTest(unittest.TestCase): def test_missing_album_artist(self): del self.data['tags']['album-artist'] del self.albumartist['name'] + del self.albumartist['musicbrainz_id'] self.check() def test_missing_album_artist_musicbrainz_id(self): @@ -231,7 +232,7 @@ class TranslatorTest(unittest.TestCase): self.check() def test_invalid_date(self): - self.data['tags']['date'] = FakeGstDate(65535, 1, 1) + self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] del self.track['date'] self.check() @@ -293,18 +294,18 @@ class ScannerTest(unittest.TestCase): def test_artist_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'artist', 'name') - self.check_tag('scanner/simple/song1.ogg', 'artist', 'name') + self.check_tag('scanner/simple/song1.mp3', 'artist', ['name']) + self.check_tag('scanner/simple/song1.ogg', 'artist', ['name']) def test_album_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'album', 'albumname') - self.check_tag('scanner/simple/song1.ogg', 'album', 'albumname') + self.check_tag('scanner/simple/song1.mp3', 'album', ['albumname']) + self.check_tag('scanner/simple/song1.ogg', 'album', ['albumname']) def test_track_is_set(self): self.scan(self.find('scanner/simple')) - self.check_tag('scanner/simple/song1.mp3', 'title', 'trackname') - self.check_tag('scanner/simple/song1.ogg', 'title', 'trackname') + self.check_tag('scanner/simple/song1.mp3', 'title', ['trackname']) + self.check_tag('scanner/simple/song1.ogg', 'title', ['trackname']) def test_nonexistant_dir_does_not_fail(self): self.scan(self.find('scanner/does-not-exist')) From b0b5d4972f756cb2b4128837234f488d371e3910 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 19:39:11 +0100 Subject: [PATCH 203/238] models: Make .copy(foo=None) null out field. --- mopidy/models.py | 8 ++++++-- tests/test_models.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index ed371f23..aab69a3f 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -69,10 +69,14 @@ class ImmutableObject(object): data = {} for key in self.__dict__.keys(): public_key = key.lstrip('_') - data[public_key] = values.pop(public_key, self.__dict__[key]) + value = values.pop(public_key, self.__dict__[key]) + if value is not None: + data[public_key] = value for key in values.keys(): if hasattr(self, key): - data[key] = values.pop(key) + value = values.pop(key) + if value is not None: + data[key] = value if values: raise TypeError( 'copy() got an unexpected keyword argument "%s"' % key) diff --git a/tests/test_models.py b/tests/test_models.py index 02cba8f4..5872e0e6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -53,6 +53,10 @@ class GenericCopyTest(unittest.TestCase): test = lambda: Track().copy(invalid_key=True) self.assertRaises(TypeError, test) + def test_copying_track_to_remove(self): + track = Track(name='foo').copy(name=None) + self.assertEquals(track.__dict__, Track().__dict__) + class RefTest(unittest.TestCase): def test_uri(self): From ac9f106107eaf72a48a6d14cbc97ba5318bef0b3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 19:39:41 +0100 Subject: [PATCH 204/238] audio: Update how translator test data is built. --- tests/audio/test_scan.py | 189 +++++++++++---------------------------- 1 file changed, 54 insertions(+), 135 deletions(-) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index e3db53ef..9a225d18 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -21,6 +21,7 @@ class FakeGstDate(object): self.day = day +# TODO: keep ids without name? class TranslatorTest(unittest.TestCase): def setUp(self): self.data = { @@ -28,13 +29,13 @@ class TranslatorTest(unittest.TestCase): 'duration': 4531000000, 'mtime': 1234, 'tags': { - 'album': ['albumname'], + 'album': ['album'], 'track-number': [1], - 'artist': ['name'], + 'artist': ['artist'], 'composer': ['composer'], 'performer': ['performer'], - 'album-artist': ['albumartistname'], - 'title': ['trackname'], + 'album-artist': ['albumartist'], + 'title': ['track'], 'track-count': [2], 'album-disc-number': [2], 'album-disc-count': [3], @@ -42,204 +43,122 @@ class TranslatorTest(unittest.TestCase): 'container-format': ['ID3 tag'], 'genre': ['genre'], 'comment': ['comment'], - 'musicbrainz-trackid': ['mbtrackid'], - 'musicbrainz-albumid': ['mbalbumid'], - 'musicbrainz-artistid': ['mbartistid'], - 'musicbrainz-albumartistid': ['mbalbumartistid'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-albumartistid': ['albumartistid'], }, } - self.album = { - 'name': 'albumname', - 'num_tracks': 2, - 'num_discs': 3, - 'musicbrainz_id': 'mbalbumid', - } + artist = Artist(name='artist', musicbrainz_id='artistid') + composer = Artist(name='composer') + performer = Artist(name='performer') + albumartist = Artist(name='albumartist', + musicbrainz_id='albumartistid') - self.artist_single = { - 'name': 'name', - 'musicbrainz_id': 'mbartistid', - } + album = Album(name='album', num_tracks=2, num_discs=3, + musicbrainz_id='albumid', artists=[albumartist]) - self.artist_multiple = { - 'name': ['name1', 'name2'], - 'musicbrainz_id': 'mbartistid', - } + self.track = Track(uri='uri', name='track', date='2006-01-01', + genre='genre', track_no=1, disc_no=2, length=4531, + comment='comment', musicbrainz_id='trackid', + last_modified=1234, album=album, artists=[artist], + composers=[composer], performers=[performer]) - self.artist = self.artist_single - - self.composer_single = { - 'name': 'composer', - } - - self.composer_multiple = { - 'name': ['composer1', 'composer2'], - } - - self.composer = self.composer_single - - self.performer_single = { - 'name': 'performer', - } - - self.performer_multiple = { - 'name': ['performer1', 'performer2'], - } - - self.performer = self.performer_single - - self.albumartist = { - 'name': 'albumartistname', - 'musicbrainz_id': 'mbalbumartistid', - } - - self.track = { - 'uri': 'uri', - 'name': 'trackname', - 'date': '2006-01-01', - 'genre': 'genre', - 'track_no': 1, - 'disc_no': 2, - 'comment': 'comment', - 'length': 4531, - 'musicbrainz_id': 'mbtrackid', - 'last_modified': 1234, - } - - def build_track(self): - if self.albumartist: - self.album['artists'] = [Artist(**self.albumartist)] - self.track['album'] = Album(**self.album) - - if ('name' in self.artist - and not isinstance(self.artist['name'], basestring)): - self.track['artists'] = [Artist(name=artist) - for artist in self.artist['name']] - elif 'name' in self.artist: - self.track['artists'] = [Artist(**self.artist)] - - if ('name' in self.composer - and not isinstance(self.composer['name'], basestring)): - self.track['composers'] = [Artist(name=artist) - for artist in self.composer['name']] - else: - self.track['composers'] = [Artist(**self.composer)] \ - if self.composer else '' - - if ('name' in self.performer - and not isinstance(self.performer['name'], basestring)): - self.track['performers'] = [Artist(name=artist) - for artist in self.performer['name']] - else: - self.track['performers'] = [Artist(**self.performer)] \ - if self.performer else '' - - return Track(**self.track) - - def check(self): - expected = self.build_track() + def check(self, expected): actual = scan.audio_data_to_track(self.data) self.assertEqual(expected, actual) def test_basic_data(self): - self.check() + self.check(self.track) def test_missing_track_number(self): del self.data['tags']['track-number'] - del self.track['track_no'] - self.check() + self.check(self.track.copy(track_no=None)) def test_missing_track_count(self): del self.data['tags']['track-count'] - del self.album['num_tracks'] - self.check() + album = self.track.album.copy(num_tracks=None) + self.check(self.track.copy(album=album)) def test_missing_track_name(self): del self.data['tags']['title'] - del self.track['name'] - self.check() + self.check(self.track.copy(name=None)) def test_missing_track_musicbrainz_id(self): del self.data['tags']['musicbrainz-trackid'] - del self.track['musicbrainz_id'] - self.check() + self.check(self.track.copy(musicbrainz_id=None)) def test_missing_album_name(self): del self.data['tags']['album'] - del self.album['name'] - self.check() + album = self.track.album.copy(name=None) + self.check(self.track.copy(album=album)) def test_missing_album_musicbrainz_id(self): del self.data['tags']['musicbrainz-albumid'] - del self.album['musicbrainz_id'] - self.check() + album = self.track.album.copy(musicbrainz_id=None) + self.check(self.track.copy(album=album)) def test_missing_artist_name(self): del self.data['tags']['artist'] - del self.artist['name'] - self.check() + self.check(self.track.copy(artists=[])) def test_missing_composer_name(self): del self.data['tags']['composer'] - del self.composer['name'] - self.check() + self.check(self.track.copy(composers=[])) def test_multiple_track_composers(self): self.data['tags']['composer'] = ['composer1', 'composer2'] - self.composer = self.composer_multiple - self.check() + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.copy(composers=composers)) def test_multiple_track_performers(self): self.data['tags']['performer'] = ['performer1', 'performer2'] - self.performer = self.performer_multiple - self.check() + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.copy(performers=performers)) def test_missing_performer_name(self): del self.data['tags']['performer'] - del self.performer['name'] - self.check() + self.check(self.track.copy(performers=[])) def test_missing_artist_musicbrainz_id(self): del self.data['tags']['musicbrainz-artistid'] - del self.artist['musicbrainz_id'] - self.check() + artist = list(self.track.artists)[0].copy(musicbrainz_id=None) + self.check(self.track.copy(artists=[artist])) def test_multiple_track_artists(self): self.data['tags']['artist'] = ['name1', 'name2'] - self.data['musicbrainz-artistid'] = 'mbartistid' - self.artist = self.artist_multiple - self.check() + self.data['musicbrainz-artistid'] = 'artistid' + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.copy(artists=artists)) def test_missing_album_artist(self): del self.data['tags']['album-artist'] - del self.albumartist['name'] - del self.albumartist['musicbrainz_id'] - self.check() + album = self.track.album.copy(artists=[]) + self.check(self.track.copy(album=album)) def test_missing_album_artist_musicbrainz_id(self): del self.data['tags']['musicbrainz-albumartistid'] - del self.albumartist['musicbrainz_id'] - self.check() + albumartist = list(self.track.album.artists)[0] + albumartist = albumartist.copy(musicbrainz_id=None) + album = self.track.album.copy(artists=[albumartist]) + self.check(self.track.copy(album=album)) def test_missing_genre(self): del self.data['tags']['genre'] - del self.track['genre'] - self.check() + self.check(self.track.copy(genre=None)) def test_missing_date(self): del self.data['tags']['date'] - del self.track['date'] - self.check() + self.check(self.track.copy(date=None)) def test_invalid_date(self): self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] - del self.track['date'] - self.check() + self.check(self.track.copy(date=None)) def test_missing_comment(self): del self.data['tags']['comment'] - del self.track['comment'] - self.check() + self.check(self.track.copy(comment=None)) class ScannerTest(unittest.TestCase): From 3e9cd0c4b78b2b655cee702a8b41a4897f990c67 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 20:03:02 +0100 Subject: [PATCH 205/238] audio: Add tests for all fields that can be converted --- mopidy/audio/scan.py | 4 +- tests/audio/test_scan.py | 211 ++++++++++++++++++++++++++++++--------- 2 files changed, 165 insertions(+), 50 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 0bee4a9c..4b83cdc6 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -139,7 +139,7 @@ def audio_data_to_track(data): target.setdefault(target_key, result) first = lambda values: values[0] - join = lambda values: ', '.join(values) + join = lambda values: '; '.join(values) artists = lambda values: [Artist(name=v) for v in values] _retrieve(gst.TAG_ARTIST, 'artists', track_kwargs, artists) @@ -151,7 +151,7 @@ def audio_data_to_track(data): _retrieve(gst.TAG_GENRE, 'genre', track_kwargs, join) _retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs, first) - _retrieve(gst.TAG_ALBUM, 'name', album_kwargs, join) + _retrieve(gst.TAG_ALBUM, 'name', album_kwargs, first) _retrieve(gst.TAG_ALBUM_ARTIST, 'artists', album_kwargs, artists) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs, first) _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs, first) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 9a225d18..cc44ecd6 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -47,6 +47,7 @@ class TranslatorTest(unittest.TestCase): 'musicbrainz-albumid': ['albumid'], 'musicbrainz-artistid': ['artistid'], 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], }, } @@ -62,81 +63,176 @@ class TranslatorTest(unittest.TestCase): self.track = Track(uri='uri', name='track', date='2006-01-01', genre='genre', track_no=1, disc_no=2, length=4531, comment='comment', musicbrainz_id='trackid', - last_modified=1234, album=album, artists=[artist], - composers=[composer], performers=[performer]) + last_modified=1234, album=album, bitrate=1000, + artists=[artist], composers=[composer], + performers=[performer]) def check(self, expected): actual = scan.audio_data_to_track(self.data) self.assertEqual(expected, actual) - def test_basic_data(self): + def test_track(self): self.check(self.track) - def test_missing_track_number(self): + def test_none_track_length(self): + self.data['duration'] = None + self.check(self.track.copy(length=None)) + + def test_none_track_last_modified(self): + self.data['mtime'] = None + self.check(self.track.copy(last_modified=None)) + + def test_missing_track_no(self): del self.data['tags']['track-number'] self.check(self.track.copy(track_no=None)) - def test_missing_track_count(self): - del self.data['tags']['track-count'] - album = self.track.album.copy(num_tracks=None) - self.check(self.track.copy(album=album)) + def test_multiple_track_no(self): + self.data['tags']['track-number'].append(9) + self.check(self.track) + + def test_missing_track_disc_no(self): + del self.data['tags']['album-disc-number'] + self.check(self.track.copy(disc_no=None)) + + def test_multiple_track_disc_no(self): + self.data['tags']['album-disc-number'].append(9) + self.check(self.track) def test_missing_track_name(self): del self.data['tags']['title'] self.check(self.track.copy(name=None)) + def test_multiple_track_name(self): + self.data['tags']['title'] = ['name1', 'name2'] + self.check(self.track.copy(name='name1; name2')) + def test_missing_track_musicbrainz_id(self): del self.data['tags']['musicbrainz-trackid'] self.check(self.track.copy(musicbrainz_id=None)) + def test_multiple_track_musicbrainz_id(self): + self.data['tags']['musicbrainz-trackid'].append('id') + self.check(self.track) + + def test_missing_track_bitrate(self): + del self.data['tags']['bitrate'] + self.check(self.track.copy(bitrate=None)) + + def test_multiple_track_bitrate(self): + self.data['tags']['bitrate'].append(1234) + self.check(self.track) + + def test_missing_track_genre(self): + del self.data['tags']['genre'] + self.check(self.track.copy(genre=None)) + + def test_multiple_track_genre(self): + self.data['tags']['genre'] = ['genre1', 'genre2'] + self.check(self.track.copy(genre='genre1; genre2')) + + def test_missing_track_date(self): + del self.data['tags']['date'] + self.check(self.track.copy(date=None)) + + def test_multiple_track_date(self): + self.data['tags']['date'].append(FakeGstDate(2030, 1, 1)) + self.check(self.track) + + def test_invalid_track_date(self): + self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] + self.check(self.track.copy(date=None)) + + def test_missing_track_comment(self): + del self.data['tags']['comment'] + self.check(self.track.copy(comment=None)) + + def test_multiple_track_comment(self): + self.data['tags']['comment'] = ['comment1', 'comment2'] + self.check(self.track.copy(comment='comment1; comment2')) + + def test_missing_track_artist_name(self): + del self.data['tags']['artist'] + self.check(self.track.copy(artists=[])) + + def test_multiple_track_artist_name(self): + self.data['tags']['artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.copy(artists=artists)) + + def test_missing_track_artist_musicbrainz_id(self): + del self.data['tags']['musicbrainz-artistid'] + artist = list(self.track.artists)[0].copy(musicbrainz_id=None) + self.check(self.track.copy(artists=[artist])) + + def test_multiple_track_artist_musicbrainz_id(self): + self.data['tags']['musicbrainz-artistid'].append('id') + self.check(self.track) + + def test_missing_track_composer_name(self): + del self.data['tags']['composer'] + self.check(self.track.copy(composers=[])) + + def test_multiple_track_composer_name(self): + self.data['tags']['composer'] = ['composer1', 'composer2'] + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.copy(composers=composers)) + + def test_missing_track_performer_name(self): + del self.data['tags']['performer'] + self.check(self.track.copy(performers=[])) + + def test_multiple_track_performe_name(self): + self.data['tags']['performer'] = ['performer1', 'performer2'] + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.copy(performers=performers)) + def test_missing_album_name(self): del self.data['tags']['album'] album = self.track.album.copy(name=None) self.check(self.track.copy(album=album)) + def test_multiple_album_name(self): + self.data['tags']['album'].append('album2') + self.check(self.track) + def test_missing_album_musicbrainz_id(self): del self.data['tags']['musicbrainz-albumid'] album = self.track.album.copy(musicbrainz_id=None) self.check(self.track.copy(album=album)) - def test_missing_artist_name(self): - del self.data['tags']['artist'] - self.check(self.track.copy(artists=[])) + def test_multiple_album_musicbrainz_id(self): + self.data['tags']['musicbrainz-albumid'].append('id') + self.check(self.track) - def test_missing_composer_name(self): - del self.data['tags']['composer'] - self.check(self.track.copy(composers=[])) + def test_missing_album_num_tracks(self): + del self.data['tags']['track-count'] + album = self.track.album.copy(num_tracks=None) + self.check(self.track.copy(album=album)) - def test_multiple_track_composers(self): - self.data['tags']['composer'] = ['composer1', 'composer2'] - composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.copy(composers=composers)) + def test_multiple_album_num_tracks(self): + self.data['tags']['track-count'].append(9) + self.check(self.track) - def test_multiple_track_performers(self): - self.data['tags']['performer'] = ['performer1', 'performer2'] - performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.copy(performers=performers)) + def test_missing_album_num_discs(self): + del self.data['tags']['album-disc-count'] + album = self.track.album.copy(num_discs=None) + self.check(self.track.copy(album=album)) - def test_missing_performer_name(self): - del self.data['tags']['performer'] - self.check(self.track.copy(performers=[])) + def test_multiple_album_num_discs(self): + self.data['tags']['album-disc-count'].append(9) + self.check(self.track) - def test_missing_artist_musicbrainz_id(self): - del self.data['tags']['musicbrainz-artistid'] - artist = list(self.track.artists)[0].copy(musicbrainz_id=None) - self.check(self.track.copy(artists=[artist])) - - def test_multiple_track_artists(self): - self.data['tags']['artist'] = ['name1', 'name2'] - self.data['musicbrainz-artistid'] = 'artistid' - artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.copy(artists=artists)) - - def test_missing_album_artist(self): + def test_missing_album_artist_name(self): del self.data['tags']['album-artist'] album = self.track.album.copy(artists=[]) self.check(self.track.copy(album=album)) + def test_multiple_album_artist_name(self): + self.data['tags']['album-artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + album = self.track.album.copy(artists=artists) + self.check(self.track.copy(album=album)) + def test_missing_album_artist_musicbrainz_id(self): del self.data['tags']['musicbrainz-albumartistid'] albumartist = list(self.track.album.artists)[0] @@ -144,21 +240,40 @@ class TranslatorTest(unittest.TestCase): album = self.track.album.copy(artists=[albumartist]) self.check(self.track.copy(album=album)) - def test_missing_genre(self): - del self.data['tags']['genre'] - self.check(self.track.copy(genre=None)) + def test_multiple_album_artist_musicbrainz_id(self): + self.data['tags']['musicbrainz-albumartistid'].append('id') + self.check(self.track) - def test_missing_date(self): - del self.data['tags']['date'] - self.check(self.track.copy(date=None)) + def test_stream_organization_track_name(self): + del self.data['tags']['title'] + self.data['tags']['organization'] = ['organization'] + self.check(self.track.copy(name='organization')) - def test_invalid_date(self): - self.data['tags']['date'] = [FakeGstDate(65535, 1, 1)] - self.check(self.track.copy(date=None)) + def test_multiple_organization_track_name(self): + del self.data['tags']['title'] + self.data['tags']['organization'] = ['organization1', 'organization2'] + self.check(self.track.copy(name='organization1; organization2')) - def test_missing_comment(self): + # TODO: combine all comment types? + def test_stream_location_track_comment(self): del self.data['tags']['comment'] - self.check(self.track.copy(comment=None)) + self.data['tags']['location'] = ['location'] + self.check(self.track.copy(comment='location')) + + def test_multiple_location_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['location'] = ['location1', 'location2'] + self.check(self.track.copy(comment='location1; location2')) + + def test_stream_copyright_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['copyright'] = ['copyright'] + self.check(self.track.copy(comment='copyright')) + + def test_multiple_copyright_track_comment(self): + del self.data['tags']['comment'] + self.data['tags']['copyright'] = ['copyright1', 'copyright2'] + self.check(self.track.copy(comment='copyright1; copyright2')) class ScannerTest(unittest.TestCase): From 81b920a9e414daa1499a6b4ed2cfc2bc9f340498 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 20:34:05 +0100 Subject: [PATCH 206/238] model: Update to handle None in sets. Also adds some missing tests for composers and performers. --- mopidy/models.py | 19 ++++++++++--------- tests/test_models.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index aab69a3f..e1a1270f 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -278,8 +278,8 @@ class Album(ImmutableObject): # actual usage of this field with more than one image. def __init__(self, *args, **kwargs): - self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) - self.__dict__['images'] = frozenset(kwargs.pop('images', [])) + self.__dict__['artists'] = frozenset(kwargs.pop('artists', None) or []) + self.__dict__['images'] = frozenset(kwargs.pop('images', None) or []) super(Album, self).__init__(*args, **kwargs) @@ -365,9 +365,10 @@ class Track(ImmutableObject): last_modified = 0 def __init__(self, *args, **kwargs): - self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) - self.__dict__['composers'] = frozenset(kwargs.pop('composers', [])) - self.__dict__['performers'] = frozenset(kwargs.pop('performers', [])) + get = lambda key: frozenset(kwargs.pop(key, None) or []) + self.__dict__['artists'] = get('artists') + self.__dict__['composers'] = get('composers') + self.__dict__['performers'] = get('performers') super(Track, self).__init__(*args, **kwargs) @@ -436,7 +437,7 @@ class Playlist(ImmutableObject): last_modified = None def __init__(self, *args, **kwargs): - self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) super(Playlist, self).__init__(*args, **kwargs) # TODO: def insert(self, pos, track): ... ? @@ -472,7 +473,7 @@ class SearchResult(ImmutableObject): 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', [])) + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or []) + self.__dict__['artists'] = tuple(kwargs.pop('artists', None) or []) + self.__dict__['albums'] = tuple(kwargs.pop('albums', None) or []) super(SearchResult, self).__init__(*args, **kwargs) diff --git a/tests/test_models.py b/tests/test_models.py index 5872e0e6..9a4f97b7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -276,6 +276,9 @@ class AlbumTest(unittest.TestCase): self.assertIn(artist, album.artists) self.assertRaises(AttributeError, setattr, album, 'artists', None) + def test_artists_none(self): + self.assertEqual(set(), Album(artists=None).artists) + def test_num_tracks(self): num_tracks = 11 album = Album(num_tracks=num_tracks) @@ -307,6 +310,9 @@ class AlbumTest(unittest.TestCase): self.assertIn(image, album.images) self.assertRaises(AttributeError, setattr, album, 'images', None) + def test_images_none(self): + self.assertEqual(set(), Album(images=None).images) + def test_invalid_kwarg(self): test = lambda: Album(foo='baz') self.assertRaises(TypeError, test) @@ -476,6 +482,27 @@ class TrackTest(unittest.TestCase): self.assertEqual(set(track.artists), set(artists)) self.assertRaises(AttributeError, setattr, track, 'artists', None) + def test_artists_none(self): + self.assertEqual(set(), Track(artists=None).artists) + + def test_composers(self): + artists = [Artist(name='name1'), Artist(name='name2')] + track = Track(composers=artists) + self.assertEqual(set(track.composers), set(artists)) + self.assertRaises(AttributeError, setattr, track, 'composers', None) + + def test_composers_none(self): + self.assertEqual(set(), Track(composers=None).composers) + + def test_performers(self): + artists = [Artist(name='name1'), Artist(name='name2')] + track = Track(performers=artists) + self.assertEqual(set(track.performers), set(artists)) + self.assertRaises(AttributeError, setattr, track, 'performers', None) + + def test_performers_none(self): + self.assertEqual(set(), Track(performers=None).performers) + def test_album(self): album = Album() track = Track(album=album) From 2dc8282b259ec07acf2d726dee251ac9015e648f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 20:34:22 +0100 Subject: [PATCH 207/238] audio: Cleanup translator code. This code was trying to be to smart for it's own good. This commit greatly reduces the magic and leaves us with much more straight forward code. --- mopidy/audio/scan.py | 93 +++++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 41 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 4b83cdc6..b0a18de5 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -127,46 +127,63 @@ class Scanner(object): return os.path.getmtime(path.uri_to_path(uri)) +def _artists(tags, artist_name, artist_id=None): + # Name missing, don't set artist + if not tags.get(artist_name): + return None + # One artist name and id, provide artist with id. + if len(tags[artist_name]) == 1 and artist_id in tags: + return [Artist(name=tags[artist_name][0], + musicbrainz_id=tags[artist_id][0])] + # Multiple artist, provide artists without id. + return [Artist(name=name) for name in tags[artist_name]] + + +def _date(tags): + if not tags.get(gst.TAG_DATE): + return None + try: + date = tags[gst.TAG_DATE][0] + return datetime.date(date.year, date.month, date.day).isoformat() + except ValueError: + return None + + def audio_data_to_track(data): """Convert taglist data + our extras to a track.""" tags = data['tags'] album_kwargs = {} track_kwargs = {} - def _retrieve(source_key, target_key, target, convert): - if tags.get(source_key, None): - result = convert(tags[source_key]) - target.setdefault(target_key, result) + track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) + track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) + track_kwargs['artists'] = _artists( + tags, gst.TAG_ARTIST, 'musicbrainz-artistid') + album_kwargs['artists'] = _artists( + tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') - first = lambda values: values[0] - join = lambda values: '; '.join(values) - artists = lambda values: [Artist(name=v) for v in values] + track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) + if not track_kwargs['name']: + track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) - _retrieve(gst.TAG_ARTIST, 'artists', track_kwargs, artists) - _retrieve(gst.TAG_COMPOSER, 'composers', track_kwargs, artists) - _retrieve(gst.TAG_PERFORMER, 'performers', track_kwargs, artists) - _retrieve(gst.TAG_TITLE, 'name', track_kwargs, join) - _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs, first) - _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs, first) - _retrieve(gst.TAG_GENRE, 'genre', track_kwargs, join) - _retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs, first) + track_kwargs['comment'] = '; '.join(tags.get('comment', [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) - _retrieve(gst.TAG_ALBUM, 'name', album_kwargs, first) - _retrieve(gst.TAG_ALBUM_ARTIST, 'artists', album_kwargs, artists) - _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs, first) - _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs, first) + track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] + track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] + track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] + track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] - # Following keys don't seem to have TAG_* constant. - _retrieve('comment', 'comment', track_kwargs, join) - _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs, first) - _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs, first) + album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] + album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] + album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] + album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] - # For streams, will not override if a better value has already been set. - _retrieve(gst.TAG_ORGANIZATION, 'name', track_kwargs, join) - _retrieve(gst.TAG_LOCATION, 'comment', track_kwargs, join) - _retrieve(gst.TAG_COPYRIGHT, 'comment', track_kwargs, join) - - if tags.get(gst.TAG_DATE, None): + if tags.get(gst.TAG_DATE): date = tags[gst.TAG_DATE][0] try: date = datetime.date(date.year, date.month, date.day) @@ -175,19 +192,13 @@ def audio_data_to_track(data): else: track_kwargs['date'] = date.isoformat() - def _retrive_mb_artistid(source_key, target): - if source_key in tags and len(target.get('artists', [])) == 1: - target['artists'][0] = target['artists'][0].copy( - musicbrainz_id=tags[source_key][0]) + track_kwargs['date'] = _date(tags) + track_kwargs['last_modified'] = int(data.get('mtime') or 0) + track_kwargs['length'] = (data.get(gst.TAG_DURATION) or 0) // gst.MSECOND - _retrive_mb_artistid('musicbrainz-artistid', track_kwargs) - _retrive_mb_artistid('musicbrainz-albumartistid', album_kwargs) - - if data['mtime']: - track_kwargs['last_modified'] = int(data['mtime']) - - if data[gst.TAG_DURATION]: - track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND + # Clear out any empty values we found + track_kwargs = {k: v for k, v in track_kwargs.items() if v} + album_kwargs = {k: v for k, v in album_kwargs.items() if v} track_kwargs['uri'] = data['uri'] track_kwargs['album'] = Album(**album_kwargs) From 21ce22fb7bb7f3b2148e296c3d88a43e1b5051c3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 22:48:46 +0100 Subject: [PATCH 208/238] docs: Add config to frontend and backend examples. --- docs/extensiondev.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 8f66faf6..f24f02aa 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -348,7 +348,7 @@ passed a reference to the core API when it's created. See the class SoundspotFrontend(pykka.ThreadingActor, core.CoreListener): - def __init__(self, core): + def __init__(self, config, core): super(SoundspotFrontend, self).__init__() self.core = core @@ -374,7 +374,7 @@ details. class SoundspotBackend(pykka.ThreadingActor, backend.Backend): - def __init__(self, audio): + def __init__(self, config, audio): super(SoundspotBackend, self).__init__() self.audio = audio From 06719d0e66d1a045764c00627e59e57231d51a93 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 15 Jan 2014 23:51:28 +0100 Subject: [PATCH 209/238] Bump version to 0.18.0a1 for easier testing of updated extensions --- mopidy/__init__.py | 2 +- tests/test_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index a03e1cb9..9f9ce100 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.17.0' +__version__ = '0.18.0a1' diff --git a/tests/test_version.py b/tests/test_version.py index 6c113265..5fb1a60d 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -41,5 +41,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.14.1'), SV('0.14.2')) self.assertLess(SV('0.14.2'), SV('0.15.0')) self.assertLess(SV('0.15.0'), SV('0.16.0')) - self.assertLess(SV('0.16.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.17.1')) + self.assertLess(SV('0.16.0'), SV('0.17.0')) + self.assertLess(SV('0.17.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.18.1')) From c5be900ab48c77968b771f14e1cdd141d6984a98 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 15 Jan 2014 23:53:17 +0100 Subject: [PATCH 210/238] audio: Review fixes --- mopidy/audio/scan.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index b0a18de5..56f385e3 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -52,11 +52,11 @@ class Scanner(object): :return: Dictionary of tags, duration, mtime and uri information. """ try: - data = {'uri': uri} self._setup(uri) - data['tags'] = self._collect() - data['mtime'] = self._query_mtime(uri) - data['duration'] = self._query_duration() + tags = self._collect() # Ensure collect before queries. + data = {'uri': uri, 'tags': tags, + 'mtime': self._query_mtime(uri), + 'duration': self._query_duration()} finally: self._reset() @@ -97,10 +97,10 @@ class Scanner(object): if message.src == self._pipe: return tags elif message.type == gst.MESSAGE_TAG: - # Taglists are not really dicts, hence the key usage. - # Beyond that, we only keep the last tag for each key, - # as we assume this is the best, and force everything - # to lists. + # Taglists are not really dicts, hence the lack of .items() and + # explicit .keys. We only keep the last tag for each key, as we + # assume this is the best, some formats will produce multiple + # taglists. Lastly we force everything to lists for conformity. taglist = message.parse_tag() for key in taglist.keys(): value = taglist[key] @@ -183,15 +183,6 @@ def audio_data_to_track(data): album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] - if tags.get(gst.TAG_DATE): - date = tags[gst.TAG_DATE][0] - try: - date = datetime.date(date.year, date.month, date.day) - except ValueError: - pass # Ignore invalid dates - else: - track_kwargs['date'] = date.isoformat() - track_kwargs['date'] = _date(tags) track_kwargs['last_modified'] = int(data.get('mtime') or 0) track_kwargs['length'] = (data.get(gst.TAG_DURATION) or 0) // gst.MSECOND From fab3d8ae684426476e47cbe64581af7404900dc2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 16 Jan 2014 11:47:24 +0100 Subject: [PATCH 211/238] docs: Fix syntax error --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a3f127b9..2e4e1d54 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -81,7 +81,7 @@ v0.18.0 (UNRELEASED) :meth:`~mopidy.ext.Extension.get_frontend_classes`, and :meth:`~mopidy.ext.Extension.register_gstreamer_elements`. -*Audio** +**Audio** - Added :confval:`audio/mixer_volume` to set the initial volume of mixers. This is especially useful for setting the software mixer volume to something From ba87613fd1a292d6af007b1079fcd1d1fae80bbb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 16 Jan 2014 21:08:55 +0100 Subject: [PATCH 212/238] local: Replace invalid UTF-8 data from library --- mopidy/local/json.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 9a7d02f8..ce11f058 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -59,7 +59,8 @@ class _BrowseCache(object): for uri in uris: path = translator.local_track_uri_to_path(uri, b'/') - parts = self.splitpath_re.findall(path.decode(self.encoding)) + parts = self.splitpath_re.findall( + path.decode(self.encoding, 'replace')) filename = parts.pop() node = self._root for part in parts: From 826419d829a00092ae92154dc744799ac08e43b2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Jan 2014 22:41:32 +0100 Subject: [PATCH 213/238] backend/core: Switch to root_directory instead of name --- mopidy/backend.py | 7 +++++-- mopidy/core/actor.py | 6 +++--- mopidy/core/library.py | 13 +++++++++---- mopidy/local/library.py | 5 +++-- tests/core/test_library.py | 6 ++++-- tests/dummy_backend.py | 4 ++-- 6 files changed, 26 insertions(+), 15 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 9ada95c5..552e4304 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -52,9 +52,12 @@ class LibraryProvider(object): pykka_traversable = True - root_directory_name = None + root_directory = None """ - Name of the library's root directory in Mopidy's virtual file system. + :class:`models.Ref.directory` instance with an uri and name set + representing the root of this libraries browse tree. URIs must + use one of the schemes supported by the backend, and name should + be set to a human friendly value. *MUST be set by any class that implements :meth:`LibraryProvider.browse`.* """ diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 0f152436..b1f4700b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -115,6 +115,6 @@ class Backends(list): self.with_playlists[scheme] = backend if has_library: - root_dir_name = backend.library.root_directory_name.get() - if root_dir_name is not None: - self.with_browsable_library[root_dir_name] = backend + root_dir = backend.library.root_directory.get() + if root_dir is not None: + self.with_browsable_library[root_dir] = backend diff --git a/mopidy/core/library.py b/mopidy/core/library.py index cea21b10..2adf90b0 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -64,10 +64,14 @@ class LibraryController(object): if not path.startswith('/'): return [] + mapping = {} + for ref in self.backends.with_browsable_library.keys(): + name = urlparse.urlparse(ref.uri).scheme + mapping[name] = ref + if path == '/': - return [ - Ref.directory(uri='/%s' % name, name=name) - for name in self.backends.with_browsable_library.keys()] + return [Ref.directory(uri='/%s' % name, name=name) + for name in sorted(mapping)] groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() library_name = groups['library'] @@ -75,7 +79,8 @@ class LibraryController(object): if not backend_path.startswith('/'): backend_path = '/%s' % backend_path - backend = self.backends.with_browsable_library.get(library_name, None) + backend = self.backends.with_browsable_library.get( + mapping.get(library_name), None) if not backend: return [] diff --git a/mopidy/local/library.py b/mopidy/local/library.py index dc068457..a626f566 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import logging -from mopidy import backend +from mopidy import backend, models logger = logging.getLogger(__name__) @@ -10,7 +10,8 @@ logger = logging.getLogger(__name__) class LocalLibraryProvider(backend.LibraryProvider): """Proxy library that delegates work to our active local library.""" - root_directory_name = 'local' + root_directory = models.Ref.directory(uri=b'local:directory', + name='Local media') def __init__(self, backend, library): super(LocalLibraryProvider, self).__init__(backend) diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 836a434e..3cf7facf 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -9,16 +9,18 @@ from mopidy.models import Ref, SearchResult, Track class CoreLibraryTest(unittest.TestCase): def setUp(self): + dummy1_root = Ref.directory(uri='dummy1:directory', name='dummy1') self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] self.library1 = mock.Mock(spec=backend.LibraryProvider) - self.library1.root_directory_name.get.return_value = 'dummy1' + self.library1.root_directory.get.return_value = dummy1_root self.backend1.library = self.library1 + dummy2_root = Ref.directory(uri='dummy2:directory', name='dummy2') self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] self.library2 = mock.Mock(spec=backend.LibraryProvider) - self.library2.root_directory_name.get.return_value = 'dummy2' + self.library2.root_directory.get.return_value = dummy2_root self.backend2.library = self.library2 # A backend without the optional library provider diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 258340b9..378ccadd 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -9,7 +9,7 @@ from __future__ import unicode_literals import pykka from mopidy import backend -from mopidy.models import Playlist, SearchResult +from mopidy.models import Playlist, Ref, SearchResult def create_dummy_backend_proxy(config=None, audio=None): @@ -28,7 +28,7 @@ class DummyBackend(pykka.ThreadingActor, backend.Backend): class DummyLibraryProvider(backend.LibraryProvider): - root_directory_name = 'dummy' + root_directory = Ref.directory(uri='dummy:directory', name='dummy') def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) From a3a0f8caace4e98c3793761bb14eb000bda9ab2c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 17 Jan 2014 12:01:08 +0100 Subject: [PATCH 214/238] packaging: Make wheels universal --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..5e409001 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 From b38d3dba79700f4feba0218c0847bb2ce7b1d641 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 17 Jan 2014 15:01:18 +0100 Subject: [PATCH 215/238] docs: Add Mopidy-Dirble extension --- docs/ext/external.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ext/external.rst b/docs/ext/external.rst index e6b61596..0ead8ac2 100644 --- a/docs/ext/external.rst +++ b/docs/ext/external.rst @@ -33,6 +33,15 @@ Provides a backend for playing music from your `Beets `_ music library through Beets' web extension. +Mopidy-Dirble +============= + +https://github.com/mopidy/mopidy-dirble + +Provides a backend for browsing the Internet radio channels from the `Dirble +`_ directory. + + Mopidy-GMusic ============= From 999f4780106b9b27b15bb2c05468bdf80d43f131 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 16 Jan 2014 23:55:24 +0100 Subject: [PATCH 216/238] core: Update browse to use uri isntead of path --- mopidy/backend.py | 3 ++ mopidy/core/actor.py | 10 +++--- mopidy/core/library.py | 64 ++++++++++++-------------------------- tests/core/test_library.py | 36 ++++++++++----------- 4 files changed, 45 insertions(+), 68 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 552e4304..8105034a 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -37,6 +37,9 @@ class Backend(object): def has_library(self): return self.library is not None + def has_library_browse(self): + return self.has_library() and self.library.root_directory is not None + def has_playback(self): return self.playback is not None diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index b1f4700b..b27bb3cc 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -88,7 +88,7 @@ class Backends(list): super(Backends, self).__init__(backends) self.with_library = collections.OrderedDict() - self.with_browsable_library = collections.OrderedDict() + self.with_library_browse = collections.OrderedDict() self.with_playback = collections.OrderedDict() self.with_playlists = collections.OrderedDict() @@ -97,6 +97,7 @@ class Backends(list): for backend in backends: has_library = backend.has_library().get() + has_library_browse = backend.has_library_browse().get() has_playback = backend.has_playback().get() has_playlists = backend.has_playlists().get() @@ -109,12 +110,9 @@ class Backends(list): if has_library: self.with_library[scheme] = backend + if has_library_browse: + self.with_library_browse[scheme] = backend if has_playback: self.with_playback[scheme] = backend if has_playlists: self.with_playlists[scheme] = backend - - if has_library: - root_dir = backend.library.root_directory.get() - if root_dir is not None: - self.with_browsable_library[root_dir] = backend diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 2adf90b0..8eddfdbe 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,13 +1,10 @@ from __future__ import unicode_literals import collections -import re import urlparse import pykka -from mopidy.models import Ref - class LibraryController(object): pykka_traversable = True @@ -32,15 +29,16 @@ class LibraryController(object): (b, None) for b in self.backends.with_library.values()]) return backends_to_uris - def browse(self, path): + def browse(self, uri): """ - Browse directories and tracks at the given ``path``. + Browse directories and tracks at the given ``uri``. - ``path`` is a string that always starts with "/". It points to a - directory in Mopidy's virtual file system. + ``uri`` is a bytestring which represents some directory belonging to + a backend. To get the intial root directories for backends pass None + as the URI. Returns a list of :class:`mopidy.models.Ref` objects for the - directories and tracks at the given ``path``. + directories and tracks at the given ``uri``. The :class:`~mopidy.models.Ref` objects representing tracks keep the track's original URI. A matching pair of objects can look like this:: @@ -49,50 +47,28 @@ class LibraryController(object): Ref.track(uri='dummy:/foo.mp3', name='foo') The :class:`~mopidy.models.Ref` objects representing directories have - plain paths, not including any URI schema. For example, the dummy - library's ``/bar`` directory is returned like this:: + backend specific URIs. These are opaque values, so no one but the + backend that created them should try and derive any meaning from them. + The only valid exception to this is checking the scheme, as it is used + to route browse requests to the correct backend. - Ref.directory(uri='/dummy/bar', name='bar') + For example, the dummy library's ``/bar`` directory could bereturned + like this:: - Note to backend implementors: The ``/dummy`` part of the URI is added - by Mopidy core, not the individual backends. + Ref.directory(uri='dummy:directory:/bar', name='bar') - :param path: path to browse - :type path: string + :param bytestring uri: uri to browse :rtype: list of :class:`mopidy.models.Ref` """ - if not path.startswith('/'): - return [] + if uri is None: + backends = self.backends.with_library_browse.values() + return [b.library.root_directory.get() for b in backends] - mapping = {} - for ref in self.backends.with_browsable_library.keys(): - name = urlparse.urlparse(ref.uri).scheme - mapping[name] = ref - - if path == '/': - return [Ref.directory(uri='/%s' % name, name=name) - for name in sorted(mapping)] - - groups = re.match('/(?P[^/]+)(?P.*)', path).groupdict() - library_name = groups['library'] - backend_path = groups['path'] - if not backend_path.startswith('/'): - backend_path = '/%s' % backend_path - - backend = self.backends.with_browsable_library.get( - mapping.get(library_name), None) + scheme = urlparse.urlparse(uri).scheme + backend = self.backends.with_library_browse.get(scheme) if not backend: return [] - - refs = backend.library.browse(backend_path).get() - result = [] - for ref in refs: - if ref.type == Ref.DIRECTORY: - uri = '/'.join(['', library_name, ref.uri.lstrip('/')]) - result.append(ref.copy(uri=uri)) - else: - result.append(ref) - return result + return backend.library.browse(uri).get() def find_exact(self, query=None, uris=None, **kwargs): """ diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 3cf7facf..7a40194d 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -27,16 +27,17 @@ class CoreLibraryTest(unittest.TestCase): self.backend3 = mock.Mock() self.backend3.uri_schemes.get.return_value = ['dummy3'] self.backend3.has_library().get.return_value = False + self.backend3.has_library_browse().get.return_value = False self.core = core.Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): - result = self.core.library.browse('/') + result = self.core.library.browse(None) self.assertEqual(result, [ - Ref.directory(uri='/dummy1', name='dummy1'), - Ref.directory(uri='/dummy2', name='dummy2'), + Ref.directory(uri='dummy1:directory', name='dummy1'), + Ref.directory(uri='dummy2:directory', name='dummy2'), ]) self.assertFalse(self.library1.browse.called) self.assertFalse(self.library2.browse.called) @@ -51,32 +52,32 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dummy1_selects_dummy1_backend(self): self.library1.browse().get.return_value = [ - Ref.directory(uri='/foo/bar', name='bar'), - Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), + Ref.directory(uri='dummy1:directory:/foo/bar', name='bar'), + Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ] self.library1.browse.reset_mock() - self.core.library.browse('/dummy1/foo') + self.core.library.browse('dummy1:directory:/foo') self.assertEqual(self.library1.browse.call_count, 1) self.assertEqual(self.library2.browse.call_count, 0) - self.library1.browse.assert_called_with('/foo') + self.library1.browse.assert_called_with('dummy1:directory:/foo') def test_browse_dummy2_selects_dummy2_backend(self): self.library2.browse().get.return_value = [ - Ref.directory(uri='/bar/quux', name='quux'), - Ref.track(uri='dummy2:/foo/baz.mp3', name='Baz'), + Ref.directory(uri='dummy2:directory:/bar/baz', name='quux'), + Ref.track(uri='dummy2:track:/bar/foo.mp3', name='Baz'), ] self.library2.browse.reset_mock() - self.core.library.browse('/dummy2/bar') + self.core.library.browse('dummy2:directory:/bar') self.assertEqual(self.library1.browse.call_count, 0) self.assertEqual(self.library2.browse.call_count, 1) - self.library2.browse.assert_called_with('/bar') + self.library2.browse.assert_called_with('dummy2:directory:/bar') def test_browse_dummy3_returns_nothing(self): - result = self.core.library.browse('/dummy3') + result = self.core.library.browse('dummy3:test') self.assertEqual(result, []) self.assertEqual(self.library1.browse.call_count, 0) @@ -84,16 +85,15 @@ class CoreLibraryTest(unittest.TestCase): def test_browse_dir_returns_subdirs_and_tracks(self): self.library1.browse().get.return_value = [ - Ref.directory(uri='/foo/bar', name='bar'), - Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), + Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'), + Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ] self.library1.browse.reset_mock() - result = self.core.library.browse('/dummy1/foo') - + result = self.core.library.browse('dummy1:directory:/foo') self.assertEqual(result, [ - Ref.directory(uri='/dummy1/foo/bar', name='bar'), - Ref.track(uri='dummy1:/foo/baz.mp3', name='Baz'), + Ref.directory(uri='dummy1:directory:/foo/bar', name='Bar'), + Ref.track(uri='dummy1:track:/foo/baz.mp3', name='Baz'), ]) def test_lookup_selects_dummy1_backend(self): From d6aa9fb0133c2c75f0e741a41f4fa76e96eb189e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Jan 2014 01:06:23 +0100 Subject: [PATCH 217/238] local: Convert local browsing to uri based system. --- mopidy/local/json.py | 44 +++++++++++++++----------------------- mopidy/local/translator.py | 7 ++++++ tests/local/json_test.py | 18 ++++++---------- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index ce11f058..f25b3813 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -49,41 +49,31 @@ class _BrowseCache(object): splitpath_re = re.compile(r'([^/]+)') def __init__(self, uris): - """Create a dictionary tree for quick browsing. + # {parent_uri: {uri: ref}} + self._cache = {} - {'foo': {'bar': {None: [ref1, ref2]}, - 'baz': {}, - None: [ref3]}} - """ - self._root = collections.OrderedDict() - - for uri in uris: - path = translator.local_track_uri_to_path(uri, b'/') + for track_uri in uris: + path = translator.local_track_uri_to_path(track_uri, b'/') parts = self.splitpath_re.findall( path.decode(self.encoding, 'replace')) - filename = parts.pop() - node = self._root - for part in parts: - node = node.setdefault(part, collections.OrderedDict()) - ref = models.Ref.track(uri=uri, name=filename) - node.setdefault(None, []).append(ref) + track_ref = models.Ref.track(uri=track_uri, name=parts.pop()) - def lookup(self, path): - results = [] - node = self._root + parent = 'local:directory' + for i in range(len(parts)): + self._cache.setdefault(parent, collections.OrderedDict()) - for part in self.splitpath_re.findall(path): - node = node.get(part, {}) + directory = b'/'.join(parts[:i+1]) + dir_uri = translator.path_to_local_directory_uri(directory) + dir_ref = models.Ref.directory(uri=dir_uri, name=parts[i]) + self._cache[parent][dir_uri] = dir_ref - for key, value in node.items(): - if key is not None: - uri = os.path.join(path, key) - results.append(models.Ref.directory(uri=uri, name=key)) + parent = dir_uri - # Get tracks afterwards to ensure ordering. - results.extend(node.get(None, [])) + self._cache.setdefault(parent, collections.OrderedDict()) + self._cache[parent][track_uri] = track_ref - return results + def lookup(self, uri): + return self._cache.get(uri, {}).values() class JsonLibrary(local.Library): diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 7ec6d3fe..7d28be01 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -33,6 +33,13 @@ def path_to_local_track_uri(relpath): return b'local:track:%s' % urllib.quote(relpath) +def path_to_local_directory_uri(relpath): + """Convert path releative to media_dir to local directory URI.""" + if isinstance(relpath, unicode): + relpath = relpath.encode('utf-8') + return b'local:directory:%s' % urllib.quote(relpath) + + def m3u_extinf_to_track(line): """Convert extended M3U directive to track template.""" m = M3U_EXTINF_RE.match(line) diff --git a/tests/local/json_test.py b/tests/local/json_test.py index af606c05..9c8686e9 100644 --- a/tests/local/json_test.py +++ b/tests/local/json_test.py @@ -14,23 +14,19 @@ class BrowseCacheTest(unittest.TestCase): self.cache = json._BrowseCache(self.uris) def test_lookup_root(self): - expected = [Ref.directory(uri='/foo', name='foo')] - self.assertEqual(expected, self.cache.lookup('/')) + expected = [Ref.directory(uri='local:directory:foo', name='foo')] + self.assertEqual(expected, self.cache.lookup('local:directory')) def test_lookup_foo(self): - expected = [Ref.directory(uri='/foo/bar', name='bar'), + expected = [Ref.directory(uri='local:directory:foo/bar', name='bar'), Ref.track(uri=self.uris[2], name='song3')] - self.assertEqual(expected, self.cache.lookup('/foo')) + self.assertEqual(expected, self.cache.lookup('local:directory:foo')) def test_lookup_foo_bar(self): expected = [Ref.track(uri=self.uris[0], name='song1'), Ref.track(uri=self.uris[1], name='song2')] - self.assertEqual(expected, self.cache.lookup('/foo/bar')) + self.assertEqual( + expected, self.cache.lookup('local:directory:foo/bar')) def test_lookup_foo_baz(self): - self.assertEqual([], self.cache.lookup('/foo/baz')) - - def test_lookup_normalize_slashes(self): - expected = [Ref.track(uri=self.uris[0], name='song1'), - Ref.track(uri=self.uris[1], name='song2')] - self.assertEqual(expected, self.cache.lookup('/foo//bar/')) + self.assertEqual([], self.cache.lookup('local:directory:foo/baz')) From 43e16ddb6588886debdb3354a507a8e8ecad13ac Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Jan 2014 17:14:32 +0100 Subject: [PATCH 218/238] mpd: Switch mpd to use path<->uri mapping for browsing --- mopidy/mpd/dispatcher.py | 16 ++++ mopidy/mpd/protocol/current_playlist.py | 8 +- mopidy/mpd/protocol/music_db.py | 74 ++++++++++------ mopidy/mpd/translator.py | 9 ++ tests/dummy_backend.py | 2 +- tests/mpd/protocol/test_current_playlist.py | 12 +-- tests/mpd/protocol/test_music_db.py | 97 +++++++++++++++------ 7 files changed, 156 insertions(+), 62 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 4ddb4025..c269a5b3 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -292,3 +292,19 @@ class MpdContext(object): if uri not in self._playlist_name_from_uri: self.refresh_playlists_mapping() return self._playlist_name_from_uri[uri] + + # TODO: consider making context.browse(path) which uses this internally. + # advantage would be that all browse requests then go through the same code + # and we could prebuild/cache path->uri relationships instead of having to + # look them up all the time. + def directory_path_to_uri(self, path): + parts = re.findall(r'[^/]+', path) + uri = None + for part in parts: + for ref in self.core.library.browse(uri).get(): + if ref.type == ref.DIRECTORY and part == ref.name: + uri = ref.uri + break + else: + raise exceptions.MpdNoExistError() + return uri diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index ab799fb4..054fbf3a 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -27,8 +27,12 @@ def add(context, uri): if tl_tracks: return - if not uri.startswith('/'): - uri = '/%s' % uri + try: + uri = context.directory_path_to_uri(translator.normalize_path(uri)) + except MpdNoExistError as e: + e.command = 'add' + e.message = 'directory or file not found' + raise browse_futures = [context.core.library.browse(uri)] lookup_futures = [] diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 7ef11111..e95303fc 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -417,25 +417,33 @@ def listall(context, uri=None): Lists all songs and directories in ``URI``. """ - if uri is None: - uri = '/' - if not uri.startswith('/'): - uri = '/%s' % uri - result = [] - browse_futures = [context.core.library.browse(uri)] + root_path = translator.normalize_path(uri) + # TODO: doesn't the dispatcher._call_handler have enough info to catch + # the error this can produce, set the command and then 'raise'? + try: + uri = context.directory_path_to_uri(root_path) + except MpdNoExistError as e: + e.command = 'listall' + e.message = 'Not found' + raise + browse_futures = [(root_path, context.core.library.browse(uri))] + while browse_futures: - for ref in browse_futures.pop().get(): + base_path, future = browse_futures.pop() + for ref in future.get(): if ref.type == Ref.DIRECTORY: - result.append(('directory', ref.uri)) - browse_futures.append(context.core.library.browse(ref.uri)) + path = '/'.join([base_path, ref.name.replace('/', '')]) + result.append(('directory', path)) + browse_futures.append( + (path, context.core.library.browse(ref.uri))) elif ref.type == Ref.TRACK: result.append(('file', ref.uri)) if not result: raise MpdNoExistError('Not found', command='listall') - return [('directory', uri)] + result + return [('directory', root_path)] + result @handle_request(r'listallinfo$') @@ -449,18 +457,25 @@ def listallinfo(context, uri=None): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - if uri is None: - uri = '/' - if not uri.startswith('/'): - uri = '/%s' % uri - dirs_and_futures = [] - browse_futures = [context.core.library.browse(uri)] + result = [] + root_path = translator.normalize_path(uri) + try: + uri = context.directory_path_to_uri(root_path) + except MpdNoExistError as e: + e.command = 'listallinfo' + e.message = 'Not found' + raise + browse_futures = [(root_path, context.core.library.browse(uri))] + while browse_futures: - for ref in browse_futures.pop().get(): + base_path, future = browse_futures.pop() + for ref in future.get(): if ref.type == Ref.DIRECTORY: - dirs_and_futures.append(('directory', ref.uri)) - browse_futures.append(context.core.library.browse(ref.uri)) + path = '/'.join([base_path, ref.name.replace('/', '')]) + future = context.core.library.browse(ref.uri) + browse_futures.append((path, future)) + dirs_and_futures.append(('directory', path)) elif ref.type == Ref.TRACK: # TODO Lookup tracks in batch for better performance dirs_and_futures.append(context.core.library.lookup(ref.uri)) @@ -476,7 +491,7 @@ def listallinfo(context, uri=None): if not result: raise MpdNoExistError('Not found', command='listallinfo') - return [('directory', uri)] + result + return [('directory', root_path)] + result @handle_request(r'lsinfo$') @@ -498,16 +513,21 @@ def lsinfo(context, uri=None): ""``, and ``lsinfo "/"``. """ result = [] - if uri is None or uri == '/' or uri == '': + root_path = translator.normalize_path(uri, relative=True) + try: + uri = context.directory_path_to_uri(root_path) + except MpdNoExistError as e: + e.command = 'lsinfo' + e.message = 'Not found' + raise + + if uri is None: result.extend(stored_playlists.listplaylists(context)) - uri = '/' - if not uri.startswith('/'): - uri = '/%s' % uri + for ref in context.core.library.browse(uri).get(): if ref.type == Ref.DIRECTORY: - assert ref.uri.startswith('/'), ( - 'Directory URIs must start with /: %r' % ref) - result.append(('directory', ref.uri[1:])) + path = '/'.join([root_path, ref.name.replace('/', '')]) + result.append(('directory', path.lstrip('/'))) elif ref.type == Ref.TRACK: # TODO Lookup tracks in batch for better performance tracks = context.core.library.lookup(ref.uri).get() diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 49ebce35..520e9ac8 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -1,11 +1,20 @@ from __future__ import unicode_literals +import re import shlex from mopidy.mpd.exceptions import MpdArgError from mopidy.models import TlTrack # TODO: special handling of local:// uri scheme +normalize_path_re = re.compile(r'[^/]+') + + +def normalize_path(path, relative=False): + parts = normalize_path_re.findall(path or '') + if not relative: + parts.insert(0, '') + return '/'.join(parts) def track_to_mpd_format(track, position=None): diff --git a/tests/dummy_backend.py b/tests/dummy_backend.py index 378ccadd..94b01433 100644 --- a/tests/dummy_backend.py +++ b/tests/dummy_backend.py @@ -28,7 +28,7 @@ class DummyBackend(pykka.ThreadingActor, backend.Backend): class DummyLibraryProvider(backend.LibraryProvider): - root_directory = Ref.directory(uri='dummy:directory', name='dummy') + root_directory = Ref.directory(uri='dummy:/', name='dummy') def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 34221fcd..ff8e198e 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -27,7 +27,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add_with_empty_uri_should_not_add_anything_and_ok(self): self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} self.sendRequest('add ""') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) @@ -35,13 +35,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add_with_library_should_recurse(self): tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/b', name='b')] + Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo')], - '/foo': [Ref.track(uri='dummy:/b', name='b')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('add "/dummy"') self.assertEqual(self.core.tracklist.tracks.get(), tracks) @@ -50,7 +50,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add_root_should_not_add_anything_and_ok(self): self.backend.library.dummy_library = [Track(uri='dummy:/a', name='a')] self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} self.sendRequest('add "/"') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) diff --git a/tests/mpd/protocol/test_music_db.py b/tests/mpd/protocol/test_music_db.py index d2ecd66c..8d74fb95 100644 --- a/tests/mpd/protocol/test_music_db.py +++ b/tests/mpd/protocol/test_music_db.py @@ -124,34 +124,34 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_listall_without_uri(self): tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/b', name='b')] + Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo')], - '/foo': [Ref.track(uri='dummy:/b', name='b')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('listall') self.assertInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') - self.assertInResponse('file: dummy:/b') + self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') def test_listall_with_uri(self): tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/b', name='b')] + Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo')], - '/foo': [Ref.track(uri='dummy:/b', name='b')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('listall "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertInResponse('directory: /dummy/foo') - self.assertInResponse('file: dummy:/b') + self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('OK') def test_listall_with_unknown_uri(self): @@ -159,39 +159,57 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {listall} Not found') + def test_listall_for_dir_with_and_without_leading_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listall "dummy"') + response2 = self.sendRequest('listall "/dummy"') + self.assertEqual(response1, response2) + + def test_listall_for_dir_with_and_without_trailing_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listall "dummy"') + response2 = self.sendRequest('listall "dummy/"') + self.assertEqual(response1, response2) + def test_listallinfo_without_uri(self): tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/b', name='b')] + Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo')], - '/foo': [Ref.track(uri='dummy:/b', name='b')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('listallinfo') self.assertInResponse('file: dummy:/a') self.assertInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') - self.assertInResponse('file: dummy:/b') + self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') self.assertInResponse('OK') def test_listallinfo_with_uri(self): tracks = [Track(uri='dummy:/a', name='a'), - Track(uri='dummy:/b', name='b')] + Track(uri='dummy:/foo/b', name='b')] self.backend.library.dummy_library = tracks self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo')], - '/foo': [Ref.track(uri='dummy:/b', name='b')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')], + 'dummy:/foo': [Ref.track(uri='dummy:/foo/b', name='b')]} self.sendRequest('listallinfo "/dummy/foo"') self.assertNotInResponse('file: dummy:/a') self.assertNotInResponse('Title: a') self.assertInResponse('directory: /dummy/foo') - self.assertInResponse('file: dummy:/b') + self.assertInResponse('file: dummy:/foo/b') self.assertInResponse('Title: b') self.assertInResponse('OK') @@ -200,6 +218,24 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {listallinfo} Not found') + def test_listallinfo_for_dir_with_and_without_leading_slash_is_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listallinfo "dummy"') + response2 = self.sendRequest('listallinfo "/dummy"') + self.assertEqual(response1, response2) + + def test_listallinfo_for_dir_with_and_without_trailing_slash_is_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('listallinfo "dummy"') + response2 = self.sendRequest('listallinfo "dummy/"') + self.assertEqual(response1, response2) + def test_lsinfo_without_path_returns_same_as_for_root(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) self.backend.playlists.playlists = [ @@ -231,8 +267,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_root_includes_dirs_for_each_lib_with_content(self): self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo', name='foo')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} self.sendRequest('lsinfo "/"') self.assertInResponse('directory: dummy') @@ -240,19 +276,28 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_with_and_without_leading_slash_is_the_same(self): self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a'), - Ref.directory(uri='/foo', name='foo')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} response1 = self.sendRequest('lsinfo "dummy"') response2 = self.sendRequest('lsinfo "/dummy"') self.assertEqual(response1, response2) + def test_lsinfo_for_dir_with_and_without_trailing_slash_is_the_same(self): + self.backend.library.dummy_browse_result = { + 'dummy:/': [Ref.track(uri='dummy:/a', name='a'), + Ref.directory(uri='dummy:/foo', name='foo')]} + + response1 = self.sendRequest('lsinfo "dummy"') + response2 = self.sendRequest('lsinfo "dummy/"') + self.assertEqual(response1, response2) + def test_lsinfo_for_dir_includes_tracks(self): self.backend.library.dummy_library = [ Track(uri='dummy:/a', name='a'), ] self.backend.library.dummy_browse_result = { - '/': [Ref.track(uri='dummy:/a', name='a')]} + 'dummy:/': [Ref.track(uri='dummy:/a', name='a')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('file: dummy:/a') @@ -261,7 +306,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_lsinfo_for_dir_includes_subdirs(self): self.backend.library.dummy_browse_result = { - '/': [Ref.directory(uri='/foo', name='foo')]} + 'dummy:/': [Ref.directory(uri='/foo', name='foo')]} self.sendRequest('lsinfo "/dummy"') self.assertInResponse('directory: dummy/foo') From 06856851f7a8ba348511389a43d4de679776ccdf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 17 Jan 2014 23:43:43 +0100 Subject: [PATCH 219/238] local: Make search filters more robust (fixes #635) --- mopidy/local/search.py | 25 +++++++++++++------------ tests/data/library.json.gz | Bin 394 -> 411 bytes tests/local/test_library.py | 3 +++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/mopidy/local/search.py b/mopidy/local/search.py index 870afcfd..68d0a1f5 100644 --- a/mopidy/local/search.py +++ b/mopidy/local/search.py @@ -101,25 +101,26 @@ def search(tracks, query=None, uris=None): else: q = value.strip().lower() - uri_filter = lambda t: q in t.uri.lower() - track_name_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: bool(t.uri and q in t.uri.lower()) + track_name_filter = lambda t: bool(t.name and q in t.name.lower()) + album_filter = lambda t: bool( + t.album and t.album.name and q in t.album.name.lower()) + artist_filter = lambda t: bool(filter( + lambda a: bool(a.name and q in a.name.lower()), t.artists)) albumartist_filter = lambda t: any([ - q in a.name.lower() + a.name and q in a.name.lower() for a in getattr(t.album, 'artists', [])]) composer_filter = lambda t: any([ - q in a.name.lower() + a.name and q in a.name.lower() for a in getattr(t, 'composers', [])]) performer_filter = lambda t: any([ - q in a.name.lower() + a.name and q in a.name.lower() for a in getattr(t, 'performers', [])]) track_no_filter = lambda t: q == t.track_no - genre_filter = lambda t: t.genre and q in t.genre.lower() - date_filter = lambda t: t.date and t.date.startswith(q) - comment_filter = lambda t: t.comment and q in t.comment.lower() + genre_filter = lambda t: bool(t.genre and q in t.genre.lower()) + date_filter = lambda t: bool(t.date and t.date.startswith(q)) + comment_filter = lambda t: bool( + t.comment and q in t.comment.lower()) any_filter = lambda t: ( uri_filter(t) or track_name_filter(t) or diff --git a/tests/data/library.json.gz b/tests/data/library.json.gz index 07cd48d128bb4cffcdf379a74ce7bf66469830d7..768b828232aad2d295e6b76fb1e7cfd7f6d3af84 100644 GIT binary patch literal 411 zcmV;M0c8FkiwFqVvDs1r18iwxa$$0LE^2dcZUDuV-)@5-6vpp;3c~f;AXu05Ztt+W zr7_IX)=Z%Rx|_y#A5uk|)!ILh8e{M~hXd#PReFp7fLhZ0N5LidGH(DJ?bGPA{617h z?gS%cStZu2%rYax4;z2$qnuW(3D}A8FhZp+lOJCl?kgq&(qGcJrCJMmKg z!|o@0AMd>Dd*v2eQ*Bc*Lh%gY86v0b)(=m7ad^JiJ6}A>7vI4b2jh!x{u z?_|j&S#k$i5{zteBiWH}HWyXJxVCQW3I)tf!G>~8l`e9i&mDU1ci^`A0FGo5M{)s2 zibi-O!FZ4xc_gDe_Chh~Z$pb~8+skk#U#+h)c3VzT-t(m5QjLrp6M{{^*xgtAuFF) z(`#5q98K?Y)YqTE!$-b{GyQ@SjgKW$s_ianjf1}iPa$}dj^^?E9KooL{s0x{M@`WR F005VN$`=3t literal 394 zcmV;50d@W#iwFoGcb`%M|7>Yua$$0LE^2dcZUDuVOKyWO5Qg`h!t%O_{2)}yu6O9J zijYg3fC@G;PB$obuW^DQY6#F^6(KbIGak>($DA zPL__6r8CG2`X+D;?QDqv0q4oqlP=_~>Ic0$iR2d>mV;0Q-?gm-X6 zXn;rPjR&2`BOK&W%8IMy3fep>=>LFjMuBd|-mfDU$|kf1_VMX@ro*VyORf%56-#1` o9$_7rXf$u4?av^%riS09flP`f0Il)s8o}WF0}y$t#qtRN0Om2m9{>OV diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 3a0ed090..575f1fb8 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -24,6 +24,7 @@ class LocalLibraryProviderTest(unittest.TestCase): Artist(name='artist4'), Artist(name='artist5'), Artist(name='artist6'), + Artist(), ] albums = [ @@ -31,6 +32,7 @@ class LocalLibraryProviderTest(unittest.TestCase): Album(name='album2', artists=[artists[1]]), Album(name='album3', artists=[artists[2]]), Album(name='album4'), + Album(artists=[artists[-1]]), ] tracks = [ @@ -57,6 +59,7 @@ class LocalLibraryProviderTest(unittest.TestCase): Track( uri='local:track:path6', name='track6', genre='genre2', album=albums[3], length=4000, performers=[artists[5]]), + Track(uri='local:track:nameless', album=albums[-1]), ] config = { From 08b7d199f7fb5b9772f52bc2374bf102f738732c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 17 Jan 2014 23:52:18 +0100 Subject: [PATCH 220/238] review: Typos and code formating --- mopidy/backend.py | 4 ++-- mopidy/core/library.py | 4 ++-- mopidy/local/json.py | 2 +- mopidy/local/translator.py | 2 +- mopidy/mpd/dispatcher.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/backend.py b/mopidy/backend.py index 8105034a..6f895985 100644 --- a/mopidy/backend.py +++ b/mopidy/backend.py @@ -57,8 +57,8 @@ class LibraryProvider(object): root_directory = None """ - :class:`models.Ref.directory` instance with an uri and name set - representing the root of this libraries browse tree. URIs must + :class:`models.Ref.directory` instance with a URI and name set + representing the root of this library's browse tree. URIs must use one of the schemes supported by the backend, and name should be set to a human friendly value. diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 8eddfdbe..aaebb129 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -52,12 +52,12 @@ class LibraryController(object): The only valid exception to this is checking the scheme, as it is used to route browse requests to the correct backend. - For example, the dummy library's ``/bar`` directory could bereturned + For example, the dummy library's ``/bar`` directory could be returned like this:: Ref.directory(uri='dummy:directory:/bar', name='bar') - :param bytestring uri: uri to browse + :param string uri: URI to browse :rtype: list of :class:`mopidy.models.Ref` """ if uri is None: diff --git a/mopidy/local/json.py b/mopidy/local/json.py index f25b3813..b87c5bce 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -62,7 +62,7 @@ class _BrowseCache(object): for i in range(len(parts)): self._cache.setdefault(parent, collections.OrderedDict()) - directory = b'/'.join(parts[:i+1]) + directory = '/'.join(parts[:i+1]) dir_uri = translator.path_to_local_directory_uri(directory) dir_ref = models.Ref.directory(uri=dir_uri, name=parts[i]) self._cache[parent][dir_uri] = dir_ref diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 7d28be01..c3f9874b 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -34,7 +34,7 @@ def path_to_local_track_uri(relpath): def path_to_local_directory_uri(relpath): - """Convert path releative to media_dir to local directory URI.""" + """Convert path relative to :confval:`local/media_dir` directory URI.""" if isinstance(relpath, unicode): relpath = relpath.encode('utf-8') return b'local:directory:%s' % urllib.quote(relpath) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index c269a5b3..976c6e32 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -302,7 +302,7 @@ class MpdContext(object): uri = None for part in parts: for ref in self.core.library.browse(uri).get(): - if ref.type == ref.DIRECTORY and part == ref.name: + if ref.type == ref.DIRECTORY and ref.name == part: uri = ref.uri break else: From 52e66add97f2a06aab0e29c07901005841b92edc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Jan 2014 00:16:53 +0100 Subject: [PATCH 221/238] review: Update browse docstring with respect to URI type --- mopidy/core/library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index aaebb129..ce92cced 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -33,9 +33,9 @@ class LibraryController(object): """ Browse directories and tracks at the given ``uri``. - ``uri`` is a bytestring which represents some directory belonging to - a backend. To get the intial root directories for backends pass None - as the URI. + ``uri`` is a sring which represents some directory belonging to a + backend. To get the intial root directories for backends pass None as + the URI. Returns a list of :class:`mopidy.models.Ref` objects for the directories and tracks at the given ``uri``. From 31abe0bc9340d6467ec669f350449a36551878be Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 00:34:41 +0100 Subject: [PATCH 222/238] mpd: Annotate exceptions with current command automatically (fixes #649) --- mopidy/mpd/dispatcher.py | 7 ++++++- mopidy/mpd/exceptions.py | 5 ++++- mopidy/mpd/protocol/audio_output.py | 4 ++-- mopidy/mpd/protocol/connection.py | 4 ++-- mopidy/mpd/protocol/current_playlist.py | 18 +++++++++--------- mopidy/mpd/protocol/empty.py | 2 +- mopidy/mpd/protocol/music_db.py | 6 +++--- mopidy/mpd/protocol/playback.py | 8 ++++---- mopidy/mpd/protocol/stickers.py | 10 +++++----- mopidy/mpd/protocol/stored_playlists.py | 6 +++--- tests/mpd/protocol/test_channels.py | 10 +++++----- tests/mpd/protocol/test_current_playlist.py | 6 +++--- tests/mpd/protocol/test_playback.py | 8 ++++---- tests/mpd/protocol/test_status.py | 2 +- tests/mpd/protocol/test_stickers.py | 12 ++++++------ tests/mpd/protocol/test_stored_playlists.py | 14 +++++++------- tests/mpd/test_exceptions.py | 11 ++++------- 17 files changed, 69 insertions(+), 64 deletions(-) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 4ddb4025..a601f13e 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -165,7 +165,12 @@ class MpdDispatcher(object): def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) - return handler(self.context, **kwargs) + try: + return handler(self.context, **kwargs) + except exceptions.MpdAckError as exc: + if exc.command is None: + exc.command = handler.__name__.split('__', 1)[0] + raise def _find_handler(self, request): for pattern in protocol.request_handlers: diff --git a/mopidy/mpd/exceptions.py b/mopidy/mpd/exceptions.py index 07e3a421..ec874553 100644 --- a/mopidy/mpd/exceptions.py +++ b/mopidy/mpd/exceptions.py @@ -21,7 +21,7 @@ class MpdAckError(MopidyException): error_code = 0 - def __init__(self, message='', index=0, command=''): + def __init__(self, message='', index=0, command=None): super(MpdAckError, self).__init__(message, index, command) self.message = message self.index = index @@ -50,6 +50,7 @@ class MpdPermissionError(MpdAckError): def __init__(self, *args, **kwargs): super(MpdPermissionError, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' self.message = 'you don\'t have permission for "%s"' % self.command @@ -58,12 +59,14 @@ class MpdUnknownCommand(MpdAckError): def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) + assert self.command is not None, 'command must be given explicitly' self.message = 'unknown command "%s"' % self.command self.command = '' class MpdNoCommand(MpdUnknownCommand): def __init__(self, *args, **kwargs): + kwargs['command'] = '' super(MpdNoCommand, self).__init__(*args, **kwargs) self.message = 'No command given' diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 606eb1d3..802be6c0 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -16,7 +16,7 @@ def disableoutput(context, outputid): if int(outputid) == 0: context.core.playback.set_mute(False) else: - raise MpdNoExistError('No such audio output', command='disableoutput') + raise MpdNoExistError('No such audio output') @handle_request(r'enableoutput\ "(?P\d+)"$') @@ -31,7 +31,7 @@ def enableoutput(context, outputid): if int(outputid) == 0: context.core.playback.set_mute(True) else: - raise MpdNoExistError('No such audio output', command='enableoutput') + raise MpdNoExistError('No such audio output') @handle_request(r'outputs$') diff --git a/mopidy/mpd/protocol/connection.py b/mopidy/mpd/protocol/connection.py index 2c615e65..a6f9ffcb 100644 --- a/mopidy/mpd/protocol/connection.py +++ b/mopidy/mpd/protocol/connection.py @@ -30,7 +30,7 @@ def kill(context): @handle_request(r'password\ "(?P[^"]+)"$', auth_required=False) -def password_(context, password): +def password(context, password): """ *musicpd.org, connection section:* @@ -42,7 +42,7 @@ def password_(context, password): if password == context.config['mpd']['password']: context.dispatcher.authenticated = True else: - raise MpdPasswordError('incorrect password', command='password') + raise MpdPasswordError('incorrect password') @handle_request(r'ping$', auth_required=False) diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index ab799fb4..6263e2e8 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -44,7 +44,7 @@ def add(context, uri): tracks.extend(future.get()) if not tracks: - raise MpdNoExistError('directory or file not found', command='add') + raise MpdNoExistError('directory or file not found') context.core.tracklist.add(tracks=tracks) @@ -69,14 +69,14 @@ def addid(context, uri, songpos=None): - ``addid ""`` should return an error. """ if not uri: - raise MpdNoExistError('No such song', command='addid') + raise MpdNoExistError('No such song') if songpos is not None: songpos = int(songpos) if songpos and songpos > context.core.tracklist.length.get(): - raise MpdArgError('Bad song index', command='addid') + raise MpdArgError('Bad song index') tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='addid') + raise MpdNoExistError('No such song') return ('Id', tl_tracks[0].tlid) @@ -125,7 +125,7 @@ def deleteid(context, tlid): tlid = int(tlid) tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='deleteid') + raise MpdNoExistError('No such song') @handle_request(r'clear$') @@ -181,7 +181,7 @@ def moveid(context, tlid, to): to = int(to) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='moveid') + raise MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() context.core.tracklist.move(position, position + 1, to) @@ -239,7 +239,7 @@ def playlistid(context, tlid=None): tlid = int(tlid) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='playlistid') + raise MpdNoExistError('No such song') position = context.core.tracklist.index(tl_tracks[0]).get() return translator.track_to_mpd_format(tl_tracks[0], position=position) else: @@ -276,7 +276,7 @@ def playlistinfo(context, songpos=None, start=None, end=None): start = 0 start = int(start) if not (0 <= start <= context.core.tracklist.length.get()): - raise MpdArgError('Bad song index', command='playlistinfo') + raise MpdArgError('Bad song index') if end is not None: end = int(end) if end > context.core.tracklist.length.get(): @@ -403,7 +403,7 @@ def swapid(context, tlid1, tlid2): tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get() tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get() if not tl_tracks1 or not tl_tracks2: - raise MpdNoExistError('No such song', command='swapid') + raise MpdNoExistError('No such song') position1 = context.core.tracklist.index(tl_tracks1[0]).get() position2 = context.core.tracklist.index(tl_tracks2[0]).get() swap(context, position1, position2) diff --git a/mopidy/mpd/protocol/empty.py b/mopidy/mpd/protocol/empty.py index 9cb0aa6b..64cfc1fb 100644 --- a/mopidy/mpd/protocol/empty.py +++ b/mopidy/mpd/protocol/empty.py @@ -7,4 +7,4 @@ from mopidy.mpd.exceptions import MpdNoCommand @handle_request(r'[\ ]*$') def empty(context): """The original MPD server returns an error on an empty request.""" - raise MpdNoCommand + raise MpdNoCommand() diff --git a/mopidy/mpd/protocol/music_db.py b/mopidy/mpd/protocol/music_db.py index 7ef11111..774ec383 100644 --- a/mopidy/mpd/protocol/music_db.py +++ b/mopidy/mpd/protocol/music_db.py @@ -163,7 +163,7 @@ def count(context, mpd_query): try: query = _query_from_mpd_search_format(mpd_query) except ValueError: - raise MpdArgError('incorrect arguments', command='count') + raise MpdArgError('incorrect arguments') results = context.core.library.find_exact(**query).get() result_tracks = _get_tracks(results) return [ @@ -433,7 +433,7 @@ def listall(context, uri=None): result.append(('file', ref.uri)) if not result: - raise MpdNoExistError('Not found', command='listall') + raise MpdNoExistError('Not found') return [('directory', uri)] + result @@ -474,7 +474,7 @@ def listallinfo(context, uri=None): result.append(obj) if not result: - raise MpdNoExistError('Not found', command='listallinfo') + raise MpdNoExistError('Not found') return [('directory', uri)] + result diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index c09afde8..4f8ae73a 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -151,12 +151,12 @@ def playid(context, tlid): return _play_minus_one(context) tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: - raise MpdNoExistError('No such song', command='playid') + raise MpdNoExistError('No such song') return context.core.playback.play(tl_tracks[0]).get() @handle_request(r'play\ ("?)(?P-?\d+)\1$') -def playpos(context, songpos): +def play__pos(context, songpos): """ *musicpd.org, playback section:* @@ -184,7 +184,7 @@ def playpos(context, songpos): tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] return context.core.playback.play(tl_track).get() except IndexError: - raise MpdArgError('Bad song index', command='play') + raise MpdArgError('Bad song index') def _play_minus_one(context): @@ -325,7 +325,7 @@ def seek(context, songpos, seconds): """ tl_track = context.core.playback.current_tl_track.get() if context.core.tracklist.index(tl_track).get() != int(songpos): - playpos(context, songpos) + play__pos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() diff --git a/mopidy/mpd/protocol/stickers.py b/mopidy/mpd/protocol/stickers.py index 1243d7a6..17798523 100644 --- a/mopidy/mpd/protocol/stickers.py +++ b/mopidy/mpd/protocol/stickers.py @@ -7,7 +7,7 @@ from mopidy.mpd.exceptions import MpdNotImplemented @handle_request( r'sticker\ delete\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"(\ "(?P[^"]+)")*$') -def sticker_delete(context, field, uri, name=None): +def sticker__delete(context, field, uri, name=None): """ *musicpd.org, sticker section:* @@ -22,7 +22,7 @@ def sticker_delete(context, field, uri, name=None): @handle_request( r'sticker\ find\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"$') -def sticker_find(context, field, uri, name): +def sticker__find(context, field, uri, name): """ *musicpd.org, sticker section:* @@ -38,7 +38,7 @@ def sticker_find(context, field, uri, name): @handle_request( r'sticker\ get\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"$') -def sticker_get(context, field, uri, name): +def sticker__get(context, field, uri, name): """ *musicpd.org, sticker section:* @@ -50,7 +50,7 @@ def sticker_get(context, field, uri, name): @handle_request(r'sticker\ list\ "(?P[^"]+)"\ "(?P[^"]+)"$') -def sticker_list(context, field, uri): +def sticker__list(context, field, uri): """ *musicpd.org, sticker section:* @@ -64,7 +64,7 @@ def sticker_list(context, field, uri): @handle_request( r'sticker\ set\ "(?P[^"]+)"\ "(?P[^"]+)"\ ' r'"(?P[^"]+)"\ "(?P[^"]+)"$') -def sticker_set(context, field, uri, name, value): +def sticker__set(context, field, uri, name, value): """ *musicpd.org, sticker section:* diff --git a/mopidy/mpd/protocol/stored_playlists.py b/mopidy/mpd/protocol/stored_playlists.py index 6564236e..a852d795 100644 --- a/mopidy/mpd/protocol/stored_playlists.py +++ b/mopidy/mpd/protocol/stored_playlists.py @@ -24,7 +24,7 @@ def listplaylist(context, name): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist', command='listplaylist') + raise MpdNoExistError('No such playlist') return ['file: %s' % t.uri for t in playlist.tracks] @@ -44,7 +44,7 @@ def listplaylistinfo(context, name): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist', command='listplaylistinfo') + raise MpdNoExistError('No such playlist') return playlist_to_mpd_format(playlist) @@ -115,7 +115,7 @@ def load(context, name, start=None, end=None): """ playlist = context.lookup_playlist_from_name(name) if not playlist: - raise MpdNoExistError('No such playlist', command='load') + raise MpdNoExistError('No such playlist') if start is not None: start = int(start) if end is not None: diff --git a/tests/mpd/protocol/test_channels.py b/tests/mpd/protocol/test_channels.py index 5d4ee670..be3b96a8 100644 --- a/tests/mpd/protocol/test_channels.py +++ b/tests/mpd/protocol/test_channels.py @@ -6,20 +6,20 @@ from tests.mpd import protocol class ChannelsHandlerTest(protocol.BaseTestCase): def test_subscribe(self): self.sendRequest('subscribe "topic"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {subscribe} Not implemented') def test_unsubscribe(self): self.sendRequest('unsubscribe "topic"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {unsubscribe} Not implemented') def test_channels(self): self.sendRequest('channels') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {channels} Not implemented') def test_readmessages(self): self.sendRequest('readmessages') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {readmessages} Not implemented') def test_sendmessage(self): self.sendRequest('sendmessage "topic" "a message"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sendmessage} Not implemented') diff --git a/tests/mpd/protocol/test_current_playlist.py b/tests/mpd/protocol/test_current_playlist.py index 34221fcd..dbb77d08 100644 --- a/tests/mpd/protocol/test_current_playlist.py +++ b/tests/mpd/protocol/test_current_playlist.py @@ -253,7 +253,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistfind(self): self.sendRequest('playlistfind "tag" "needle"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistfind} Not implemented') def test_playlistfind_by_filename_not_in_tracklist(self): self.sendRequest('playlistfind "filename" "file:///dev/null"') @@ -391,11 +391,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistsearch(self): self.sendRequest('playlistsearch "any" "needle"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_playlistsearch_without_quotes(self): self.sendRequest('playlistsearch any "needle"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): self.core.tracklist.add( diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index a572aabe..67b4e787 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -36,7 +36,7 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_crossfade(self): self.sendRequest('crossfade "10"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {crossfade} Not implemented') def test_random_off(self): self.sendRequest('random "0"') @@ -135,15 +135,15 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_replay_gain_mode_off(self): self.sendRequest('replay_gain_mode "off"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_track(self): self.sendRequest('replay_gain_mode "track"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_mode_album(self): self.sendRequest('replay_gain_mode "album"') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('ACK [0@0] {replay_gain_mode} Not implemented') def test_replay_gain_status_default(self): self.sendRequest('replay_gain_status') diff --git a/tests/mpd/protocol/test_status.py b/tests/mpd/protocol/test_status.py index 8ded6938..7d30ea89 100644 --- a/tests/mpd/protocol/test_status.py +++ b/tests/mpd/protocol/test_status.py @@ -8,7 +8,7 @@ from tests.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): self.sendRequest('clearerror') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {clearerror} Not implemented') def test_currentsong(self): track = Track() diff --git a/tests/mpd/protocol/test_stickers.py b/tests/mpd/protocol/test_stickers.py index 31fd5da0..c3ce264a 100644 --- a/tests/mpd/protocol/test_stickers.py +++ b/tests/mpd/protocol/test_stickers.py @@ -7,29 +7,29 @@ class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): self.sendRequest( 'sticker get "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_set(self): self.sendRequest( 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_with_name(self): self.sendRequest( 'sticker delete "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_delete_without_name(self): self.sendRequest( 'sticker delete "song" "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_list(self): self.sendRequest( 'sticker list "song" "file:///dev/urandom"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') def test_sticker_find(self): self.sendRequest( 'sticker find "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {sticker} Not implemented') diff --git a/tests/mpd/protocol/test_stored_playlists.py b/tests/mpd/protocol/test_stored_playlists.py index a65b3ed7..636c5c2c 100644 --- a/tests/mpd/protocol/test_stored_playlists.py +++ b/tests/mpd/protocol/test_stored_playlists.py @@ -189,28 +189,28 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_playlistadd(self): self.sendRequest('playlistadd "name" "dummy:a"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistadd} Not implemented') def test_playlistclear(self): self.sendRequest('playlistclear "name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistclear} Not implemented') def test_playlistdelete(self): self.sendRequest('playlistdelete "name" "5"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistdelete} Not implemented') def test_playlistmove(self): self.sendRequest('playlistmove "name" "5" "10"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {playlistmove} Not implemented') def test_rename(self): self.sendRequest('rename "old_name" "new_name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {rename} Not implemented') def test_rm(self): self.sendRequest('rm "name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {rm} Not implemented') def test_save(self): self.sendRequest('save "name"') - self.assertEqualResponse('ACK [0@0] {} Not implemented') + self.assertEqualResponse('ACK [0@0] {save} Not implemented') diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index b470ed44..ef84a5f9 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -25,7 +25,7 @@ class MpdExceptionsTest(unittest.TestCase): def test_get_mpd_ack_with_default_values(self): e = MpdAckError('A description') - self.assertEqual(e.get_mpd_ack(), 'ACK [0@0] {} A description') + self.assertEqual(e.get_mpd_ack(), 'ACK [0@0] {None} A description') def test_get_mpd_ack_with_values(self): try: @@ -38,24 +38,21 @@ class MpdExceptionsTest(unittest.TestCase): raise MpdUnknownCommand(command='play') except MpdAckError as e: self.assertEqual( - e.get_mpd_ack(), - 'ACK [5@0] {} unknown command "play"') + e.get_mpd_ack(), 'ACK [5@0] {} unknown command "play"') def test_mpd_no_command(self): try: raise MpdNoCommand except MpdAckError as e: self.assertEqual( - e.get_mpd_ack(), - 'ACK [5@0] {} No command given') + e.get_mpd_ack(), 'ACK [5@0] {} No command given') def test_mpd_system_error(self): try: raise MpdSystemError('foo') except MpdSystemError as e: self.assertEqual( - e.get_mpd_ack(), - 'ACK [52@0] {} foo') + e.get_mpd_ack(), 'ACK [52@0] {None} foo') def test_mpd_permission_error(self): try: From d447cbd798166bcab3647a84804c636521bcfbb4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 00:59:15 +0100 Subject: [PATCH 223/238] core: Fix typo --- mopidy/core/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ce92cced..1ff4e874 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -33,7 +33,7 @@ class LibraryController(object): """ Browse directories and tracks at the given ``uri``. - ``uri`` is a sring which represents some directory belonging to a + ``uri`` is a string which represents some directory belonging to a backend. To get the intial root directories for backends pass None as the URI. From b11338b1ab8123303ed5c1a35552177ac58efde8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 01:02:36 +0100 Subject: [PATCH 224/238] Bump version to 0.18.0 --- mopidy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 9f9ce100..623e202e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.18.0a1' +__version__ = '0.18.0' From e7d4d362fe4f11cc7e127598b870c224395d73bd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 01:19:20 +0100 Subject: [PATCH 225/238] docs: Update changelog --- docs/changelog.rst | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2e4e1d54..db078bc1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,24 @@ This changelog is used to track all major changes to Mopidy. v0.18.0 (UNRELEASED) ==================== +The focus of 0.18 have been on two fronts: the local library and browsing. + +First, the local library's old tag cache file used for storing the track +metadata scanned from your music collection has been replaced with a far +simpler implementation using JSON as the storage format. At the same time, the +local library have been made replaceable by extensions, so you can now create +extensions that use your favorite database to store the metadata. + +Second, we've finally implemented the long awaited "file system" browsing +feature that you know from MPD. It is supported by both the MPD frontend and +the local and Spotify backends. It is also used by the new Mopidy-Dirble +extension to provide you with a directory of Internet radio stations from all +over the world. + +Since the release of 0.17, we've closed or merged 47 issues and pull requests +through about 270 commits by :ref:`11 people `, including six new +guys. Thanks to everyone that has contributed! + **Core API** - Add :meth:`mopidy.core.Core.version` for HTTP clients to manage compatibility @@ -21,7 +39,7 @@ v0.18.0 (UNRELEASED) implementing :meth:`mopidy.backend.LibraryProvider.browse`. - Events emitted on play/stop, pause/resume, next/previous and on end of track - has been cleaned up to work consistenly. See the message of + has been cleaned up to work consistently. See the message of :commit:`1d108752f6` for the full details. (Fixes: :issue:`629`) **Backend API** @@ -89,6 +107,12 @@ v0.18.0 (UNRELEASED) **Local backend** +.. note:: + + After upgrading to Mopidy 0.18 you must run ```mopidy local scan`` to + reindex your local music collection. This is due to the change of storage + format. + - Added support for browsing local directories in Mopidy's virtual file system. - Finished the work on creating pluggable libraries. Users can now From 6acd03995f6512c22e7fd87632cede287dd9cbfe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 01:20:51 +0100 Subject: [PATCH 226/238] docs: Fix syntax error --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index db078bc1..bbdebb80 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -109,7 +109,7 @@ guys. Thanks to everyone that has contributed! .. note:: - After upgrading to Mopidy 0.18 you must run ```mopidy local scan`` to + After upgrading to Mopidy 0.18 you must run ``mopidy local scan`` to reindex your local music collection. This is due to the change of storage format. From 316a1bf20fa8cf95a7399f14757e8aed35ac7401 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Jan 2014 01:30:02 +0100 Subject: [PATCH 227/238] local: Ensure logging does not divide by zero in scanner. --- docs/ext/local.rst | 1 + mopidy/local/commands.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/ext/local.rst b/docs/ext/local.rst index 5d3562a9..31d00d66 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -90,6 +90,7 @@ See :ref:`config` for general help on configuring Mopidy. Number of tracks to wait before telling library it should try and store its progress so far. Some libraries might not respect this setting. + Set this to zero to disable flushing. .. confval:: local/excluded_file_extensions diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 5e4bfe62..85939b43 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -144,10 +144,14 @@ class _Progress(object): def increment(self): self.count += 1 - return self.count % self.batch_size == 0 + return self.batch_size and self.count % self.batch_size == 0 def log(self): duration = time.time() - self.start - remainder = duration / self.count * (self.total - self.count) - logger.info('Scanned %d of %d files in %ds, ~%ds left.', - self.count, self.total, duration, remainder) + if self.count >= self.total or not self.count: + logger.info('Scanned %d of %d files in %ds.', + self.count, self.total, duration) + else: + remainder = duration / self.count * (self.total - self.count) + logger.info('Scanned %d of %d files in %ds, ~%ds left.', + self.count, self.total, duration, remainder) From e97e6206358a294d711a7430b779647a6bc9185f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Jan 2014 01:35:33 +0100 Subject: [PATCH 228/238] local: Rename json test to new naming scheme --- tests/local/{json_test.py => test_json.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/local/{json_test.py => test_json.py} (100%) diff --git a/tests/local/json_test.py b/tests/local/test_json.py similarity index 100% rename from tests/local/json_test.py rename to tests/local/test_json.py From 1ea0978af5faa66e0acde949e1c953aa121412cb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 01:38:01 +0100 Subject: [PATCH 229/238] dummy: Move dummy backend back into the mopidy package --- docs/changelog.rst | 5 +++++ mopidy/{backend.py => backend/__init__.py} | 0 tests/dummy_backend.py => mopidy/backend/dummy.py | 0 tests/core/test_events.py | 5 ++--- tests/mpd/protocol/__init__.py | 5 ++--- tests/mpd/test_dispatcher.py | 5 ++--- tests/mpd/test_status.py | 5 ++--- tests/utils/test_jsonrpc.py | 5 ++--- 8 files changed, 15 insertions(+), 15 deletions(-) rename mopidy/{backend.py => backend/__init__.py} (100%) rename tests/dummy_backend.py => mopidy/backend/dummy.py (100%) diff --git a/docs/changelog.rst b/docs/changelog.rst index bbdebb80..61234f6d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -68,6 +68,11 @@ guys. Thanks to everyone that has contributed! by backends that wants to expose directories of tracks in Mopidy's virtual file system. +**Frontend API** + +- The dummy backend used for testing many frontends have moved from + :mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`. + **Commands** - Reduce amount of logging from dependencies when using :option:`mopidy -v`. diff --git a/mopidy/backend.py b/mopidy/backend/__init__.py similarity index 100% rename from mopidy/backend.py rename to mopidy/backend/__init__.py diff --git a/tests/dummy_backend.py b/mopidy/backend/dummy.py similarity index 100% rename from tests/dummy_backend.py rename to mopidy/backend/dummy.py diff --git a/tests/core/test_events.py b/tests/core/test_events.py index d975ae29..ffa84e6e 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -6,15 +6,14 @@ import unittest import pykka from mopidy import core +from mopidy.backend import dummy from mopidy.models import Track -from tests import dummy_backend - @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.backend = dummy_backend.create_dummy_backend_proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index 216afe33..97b73b7a 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -6,10 +6,9 @@ import unittest import pykka from mopidy import core +from mopidy.backend import dummy from mopidy.mpd import session -from tests import dummy_backend - class MockConnection(mock.Mock): def __init__(self, *args, **kwargs): @@ -32,7 +31,7 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): - self.backend = dummy_backend.create_dummy_backend_proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() diff --git a/tests/mpd/test_dispatcher.py b/tests/mpd/test_dispatcher.py index 36f2f5e1..c4da1714 100644 --- a/tests/mpd/test_dispatcher.py +++ b/tests/mpd/test_dispatcher.py @@ -5,12 +5,11 @@ import unittest import pykka from mopidy import core +from mopidy.backend import dummy from mopidy.mpd.dispatcher import MpdDispatcher from mopidy.mpd.exceptions import MpdAckError from mopidy.mpd.protocol import request_handlers, handle_request -from tests import dummy_backend - class MpdDispatcherTest(unittest.TestCase): def setUp(self): @@ -19,7 +18,7 @@ class MpdDispatcherTest(unittest.TestCase): 'password': None, } } - self.backend = dummy_backend.create_dummy_backend_proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher(config=config) diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 5c22be36..cd910340 100644 --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -5,13 +5,12 @@ import unittest import pykka from mopidy import core +from mopidy.backend import dummy from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.mpd import dispatcher from mopidy.mpd.protocol import status -from tests import dummy_backend - PAUSED = PlaybackState.PAUSED PLAYING = PlaybackState.PLAYING STOPPED = PlaybackState.STOPPED @@ -22,7 +21,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = dummy_backend.create_dummy_backend_proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context diff --git a/tests/utils/test_jsonrpc.py b/tests/utils/test_jsonrpc.py index f562f113..6bd6a32b 100644 --- a/tests/utils/test_jsonrpc.py +++ b/tests/utils/test_jsonrpc.py @@ -7,10 +7,9 @@ import unittest import pykka from mopidy import core, models +from mopidy.backend import dummy from mopidy.utils import jsonrpc -from tests import dummy_backend - class Calculator(object): def model(self): @@ -41,7 +40,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): def setUp(self): - self.backend = dummy_backend.create_dummy_backend_proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.jrw = jsonrpc.JsonRpcWrapper( From 838f7cd4d42f8966ba860ab84494a878390875dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 01:39:56 +0100 Subject: [PATCH 230/238] docs: Update --verbose description in manpage --- docs/commands/mopidy.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/mopidy.rst b/docs/commands/mopidy.rst index 49c7b5b9..75515a8d 100644 --- a/docs/commands/mopidy.rst +++ b/docs/commands/mopidy.rst @@ -43,7 +43,7 @@ Options .. cmdoption:: --verbose, -v - Show more output: debug level and higher. + Show more output. Repeat up to 3 times for even more. .. cmdoption:: --save-debug-log From e4869e9e348dd5a0441edc0aca7c0298c2c892a9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 02:12:04 +0100 Subject: [PATCH 231/238] backend: Make old DummyBackend imports work --- mopidy/backends/dummy.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 mopidy/backends/dummy.py diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py new file mode 100644 index 00000000..7c13c9b1 --- /dev/null +++ b/mopidy/backends/dummy.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +# Make classes previously residing here available in the old location for +# backwards compatibility with extensions targeting Mopidy < 0.18. +from mopidy.backend.dummy import * # noqa From 06abe7c5e05753d6151f7588d43bec49d5ced62f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 02:27:37 +0100 Subject: [PATCH 232/238] docs: Add logging to extension example --- docs/extensiondev.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index f24f02aa..be68ffc5 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -261,6 +261,7 @@ This is ``mopidy_soundspot/__init__.py``:: from __future__ import unicode_literals + import logging import os import pygst @@ -273,6 +274,9 @@ This is ``mopidy_soundspot/__init__.py``:: __version__ = '0.1' + # If you need to log, use loggers named after the current Python module + logger = logging.getLogger(__name__) + class Extension(ext.Extension): From 04bea8e8563dcd2f7c01dc49405b438d150c6ab6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 18 Jan 2014 02:27:56 +0100 Subject: [PATCH 233/238] docs: Remove depdendency check from validate_environment() example --- docs/extensiondev.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index be68ffc5..517fd027 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -299,10 +299,10 @@ This is ``mopidy_soundspot/__init__.py``:: return SoundspotCommand() def validate_environment(self): - try: - import pysoundspot - except ImportError as e: - raise exceptions.ExtensionError('pysoundspot library not found', e) + # Any manual checks of the environment to fail early. + # Dependencies described by setup.py are checked by Mopidy, so you + # should not check their presence here. + pass def setup(self, registry): # You will typically only do one of the following things in a From fdee34950ffe52a963f29ddd5f12ac36495f9637 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sat, 18 Jan 2014 09:31:14 +0000 Subject: [PATCH 234/238] MPD docs typo --- docs/ext/mpd.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 10ecdb24..ecfab949 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -10,7 +10,7 @@ Mopidy and enabled by default. .. warning:: - As a simple security measure, the HTTP server is by default only available + As a simple security measure, the MPD server is by default only available from localhost. To make it available from other computers, change the :confval:`mpd/hostname` config value. Before you do so, note that the MPD server does not support any form of encryption and only a single clear From 2faae3fc05e1b6562546e382b9c5c5afff6d8377 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Jan 2014 12:25:09 +0100 Subject: [PATCH 235/238] local: Add debug timing to loading library and building browse cache --- mopidy/local/json.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index b87c5bce..ae8caaf2 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -8,6 +8,7 @@ import os import re import sys import tempfile +import time import mopidy from mopidy import local, models @@ -76,6 +77,20 @@ class _BrowseCache(object): return self._cache.get(uri, {}).values() +# TODO: make this available to other code? +class DebugTimer(object): + def __init__(self, msg): + self.msg = msg + self.start = None + + def __enter__(self): + self.start = time.time() + + def __exit__(self, exc_type, exc_value, traceback): + duration = (time.time() - self.start) * 1000 + logger.debug('%s: %dms', self.msg, duration) + + class JsonLibrary(local.Library): name = b'json' @@ -86,16 +101,18 @@ class JsonLibrary(local.Library): self._json_file = os.path.join( config['local']['data_dir'], b'library.json.gz') - def browse(self, path): + def browse(self, uri): if not self._browse_cache: return [] - return self._browse_cache.lookup(path) + return self._browse_cache.lookup(uri) def load(self): - logger.debug('Loading json library from %s', self._json_file) - library = load_library(self._json_file) - self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) - self._browse_cache = _BrowseCache(sorted(self._tracks)) + logger.debug('Loading library: %s', self._json_file) + with DebugTimer('Loading tracks'): + library = load_library(self._json_file) + self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) + with DebugTimer('Building browse cache'): + self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) return len(self._tracks) def lookup(self, uri): From 1ebe7f612af3c71063a4c3035732319602ffb66c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 18 Jan 2014 12:26:21 +0100 Subject: [PATCH 236/238] local: Build browse cache backwards to reduce work needed --- mopidy/local/json.py | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index ae8caaf2..ad6be5d3 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -50,8 +50,8 @@ class _BrowseCache(object): splitpath_re = re.compile(r'([^/]+)') def __init__(self, uris): - # {parent_uri: {uri: ref}} - self._cache = {} + # TODO: local.ROOT_DIRECTORY_URI + self._cache = {'local:directory': collections.OrderedDict()} for track_uri in uris: path = translator.local_track_uri_to_path(track_uri, b'/') @@ -59,19 +59,36 @@ class _BrowseCache(object): path.decode(self.encoding, 'replace')) track_ref = models.Ref.track(uri=track_uri, name=parts.pop()) - parent = 'local:directory' - for i in range(len(parts)): - self._cache.setdefault(parent, collections.OrderedDict()) - + # Look for our parents backwards as this is faster than having to + # do a complete search for each add. + parent_uri = None + child = None + for i in reversed(range(len(parts))): directory = '/'.join(parts[:i+1]) - dir_uri = translator.path_to_local_directory_uri(directory) - dir_ref = models.Ref.directory(uri=dir_uri, name=parts[i]) - self._cache[parent][dir_uri] = dir_ref + uri = translator.path_to_local_directory_uri(directory) + # First dir we process is our parent + if not parent_uri: + parent_uri = uri - parent = dir_uri + # We found ourselves and we exist, done. + if uri in self._cache: + break - self._cache.setdefault(parent, collections.OrderedDict()) - self._cache[parent][track_uri] = track_ref + # Initialize ourselves, store child if present, and add + # ourselves as child for next loop. + self._cache[uri] = collections.OrderedDict() + if child: + self._cache[uri][child.uri] = child + child = models.Ref.directory(uri=uri, name=parts[i]) + else: + # Loop completed, so final child needs to be added to root. + if child: + self._cache['local:directory'][child.uri] = child + # If no parent was set we belong in the root. + if not parent_uri: + parent_uri = 'local:directory' + + self._cache[parent_uri][track_uri] = track_ref def lookup(self, uri): return self._cache.get(uri, {}).values() @@ -92,7 +109,7 @@ class DebugTimer(object): class JsonLibrary(local.Library): - name = b'json' + name = 'json' def __init__(self, config): self._tracks = {} From 9ba168e9b90e92d65fb0a757b3458d0cebe42fa1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 19 Jan 2014 22:21:02 +0100 Subject: [PATCH 237/238] local: Update browse cache code to add all directories Tests have been updated to make sure multiple folders are added. We had forgotten to add child folders to the ones that had already been created. --- mopidy/local/json.py | 3 +++ tests/local/test_json.py | 26 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index ad6be5d3..10611f6f 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -66,12 +66,15 @@ class _BrowseCache(object): for i in reversed(range(len(parts))): directory = '/'.join(parts[:i+1]) uri = translator.path_to_local_directory_uri(directory) + # First dir we process is our parent if not parent_uri: parent_uri = uri # We found ourselves and we exist, done. if uri in self._cache: + if child: + self._cache[uri][child.uri] = child break # Initialize ourselves, store child if present, and add diff --git a/tests/local/test_json.py b/tests/local/test_json.py index 9c8686e9..3ccb6e6d 100644 --- a/tests/local/test_json.py +++ b/tests/local/test_json.py @@ -7,26 +7,34 @@ from mopidy.models import Ref class BrowseCacheTest(unittest.TestCase): + maxDiff = None + def setUp(self): - self.uris = [b'local:track:foo/bar/song1', - b'local:track:foo/bar/song2', - b'local:track:foo/song3'] + self.uris = ['local:track:foo/bar/song1', + 'local:track:foo/bar/song2', + 'local:track:foo/baz/song3', + 'local:track:foo/song4', + 'local:track:song5'] self.cache = json._BrowseCache(self.uris) def test_lookup_root(self): - expected = [Ref.directory(uri='local:directory:foo', name='foo')] - self.assertEqual(expected, self.cache.lookup('local:directory')) + expected = [Ref.directory(uri='local:directory:foo', name='foo'), + Ref.track(uri='local:track:song5', name='song5')] + self.assertItemsEqual(expected, self.cache.lookup('local:directory')) def test_lookup_foo(self): expected = [Ref.directory(uri='local:directory:foo/bar', name='bar'), - Ref.track(uri=self.uris[2], name='song3')] - self.assertEqual(expected, self.cache.lookup('local:directory:foo')) + Ref.directory(uri='local:directory:foo/baz', name='baz'), + Ref.track(uri=self.uris[3], name='song4')] + result = self.cache.lookup('local:directory:foo') + self.assertItemsEqual(expected, result) def test_lookup_foo_bar(self): expected = [Ref.track(uri=self.uris[0], name='song1'), Ref.track(uri=self.uris[1], name='song2')] - self.assertEqual( + self.assertItemsEqual( expected, self.cache.lookup('local:directory:foo/bar')) def test_lookup_foo_baz(self): - self.assertEqual([], self.cache.lookup('local:directory:foo/baz')) + result = self.cache.lookup('local:directory:foo/unknown') + self.assertItemsEqual([], result) From 54beea9ead19f5b71c203285c9747b0ecf90937b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 19 Jan 2014 22:28:03 +0100 Subject: [PATCH 238/238] docs: Update changelog for v0.18.0 --- docs/changelog.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 61234f6d..dcab214b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog This changelog is used to track all major changes to Mopidy. -v0.18.0 (UNRELEASED) +v0.18.0 (2014-01-19) ==================== The focus of 0.18 have been on two fronts: the local library and browsing. @@ -21,8 +21,8 @@ the local and Spotify backends. It is also used by the new Mopidy-Dirble extension to provide you with a directory of Internet radio stations from all over the world. -Since the release of 0.17, we've closed or merged 47 issues and pull requests -through about 270 commits by :ref:`11 people `, including six new +Since the release of 0.17, we've closed or merged 49 issues and pull requests +through about 285 commits by :ref:`11 people `, including six new guys. Thanks to everyone that has contributed! **Core API**