diff --git a/.mailmap b/.mailmap index 93a5573b..45935be6 100644 --- a/.mailmap +++ b/.mailmap @@ -16,4 +16,4 @@ Janez Troha Luke Giuliani Colin Montgomerie Ignasi Fosch -Christopher Schirner +Christopher Schirner diff --git a/AUTHORS b/AUTHORS index ccca6979..cd347da8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -42,7 +42,8 @@ - Sam Willcocks - Ignasi Fosch - Arjun Naik -- Christopher Schirner +- Christopher Schirner - Dmitry Sandalov - Lukas Vogel - Thomas Amland +- Deni Bertovic diff --git a/docs/changelog.rst b/docs/changelog.rst index 264f101c..a4d5ee44 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,8 +39,6 @@ v0.20.0 (UNRELEASED) - Add symlink support with loop protection to file finder (Fixes: :issue:`858`, PR: :isusue:`874`) -- Fix scanning of modified files. (PR: :issue:`904`) - **MPD frontend** - In stored playlist names, replace "/", which are illegal, with "|" instead of @@ -49,15 +47,11 @@ v0.20.0 (UNRELEASED) - Enable browsing of artist references, in addition to albums and playlists. (PR: :issue:`884`) -- Re-enable browsing of empty directories. (PR: :issue:`906`) - -- Quick workaround for :issue:`881`, which allows for newlines in comments. - (PR: :issue:`882`) - **Audio** - Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a - :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` end the stream. + :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the + stream. - Internal code cleanup within audio subsystem: @@ -94,16 +88,26 @@ v0.20.0 (UNRELEASED) make sense for a server such as Mopidy. Currently the only way to find out if it is in use and will be missed is to go ahead and remove it. - **Stream backend** - Add basic tests for the stream library provider. -v0.19.5 (UNRELEASED) +v0.19.5 (2014-12-23) ==================== -Bug fix release. +Today is Mopidy's five year anniversary. We're celebrating with a bugfix +release and are looking forward to the next five years! + +- Config: Support UTF-8 in extension's default config. If an extension with + non-ASCII characters in its default config was installed, and Mopidy didn't + already have a config file, Mopidy would crashed when trying to create the + initial config file based on the default config of all available extensions. + (Fixes: :discuss:`428`) + +- Extensions: Fix crash when unpacking data from + :exc:`pkg_resources.VersionConflict` created with a single argument. (Fixes: + :issue:`911`) - Models: Hide empty collections from :func:`repr()` representations. @@ -117,6 +121,20 @@ Bug fix release. :attr:`mopidy.models.Track.track_no`, and :attr:`mopidy.models.Track.last_modified` from ``0`` to :class:`None`. +- Core: When skipping to the next track in consume mode, remove the skipped + track from the tracklist. This is consistent with the original MPD server's + behavior. (Fixes: :issue:`902`) + +- Local: Fix scanning of modified files. (PR: :issue:`904`) + +- MPD: Re-enable browsing of empty directories. (PR: :issue:`906`) + +- MPD: Remove track comments from responses. They are not included by the + original MPD server, and this works around :issue:`881`. (PR: :issue:`882`) + +- HTTP: Errors while starting HTTP apps are logged instead of crashing the HTTP + server. (Fixes: :issue:`875`) + v0.19.4 (2014-09-01) ==================== diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 7f21a6c6..9b24ae46 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -18,7 +18,7 @@ DLNA Digital Media Server (DMS) / UPnP AV MediaServer: A MediaServer provides a library of media and is capable of streaming that media to a MediaRenderer. If Mopidy was a MediaServer, you could browse and play Mopidy's music on a TV, smartphone, or tablet supporting UPnP. Mopidy - does not currently support this, but we may in the future. :issue:`52` is + does not currently support this, but we may in the future. :issue:`52` is the relevant wishlist issue. DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer: @@ -27,13 +27,51 @@ DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer: given media, typically served by a MediaServer. If Mopidy was a MediaRenderer, you could use e.g. your smartphone or tablet to make Mopidy play media. Mopidy *does already* have experimental support for being a - MediaRenderer with the help of Rygel, as you can read more about below. + MediaRenderer, as you can read more about below. + + +Mopidy as an UPnP MediaRenderer +=============================== + +There are two ways Mopidy can be made available as an UPnP MediaRenderer: +Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli. + + +.. _upmpdcli: + +upmpdcli +-------- + +`upmpdcli `_ is recommended, since it +is easier to setup, and offers `OpenHome ohMedia`_ +compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while +using the MPD protocol to control Mopidy. + +1. Install upmpdcli. On Debian/Ubuntu:: + + apt-get install upmpdcli + + Alternatively, follow the instructions from the upmpdcli website. + +2. The default settings of upmpdcli will work with the default settings of + :ref:`ext-mpd`. Edit :file:`/etc/upmpdcli.conf` if you want to use different + ports, hosts, or other settings. + +3. Start upmpdcli using the command:: + + upmpdcli + + Or, run it in the background as a service:: + + sudo service upmpdcli start + +4. A UPnP renderer should be available now. .. _rygel: -How to make Mopidy available as an UPnP MediaRenderer -===================================================== +Rygel +----- With the help of `the Rygel project `_ Mopidy can be made available as an UPnP MediaRenderer. Rygel will interface with the MPRIS @@ -41,15 +79,15 @@ interface provided by the `Mopidy-MPRIS extension `_, and make Mopidy available as a MediaRenderer on the local network. Since this depends on the MPRIS frontend, which again depends on D-Bus being available, this will only work on Linux, and -not OS X. MPRIS/D-Bus is only available to other applications on the same +not OS X. MPRIS/D-Bus is only available to other applications on the same host, so Rygel must be running on the same machine as Mopidy. -1. Start Mopidy and make sure the MPRIS frontend is working. It is activated +1. Start Mopidy and make sure the MPRIS frontend is working. It is activated by default when the Mopidy-MPRIS extension is installed, but you may miss dependencies or be using OS X, in which case it will not work. Check the console output when Mopidy is started for any errors related to the MPRIS frontend. If you're unsure it is working, there are instructions for how to - test it on in the `Mopidy-MPRIS readme + test it in the `Mopidy-MPRIS readme `_. 2. Install Rygel. On Debian/Ubuntu:: diff --git a/docs/conf.py b/docs/conf.py index 0715f326..c300aa62 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -157,6 +157,7 @@ extlinks = { 'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '), 'mpris': ( 'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'), + 'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'), } diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index c13e3ec4..3c70340c 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -74,13 +74,13 @@ you a lot better performance. #. Install Mopidy and its dependencies as described in :ref:`debian-install`. #. Finally, you need to set a couple of :doc:`config values `, and - then you're ready to :doc:`run Mopidy `. Alternatively you may + then you're ready to :doc:`run Mopidy `. Alternatively you may want to have Mopidy run as a :doc:`system service `, automatically starting at boot. -Appendix: Fixing audio quality issues -===================================== +Appendix A: 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 @@ -141,3 +141,35 @@ not determined the exact revision that fixed this:: The only remaining known issue is a slight gap in playback at track changes this is likely due to gapless playback not being implemented and is being worked on irrespective of Raspberry Pi related work. + + +Appendix B: Raspbmc not booting +=============================== + +Due to a dependency version problem where XBMC uses another version of libtag +than what Debian originally ships with, you might have to make some minor +changes for Raspbmc to start properly after installing Mopidy. + +If you notice that XBMC is not starting but gets stuck in a loop, +you need to make the following changes:: + + sudo ln -sf /home/pi/.xbmc-current/xbmc-bin/lib/xbmc/system/libtag.so.1 \ + /usr/lib/arm-linux-gnueabihf/libtag.so.1 + +However, this will not persist the changes. To persist the changes edit +:file:`/etc/ld.so.conf.d/arm-linux-gnueabihf.conf` and add the following at the +top:: + + /home/pi/.xbmc-current/xbmc-bin/lib/xbmc/system + +It's very important to add it at the top of the file as this indicates the +priority of the folder in which to look for shared libraries. + +XBMC doesn't play nicely with the system wide installed version of libtag that +got installed together with Mopidy, but rather vendors in its own version. + +More info about this issue can be found in `this post +`_. + +Please note that if you're running Xbian or another XBMC distribution these +instructions might vary for your system. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 18ecb22d..60d7a428 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.19.4' +__version__ = '0.19.5' diff --git a/mopidy/commands.py b/mopidy/commands.py index 4b00a685..fecabe98 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -2,9 +2,11 @@ from __future__ import absolute_import, print_function, unicode_literals import argparse import collections +import contextlib import logging import os import sys +import time import glib @@ -63,6 +65,13 @@ class _HelpAction(argparse.Action): raise _HelpError() +@contextlib.contextmanager +def _startup_timer(name): + start = time.time() + yield + logger.debug('%s startup took %dms', name, (time.time() - start) * 1000) + + class Command(object): """Command parser and runner for building trees of commands. @@ -339,8 +348,9 @@ class RootCommand(Command): backends = [] for backend_class in backend_classes: try: - backend = backend_class.start( - config=config, audio=audio).proxy() + with _startup_timer(backend_class.__name__): + backend = backend_class.start( + config=config, audio=audio).proxy() backends.append(backend) except exceptions.BackendError as exc: logger.error( @@ -361,7 +371,8 @@ class RootCommand(Command): for frontend_class in frontend_classes: try: - frontend_class.start(config=config, core=core) + with _startup_timer(frontend_class.__name__): + frontend_class.start(config=config, core=core) except exceptions.FrontendError as exc: logger.error( 'Frontend (%s) initialization error: %s', diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 6edca51c..db451cef 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -98,8 +98,12 @@ def format_initial(extensions): versions = ['Mopidy %s' % versioning.get_version()] for extension in sorted(extensions, key=lambda ext: ext.dist_name): versions.append('%s %s' % (extension.dist_name, extension.version)) - description = _INITIAL_HELP.strip() % {'versions': '\n# '.join(versions)} - return description + '\n\n' + _format(config, {}, schemas, False, True) + + header = _INITIAL_HELP.strip() % {'versions': '\n# '.join(versions)} + formatted_config = _format( + config=config, comments={}, schemas=schemas, + display=False, disable=True).decode('utf-8') + return header + '\n\n' + formatted_config def _load(files, defaults, overrides): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 15a487b2..8a415257 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -185,16 +185,20 @@ 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. """ - tl_track = self.core.tracklist.next_track(self.current_tl_track) - if tl_track: + original_tl_track = self.current_tl_track + next_tl_track = self.core.tracklist.next_track(original_tl_track) + + if next_tl_track: # TODO: switch to: # backend.play(track) # wait for state change? - self.change_track(tl_track) + self.change_track(next_tl_track) else: self.stop() self.current_tl_track = None + self.core.tracklist.mark_played(original_tl_track) + def pause(self): """Pause playback.""" backend = self._get_backend() diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 4b378a02..48d20777 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -449,10 +449,10 @@ class TracklistController(object): def mark_played(self, tl_track): """Private method used by :class:`mopidy.core.PlaybackController`.""" - if not self.consume: - return False - self.remove(tlid=[tl_track.tlid]) - return True + if self.consume and tl_track is not None: + self.remove(tlid=[tl_track.tlid]) + return True + return False def _trigger_tracklist_changed(self): if self.random: diff --git a/mopidy/ext.py b/mopidy/ext.py index 7666bb3a..2f02c43b 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -188,10 +188,13 @@ def validate_extension(extension): extension.ext_name, ex) return False except pkg_resources.VersionConflict as ex: - found, required = ex.args - logger.info( - 'Disabled extension %s: %s required, but found %s at %s', - extension.ext_name, required, found, found.location) + if len(ex.args) == 2: + found, required = ex.args + logger.info( + 'Disabled extension %s: %s required, but found %s at %s', + extension.ext_name, required, found, found.location) + else: + logger.info('Disabled extension %s: %s', extension.ext_name, ex) return False try: diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index d37a5672..200ef833 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -129,11 +129,16 @@ class HttpServer(threading.Thread): def _get_app_request_handlers(self): result = [] for app in self.apps: + try: + request_handlers = app['factory'](self.config, self.core) + except Exception: + logger.exception('Loading %s failed.', app['name']) + continue + result.append(( r'/%s' % app['name'], handlers.AddSlashHandler )) - request_handlers = app['factory'](self.config, self.core) for handler in request_handlers: handler = list(handler) handler[0] = '/%s%s' % (app['name'], handler[0]) diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 8167a774..5d9cecd9 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -196,17 +196,12 @@ class MpdDispatcher(object): def _format_lines(self, line): if isinstance(line, dict): - return [self._escape_newlines('%s: %s' % (key, value)) - for (key, value) - in line.items()] + return ['%s: %s' % (key, value) for (key, value) in line.items()] if isinstance(line, tuple): (key, value) = line - return [self._escape_newlines('%s: %s' % (key, value))] + return ['%s: %s' % (key, value)] return [line] - def _escape_newlines(self, text): - return text.replace('\n', '\\n') - class MpdContext(object): """ diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index ec3a270b..23fb2874 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -81,9 +81,6 @@ def track_to_mpd_format(track, position=None): if track.disc_no: result.append(('Disc', track.disc_no)) - if track.comment: - result.append(('Comment', track.comment)) - if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 4c546ec3..c72d3b18 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -46,12 +46,14 @@ def get_or_create_file(file_path, mkdir=True, content=None): if not isinstance(file_path, bytes): raise ValueError('Path is not a bytestring.') file_path = expand_path(file_path) + if isinstance(content, compat.text_type): + content = content.encode('utf-8') if mkdir: get_or_create_dir(os.path.dirname(file_path)) if not os.path.isfile(file_path): logger.info('Creating file %s', file_path) - with open(file_path, 'w') as fh: - if content: + with open(file_path, 'wb') as fh: + if content is not None: fh.write(content) return file_path diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 18b73b17..04e3f260 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -239,6 +239,23 @@ class CorePlaybackTest(unittest.TestCase): # TODO Test next() more + def test_next_keeps_finished_track_in_tracklist(self): + tl_track = self.tl_tracks[0] + self.core.playback.play(tl_track) + + self.core.playback.next() + + self.assertIn(tl_track, self.core.tracklist.tl_tracks) + + def test_next_in_consume_mode_removes_finished_track(self): + tl_track = self.tl_tracks[0] + self.core.playback.play(tl_track) + self.core.tracklist.consume = True + + self.core.playback.next() + + self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_next_emits_events(self, listener_mock): @@ -265,6 +282,23 @@ class CorePlaybackTest(unittest.TestCase): # TODO Test previous() more + def test_previous_keeps_finished_track_in_tracklist(self): + tl_track = self.tl_tracks[1] + self.core.playback.play(tl_track) + + self.core.playback.previous() + + self.assertIn(tl_track, self.core.tracklist.tl_tracks) + + def test_previous_keeps_finished_track_even_in_consume_mode(self): + tl_track = self.tl_tracks[1] + self.core.playback.play(tl_track) + self.core.tracklist.consume = True + + self.core.playback.previous() + + self.assertIn(tl_track, self.core.tracklist.tl_tracks) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_previous_emits_events(self, listener_mock): @@ -291,6 +325,23 @@ class CorePlaybackTest(unittest.TestCase): # TODO Test on_end_of_track() more + def test_on_end_of_track_keeps_finished_track_in_tracklist(self): + tl_track = self.tl_tracks[0] + self.core.playback.play(tl_track) + + self.core.playback.on_end_of_track() + + self.assertIn(tl_track, self.core.tracklist.tl_tracks) + + def test_on_end_of_track_in_consume_mode_removes_finished_track(self): + tl_track = self.tl_tracks[0] + self.core.playback.play(tl_track) + self.core.tracklist.consume = True + + self.core.playback.on_end_of_track() + + self.assertNotIn(tl_track, self.core.tracklist.tl_tracks) + @mock.patch( 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) def test_on_end_of_track_emits_events(self, listener_mock): diff --git a/tests/local/test_playback.py b/tests/local/test_playback.py index 67e49178..ae031191 100644 --- a/tests/local/test_playback.py +++ b/tests/local/test_playback.py @@ -347,7 +347,7 @@ class LocalPlaybackProviderTest(unittest.TestCase): self.tracklist.consume = True self.playback.play() self.playback.next() - self.assertIn(self.tracks[0], self.tracklist.tracks) + self.assertNotIn(self.tracks[0], self.tracklist.tracks) @populate_tracklist def test_next_with_single_and_repeat(self): diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index b38a93e8..82e60d93 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -73,10 +73,10 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('Track', '7/13'), result) self.assertIn(('Date', datetime.date(1977, 1, 1)), result) self.assertIn(('Disc', '1'), result) - self.assertIn(('Comment', 'a comment'), result) self.assertIn(('Pos', 9), result) self.assertIn(('Id', 122), result) - self.assertEqual(len(result), 15) + self.assertNotIn(('Comment', 'a comment'), result) + self.assertEqual(len(result), 14) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.copy(musicbrainz_id='foo') diff --git a/tests/test_version.py b/tests/test_version.py index 32b40fca..d391760b 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -50,5 +50,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.19.0'), SV('0.19.1')) self.assertLess(SV('0.19.1'), SV('0.19.2')) self.assertLess(SV('0.19.2'), SV('0.19.3')) - self.assertLess(SV('0.19.3'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.19.5')) + self.assertLess(SV('0.19.3'), SV('0.19.4')) + self.assertLess(SV('0.19.4'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.19.6')) diff --git a/tests/utils/test_path.py b/tests/utils/test_path.py index 4467e07e..36d1f7db 100644 --- a/tests/utils/test_path.py +++ b/tests/utils/test_path.py @@ -107,12 +107,12 @@ class GetOrCreateFileTest(unittest.TestCase): with self.assertRaises(IOError): path.get_or_create_file(conflicting_dir) - def test_create_dir_with_unicode(self): + def test_create_dir_with_unicode_filename_throws_value_error(self): with self.assertRaises(ValueError): file_path = compat.text_type(os.path.join(self.parent, b'test')) path.get_or_create_file(file_path) - def test_create_file_with_none(self): + def test_create_file_with_none_filename_throws_value_error(self): with self.assertRaises(ValueError): path.get_or_create_file(None) @@ -121,12 +121,18 @@ class GetOrCreateFileTest(unittest.TestCase): with self.assertRaises(IOError): path.get_or_create_file(file_path, mkdir=False) - def test_create_dir_with_default_content(self): + def test_create_dir_with_bytes_content(self): file_path = os.path.join(self.parent, b'test') created = path.get_or_create_file(file_path, content=b'foobar') with open(created) as fh: self.assertEqual(fh.read(), b'foobar') + def test_create_dir_with_unicode_content(self): + file_path = os.path.join(self.parent, b'test') + created = path.get_or_create_file(file_path, content='foobaræøå') + with open(created) as fh: + self.assertEqual(fh.read(), b'foobaræøå') + class PathToFileURITest(unittest.TestCase): def test_simple_path(self):