diff --git a/docs/changelog.rst b/docs/changelog.rst index 10c413ec..4aad8690 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,18 @@ Changelog This changelog is used to track all major changes to Mopidy. +v1.0.5 (2015-05-19) +=================== + +Bug fix release. + +- Core: Add workaround for playlist providers that do not support + creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`) + +- M3U: Fix encoding error when saving playlists with non-ASCII track + titles. (Fixes: :issue:`1175`, PR :issue:`1176`) + + v1.0.4 (2015-04-30) =================== diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 91d0f8db..fe7ef21d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -12,38 +12,8 @@ http://mpd.wikia.com/wiki/Clients. :local: -Test procedure -============== - -In some cases, we've used the following test procedure to compare the feature -completeness of clients: - -#. Connect to Mopidy -#. Search for "foo", with search type "any" if it can be selected -#. Add "The Pretender" from the search results to the current playlist -#. Start playback -#. Pause and resume playback -#. Adjust volume -#. Find a playlist and append it to the current playlist -#. Skip to next track -#. Skip to previous track -#. Select the last track from the current playlist -#. Turn on repeat mode -#. Seek to 10 seconds or so before the end of the track -#. Wait for the end of the track and confirm that playback continues at the - start of the playlist -#. Turn off repeat mode -#. Turn on random mode -#. Skip to next track and confirm that it random mode works -#. Turn off random mode -#. Stop playback -#. Check if the app got support for single mode and consume mode -#. Kill Mopidy and confirm that the app handles it without crashing - - - -Console clients -=============== +MPD console clients +=================== ncmpcpp ------- @@ -83,8 +53,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with Mopidy. -Graphical clients -================= +MPD graphical clients +===================== GMPC ---- @@ -132,22 +102,12 @@ client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: -Android clients -=============== - -We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 -on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test -procedure. - +MPD Android clients +=================== MPDroid ------- -Test date: - 2012-11-06 -Tested version: - 1.03.1 (released 2012-10-16) - .. image:: mpd-client-mpdroid.jpg :width: 288 :height: 512 @@ -155,128 +115,17 @@ Tested version: You can get `MPDroid from Google Play `_. -- MPDroid started out as a fork of PMix, and is now much better. - -- MPDroid's user interface looks nice. - -- Everything in the test procedure works. - -- In contrast to all other Android clients, MPDroid does support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - MPDroid is a good MPD client, and really the only one we can recommend. -BitMPC ------- - -Test date: - 2012-11-06 -Tested version: - 1.0.0 (released 2010-04-12) - -You can get `BitMPC from Google Play -`_. - -- The user interface lacks some finishing touches. E.g. you can't enter a - hostname for the server. Only IPv4 addresses are allowed. - -- When we last tested the same version of BitMPC using Android 2.1: - - - All features exercised in the test procedure worked. - - - BitMPC lacked support for single mode and consume mode. - - - BitMPC crashed if Mopidy was killed or crashed. - -- When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as we fired off our - search, and continued to crash on startup after that. - -In conclusion, BitMPC is usable if you got an older Android phone and don't -care about looks. For newer Android versions, BitMPC will probably not work as -it hasn't been maintained for 2.5 years. - - -Droid MPD Client ----------------- - -Test date: - 2012-11-06 -Tested version: - 1.4.0 (released 2011-12-20) - -You can get `Droid MPD Client from Google Play -`_. - -- No intutive way to ask the app to connect to the server after adding the - server hostname to the settings. - -- To find the search functionality, you have to select the menu, - then "Playlist manager", then the search tab. I do not understand why search - is hidden inside "Playlist manager". - -- The tabs "Artists" and "Albums" did not contain anything, and did not cause - any requests. - -- The tab "Folders" showed a spinner and said "Updating data..." but did not - send any requests. - -- Searching for "foo" did nothing. No request was sent to the server. - -- Droid MPD client does not support single mode or consume mode. - -- Not able to complete the test procedure, due to the above problems. - -In conclusion, not a client we can recommend. - - -PMix ----- - -Test date: - 2012-11-06 -Tested version: - 0.4.0 (released 2010-03-06) - -You can get `PMix from Google Play -`_. - -PMix haven't been updated for 2.5 years, and has less working features than -it's fork MPDroid. Ignore PMix and use MPDroid instead. - - -MPD Remote ----------- - -Test date: - 2012-11-06 -Tested version: - 1.0 (released 2012-05-01) - -You can get `MPD Remote from Google Play -`_. - -This app looks terrible in the screen shots, got just 100+ downloads, and got a -terrible rating. I honestly didn't take the time to test it. - - .. _ios_mpd_clients: -iOS clients -=========== +MPD iOS clients +=============== MPoD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpod.jpg :width: 320 :height: 480 @@ -285,26 +134,10 @@ The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. -- The user interface looks nice. - -- All features exercised in the test procedure worked with MPaD, except seek, - which I didn't figure out to do. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - MPaD ---- -Test date: - 2012-11-06 -Tested version: - 1.7.1 - .. image:: mpd-client-mpad.jpg :width: 480 :height: 360 @@ -313,25 +146,11 @@ The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ -- The user interface looks nice, though I would like to be able to view the - current playlist in the large part of the split view. - -- All features exercised in the test procedure worked with MPaD. - -- Search only works in the "Browse" tab, and not under in the "Artist", - "Album", or "Song" tabs. For the tabs where search doesn't work, no queries - are sent to Mopidy when searching. - -- Single mode and consume mode is supported. - -- The server menu can be very slow top open, and there is no visible feedback - when waiting for the connection to a server to succeed. - .. _mpd-web-clients: -Web clients -=========== +MPD web clients +=============== The following web clients use the MPD protocol to communicate with Mopidy. For other web clients, see :ref:`http-clients`. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 0bc5410e..802a44d4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -30,4 +30,4 @@ except ImportError: warnings.filterwarnings('ignore', 'could not open display') -__version__ = '1.0.4' +__version__ = '1.0.5' diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 669e1f35..1b4c2692 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -118,13 +118,19 @@ class PlaylistsController(object): :rtype: :class:`mopidy.models.Playlist` """ if uri_scheme in self.backends.with_playlists: - backend = self.backends.with_playlists[uri_scheme] + backends = [self.backends.with_playlists[uri_scheme]] else: - # TODO: this fallback looks suspicious - backend = list(self.backends.with_playlists.values())[0] - playlist = backend.playlists.create(name).get() - listener.CoreListener.send('playlist_changed', playlist=playlist) - return playlist + backends = self.backends.with_playlists.values() + for backend in backends: + try: + playlist = backend.playlists.create(name).get() + except Exception: + playlist = None + # Workaround for playlist providers that return None from create() + if not playlist: + continue + listener.CoreListener.send('playlist_changed', playlist=playlist) + return playlist def delete(self, uri): """ diff --git a/mopidy/m3u/playlists.py b/mopidy/m3u/playlists.py index c09eccdf..8800e468 100644 --- a/mopidy/m3u/playlists.py +++ b/mopidy/m3u/playlists.py @@ -88,11 +88,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): self._playlists[playlist.uri] = playlist return playlist - 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:' + str(runtime) + ',' + title + '\n') - def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()): name = self._invalid_filename_chars.sub('|', name.strip()) # make sure we end up with a valid path segment @@ -113,15 +108,6 @@ class M3UPlaylistsProvider(backend.PlaylistsProvider): name, _ = os.path.splitext(os.path.basename(path).decode(encoding)) else: raise ValueError('M3U playlist needs name or URI') - extended = any(track.name for track in playlist.tracks) - - with open(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') - + translator.save_m3u(path, playlist.tracks, 'latin1') # assert playlist name matches file name/uri return playlist.copy(uri=uri, name=name) diff --git a/mopidy/m3u/translator.py b/mopidy/m3u/translator.py index 4eefce9d..a6e006b1 100644 --- a/mopidy/m3u/translator.py +++ b/mopidy/m3u/translator.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import codecs import logging import os import re @@ -108,3 +109,18 @@ def parse_m3u(file_path, media_dir=None): track = Track() return tracks + + +def save_m3u(filename, tracks, encoding='latin1', errors='replace'): + extended = any(track.name for track in tracks) + # codecs.open() always uses binary mode, just being explicit here + with codecs.open(filename, 'wb', encoding, errors) as m3u: + if extended: + m3u.write('#EXTM3U' + os.linesep) + for track in tracks: + if extended and track.name: + m3u.write('#EXTINF:%d,%s%s' % ( + track.length // 1000 if track.length else -1, + track.name, + os.linesep)) + m3u.write(track.uri + os.linesep) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 081f73e6..e02f6204 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -118,6 +118,32 @@ class PlaylistsTest(unittest.TestCase): self.sp1.create.assert_called_once_with('foo') self.assertFalse(self.sp2.create.called) + def test_create_without_uri_scheme_ignores_none_result(self): + playlist = Playlist() + self.sp1.create().get.return_value = None + self.sp1.reset_mock() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.sp2.create.assert_called_once_with('foo') + + def test_create_without_uri_scheme_ignores_exception(self): + playlist = Playlist() + self.sp1.create().get.side_effect = Exception + self.sp1.reset_mock() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.sp2.create.assert_called_once_with('foo') + def test_create_with_uri_scheme_selects_the_matching_backend(self): playlist = Playlist() self.sp2.create().get.return_value = playlist diff --git a/tests/m3u/test_playlists.py b/tests/m3u/test_playlists.py index 355aabf5..b7ac827f 100644 --- a/tests/m3u/test_playlists.py +++ b/tests/m3u/test_playlists.py @@ -107,9 +107,28 @@ class M3UPlaylistsProviderTest(unittest.TestCase): path = playlist_uri_to_path(playlist.uri, self.playlists_dir) with open(path) as f: - contents = f.read().splitlines() + m3u = f.read().splitlines() + self.assertEqual(['#EXTM3U', '#EXTINF:60,Test', track.uri], m3u) - self.assertEqual(contents, ['#EXTM3U', '#EXTINF:60,Test', track.uri]) + def test_latin1_playlist_contents_is_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test\x9f', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path, 'rb') as f: + m3u = f.read().splitlines() + self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test\x9f', track.uri], m3u) + + def test_utf8_playlist_contents_is_replaced_and_written_to_disk(self): + track = Track(uri=generate_song(1), name='Test\u07b4', length=60000) + playlist = self.core.playlists.create('test') + playlist = self.core.playlists.save(playlist.copy(tracks=[track])) + path = playlist_uri_to_path(playlist.uri, self.playlists_dir) + + with open(path, 'rb') as f: + m3u = f.read().splitlines() + self.assertEqual([b'#EXTM3U', b'#EXTINF:60,Test?', track.uri], m3u) def test_playlists_are_loaded_at_startup(self): track = Track(uri='dummy:track:path2') diff --git a/tests/test_version.py b/tests/test_version.py index 37d0b459..ed413cc1 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -59,5 +59,6 @@ class VersionTest(unittest.TestCase): self.assertVersionLess('1.0.0', '1.0.1') self.assertVersionLess('1.0.1', '1.0.2') self.assertVersionLess('1.0.2', '1.0.3') - self.assertVersionLess('1.0.3', __version__) - self.assertVersionLess(__version__, '1.0.5') + self.assertVersionLess('1.0.3', '1.0.4') + self.assertVersionLess('1.0.4', __version__) + self.assertVersionLess(__version__, '1.0.6')