diff --git a/.mailmap b/.mailmap index b38c3f66..2ff779fc 100644 --- a/.mailmap +++ b/.mailmap @@ -5,3 +5,6 @@ Kristian Klette Johannes Knutsen Johannes Knutsen John Bäckstrand +Alli Witheford +Alexandre Petitjean +Alexandre Petitjean diff --git a/AUTHORS b/AUTHORS index 87925152..903aca92 100644 --- a/AUTHORS +++ b/AUTHORS @@ -19,3 +19,7 @@ - Nick Steel - Zan Dobersek - Thomas Refis +- Janez Troha +- Tobias Sauerwein +- Alli Witheford +- Alexandre Petitjean diff --git a/README.rst b/README.rst index f667b7db..c9db495e 100644 --- a/README.rst +++ b/README.rst @@ -25,4 +25,5 @@ To get started with Mopidy, check out `the docs `_. - Mailing list: `mopidy@googlegroups.com `_ - Twitter: `@mopidy `_ -.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop +.. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop + :target: https://travis-ci.org/mopidy/mopidy diff --git a/docs/changelog.rst b/docs/changelog.rst index 6241f748..43e99350 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,53 @@ v0.15.0 (UNRELEASED) - :option:`mopidy --show-config` will now take into consideration any :option:`mopidy --option` arguments appearing later on the command line. +**Audio** + +- Added support for viusalization. :confval:`audio/visualizer` can now be set + to GStreamer visualizers. + +**Local backend** + +- An album's number of discs and a track's disc number are now extracted when + scanning your music collection. + +- The scanner now gives up scanning a file after a second, and continues with + the next file. This fixes some hangs on non-media files, like logs. (Fixes: + :issue:`476`, :issue:`483`) + +- Added support for plugable library updaters. This allows extension writers + to start providing their own custom libraries instead of being stuck with + just our tag cache as the only option. + +- Converted local backend to use new `local:playlist:path` and + `local:track:path` uri scheme. Also moves support of `file://` to streaming + backend. + +**Spotify backend** + +- Prepend playlist folder names to the playlist name, so that the playlist + hierarchy from your Spotify account is available in Mopidy. (Fixes: + :issue:`62`) + +- Fix proxy config values that was broken with the config system change in + 0.14. (Fixes: :issue:`472`) + +**MPD frontend** + +- Replace newline, carriage return and forward slash in playlist names. (Fixes: + :issue:`474`, :issue:`480`) + + +v0.14.2 (2013-07-01) +==================== + +This is a maintenance release to make Mopidy 0.14 work with pyspotify 1.11. + +**Dependencies** + +- pyspotify >= 1.9, < 2 is now required for Spotify support. In other words, + you're free to upgrade to pyspotify 1.11, but it isn't a requirement. + v0.14.1 (2013-04-28) ==================== diff --git a/docs/conf.py b/docs/conf.py index a71c9a61..f3e4166c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,10 @@ class Mock(object): if name in ('__file__', '__path__'): return '/dev/null' elif (name[0] == name[0].upper() - and not name.startswith('MIXER_TRACK_')): + # gst.interfaces.MIXER_TRACK_* + and not name.startswith('MIXER_TRACK_') + # dbus.String() + and not name == 'String'): return type(name, (), {}) else: return Mock() @@ -98,7 +101,7 @@ master_doc = 'index' # General information about the project. project = 'Mopidy' -copyright = '2010-2013, Stein Magnus Jodal and contributors' +copyright = '2009-2013, Stein Magnus Jodal and contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/config.rst b/docs/config.rst index 0daf7d9d..6fd7579d 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -90,6 +90,16 @@ Core configuration values ``gst-inspect-0.10`` to see what output properties can be set on the sink. For example: ``gst-inspect-0.10 shout2send`` +.. confval:: audio/visualizer + + Visualizer to use. + + Can be left blank if no visualizer is desired. Otherwise this expects a + GStreamer visualizer. Typical values are ``monoscope``, ``goom``, + ``goom2k1`` or one of the `libvisual`_ visualizers. + +.. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html + .. confval:: logging/console_format The log format used for informational logging. @@ -137,6 +147,24 @@ Core configuration values .. _the Python logging docs: http://docs.python.org/2/library/logging.config.html +Extension configuration +======================= + +Mopidy's extensions have their own config values that you may want to tweak. +For the available config values, please refer to the docs for each extension. +Most, if not all, can be found at :ref:`ext`. + +Mopidy extensions are enabled by default when they are installed. If you want +to disable an extension without uninstalling it, all extensions support the +``enabled`` config value even if it isn't explicitly documented by all +extensions. If the ``enabled`` config value is set to ``false`` the extension +will not be started. For example, to disable the Spotify extension, add the +following to your ``mopidy.conf``:: + + [spotify] + enabled = false + + Extension configuration ======================= diff --git a/docs/ext/local.rst b/docs/ext/local.rst index fc89e69a..1abebb1d 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -47,6 +47,11 @@ Configuration values Path to tag cache for local media. +.. confval:: local/scan_timeout + + Number of milliseconds before giving up scanning a file and moving on to + the next file. + Usage ===== diff --git a/docs/index.rst b/docs/index.rst index fb91244d..ca40c96c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,7 +48,7 @@ About :maxdepth: 1 authors - licenses + license changelog versioning diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index cc46c8b1..e266dee2 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -16,12 +16,20 @@ distribution. .. _raspi-wheezy: -How to for Debian 7 (Wheezy) -============================ +How to for Raspbian "wheezy" and Debian "wheezy" +================================================ -#. Download the latest wheezy disk image from - http://downloads.raspberrypi.org/images/debian/7/. I used the one dated - 2012-08-08. +This guide applies for both: + +- Raspian "wheezy" for armhf (hard-float), and +- Debian "wheezy" for armel (soft-float) + +If you don't know which one to select, go for the armhf variant, as it'll give +you a lot better performance. + +#. Download the latest "wheezy" disk image from + http://www.raspberrypi.org/downloads/. This was last tested with the images + from 2013-05-25 for armhf and 2013-05-29 for armel. #. Flash the OS image to your SD card. See http://elinux.org/RPi_Easy_SD_Card_Setup for help. @@ -82,10 +90,10 @@ card. #. Ensure your system is up to date. On Debian based systems run:: sudo apt-get update - sudo apt-get full-upgrade + sudo apt-get dist-upgrade #. Ensure you have a new enough firmware. On Debian based systems - `rpi-update `_ + `rpi-update `_ can be used. #. Update either ``~/.asoundrc`` or ``/etc/asound.conf`` to the diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 00000000..98928f63 --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,10 @@ +******* +License +******* + +Mopidy is copyright 2009-2013 Stein Magnus Jodal and contributors. For a list +of contributors, see :doc:`authors`. For details on who have contributed what, +please refer to our git repository. + +Mopidy is licensed under the `Apache License, Version 2.0 +`_. diff --git a/docs/licenses.rst b/docs/licenses.rst deleted file mode 100644 index fc2530e5..00000000 --- a/docs/licenses.rst +++ /dev/null @@ -1,34 +0,0 @@ -******** -Licenses -******** - -For a list of contributors, see :doc:`authors`. For details on who have -contributed what, please refer to our git repository. - -Source code license -=================== - -Copyright 2009-2013 Stein Magnus Jodal and contributors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - - -Documentation license -===================== - -Copyright 2010-2013 Stein Magnus Jodal and contributors - -This work is licensed under the Creative Commons Attribution-ShareAlike 3.0 -Unported License. To view a copy of this license, visit -http://creativecommons.org/licenses/by-sa/3.0/ or send a letter to Creative -Commons, 171 Second Street, Suite 300, San Francisco, California, 94105, USA. diff --git a/fabfile.py b/fabfile.py index 3321cb16..de2cf7bb 100644 --- a/fabfile.py +++ b/fabfile.py @@ -1,21 +1,51 @@ -from fabric.api import local, settings +from fabric.api import execute, local, settings, task +@task +def docs(): + local('make -C docs/ html') + + +@task +def autodocs(): + auto(docs) + + +@task def test(path=None): path = path or 'tests/' local('nosetests ' + path) +@task def autotest(path=None): + auto(test, path=path) + + +@task +def coverage(path=None): + path = path or 'tests/' + local( + 'nosetests --with-coverage --cover-package=mopidy ' + '--cover-branches --cover-html ' + path) + + +@task +def autocoverage(path=None): + auto(coverage, path=path) + + +def auto(task, *args, **kwargs): while True: local('clear') with settings(warn_only=True): - test(path) + execute(task, *args, **kwargs) local( 'inotifywait -q -e create -e modify -e delete ' - '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') + '--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/') +@task def update_authors(): # Keep authors in the order of appearance and use awk to filter out dupes local( diff --git a/js/README.md b/js/README.md index 0e5e17c9..eddfa99f 100644 --- a/js/README.md +++ b/js/README.md @@ -51,15 +51,15 @@ Building from source 1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're running Ubuntu: - sudo apt-get install python-software-properties - sudo add-apt-repository ppa:chris-lea/node.js - sudo apt-get update - sudo apt-get install nodejs npm + sudo apt-get install python-software-properties + sudo add-apt-repository ppa:chris-lea/node.js + sudo apt-get update + sudo apt-get install nodejs 2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies: - cd js/ - npm install + cd js/ + npm install That's it. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 12694927..17cab15b 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,4 +23,4 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.14.1' +__version__ = '0.14.2' diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index af0a0c68..d78df9b7 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -22,6 +22,22 @@ mixers.register_mixers() MB = 1 << 20 +# GST_PLAY_FLAG_VIDEO (1<<0) +# GST_PLAY_FLAG_AUDIO (1<<1) +# GST_PLAY_FLAG_TEXT (1<<2) +# GST_PLAY_FLAG_VIS (1<<3) +# GST_PLAY_FLAG_SOFT_VOLUME (1<<4) +# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5) +# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6) +# GST_PLAY_FLAG_DOWNLOAD (1<<7) +# GST_PLAY_FLAG_BUFFERING (1<<8) +# GST_PLAY_FLAG_DEINTERLACE (1<<9) +# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10) + +# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD +PLAYBIN_FLAGS = (1<<1) | (1<<4) | (1<<7) +PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1<<3) + class Audio(pykka.ThreadingActor): """ @@ -55,6 +71,7 @@ class Audio(pykka.ThreadingActor): try: self._setup_playbin() self._setup_output() + self._setup_visualizer() self._setup_mixer() self._setup_message_processor() except gobject.GError as ex: @@ -78,9 +95,7 @@ class Audio(pykka.ThreadingActor): def _setup_playbin(self): playbin = gst.element_factory_make('playbin2') - - fakesink = gst.element_factory_make('fakesink') - playbin.set_property('video-sink', fakesink) + playbin.set_property('flags', PLAYBIN_FLAGS) self._connect(playbin, 'about-to-finish', self._on_about_to_finish) self._connect(playbin, 'notify::source', self._on_new_source) @@ -149,6 +164,19 @@ class Audio(pykka.ThreadingActor): 'Failed to create audio output "%s": %s', output_desc, ex) process.exit_process() + def _setup_visualizer(self): + visualizer_element = self._config['audio']['visualizer'] + if not visualizer_element: + return + try: + visualizer = gst.element_factory_make(visualizer_element) + self._playbin.set_property('vis-plugin', visualizer) + self._playbin.set_property('flags', PLAYBIN_VIS_FLAGS) + logger.info('Audio visualizer set to "%s"', visualizer_element) + except gobject.GError as ex: + logger.error( + 'Failed to create audio visualizer "%s": %s', visualizer_element, ex) + def _setup_mixer(self): mixer_desc = self._config['audio']['mixer'] track_desc = self._config['audio']['mixer_track'] diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index f0561b4c..6b980f06 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -15,11 +15,6 @@ class Backend(object): #: the backend doesn't provide a library. library = None - #: The library update provider. An instance of - #: :class:`~mopidy.backends.base.BaseLibraryUpdateProvider`, or - #: :class:`None` if the backend doesn't provide a library. - updater = None - #: The playback provider. An instance of #: :class:`~mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if #: the backend doesn't provide playback. @@ -40,9 +35,6 @@ class Backend(object): def has_library(self): return self.library is not None - def has_updater(self): - return self.updater is not None - def has_playback(self): return self.playback is not None @@ -96,15 +88,7 @@ class BaseLibraryProvider(object): class BaseLibraryUpdateProvider(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 + uri_schemes = [] def load(self): """Loads the library and returns all tracks in it. @@ -172,9 +156,22 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ self.audio.prepare_change() - self.audio.set_uri(track.uri).get() + 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. diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index f718eeb5..5c6fec47 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -21,6 +21,7 @@ class Extension(ext.Extension): schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() + schema['scan_timeout'] = config.Integer(minimum=0) return schema def validate_environment(self): @@ -29,3 +30,7 @@ class Extension(ext.Extension): def get_backend_classes(self): from .actor import LocalBackend return [LocalBackend] + + def get_library_updaters(self): + from .library import LocalLibraryUpdateProvider + return [LocalLibraryUpdateProvider] diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index b73c53e2..f3611891 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -8,8 +8,9 @@ import pykka from mopidy.backends import base from mopidy.utils import encoding, path -from .library import LocalLibraryProvider, LocalLibraryUpdateProvider +from .library import LocalLibraryProvider from .playlists import LocalPlaylistsProvider +from .playback import LocalPlaybackProvider logger = logging.getLogger('mopidy.backends.local') @@ -23,11 +24,10 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.check_dirs_and_files() self.library = LocalLibraryProvider(backend=self) - self.updater = LocalLibraryUpdateProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playback = LocalPlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - self.uri_schemes = ['file'] + self.uri_schemes = ['local'] def check_dirs_and_files(self): if not os.path.isdir(self.config['local']['media_dir']): diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf index 54c3ab78..7e0f0f2b 100644 --- a/mopidy/backends/local/ext.conf +++ b/mopidy/backends/local/ext.conf @@ -3,3 +3,4 @@ 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 diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 43768cd4..9dd112e9 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -81,7 +81,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='file:search', tracks=result_tracks) + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=result_tracks) def search(self, query=None, uris=None): # TODO Only return results within URI roots given by ``uris`` @@ -122,7 +123,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='file:search', tracks=result_tracks) + # TODO: add local:search: + return SearchResult(uri='local:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): @@ -135,11 +137,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider): # TODO: rename and move to tagcache extension. class LocalLibraryUpdateProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalLibraryUpdateProvider, self).__init__(*args, **kwargs) + uri_schemes = ['local'] + + def __init__(self, config): self._tracks = {} - self._media_dir = self.backend.config['local']['media_dir'] - self._tag_cache_file = self.backend.config['local']['tag_cache_file'] + 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) @@ -156,6 +159,8 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider): 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) diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py new file mode 100644 index 00000000..eda06ff7 --- /dev/null +++ b/mopidy/backends/local/playback.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + +import logging +import os + +from mopidy.backends import base +from mopidy.utils import path + +logger = logging.getLogger('mopidy.backends.local') + + +class LocalPlaybackProvider(base.BasePlaybackProvider): + def change_track(self, track): + media_dir = self.backend.config['local']['media_dir'] + # TODO: check that type is correct. + file_path = path.uri_to_path(track.uri).split(':', 1)[1] + file_path = os.path.join(media_dir, file_path) + track = track.copy(uri=path.path_to_uri(file_path)) + return super(LocalPlaybackProvider, self).change_track(track) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index cd370eaa..af3814ae 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -24,7 +24,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def create(self, name): name = formatting.slugify(name) - uri = path.path_to_uri(self._get_m3u_path(name)) + uri = 'local:playlist:%s.m3u' % name playlist = Playlist(uri=uri, name=name) return self.save(playlist) @@ -37,6 +37,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): self._delete_m3u(playlist.uri) def lookup(self, uri): + # TODO: store as {uri: playlist}? for playlist in self._playlists: if playlist.uri == uri: return playlist @@ -45,8 +46,8 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): playlists = [] for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): - uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] + uri = 'local:playlist:%s' % name tracks = [] for track_uri in parse_m3u(m3u, self._media_dir): @@ -61,6 +62,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): playlists.append(playlist) self.playlists = playlists + # TODO: send what scheme we loaded them for? listener.BackendListener.send('playlists_loaded') logger.info( @@ -86,38 +88,30 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): return playlist - def _get_m3u_path(self, name): - name = formatting.slugify(name) - file_path = os.path.join(self._playlists_dir, name + '.m3u') + def _m3u_uri_to_path(self, uri): + # TODO: create uri handling helpers for local uri types. + file_path = path.uri_to_path(uri).split(':', 1)[1] + file_path = os.path.join(self._playlists_dir, file_path) path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) return file_path def _save_m3u(self, playlist): - file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) + file_path = self._m3u_uri_to_path(playlist.uri) with open(file_path, 'w') as file_handle: for track in playlist.tracks: - if track.uri.startswith('file://'): - uri = path.uri_to_path(track.uri) - else: - uri = track.uri - file_handle.write(uri + '\n') + file_handle.write(track.uri + '\n') def _delete_m3u(self, uri): - file_path = path.uri_to_path(uri) - path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) + file_path = self._m3u_uri_to_path(uri) if os.path.exists(file_path): os.remove(file_path) def _rename_m3u(self, playlist): - src_file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir( - src_file_path, self._playlists_dir) + dst_name = formatting.slugify(playlist.name) + dst_uri = 'local:playlist:%s.m3u' % dst_name - dst_file_path = self._get_m3u_path(playlist.name) - path.check_file_path_is_inside_base_dir( - dst_file_path, self._playlists_dir) + src_file_path = self._m3u_uri_to_path(playlist.uri) + dst_file_path = self._m3u_uri_to_path(dst_uri) shutil.move(src_file_path, dst_file_path) - - return playlist.copy(uri=path.path_to_uri(dst_file_path)) + return playlist.copy(uri=dst_uri) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 4ae10af2..b8e98dd3 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals import logging +import os import urllib +import urlparse from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode @@ -30,7 +32,6 @@ def parse_m3u(file_path, media_dir): - m3u files are latin-1. - This function does not bother with Extended M3U directives. """ - # TODO: uris as bytes uris = [] try: @@ -46,16 +47,19 @@ def parse_m3u(file_path, media_dir): if line.startswith('#'): continue - # FIXME what about other URI types? - if line.startswith('file://'): + if urlparse.urlsplit(line).scheme: uris.append(line) + elif os.path.normpath(line) == os.path.abspath(line): + path = path_to_uri(line) + uris.append(path) else: - path = path_to_uri(media_dir, line) + path = path_to_uri(os.path.join(media_dir, line)) 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. @@ -86,17 +90,17 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''): key, value = line.split(b': ', 1) if key == b'key': - _convert_mpd_data(current, tracks, music_dir) + _convert_mpd_data(current, tracks) current.clear() current[key.lower()] = value.decode('utf-8') - _convert_mpd_data(current, tracks, music_dir) + _convert_mpd_data(current, tracks) return tracks -def _convert_mpd_data(data, tracks, music_dir): +def _convert_mpd_data(data, tracks): if not data: return @@ -160,15 +164,8 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] - path = urllib.unquote(path.encode('utf-8')) - - if isinstance(music_dir, unicode): - music_dir = music_dir.encode('utf-8') - - # Make sure we only pass bytestrings to path_to_uri to avoid implicit - # decoding of bytestrings to unicode strings - track_kwargs['uri'] = path_to_uri(music_dir, path) + track_kwargs['uri'] = 'local:track:%s' % path track_kwargs['length'] = int(data.get('time', 0)) * 1000 track = Track(**track_kwargs) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index c0592ea7..125b6ada 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -33,9 +33,17 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.cache_location = config['spotify']['cache_dir'] self.settings_location = config['spotify']['cache_dir'] + full_proxy = '' + if config['proxy']['hostname']: + full_proxy = config['proxy']['hostname'] + if config['proxy']['port']: + full_proxy += ':' + str(config['proxy']['port']) + if config['proxy']['scheme']: + full_proxy = config['proxy']['scheme'] + "://" + full_proxy + PyspotifySessionManager.__init__( self, config['spotify']['username'], config['spotify']['password'], - proxy=config['proxy']['hostname'], + proxy=full_proxy, proxy_username=config['proxy']['username'], proxy_password=config['proxy']['password']) @@ -173,9 +181,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.debug('Still getting data; skipped refresh of playlists') return playlists = [] + folders = [] for spotify_playlist in self.session.playlist_container(): + if spotify_playlist.type() == 'folder_start': + folders.append(spotify_playlist) + if spotify_playlist.type() == 'folder_end': + folders.pop() playlists.append(translator.to_mopidy_playlist( - spotify_playlist, + spotify_playlist, folders=folders, bitrate=self.bitrate, username=self.username)) playlists.append(translator.to_mopidy_playlist( self.session.starred(), diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 60961cf8..5813f880 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -67,7 +67,7 @@ def to_mopidy_track(spotify_track, bitrate=None): return track_cache[uri] -def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None): +def to_mopidy_playlist(spotify_playlist, folders=None, bitrate=None, username=None): if spotify_playlist is None or spotify_playlist.type() != 'playlist': return try: @@ -78,6 +78,9 @@ def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None): if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() + if folders: + folder_names = '/'.join(folder.name() for folder in folders) + name = folder_names + '/' + name tracks = [ to_mopidy_track(spotify_track, bitrate=bitrate) for spotify_track in spotify_playlist diff --git a/mopidy/backends/stream/ext.conf b/mopidy/backends/stream/ext.conf index 9caafac1..dc0287da 100644 --- a/mopidy/backends/stream/ext.conf +++ b/mopidy/backends/stream/ext.conf @@ -1,6 +1,7 @@ [stream] enabled = true protocols = + file http https mms diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index e9ae7d86..0767b50c 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -24,9 +24,13 @@ _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() _audio_schema['mixer_track'] = String(optional=True) _audio_schema['output'] = String() +_audio_schema['visualizer'] = String(optional=True) _proxy_schema = ConfigSchema('proxy') +_proxy_schema['scheme'] = String(optional=True, + choices=['http', 'https', 'socks4', 'socks5']) _proxy_schema['hostname'] = Hostname(optional=True) +_proxy_schema['port'] = Port(optional=True) _proxy_schema['username'] = String(optional=True) _proxy_schema['password'] = Secret(optional=True) diff --git a/mopidy/config/convert.py b/mopidy/config/convert.py index 6cb20fcd..3c3edb85 100644 --- a/mopidy/config/convert.py +++ b/mopidy/config/convert.py @@ -39,6 +39,7 @@ def convert(settings): helper('audio/output', 'OUTPUT') helper('proxy/hostname', 'SPOTIFY_PROXY_HOST') + helper('proxy/port', 'SPOTIFY_PROXY_PORT') helper('proxy/username', 'SPOTIFY_PROXY_USERNAME') helper('proxy/password', 'SPOTIFY_PROXY_PASSWORD') diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index b525ef47..06749ee1 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -11,8 +11,11 @@ pykka = info mixer = autoaudiomixer mixer_track = output = autoaudiosink +visualizer = [proxy] +scheme = hostname = +port = username = password = diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 3451992a..29651940 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import logging import re import socket -import sys from mopidy.utils import path from mopidy.config import validators @@ -72,9 +71,9 @@ class String(ConfigValue): def deserialize(self, value): value = decode(value).strip() validators.validate_required(value, self._required) - validators.validate_choice(value, self._choices) if not value: return None + validators.validate_choice(value, self._choices) return value def serialize(self, value, display=False): @@ -112,12 +111,16 @@ class Secret(ConfigValue): class Integer(ConfigValue): """Integer value.""" - def __init__(self, minimum=None, maximum=None, choices=None): + def __init__(self, minimum=None, maximum=None, choices=None, optional=False): + self._required = not optional self._minimum = minimum self._maximum = maximum self._choices = choices def deserialize(self, value): + validators.validate_required(value, self._required) + if not value: + return None value = int(value) validators.validate_choice(value, self._choices) validators.validate_minimum(value, self._minimum) @@ -223,8 +226,9 @@ class Port(Integer): allocate a port for us. """ # TODO: consider probing if port is free or not? - def __init__(self, choices=None): - super(Port, self).__init__(minimum=0, maximum=2**16-1, choices=choices) + def __init__(self, choices=None, optional=False): + super(Port, self).__init__( + minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional) class Path(ConfigValue): @@ -256,7 +260,7 @@ class Path(ConfigValue): def serialize(self, value, display=False): if isinstance(value, unicode): - value = value.encode(sys.getfilesystemencoding()) + raise ValueError('paths should always be bytes') if isinstance(value, ExpandedPath): return value.original return value diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 51254521..7ec7ff2a 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -215,6 +215,7 @@ class PlaybackController(object): logger.warning('Track is not playable: %s', tl_track.track.uri) self.core.tracklist.mark("unplayable", tl_track) if on_error_step == 1: + # TODO: can cause an endless loop for single track repeat. self.next() elif on_error_step == -1: self.previous() diff --git a/mopidy/ext.py b/mopidy/ext.py index d7c5c96f..22daa3cb 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -79,6 +79,14 @@ class Extension(object): """ return [] + def get_library_updaters(self): + """List of library updater classes + + :returns: list of :class:`~mopidy.backends.base.BaseLibraryUpdateProvider` + subclasses + """ + return [] + def register_gstreamer_elements(self): """Hook for registering custom GStreamer elements diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 6590897d..ec3b71f8 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -236,6 +236,8 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None + _invalid_playlist_chars = re.compile(r'[\n\r/]') + def __init__(self, dispatcher, session=None, config=None, core=None): self.dispatcher = dispatcher self.session = session @@ -248,10 +250,11 @@ class MpdContext(object): self.refresh_playlists_mapping() def create_unique_name(self, playlist_name): - name = playlist_name + stripped_name = self._invalid_playlist_chars.sub(' ', playlist_name) + name = stripped_name i = 2 while name in self._playlist_uri_from_name: - name = '%s [%d]' % (playlist_name, i) + name = '%s [%d]' % (stripped_name, i) i += 1 return name @@ -266,6 +269,7 @@ class MpdContext(object): for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue + # TODO: add scheme to name perhaps 'foo (spotify)' etc. name = self.create_unique_name(playlist.name) self._playlist_uri_from_name[name] = playlist.uri self._playlist_name_from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index ff79c33a..8e31dbf8 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -39,8 +39,8 @@ def _artist_as_track(artist): artists=[artist]) -@handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') -def count(context, tag, needle): +@handle_request(r'^count ' + QUERY_RE) +def count(context, mpd_query): """ *musicpd.org, music database section:* @@ -48,6 +48,11 @@ def count(context, tag, needle): Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. + + *GMPC:* + + - does not add quotes around the tag argument. + - use multiple tag-needle pairs to make more specific searches. """ return [('songs', 0), ('playtime', 0)] # TODO diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 8e9d12e0..804f693a 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -156,14 +156,14 @@ def query_from_mpd_list_format(field, mpd_query): if field == 'album': if not tokens[0]: raise ValueError - return {'artist': [tokens[0]]} # See above NOTE + return {'artist': [tokens[0]]} else: raise MpdArgError( 'should be "Album" for 3 arguments', command='list') elif len(tokens) % 2 == 0: query = {} while tokens: - key = str(tokens[0].lower()) # See above NOTE + key = tokens[0].lower() value = tokens[1] tokens = tokens[2:] if key not in ('artist', 'album', 'date', 'genre'): diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index fae8618f..d44e9262 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -34,7 +34,7 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.mpris_object = objects.MprisObject(self.config, self.core) self._send_startup_notification() except Exception as e: - logger.error('MPRIS frontend setup failed (%s)', e) + logger.warning('MPRIS frontend setup failed (%s)', e) self.stop() def on_stop(self): diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 77085f90..4632cebe 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -27,7 +27,6 @@ pygst.require('0.10') import gst from mopidy import config as config_lib, ext -from mopidy.audio import dummy as dummy_audio from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning @@ -45,21 +44,36 @@ def main(): log.setup_root_logger() log.setup_console_logging(logging_config, args.verbosity_level) - extensions = dict((e.ext_name, e) for e in ext.load_extensions()) + extensions = ext.load_extensions() config, errors = config_lib.load( - config_files, extensions.values(), config_overrides) + config_files, extensions, config_overrides) log.setup_log_levels(config) if not config['local']['media_dir']: logging.warning('Config value local/media_dir is not set.') return + if not config['local']['scan_timeout']: + logging.warning('Config value local/scan_timeout is not set.') + return + # TODO: missing config error checking and other default setup code. - audio = dummy_audio.DummyAudio() - local_backend_classes = extensions['local'].get_backend_classes() - local_backend = local_backend_classes[0](config, audio) - local_updater = local_backend.updater + 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 + + if not updaters: + logging.error('No usable library updaters found.') + return + elif len(updaters) > 1: + logging.error('More than one library updater found. ' + 'Provided by: %s', ', '.join(updaters.keys())) + return + + local_updater = updaters.values()[0](config) # TODO: switch to actor? media_dir = config['local']['media_dir'] @@ -97,9 +111,11 @@ def main(): logging.warning('Failed %s: %s', uri, error) logging.debug('Debug info for %s: %s', uri, debug) + scan_timeout = config['local']['scan_timeout'] + logging.info('Scanning new and modified tracks.') # TODO: just pass the library in instead? - scanner = Scanner(uris_update, store, debug) + scanner = Scanner(uris_update, store, debug, scan_timeout) try: scanner.start() except KeyboardInterrupt: @@ -139,6 +155,7 @@ def translator(data): _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) if gst.TAG_DATE in data and data[gst.TAG_DATE]: @@ -152,6 +169,7 @@ def translator(data): _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) # Following keys don't seem to have TAG_* constant. _retrieve('album-artist', 'name', albumartist_kwargs) @@ -174,12 +192,14 @@ def translator(data): class Scanner(object): - def __init__(self, uris, data_callback, error_callback=None): + def __init__(self, uris, data_callback, error_callback=None, scan_timeout=1000): self.data = {} self.uris = iter(uris) self.data_callback = data_callback self.error_callback = error_callback + self.scan_timeout = scan_timeout self.loop = gobject.MainLoop() + self.timeout_id = None self.fakesink = gst.element_factory_make('fakesink') self.fakesink.set_property('signal-handoffs', True) @@ -250,6 +270,14 @@ class Scanner(object): self.error_callback(uri, error, debug) self.next_uri() + def process_timeout(self): + if self.error_callback: + uri = self.uribin.get_property('uri') + self.error_callback( + uri, 'Scan timed out after %d ms' % self.scan_timeout, None) + self.next_uri() + return False + def get_duration(self): self.pipe.get_state() # Block until state change is done. try: @@ -260,6 +288,9 @@ class Scanner(object): def next_uri(self): self.data = {} + if self.timeout_id: + gobject.source_remove(self.timeout_id) + self.timeout_id = None try: uri = next(self.uris) except StopIteration: @@ -267,6 +298,7 @@ class Scanner(object): return False self.pipe.set_state(gst.STATE_NULL) self.uribin.set_property('uri', uri) + self.timeout_id = gobject.timeout_add(self.scan_timeout, self.process_timeout) self.pipe.set_state(gst.STATE_PLAYING) return True diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index e4d717d1..602b2569 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -2,12 +2,11 @@ from __future__ import unicode_literals import logging import os -import re # pylint: disable = W0402 import string # pylint: enable = W0402 -import sys import urllib +import urlparse import glib @@ -51,7 +50,7 @@ def get_or_create_file(file_path): return file_path -def path_to_uri(*paths): +def path_to_uri(path): """ Convert OS specific path to file:// URI. @@ -61,17 +60,15 @@ def path_to_uri(*paths): Returns a file:// URI as an unicode string. """ - path = os.path.join(*paths) if isinstance(path, unicode): path = path.encode('utf-8') - if sys.platform == 'win32': - return 'file:' + urllib.quote(path) - return 'file://' + urllib.quote(path) + path = urllib.quote(path) + return urlparse.urlunsplit((b'file', b'', path, b'', b'')) def uri_to_path(uri): """ - Convert the file:// to a OS specific path. + Convert an URI to a OS specific path. Returns a bytestring, since the file path can contain chars with other encoding than UTF-8. @@ -82,10 +79,7 @@ def uri_to_path(uri): """ if isinstance(uri, unicode): uri = uri.encode('utf-8') - if sys.platform == 'win32': - return urllib.unquote(re.sub(b'^file:', b'', uri)) - else: - return urllib.unquote(re.sub(b'^file://', b'', uri)) + return urllib.unquote(urlparse.urlsplit(uri).path) def split_path(path): diff --git a/requirements/external_mixers.txt b/requirements/external_mixers.txt deleted file mode 100644 index 20cb7864..00000000 --- a/requirements/external_mixers.txt +++ /dev/null @@ -1,2 +0,0 @@ -pyserial -# Available as python-serial in Debian/Ubuntu diff --git a/requirements/http.txt b/requirements/http.txt index aea7c1a8..f38bfa3c 100644 --- a/requirements/http.txt +++ b/requirements/http.txt @@ -2,4 +2,5 @@ cherrypy >= 3.2.2 # Available as python-cherrypy3 in Debian/Ubuntu ws4py >= 0.2.3 -# Available as python-ws4py from apt.mopidy.com +# Available as python-ws4py in newer Debian/Ubuntu and from apt.mopidy.com for +# older releases of Debian/Ubuntu diff --git a/requirements/spotify.txt b/requirements/spotify.txt index 333e55c8..d11a5c04 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1,4 +1,4 @@ -pyspotify >= 1.9, < 1.11 +pyspotify >= 1.9, < 2 # The libspotify Python wrapper # Available as the python-spotify package from apt.mopidy.com diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index bce0a6e2..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[nosetests] -verbosity = 1 -#with-coverage = 1 -cover-package = mopidy -cover-inclusive = 1 -cover-html = 1 diff --git a/setup.py b/setup.py index 8e02d53d..c5eea724 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ setup( 'Pykka >= 1.1', ], extras_require={ - 'spotify': ['pyspotify >= 1.9, < 1.11'], + 'spotify': ['pyspotify >= 1.9, < 2'], 'scrobbler': ['pylast >= 0.5.7'], 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], }, @@ -36,7 +36,6 @@ setup( tests_require=[ 'nose', 'mock >= 1.0', - 'unittest2', ], entry_points={ 'console_scripts': [ @@ -61,7 +60,6 @@ setup( 'License :: OSI Approved :: Apache Software License', 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Topic :: Multimedia :: Sound/Audio :: Players', ], diff --git a/tests/__init__.py b/tests/__init__.py index b358f32b..a384669e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,12 +1,11 @@ from __future__ import unicode_literals import os -import sys def path_to_data_dir(name): if not isinstance(name, bytes): - name = name.encode(sys.getfilesystemencoding()) + name = name.encode('utf-8') path = os.path.dirname(__file__) path = os.path.join(path, b'data') path = os.path.abspath(path) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index c311bdc3..617131cc 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -21,6 +21,7 @@ class AudioTest(unittest.TestCase): 'mixer': 'fakemixer track_max_volume=65536', 'mixer_track': None, 'output': 'fakesink', + 'visualizer': None, } } self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) @@ -70,6 +71,7 @@ class AudioTest(unittest.TestCase): 'mixer': 'fakemixer track_max_volume=40', 'mixer_track': None, 'output': 'fakesink', + 'visualizer': None, } } self.audio = audio.Audio.start(config=config).proxy() diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4bc525c8..23c76f38 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -7,8 +7,6 @@ import pykka from mopidy import core from mopidy.models import Track, Album, Artist -from tests import path_to_data_dir - class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] @@ -17,13 +15,10 @@ class LibraryControllerTest(object): Album(name='album2', artists=artists[1:2]), Album()] tracks = [ - Track( - uri='file://' + path_to_data_dir('uri1'), name='track1', - artists=artists[:1], album=albums[0], date='2001-02-03', - length=4000), - Track( - uri='file://' + path_to_data_dir('uri2'), name='track2', - artists=artists[1:2], album=albums[1], date='2002', length=4000), + Track(uri='local:track:path1', name='track1', artists=artists[:1], + album=albums[0], date='2001-02-03', length=4000), + Track(uri='local:track:path2', name='track2', artists=artists[1:2], + album=albums[1], date='2002', length=4000), Track()] config = {} @@ -66,11 +61,11 @@ class LibraryControllerTest(object): self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): - track_1_uri = 'file://' + path_to_data_dir('uri1') + track_1_uri = 'local:track:path1' result = self.library.find_exact(uri=track_1_uri) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - track_2_uri = 'file://' + path_to_data_dir('uri2') + track_2_uri = 'local:track:path2' result = self.library.find_exact(uri=track_2_uri) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) @@ -136,10 +131,10 @@ class LibraryControllerTest(object): self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): - result = self.library.search(uri=['RI1']) + result = self.library.search(uri=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(uri=['RI2']) + result = self.library.search(uri=['TH2']) self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track(self): @@ -183,7 +178,7 @@ class LibraryControllerTest(object): self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Bum1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) - result = self.library.search(any=['RI1']) + result = self.library.search(any=['TH1']) self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py index 684e12d8..1738722f 100644 --- a/tests/backends/local/__init__.py +++ b/tests/backends/local/__init__.py @@ -1,9 +1,4 @@ from __future__ import unicode_literals -from mopidy.utils.path import path_to_uri -from tests import path_to_data_dir - - -song = path_to_data_dir('song%s.wav') -generate_song = lambda i: path_to_uri(song % i) +generate_song = lambda i: 'local:track:song%s.wav' % i diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4c699699..530f09c8 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -5,7 +5,6 @@ import unittest from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track -from mopidy.utils.path import path_to_uri from tests import path_to_data_dir from tests.backends.base.playback import PlaybackControllerTest @@ -24,25 +23,25 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] - def add_track(self, path): - uri = path_to_uri(path_to_data_dir(path)) + def add_track(self, uri): track = Track(uri=uri, length=4464) self.tracklist.add([track]) def test_uri_scheme(self): - self.assertIn('file', self.core.uri_schemes) + self.assertNotIn('file', self.core.uri_schemes) + self.assertIn('local', self.core.uri_schemes) def test_play_mp3(self): - self.add_track('blank.mp3') + self.add_track('local:track:blank.mp3') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_ogg(self): - self.add_track('blank.ogg') + self.add_track('local:track:blank.ogg') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_flac(self): - self.add_track('blank.flac') + self.add_track('local:track:blank.flac') self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 2882e476..591a9d1d 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -7,7 +7,7 @@ import unittest from mopidy.backends.local import actor from mopidy.models import Track -from mopidy.utils.path import path_to_uri +from mopidy.utils.path import path_to_uri, uri_to_path from tests import path_to_data_dir from tests.backends.base.playlists import ( @@ -89,21 +89,20 @@ class LocalPlaylistsControllerTest( def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) - track_path = track.uri[len('file://'):] playlist = self.core.playlists.create('test') - playlist_path = playlist.uri[len('file://'):] + 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() - self.assertEqual(track_path, contents.strip()) + self.assertEqual(track.uri, contents.strip()) def test_playlists_are_loaded_at_startup(self): playlist_path = os.path.join(self.playlists_dir, 'test.m3u') - track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) + track = Track(uri='local:track:path2') playlist = self.core.playlists.create('test') playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) @@ -112,8 +111,7 @@ class LocalPlaylistsControllerTest( self.assert_(backend.playlists.playlists) self.assertEqual( - path_to_uri(playlist_path), - backend.playlists.playlists[0].uri) + 'local:playlist:test', backend.playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.playlists.playlists[0].name) self.assertEqual( diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 4f958232..5ed07fca 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -98,7 +98,7 @@ expected_tracks = [] def generate_track(path, ident): - uri = path_to_uri(path_to_data_dir(path)) + uri = 'local:track:%s' % path track = Track( uri=uri, name='trackname', artists=expected_artists, album=expected_albums[0], track_no=1, date='2006', length=4000, @@ -126,11 +126,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): def test_simple_cache(self): tracks = parse_mpd_tag_cache( path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) track = Track( - uri=uri, name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], date='2006', length=4000, - last_modified=1272319626) + 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): @@ -142,12 +141,11 @@ class MPDTagCacheToTracksTest(unittest.TestCase): tracks = parse_mpd_tag_cache( path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) artists = [Artist(name='æøå')] album = Album(name='æøå', artists=artists) track = Track( - uri=uri, name='æøå', artists=artists, album=album, length=4000, - last_modified=1272319626) + uri='local:track:song1.mp3', name='æøå', artists=artists, + album=album, length=4000, last_modified=1272319626) self.assertEqual(track, list(tracks)[0]) @@ -159,8 +157,8 @@ class MPDTagCacheToTracksTest(unittest.TestCase): def test_cache_with_blank_track_info(self): tracks = parse_mpd_tag_cache( path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) - expected = Track(uri=uri, length=4000, last_modified=1272319626) + expected = Track( + uri='local:track:song1.mp3', length=4000, last_modified=1272319626) self.assertEqual(set([expected]), tracks) def test_musicbrainz_tagcache(self): @@ -183,10 +181,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): def test_albumartist_tag_cache(self): tracks = parse_mpd_tag_cache( path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) - uri = path_to_uri(path_to_data_dir('song1.mp3')) artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) track = Track( - uri=uri, name='trackname', artists=expected_artists, track_no=1, - album=album, date='2006', length=4000, last_modified=1272319626) + 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/config/types_test.py b/tests/config/types_test.py index 24f1265e..74e9672d 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals import logging import mock import socket -import sys import unittest from mopidy.config import types @@ -99,6 +98,11 @@ class StringTest(unittest.TestCase): self.assertIsInstance(result, bytes) self.assertEqual(b'', result) + def test_deserialize_enforces_choices_optional(self): + value = types.String(optional=True, choices=['foo', 'bar', 'baz']) + self.assertEqual(None, value.deserialize(b'')) + self.assertRaises(ValueError, value.deserialize, b'foobar') + class SecretTest(unittest.TestCase): def test_deserialize_passes_through(self): @@ -164,6 +168,10 @@ class IntegerTest(unittest.TestCase): self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') + def test_deserialize_respects_optional(self): + value = types.Integer(optional=True) + self.assertEqual(None, value.deserialize('')) + class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): @@ -367,7 +375,4 @@ class PathTest(unittest.TestCase): def test_serialize_unicode_string(self): value = types.Path() - expected = 'æøå'.encode(sys.getfilesystemencoding()) - result = value.serialize('æøå') - self.assertEqual(expected, result) - self.assertIsInstance(result, bytes) + self.assertRaises(ValueError, value.serialize, 'æøå') diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index 50771a0a..9dc11777 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -3,22 +3,22 @@ mpd_version: 0.14.2 fs_charset: UTF-8 info_end songList begin -key: uri1 -file: /uri1 +key: key1 +file: /path1 Artist: artist1 Title: track1 Album: album1 Date: 2001-02-03 Time: 4 -key: uri2 -file: /uri2 +key: key1 +file: /path2 Artist: artist2 Title: track2 Album: album2 Date: 2002 Time: 4 -key: uri3 -file: /uri3 +key: key3 +file: /path3 Artist: artist3 Title: track3 Album: album3 diff --git a/tests/data/scanner/empty.wav b/tests/data/scanner/empty.wav new file mode 100644 index 00000000..efe516b3 Binary files /dev/null and b/tests/data/scanner/empty.wav differ diff --git a/tests/data/scanner/example.log b/tests/data/scanner/example.log new file mode 100644 index 00000000..c49a044d Binary files /dev/null and b/tests/data/scanner/example.log differ diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index d16a636b..fa909bab 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -7,7 +7,19 @@ from tests.frontends.mpd import protocol class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): - self.sendRequest('count "tag" "needle"') + self.sendRequest('count "artist" "needle"') + self.assertInResponse('songs: 0') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') + + def test_count_without_quotes(self): + self.sendRequest('count artist "needle"') + self.assertInResponse('songs: 0') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') + + def test_count_with_multiple_pairs(self): + self.sendRequest('count "artist" "foo" "album" "bar"') self.assertInResponse('songs: 0') self.assertInResponse('playtime: 0') self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8199be2b..820096f4 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -107,6 +107,30 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('playlist: ') self.assertInResponse('OK') + def test_listplaylists_replaces_newline_with_space(self): + self.backend.playlists.playlists = [ + Playlist(name='a\n', uri='dummy:')] + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a ') + self.assertNotInResponse('playlist: a\n') + self.assertInResponse('OK') + + def test_listplaylists_replaces_carriage_return_with_space(self): + self.backend.playlists.playlists = [ + Playlist(name='a\r', uri='dummy:')] + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a ') + self.assertNotInResponse('playlist: a\r') + self.assertInResponse('OK') + + def test_listplaylists_replaces_forward_slash_with_space(self): + self.backend.playlists.playlists = [ + Playlist(name='a/', uri='dummy:')] + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a ') + self.assertNotInResponse('playlist: a/') + self.assertInResponse('OK') + def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index c9671523..ca007533 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -26,6 +26,8 @@ class TranslatorTest(unittest.TestCase): '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', 'duration': 4531, @@ -39,6 +41,7 @@ class TranslatorTest(unittest.TestCase): self.album = { 'name': 'albumname', 'num_tracks': 2, + 'num_discs': 3, 'musicbrainz_id': 'mbalbumid', } @@ -57,6 +60,7 @@ class TranslatorTest(unittest.TestCase): 'name': 'trackname', 'date': '2006-01-01', 'track_no': 1, + 'disc_no': 2, 'length': 4531, 'musicbrainz_id': 'mbtrackid', 'last_modified': 1234, @@ -206,6 +210,14 @@ class ScannerTest(unittest.TestCase): self.scan('scanner/image') self.assert_(self.errors) + def test_log_file_is_ignored(self): + self.scan('scanner/example.log') + self.assert_(self.errors) + + def test_empty_wav_file_is_ignored(self): + self.scan('scanner/empty.wav') + self.assert_(self.errors) + @unittest.SkipTest def test_song_without_time_is_handeled(self): pass diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index a19e48f7..ed9f8044 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import os import shutil -import sys import tempfile import unittest @@ -117,86 +116,42 @@ class GetOrCreateFileTest(unittest.TestCase): class PathToFileURITest(unittest.TestCase): def test_simple_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/WINDOWS/clock.avi') - self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') - else: - result = path.path_to_uri('/etc/fstab') - self.assertEqual(result, 'file:///etc/fstab') - - def test_dir_and_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/WINDOWS/', 'clock.avi') - self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') - else: - result = path.path_to_uri('/etc', 'fstab') - self.assertEqual(result, 'file:///etc/fstab') + result = path.path_to_uri('/etc/fstab') + self.assertEqual(result, 'file:///etc/fstab') def test_space_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/test this') - self.assertEqual(result, 'file:///C://test%20this') - else: - result = path.path_to_uri('/tmp/test this') - self.assertEqual(result, 'file:///tmp/test%20this') + result = path.path_to_uri('/tmp/test this') + self.assertEqual(result, 'file:///tmp/test%20this') def test_unicode_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/æøå') - self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') - else: - result = path.path_to_uri('/tmp/æøå') - self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.path_to_uri('/tmp/æøå') + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') def test_utf8_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/æøå'.encode('utf-8')) - self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') - else: - result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) - self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') def test_latin1_in_path(self): - if sys.platform == 'win32': - result = path.path_to_uri('C:/æøå'.encode('latin-1')) - self.assertEqual(result, 'file:///C://%E6%F8%E5') - else: - result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) - self.assertEqual(result, 'file:///tmp/%E6%F8%E5') + result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///tmp/%E6%F8%E5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://WINDOWS/clock.avi') - self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8')) - else: - result = path.uri_to_path('file:///etc/fstab') - self.assertEqual(result, '/etc/fstab'.encode('utf-8')) + result = path.uri_to_path('file:///etc/fstab') + self.assertEqual(result, '/etc/fstab'.encode('utf-8')) def test_space_in_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://test%20this') - self.assertEqual(result, 'C:/test this'.encode('utf-8')) - else: - result = path.uri_to_path('file:///tmp/test%20this') - self.assertEqual(result, '/tmp/test this'.encode('utf-8')) + result = path.uri_to_path('file:///tmp/test%20this') + self.assertEqual(result, '/tmp/test this'.encode('utf-8')) def test_unicode_in_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, 'C:/æøå'.encode('utf-8')) - else: - result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) + result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') + self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) def test_latin1_in_uri(self): - if sys.platform == 'win32': - result = path.uri_to_path('file:///C://%E6%F8%E5') - self.assertEqual(result, 'C:/æøå'.encode('latin-1')) - else: - result = path.uri_to_path('file:///tmp/%E6%F8%E5') - self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) + result = path.uri_to_path('file:///tmp/%E6%F8%E5') + self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) class SplitPathTest(unittest.TestCase): diff --git a/tests/version_test.py b/tests/version_test.py index 10c35c8f..27b246d7 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -37,5 +37,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.11.1'), SV('0.12.0')) self.assertLess(SV('0.12.0'), SV('0.13.0')) self.assertLess(SV('0.13.0'), SV('0.14.0')) - self.assertLess(SV('0.14.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.14.2')) + self.assertLess(SV('0.14.0'), SV('0.14.1')) + self.assertLess(SV('0.14.1'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.14.3'))