From 6fdb0579f618d49b111c6033062cf8d5c211a395 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 01:03:19 +0100 Subject: [PATCH 01/94] travis: mopidy package now Recommends cherrypy and ws4py --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7acda2bd..df08679b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ install: - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" - - "pip install -r requirements/http.txt" # Until ws4py is packaged as a .deb before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" From 4122bd663965300dc01e927cefa4f8b5bb443f57 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 01:59:24 +0100 Subject: [PATCH 02/94] docs: Add HTTP frontends to frontend impls list --- docs/api/frontends.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 2237b4e7..8488b408 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -44,6 +44,7 @@ The following requirements applies to any frontend implementation: Frontend implementations ======================== +* :mod:`mopidy.frontends.http` * :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpris` From fe2adfae73d3d8a37dfda42863b322001b3b8573 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:02:16 +0100 Subject: [PATCH 03/94] docs: Fix syntax error in example --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 59c867d8..cfe8c46a 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -229,7 +229,7 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the .. code-block:: js - mopidy.on("state:online", function () [ + mopidy.on("state:online", function () { mopidy.playback.next(); }); From daa56e656710a5bbbc6c0db2ce245c806eadd93f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:06:05 +0100 Subject: [PATCH 04/94] docs: Fix typo --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index cfe8c46a..3c96c213 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -324,7 +324,7 @@ event listeners, and delete the object like this: .. code-block:: js // Close the WebSocket without reconnecting. Letting the object be garbage - // collected will have the same effect, so this isn't striclty necessary. + // collected will have the same effect, so this isn't strictly necessary. mopidy.close(); // Unregister all event listeners. If you don't do this, you may have From 0c6de005a0e74d0cd8a42dff49f3374b8ecd50ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:07:24 +0100 Subject: [PATCH 05/94] docs: Fix typo --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 3c96c213..32edde0f 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -452,7 +452,7 @@ Example to get started with 9. The web page should now queue and play your first playlist every time your load it. See the browser's console for output from the function, any errors, - and a all events that are emitted. + and all events that are emitted. """ # flake8: noqa From eb717693f921305038703a0785de0ae0b112fb6c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:17:29 +0100 Subject: [PATCH 06/94] Add fabfile.py with autotest task --- fabfile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 fabfile.py diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 00000000..e2d77a06 --- /dev/null +++ b/fabfile.py @@ -0,0 +1,14 @@ +from fabric.api import local + + +def test(): + local('nosetests tests/') + + +def autotest(): + while True: + local('clear') + test() + local( + 'inotifywait -q -e create -e modify -e delete ' + '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') From 1919d7f8c21f888cab96b4a52b15b6e4094c2b4e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 02:21:21 +0100 Subject: [PATCH 07/94] docs: Include full list of authors --- .mailmap | 2 ++ AUTHORS | 13 +++++++++++++ docs/authors.rst | 8 +------- fabfile.py | 6 ++++++ 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 AUTHORS diff --git a/.mailmap b/.mailmap index 15d8f359..93a4aed1 100644 --- a/.mailmap +++ b/.mailmap @@ -1,3 +1,5 @@ +Thomas Adamcik +Thomas Adamcik Kristian Klette Johannes Knutsen Johannes Knutsen diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..9c9951f8 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,13 @@ +- Stein Magnus Jodal +- Johannes Knutsen +- Thomas Adamcik +- Kristian Klette +- Martins Grunskis +- Henrik Olsson +- Antoine Pierlot-Garcin +- John Bäckstrand +- Fred Hatfull +- Erling Børresen +- David C +- Christian Johansen +- Matt Bray diff --git a/docs/authors.rst b/docs/authors.rst index 822abc15..97a2dd2b 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,13 +4,7 @@ Authors Contributors to Mopidy in the order of appearance: -- Stein Magnus Jodal -- Johannes Knutsen -- Thomas Adamcik -- Kristian Klette - -A complete list of persons with commits accepted into the Mopidy repo can be -found at `GitHub `_. +.. include:: ../AUTHORS Showing your appreciation diff --git a/fabfile.py b/fabfile.py index e2d77a06..267bdc23 100644 --- a/fabfile.py +++ b/fabfile.py @@ -12,3 +12,9 @@ def autotest(): local( 'inotifywait -q -e create -e modify -e delete ' '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') + + +def update_authors(): + # Keep authors in the order of appearance and use awk to filter out dupes + local( + "git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") From 165a0e4aef6d0f886707ef0ae046418381a2df08 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 13:48:39 +0100 Subject: [PATCH 08/94] Update PyPI short package description --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6135df31..5840ca53 100644 --- a/setup.py +++ b/setup.py @@ -94,7 +94,7 @@ setup( scripts=['bin/mopidy', 'bin/mopidy-scan'], url='http://www.mopidy.com/', license='Apache License, Version 2.0', - description='MPD server with Spotify support', + description='Music server with MPD and Spotify support', long_description=open('README.rst').read(), classifiers=[ 'Development Status :: 4 - Beta', From bac240501befc5a73c86c060e6cd16f8daa63b5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 11:52:55 +0100 Subject: [PATCH 09/94] docs: Add empty changelog entry for v0.11 --- docs/changes.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index b6e433d3..3720ccf4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,13 @@ Changes This change log is used to track all major changes to Mopidy. + +v0.11.0 (in development) +======================== + +- No changes yet. + + v0.10.0 (2012-12-12) ==================== From ac537a63c75145ee3bf60d35c74b6d3e3dda1895 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 11:51:55 +0100 Subject: [PATCH 10/94] mpd: Add 'seekcur' command --- docs/changes.rst | 4 ++- mopidy/frontends/mpd/protocol/playback.py | 20 ++++++++++++ tests/frontends/mpd/protocol/playback_test.py | 31 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3720ccf4..8de66e45 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,7 +8,9 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== -- No changes yet. +**MPD frontend** + +- Add support for ``seekcur`` command added in MPD 0.17. v0.10.0 (2012-12-12) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 5a4569e1..68c49ca0 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -349,6 +349,26 @@ def seekid(context, tlid, seconds): context.core.playback.seek(int(seconds) * 1000).get() +@handle_request(r'^seekcur "(?P\d+)"$') +@handle_request(r'^seekcur "(?P[-+]\d+)"$') +def seekcur(context, position=None, diff=None): + """ + *musicpd.org, playback section:* + + ``seekcur {TIME}`` + + Seeks to the position ``TIME`` within the current song. If prefixed by + '+' or '-', then the time is relative to the current playing position. + """ + if position is not None: + position = int(position) * 1000 + context.core.playback.seek(position).get() + elif diff is not None: + position = context.core.playback.time_position.get() + position += int(diff) * 1000 + context.core.playback.seek(position).get() + + @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') def setvol(context, volume): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 14168a35..063493ec 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -414,6 +414,37 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse('OK') + def test_seekcur_absolute_value(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + + self.sendRequest('seekcur "30"') + + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + self.assertInResponse('OK') + + def test_seekcur_positive_diff(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(10000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 10000) + + self.sendRequest('seekcur "+20"') + + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + self.assertInResponse('OK') + + def test_seekcur_negative_diff(self): + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) + + self.sendRequest('seekcur "-20"') + + self.assertLessEqual(self.core.playback.time_position.get(), 15000) + self.assertInResponse('OK') + def test_stop(self): self.sendRequest('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) From 49d585a97c7e13d3e39c315a13bddbfe58206141 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 12:08:17 +0100 Subject: [PATCH 11/94] mpd: Add 'config' command --- docs/changes.rst | 2 ++ mopidy/frontends/mpd/protocol/reflection.py | 22 ++++++++++++++++--- .../frontends/mpd/protocol/reflection_test.py | 11 ++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8de66e45..6f15ff20 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,8 @@ v0.11.0 (in development) - Add support for ``seekcur`` command added in MPD 0.17. +- Add support for ``config`` command added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index d9c35743..cc1c7222 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,8 +1,23 @@ from __future__ import unicode_literals +from mopidy.frontends.mpd.exceptions import MpdPermissionError from mopidy.frontends.mpd.protocol import handle_request, mpd_commands +@handle_request(r'^config$', auth_required=False) +def config(context): + """ + *musicpd.org, reflection section:* + + ``config`` + + Dumps configuration values that may be interesting for the client. This + command is only permitted to "local" clients (connected via UNIX domain + socket). + """ + raise MpdPermissionError(command='config') + + @handle_request(r'^commands$', auth_required=False) def commands(context): """ @@ -19,10 +34,10 @@ def commands(context): command.name for command in mpd_commands if not command.auth_required]) - # No one is permited to use kill, rest of commands are not listed by MPD, - # so we shouldn't either. + # No one is permited to use 'config' or 'kill', rest of commands are not + # listed by MPD, so we shouldn't either. command_names = command_names - set([ - 'kill', 'command_list_begin', 'command_list_ok_begin', + 'config', 'kill', 'command_list_begin', 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', 'sticker']) @@ -73,6 +88,7 @@ def notcommands(context): command.name for command in mpd_commands if command.auth_required] # No permission to use + command_names.append('config') command_names.append('kill') return [ diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py index 9c07f104..f2720473 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -6,6 +6,11 @@ from tests.frontends.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): + def test_config_is_not_allowed_across_the_network(self): + self.sendRequest('config') + self.assertEqualResponse( + 'ACK [4@0] {config} you don\'t have permission for "config"') + def test_commands_returns_list_of_all_commands(self): self.sendRequest('commands') # Check if some random commands are included @@ -13,6 +18,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertInResponse('command: play') self.assertInResponse('command: status') # Check if commands you do not have access to are not present + self.assertNotInResponse('command: config') self.assertNotInResponse('command: kill') # Check if the blacklisted commands are not present self.assertNotInResponse('command: command_list_begin') @@ -40,9 +46,10 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.sendRequest('decoders') self.assertInResponse('OK') - def test_notcommands_returns_only_kill_and_ok(self): + def test_notcommands_returns_only_config_and_kill_and_ok(self): response = self.sendRequest('notcommands') - self.assertEqual(2, len(response)) + self.assertEqual(3, len(response)) + self.assertInResponse('command: config') self.assertInResponse('command: kill') self.assertInResponse('OK') From 50cbe5f3841e2801d573483f41ed1b8b174a2a58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Dec 2012 22:12:24 +0100 Subject: [PATCH 12/94] mpd: Add range support to 'load' command --- docs/changes.rst | 3 ++ .../mpd/protocol/stored_playlists.py | 23 +++++++++--- .../mpd/protocol/stored_playlists_test.py | 36 ++++++++++++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6f15ff20..300af3d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,9 @@ v0.11.0 (in development) - Add support for ``config`` command added in MPD 0.17. +- Add support for loading a range of tracks from a playlist to the ``load`` + command, as added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index eef1f3d1..034403ec 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -92,23 +92,36 @@ def listplaylists(context): return result -@handle_request(r'^load "(?P[^"]+)"$') -def load(context, name): +@handle_request(r'^load "(?P[^"]+)"( "(?P\d+):(?P\d+)*")*$') +def load(context, name, start=None, end=None): """ *musicpd.org, stored playlists section:* - ``load {NAME}`` + ``load {NAME} [START:END]`` - Loads the playlist ``NAME.m3u`` from the playlist directory. + Loads the playlist into the current queue. Playlist plugins are + supported. A range may be specified to load only a part of the + playlist. *Clarifications:* - ``load`` appends the given playlist to the current playlist. + + - MPD 0.17.1 does not support open-ended ranges, i.e. without end + specified, for the ``load`` command, even though MPD's general range docs + allows open-ended ranges. + + - MPD 0.17.1 does not fail if the specified range is outside the playlist, + in either or both ends. """ playlists = context.core.playlists.filter(name=name).get() if not playlists: raise MpdNoExistError('No such playlist', command='load') - context.core.tracklist.add(playlists[0].tracks) + if start is not None: + start = int(start) + if end is not None: + end = int(end) + context.core.tracklist.add(playlists[0].tracks[start:end]) @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index be2afd4c..49da5d0b 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -73,7 +73,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('playlist: ') self.assertInResponse('OK') - def test_load_known_playlist_appends_to_tracklist(self): + def test_load_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.playlists = [ @@ -81,6 +81,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list"') + tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) @@ -90,6 +91,39 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqual('e', tracks[4].uri) self.assertInResponse('OK') + def test_load_with_range_loads_part_of_playlist(self): + self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.backend.playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest('load "A-list" "1:2"') + + tracks = self.core.tracklist.tracks.get() + self.assertEqual(3, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('d', tracks[2].uri) + self.assertInResponse('OK') + + def test_load_with_range_without_end_loads_rest_of_playlist(self): + self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) + self.backend.playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] + + self.sendRequest('load "A-list" "1:"') + + tracks = self.core.tracklist.tracks.get() + self.assertEqual(4, len(tracks)) + self.assertEqual('a', tracks[0].uri) + self.assertEqual('b', tracks[1].uri) + self.assertEqual('d', tracks[2].uri) + self.assertEqual('e', tracks[3].uri) + self.assertInResponse('OK') + def test_load_unknown_playlist_acks(self): self.sendRequest('load "unknown playlist"') self.assertEqual(0, len(self.core.tracklist.tracks.get())) From 6ac2c249b52d1a076aca3308f9caf52601bc2221 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 00:33:11 +0100 Subject: [PATCH 13/94] mpd: Add 'findadd' command --- docs/changes.rst | 2 ++ mopidy/frontends/mpd/protocol/music_db.py | 30 +++++++++++-------- tests/frontends/mpd/protocol/music_db_test.py | 10 ++++++- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 300af3d3..4224c5d2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -17,6 +17,8 @@ v0.11.0 (in development) - Add support for loading a range of tracks from a playlist to the ``load`` command, as added in MPD 0.17. +- Add support for the ``findadd`` command. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 00b9ec00..91f0dcf4 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -51,7 +51,8 @@ def count(context, tag, needle): @handle_request( - r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'^find ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def find(context, mpd_query): """ @@ -59,8 +60,10 @@ def find(context, mpd_query): ``find {TYPE} {WHAT}`` - Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be - ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any + tag supported by MPD, or one of the two special parameters - ``file`` + to search by full path (relative to database root), and ``any`` to + match against all available tags. ``WHAT`` is what to find. *GMPC:* @@ -82,26 +85,29 @@ def find(context, mpd_query): query = _build_query(mpd_query) except ValueError: return - return tracks_to_mpd_format( - context.core.library.find_exact(**query).get()) + result = context.core.library.find_exact(**query).get() + return tracks_to_mpd_format(result) @handle_request( r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' - r'"[^"]+"\s?)+)$') -def findadd(context, query): + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def findadd(context, mpd_query): """ *musicpd.org, music database section:* ``findadd {TYPE} {WHAT}`` Finds songs in the db that are exactly ``WHAT`` and adds them to - current playlist. ``TYPE`` can be any tag supported by MPD. - ``WHAT`` is what to find. + current playlist. Parameters have the same meaning as for ``find``. """ - # TODO Add result to current playlist - #result = context.find(query) + try: + query = _build_query(mpd_query) + except ValueError: + return + result = context.core.library.find_exact(**query).get() + context.core.tracklist.add(result) @handle_request( diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 4539eb4c..7f50d169 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -13,7 +13,15 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_findadd(self): - self.sendRequest('findadd "album" "what"') + self.backend.library.dummy_find_exact_result = [ + Track(uri='dummy:a', name='A'), + ] + self.assertEqual(self.core.tracklist.length.get(), 0) + + self.sendRequest('findadd "title" "A"') + + self.assertEqual(self.core.tracklist.length.get(), 1) + self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') def test_listall(self): From 9b1dfa69784412d84695dbab34c828c7b542ef8b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 00:40:33 +0100 Subject: [PATCH 14/94] mpd: Add 'searchadd' command --- docs/changes.rst | 2 + mopidy/frontends/mpd/protocol/music_db.py | 38 +++++++++++++++---- tests/frontends/mpd/protocol/music_db_test.py | 12 ++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4224c5d2..83252a9c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -19,6 +19,8 @@ v0.11.0 (in development) - Add support for the ``findadd`` command. +- Add support for ``searchadd`` command added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 91f0dcf4..2c0a2c32 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -340,17 +340,17 @@ def rescan(context, uri=None): @handle_request( - r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'^search ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def search(context, mpd_query): """ *musicpd.org, music database section:* - ``search {TYPE} {WHAT}`` + ``search {TYPE} {WHAT} [...]`` - Searches for any song that contains ``WHAT``. ``TYPE`` can be - ``title``, ``artist``, ``album`` or ``filename``. Search is not - case sensitive. + Searches for any song that contains ``WHAT``. Parameters have the same + meaning as for ``find``, except that search is not case sensitive. *GMPC:* @@ -374,8 +374,32 @@ def search(context, mpd_query): query = _build_query(mpd_query) except ValueError: return - return tracks_to_mpd_format( - context.core.library.search(**query).get()) + result = context.core.library.search(**query).get() + return tracks_to_mpd_format(result) + + +@handle_request( + r'^searchadd ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def searchadd(context, mpd_query): + """ + *musicpd.org, music database section:* + + ``searchadd {TYPE} {WHAT} [...]`` + + Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds + them to current playlist. + + Parameters have the same meaning as for ``find``, except that search is + not case sensitive. + """ + try: + query = _build_query(mpd_query) + except ValueError: + return + result = context.core.library.search(**query).get() + context.core.tracklist.add(result) @handle_request(r'^update( "(?P[^"]+)")*$') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 7f50d169..13f0759b 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -24,6 +24,18 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') + def test_searchadd(self): + self.backend.library.dummy_search_result = [ + Track(uri='dummy:a', name='A'), + ] + self.assertEqual(self.core.tracklist.length.get(), 0) + + self.sendRequest('searchadd "title" "a"') + + self.assertEqual(self.core.tracklist.length.get(), 1) + self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') + self.assertInResponse('OK') + def test_listall(self): self.sendRequest('listall "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') From b95c8032de14cdb7a92620eb45bf7a7259532497 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 01:18:13 +0100 Subject: [PATCH 15/94] mpd: Add 'searchaddpl' command --- docs/changes.rst | 2 + mopidy/frontends/mpd/protocol/music_db.py | 35 +++++++++++++++++ tests/frontends/mpd/protocol/music_db_test.py | 38 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 83252a9c..8add66e1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -21,6 +21,8 @@ v0.11.0 (in development) - Add support for ``searchadd`` command added in MPD 0.17. +- Add support for ``searchaddpl`` command added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 2c0a2c32..66735538 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -402,6 +402,41 @@ def searchadd(context, mpd_query): context.core.tracklist.add(result) +@handle_request( + r'^searchaddpl ' + r'"(?P[^"]+)" ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def searchaddpl(context, playlist_name, mpd_query): + """ + *musicpd.org, music database section:* + + ``searchaddpl {NAME} {TYPE} {WHAT} [...]`` + + Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds + them to the playlist named ``NAME``. + + If a playlist by that name doesn't exist it is created. + + Parameters have the same meaning as for ``find``, except that search is + not case sensitive. + """ + try: + query = _build_query(mpd_query) + except ValueError: + return + result = context.core.library.search(**query).get() + + playlists = context.core.playlists.filter(name=playlist_name).get() + if playlists: + playlist = playlists[0] + else: + playlist = context.core.playlists.create(playlist_name).get() + tracks = list(playlist.tracks) + result + playlist = playlist.copy(tracks=tracks) + context.core.playlists.save(playlist) + + @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): """ diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 13f0759b..5c887958 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -36,6 +36,44 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a') self.assertInResponse('OK') + def test_searchaddpl_appends_to_existing_playlist(self): + playlist = self.core.playlists.create('my favs').get() + playlist = playlist.copy(tracks=[ + Track(uri='dummy:x', name='X'), + Track(uri='dummy:y', name='y'), + ]) + self.core.playlists.save(playlist) + self.backend.library.dummy_search_result = [ + Track(uri='dummy:a', name='A'), + ] + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(len(playlists[0].tracks), 2) + + self.sendRequest('searchaddpl "my favs" "title" "a"') + + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(len(playlists[0].tracks), 3) + self.assertEqual(playlists[0].tracks[0].uri, 'dummy:x') + self.assertEqual(playlists[0].tracks[1].uri, 'dummy:y') + self.assertEqual(playlists[0].tracks[2].uri, 'dummy:a') + self.assertInResponse('OK') + + def test_searchaddpl_creates_missing_playlist(self): + self.backend.library.dummy_search_result = [ + Track(uri='dummy:a', name='A'), + ] + self.assertEqual( + len(self.core.playlists.filter(name='my favs').get()), 0) + + self.sendRequest('searchaddpl "my favs" "title" "a"') + + playlists = self.core.playlists.filter(name='my favs').get() + self.assertEqual(len(playlists), 1) + self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a') + self.assertInResponse('OK') + def test_listall(self): self.sendRequest('listall "file:///dev/urandom"') self.assertEqualResponse('ACK [0@0] {} Not implemented') From b43fc2ebe98cf0e2712c4d0e50748b8f9a79e386 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 01:44:20 +0100 Subject: [PATCH 16/94] mpd: Stub channel commands --- docs/changes.rst | 3 + docs/modules/frontends/mpd.rst | 8 +++ mopidy/frontends/mpd/protocol/__init__.py | 5 +- mopidy/frontends/mpd/protocol/channels.py | 69 +++++++++++++++++++ tests/frontends/mpd/protocol/channels_test.py | 25 +++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 mopidy/frontends/mpd/protocol/channels.py create mode 100644 tests/frontends/mpd/protocol/channels_test.py diff --git a/docs/changes.rst b/docs/changes.rst index 8add66e1..e313c540 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,6 +23,9 @@ v0.11.0 (in development) - Add support for ``searchaddpl`` command added in MPD 0.17. +- Add empty stubs for channel commands for client to client communication, + which was added in MPD 0.17. + v0.10.0 (2012-12-12) ==================== diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 090ca5cd..f25b90f2 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -30,6 +30,14 @@ Audio output :members: +Channels +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.channels + :synopsis: MPD protocol: channels -- client to client communication + :members: + + Command list ------------ diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index a8bdc2c7..6afde4b9 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -74,6 +74,7 @@ def load_protocol_modules(): """ # pylint: disable = W0612 from . import ( # noqa - audio_output, command_list, connection, current_playlist, empty, - music_db, playback, reflection, status, stickers, stored_playlists) + audio_output, channels, command_list, connection, current_playlist, + empty, music_db, playback, reflection, status, stickers, + stored_playlists) # pylint: enable = W0612 diff --git a/mopidy/frontends/mpd/protocol/channels.py b/mopidy/frontends/mpd/protocol/channels.py new file mode 100644 index 00000000..11ac6fda --- /dev/null +++ b/mopidy/frontends/mpd/protocol/channels.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals + +from mopidy.frontends.mpd.protocol import handle_request +from mopidy.frontends.mpd.exceptions import MpdNotImplemented + + +@handle_request(r'^subscribe "(?P[A-Za-z0-9:._-]+)"$') +def subscribe(context, channel): + """ + *musicpd.org, client to client section:* + + ``subscribe {NAME}`` + + Subscribe to a channel. The channel is created if it does not exist + already. The name may consist of alphanumeric ASCII characters plus + underscore, dash, dot and colon. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^unsubscribe "(?P[A-Za-z0-9:._-]+)"$') +def unsubscribe(context, channel): + """ + *musicpd.org, client to client section:* + + ``unsubscribe {NAME}`` + + Unsubscribe from a channel. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^channels$') +def channels(context): + """ + *musicpd.org, client to client section:* + + ``channels`` + + Obtain a list of all channels. The response is a list of "channel:" + lines. + """ + raise MpdNotImplemented # TODO + + +@handle_request(r'^readmessages$') +def readmessages(context): + """ + *musicpd.org, client to client section:* + + ``readmessages`` + + Reads messages for this client. The response is a list of "channel:" + and "message:" lines. + """ + raise MpdNotImplemented # TODO + + +@handle_request( + r'^sendmessage "(?P[A-Za-z0-9:._-]+)" "(?P[^"]*)"$') +def sendmessage(context, channel, text): + """ + *musicpd.org, client to client section:* + + ``sendmessage {CHANNEL} {TEXT}`` + + Send a message to the specified channel. + """ + raise MpdNotImplemented # TODO diff --git a/tests/frontends/mpd/protocol/channels_test.py b/tests/frontends/mpd/protocol/channels_test.py new file mode 100644 index 00000000..86cf8197 --- /dev/null +++ b/tests/frontends/mpd/protocol/channels_test.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +from tests.frontends.mpd import protocol + + +class ChannelsHandlerTest(protocol.BaseTestCase): + def test_subscribe(self): + self.sendRequest('subscribe "topic"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_unsubscribe(self): + self.sendRequest('unsubscribe "topic"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_channels(self): + self.sendRequest('channels') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_readmessages(self): + self.sendRequest('readmessages') + self.assertEqualResponse('ACK [0@0] {} Not implemented') + + def test_sendmessage(self): + self.sendRequest('sendmessage "topic" "a message"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') From 0b6673e7f59a193e77b9647b9a78ecc6c6dfefef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 01:46:59 +0100 Subject: [PATCH 17/94] mpd: Bump protocol version to 0.17.0 --- docs/changes.rst | 23 ++++++++++++----------- mopidy/frontends/mpd/protocol/__init__.py | 4 ++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index e313c540..6a5aa572 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,21 +10,22 @@ v0.11.0 (in development) **MPD frontend** -- Add support for ``seekcur`` command added in MPD 0.17. - -- Add support for ``config`` command added in MPD 0.17. - -- Add support for loading a range of tracks from a playlist to the ``load`` - command, as added in MPD 0.17. - - Add support for the ``findadd`` command. -- Add support for ``searchadd`` command added in MPD 0.17. +- Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`): -- Add support for ``searchaddpl`` command added in MPD 0.17. + - Add support for ``seekcur`` command. -- Add empty stubs for channel commands for client to client communication, - which was added in MPD 0.17. + - Add support for ``config`` command. + + - Add support for loading a range of tracks from a playlist to the ``load`` + command. + + - Add support for ``searchadd`` command. + + - Add support for ``searchaddpl`` command. + + - Add empty stubs for channel commands for client to client communication. v0.10.0 (2012-12-12) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 6afde4b9..1827624b 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -21,8 +21,8 @@ ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. LINE_TERMINATOR = '\n' -#: The MPD protocol version is 0.16.0. -VERSION = '0.16.0' +#: The MPD protocol version is 0.17.0. +VERSION = '0.17.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) From a084105898f6be5c87f0e6716df8300f4edb3d17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:07:29 +0100 Subject: [PATCH 18/94] docs: Use requirement files as single source of dependencies --- mopidy/audio/mixers/nad.py | 8 ++++---- mopidy/backends/spotify/__init__.py | 3 +-- mopidy/frontends/http/__init__.py | 4 +--- mopidy/frontends/lastfm.py | 2 +- requirements/README.rst | 4 ++-- requirements/core.txt | 1 + requirements/external_mixers.txt | 1 + requirements/http.txt | 3 +++ requirements/lastfm.txt | 2 ++ requirements/spotify.txt | 7 +++++++ 10 files changed, 23 insertions(+), 12 deletions(-) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index b5cb522d..52ab4757 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -1,11 +1,11 @@ """Mixer that controls volume using a NAD amplifier. +The NAD amplifier must be connected to the machine running Mopidy using a +serial cable. + **Dependencies:** -- pyserial (python-serial in Debian/Ubuntu) - -- The NAD amplifier must be connected to the machine running Mopidy using a - serial cable. +.. literalinclude:: ../../../../requirements/external_mixers.txt **Settings:** diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index a8e9ffda..507511f4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -20,8 +20,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** -- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) -- pyspotify >= 1.9, < 1.11 (python-spotify package from apt.mopidy.com) +.. literalinclude:: ../../../requirements/spotify.txt **Settings:** diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 32edde0f..94b8e58e 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -4,9 +4,7 @@ from a web based client. **Dependencies** -- ``cherrypy`` - -- ``ws4py`` +.. literalinclude:: ../../../requirements/http.txt **Settings** diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 7f367262..565e5041 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -8,7 +8,7 @@ Frontend which scrobbles the music you play to your `Last.fm **Dependencies:** -- `pylast `_ >= 0.5.7 +.. literalinclude:: ../../../requirements/lastfm.txt **Settings:** diff --git a/requirements/README.rst b/requirements/README.rst index cc061a7b..e1a6d757 100644 --- a/requirements/README.rst +++ b/requirements/README.rst @@ -3,8 +3,8 @@ pip requirement files ********************* The files found here are `requirement files -`_ that may be used with `pip -`_. +`_ that may be used +with `pip `_. To install the dependencies found in one of these files, simply run e.g.:: diff --git a/requirements/core.txt b/requirements/core.txt index 7f83e251..7a28564f 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1,2 @@ Pykka >= 1.0 +# Available as python-pykka from apt.mopidy.com diff --git a/requirements/external_mixers.txt b/requirements/external_mixers.txt index f6c1a1f5..20cb7864 100644 --- a/requirements/external_mixers.txt +++ b/requirements/external_mixers.txt @@ -1 +1,2 @@ pyserial +# Available as python-serial in Debian/Ubuntu diff --git a/requirements/http.txt b/requirements/http.txt index d8757e29..aea7c1a8 100644 --- a/requirements/http.txt +++ b/requirements/http.txt @@ -1,2 +1,5 @@ cherrypy >= 3.2.2 +# Available as python-cherrypy3 in Debian/Ubuntu + ws4py >= 0.2.3 +# Available as python-ws4py from apt.mopidy.com diff --git a/requirements/lastfm.txt b/requirements/lastfm.txt index 314c4223..c52256c3 100644 --- a/requirements/lastfm.txt +++ b/requirements/lastfm.txt @@ -1 +1,3 @@ pylast >= 0.5.7 +# Available as python-pylast in newer Debian/Ubuntu and from apt.mopidy.com for +# older releases of Debian/Ubuntu diff --git a/requirements/spotify.txt b/requirements/spotify.txt index b501e63e..333e55c8 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1 +1,8 @@ pyspotify >= 1.9, < 1.11 +# The libspotify Python wrapper +# Available as the python-spotify package from apt.mopidy.com + +# libspotify >= 12, < 13 +# The libspotify C library from +# https://developer.spotify.com/technologies/libspotify/ +# Available as the libspotify12 package from apt.mopidy.com From 8fdd7fdf585b562260ae209494fadac6c0b6cc88 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:13:58 +0100 Subject: [PATCH 19/94] docs: The Mopidy resources was moved to /mopidy --- mopidy/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 259bc645..0a71ccfa 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -103,10 +103,10 @@ HTTP_SERVER_HOSTNAME = u'127.0.0.1' #: Default: 6680 HTTP_SERVER_PORT = 6680 -#: Which directory Mopidy's HTTP server should serve at /. +#: Which directory Mopidy's HTTP server should serve at ``/``. #: #: Change this to have Mopidy serve e.g. files for your JavaScript client. -#: /api and /ws will continue to work as usual even if you change this setting. +#: ``/mopidy`` will continue to work as usual even if you change this setting. #: #: Used by :mod:`mopidy.frontends.http`. #: From b1f0a67dd42e8fdf041250514611003f5130f642 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:43:45 +0100 Subject: [PATCH 20/94] mpd: Reuse query regexp. Fix 'filename' expression --- mopidy/frontends/mpd/protocol/music_db.py | 31 ++++++++--------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 66735538..7cdfc5e0 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -8,6 +8,11 @@ from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.translator import tracks_to_mpd_format +QUERY_RE = ( + r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') + + def _build_query(mpd_query): """ Parses a MPD query string and converts it to the Mopidy query format. @@ -50,10 +55,7 @@ def count(context, tag, needle): return [('songs', 0), ('playtime', 0)] # TODO -@handle_request( - r'^find ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^find ' + QUERY_RE) def find(context, mpd_query): """ *musicpd.org, music database section:* @@ -89,10 +91,7 @@ def find(context, mpd_query): return tracks_to_mpd_format(result) -@handle_request( - r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^findadd ' + QUERY_RE) def findadd(context, mpd_query): """ *musicpd.org, music database section:* @@ -339,10 +338,7 @@ def rescan(context, uri=None): return update(context, uri, rescan_unmodified_files=True) -@handle_request( - r'^search ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^search ' + QUERY_RE) def search(context, mpd_query): """ *musicpd.org, music database section:* @@ -378,10 +374,7 @@ def search(context, mpd_query): return tracks_to_mpd_format(result) -@handle_request( - r'^searchadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^searchadd ' + QUERY_RE) def searchadd(context, mpd_query): """ *musicpd.org, music database section:* @@ -402,11 +395,7 @@ def searchadd(context, mpd_query): context.core.tracklist.add(result) -@handle_request( - r'^searchaddpl ' - r'"(?P[^"]+)" ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +@handle_request(r'^searchaddpl "(?P[^"]+)" ' + QUERY_RE) def searchaddpl(context, playlist_name, mpd_query): """ *musicpd.org, music database section:* From a5993628631a122624765fcdb263dd5138c864f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 15 Dec 2012 23:46:52 +0100 Subject: [PATCH 21/94] Remove unused yappi profiler --- requirements/tests.txt | 1 - tests/__main__.py | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/requirements/tests.txt b/requirements/tests.txt index 20aff929..74fe7595 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -5,4 +5,3 @@ nose pylint tox unittest2 -yappi diff --git a/tests/__main__.py b/tests/__main__.py index 11757cbb..164f1e66 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,10 +1,5 @@ from __future__ import unicode_literals import nose -import yappi -try: - yappi.start() - nose.main() -finally: - yappi.print_stats() +nose.main() From 5122a254aac132a0ea4e6dc146cc1cec784e123c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 16 Dec 2012 20:22:42 +0100 Subject: [PATCH 22/94] models: Specify time zone of Playlist.last_modified as UTC --- docs/changes.rst | 6 ++++++ mopidy/frontends/mpd/protocol/stored_playlists.py | 3 +-- mopidy/models.py | 4 ++-- tests/models_test.py | 10 +++++----- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6a5aa572..18c1f0ad 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -27,6 +27,12 @@ v0.11.0 (in development) - Add empty stubs for channel commands for client to client communication. +**Internal changes** + +*Models:* + +- Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 034403ec..b1fe87de 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -82,11 +82,10 @@ def listplaylists(context): continue result.append(('playlist', playlist.name)) last_modified = ( - playlist.last_modified or dt.datetime.now()).isoformat() + playlist.last_modified or dt.datetime.utcnow()).isoformat() # Remove microseconds last_modified = last_modified.split('.')[0] # Add time zone information - # TODO Convert to UTC before adding Z last_modified = last_modified + 'Z' result.append(('Last-Modified', last_modified)) return result diff --git a/mopidy/models.py b/mopidy/models.py index a4ed1b4f..e47ed3be 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -290,7 +290,7 @@ class Playlist(ImmutableObject): :type name: string :param tracks: playlist's tracks :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time + :param last_modified: playlist's modification time in UTC :type last_modified: :class:`datetime.datetime` """ @@ -303,7 +303,7 @@ class Playlist(ImmutableObject): #: The playlist's tracks. Read-only. tracks = tuple() - #: The playlist modification time. Read-only. + #: The playlist modification time in UTC. Read-only. #: #: :class:`datetime.datetime`, or :class:`None` if unknown. last_modified = None diff --git a/tests/models_test.py b/tests/models_test.py index 9a3062fc..1a4d869a 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -707,7 +707,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist.length, 3) def test_last_modified(self): - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) self.assertRaises( @@ -715,7 +715,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_uri(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -727,7 +727,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -739,7 +739,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() playlist = Playlist( uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) @@ -752,7 +752,7 @@ class PlaylistTest(unittest.TestCase): def test_with_new_last_modified(self): tracks = [Track()] - last_modified = datetime.datetime.now() + last_modified = datetime.datetime.utcnow() new_last_modified = last_modified + datetime.timedelta(1) playlist = Playlist( uri='an uri', name='a name', tracks=tracks, From cc2510bd5641a6b3d3b0fee5711b3fde1cad7287 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Dec 2012 15:19:52 +0100 Subject: [PATCH 23/94] tests: Rename core.playlist tests so it is included in test runs --- tests/core/{playlists.py => playlists_test.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/core/{playlists.py => playlists_test.py} (100%) diff --git a/tests/core/playlists.py b/tests/core/playlists_test.py similarity index 100% rename from tests/core/playlists.py rename to tests/core/playlists_test.py From 59ccc207ea0eb8ca4d60e1f9b0d8ce0a7a38625d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Dec 2012 15:38:32 +0100 Subject: [PATCH 24/94] core: Make all methods callable without kwargs, e.g. from Mopidy.js --- docs/changes.rst | 12 +++++++++ mopidy/core/library.py | 16 ++++++++++-- mopidy/core/playlists.py | 18 +++++++++---- mopidy/core/tracklist.py | 26 +++++++++++++------ tests/core/library_test.py | 30 ++++++++++++++++++++++ tests/core/playlists_test.py | 18 ++++++++++--- tests/core/tracklist_test.py | 49 ++++++++++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 tests/core/tracklist_test.py diff --git a/docs/changes.rst b/docs/changes.rst index 18c1f0ad..30e0c056 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -33,6 +33,18 @@ v0.11.0 (in development) - Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. +*Core API:* + +- Change the following methods to accept either a dict with filters or kwargs. + Previously they only accepted kwargs, which made them impossible to use from + the Mopidy.js through JSON-RPC, which doesn't support kwargs. + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + - :meth:`mopidy.core.PlaylistsController.filter` + - :meth:`mopidy.core.TracklistController.filter` + - :meth:`mopidy.core.TracklistController.remove` + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/core/library.py b/mopidy/core/library.py index c1a89222..3c596a3a 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -17,23 +17,29 @@ class LibraryController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) - def find_exact(self, **query): + def find_exact(self, query=None, **kwargs): """ Search the library for tracks where ``field`` is ``values``. Examples:: # Returns results matching 'a' + find_exact({'any': ['a']}) find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact({'artist': ['xyz']}) find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact({'any': ['a', 'b'], 'artist': ['xyz']}) find_exact(any=['a', 'b'], artist=['xyz']) :param query: one or more queries to search for :type query: dict :rtype: list of :class:`mopidy.models.Track` """ + query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] results = pykka.get_all(futures) @@ -72,23 +78,29 @@ class LibraryController(object): b.library.refresh(uri) for b in self.backends.with_library] pykka.get_all(futures) - def search(self, **query): + def search(self, query=None, **kwargs): """ Search the library for tracks where ``field`` contains ``values``. Examples:: # Returns results matching 'a' + search({'any': ['a']}) search(any=['a']) + # Returns results matching artist 'xyz' + search({'artist': ['xyz']}) search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search({'any': ['a', 'b'], 'artist': ['xyz']}) search(any=['a', 'b'], artist=['xyz']) :param query: one or more queries to search for :type query: dict :rtype: list of :class:`mopidy.models.Track` """ + query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] results = pykka.get_all(futures) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 6a368ac6..62098c7f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -70,21 +70,29 @@ class PlaylistsController(object): if backend: backend.playlists.delete(uri).get() - def filter(self, **criteria): + def filter(self, criteria=None, **kwargs): """ Filter playlists by the given criterias. Examples:: - filter(name='a') # Returns track with name 'a' - filter(uri='xyz') # Returns track with URI 'xyz' - filter(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' + # Returns track with name 'a' + filter({'name': 'a'}) + filter(name='a') + + # Returns track with URI 'xyz' + filter({'uri': 'xyz'}) + filter(uri='xyz') + + # Returns track with name 'a' and URI 'xyz' + filter({'name': 'a', 'uri': 'xyz'}) + filter(name='a', uri='xyz') :param criteria: one or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.Playlist` """ + criteria = criteria or kwargs matches = self.playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 656e15b1..402e6c09 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -103,21 +103,33 @@ class TracklistController(object): self._tl_tracks = [] self._increase_version() - def filter(self, **criteria): + def filter(self, criteria=None, **kwargs): """ Filter the tracklist by the given criterias. Examples:: - filter(tlid=7) # Returns track with TLID 7 (tracklist ID) - filter(id=1) # Returns track with ID 1 - filter(uri='xyz') # Returns track with URI 'xyz' - filter(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' + # Returns track with TLID 7 (tracklist ID) + filter({'tlid': 7}) + filter(tlid=7) + + # Returns track with ID 1 + filter({'id': 1}) + filter(id=1) + + # Returns track with URI 'xyz' + filter({'uri': 'xyz'}) + filter(uri='xyz') + + # Returns track with ID 1 and URI 'xyz' + filter({'id': 1, 'uri': 'xyz'}) + filter(id=1, uri='xyz') :param criteria: on or more criteria to match by :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` """ + criteria = criteria or kwargs matches = self._tl_tracks for (key, value) in criteria.iteritems(): if key == 'tlid': @@ -172,7 +184,7 @@ class TracklistController(object): self._tl_tracks = new_tl_tracks self._increase_version() - def remove(self, **criteria): + def remove(self, criteria=None, **kwargs): """ Remove the matching tracks from the tracklist. @@ -184,7 +196,7 @@ class TracklistController(object): :type criteria: dict :rtype: list of :class:`mopidy.models.TlTrack` that was removed """ - tl_tracks = self.filter(**criteria) + tl_tracks = self.filter(criteria, **kwargs) for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 1bd481de..a2c358d7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -87,6 +87,21 @@ class CoreLibraryTest(unittest.TestCase): self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) + def test_find_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.find_exact().get.return_value = [track1] + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(dict(any=['a'])) + + self.assertIn(track1, result) + self.assertIn(track2, result) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') @@ -101,3 +116,18 @@ class CoreLibraryTest(unittest.TestCase): self.assertIn(track2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) + + def test_search_accepts_query_dict_instead_of_kwargs(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.search().get.return_value = [track1] + self.library1.search.reset_mock() + self.library2.search().get.return_value = [track2] + self.library2.search.reset_mock() + + result = self.core.library.search(dict(any=['a'])) + + self.assertIn(track1, result) + self.assertIn(track2, result) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/core/playlists_test.py b/tests/core/playlists_test.py index 949625fe..cea93c5b 100644 --- a/tests/core/playlists_test.py +++ b/tests/core/playlists_test.py @@ -27,12 +27,12 @@ class PlaylistsTest(unittest.TestCase): self.backend3.has_playlists().get.return_value = False self.backend3.playlists = None - self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) - self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) + self.pl1a = Playlist(name='A', tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(name='B', tracks=[Track(uri='dummy1:b')]) self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] - self.pl2a = Playlist(tracks=[Track(uri='dummy2:a')]) - self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) + self.pl2a = Playlist(name='A', tracks=[Track(uri='dummy2:a')]) + self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] self.core = Core(audio=None, backends=[ @@ -103,6 +103,16 @@ class PlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) + def test_filter_returns_matching_playlists(self): + result = self.core.playlists.filter(name='A') + + self.assertEqual(2, len(result)) + + def test_filter_accepts_dict_instead_of_kwargs(self): + result = self.core.playlists.filter({'name': 'A'}) + + self.assertEqual(2, len(result)) + def test_lookup_selects_the_dummy1_backend(self): self.core.playlists.lookup('dummy1:a') diff --git a/tests/core/tracklist_test.py b/tests/core/tracklist_test.py new file mode 100644 index 00000000..550cfe63 --- /dev/null +++ b/tests/core/tracklist_test.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals + +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class TracklistTest(unittest.TestCase): + def setUp(self): + self.tracks = [ + Track(uri='a', name='foo'), + Track(uri='b', name='foo'), + Track(uri='c', name='bar') + ] + self.core = Core(audio=None, backends=[]) + self.tl_tracks = self.core.tracklist.add(self.tracks) + + def test_remove_removes_tl_tracks_matching_query(self): + tl_tracks = self.core.tracklist.remove(name='foo') + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + self.assertEqual(1, self.core.tracklist.length) + self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) + + def test_remove_works_with_dict_instead_of_kwargs(self): + tl_tracks = self.core.tracklist.remove({'name': 'foo'}) + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + self.assertEqual(1, self.core.tracklist.length) + self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) + + def test_filter_returns_tl_tracks_matching_query(self): + tl_tracks = self.core.tracklist.filter(name='foo') + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + def test_filter_works_with_dict_instead_of_kwargs(self): + tl_tracks = self.core.tracklist.filter({'name': 'foo'}) + + self.assertEqual(2, len(tl_tracks)) + self.assertListEqual(self.tl_tracks[:2], tl_tracks) + + # TODO Extract tracklist tests from the base backend tests From 6008a53027dcae841a084e6f89bfcc3aac008925 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 17 Dec 2012 15:42:12 +0100 Subject: [PATCH 25/94] tests: Update JSON-RPC introspection test to match changes core API --- tests/utils/jsonrpc_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 7c8a0a9b..59cb89b5 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -609,4 +609,6 @@ class JsonRpcInspectorTest(JsonRpcTestBase): self.assertEquals( methods['core.tracklist.filter']['params'][0]['name'], 'criteria') self.assertEquals( - methods['core.tracklist.filter']['params'][0]['kwargs'], True) + methods['core.tracklist.filter']['params'][1]['name'], 'kwargs') + self.assertEquals( + methods['core.tracklist.filter']['params'][1]['kwargs'], True) From 15d56b84cb62f8e2bf08bdd23e9d9ff9f9b9366a Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 17 Dec 2012 00:06:20 +0100 Subject: [PATCH 26/94] core: Trigger volume changed event in set_volume --- mopidy/core/playback.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4941ef0f..901976d6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -283,6 +283,8 @@ class PlaybackController(object): return self._volume def set_volume(self, volume): + self._trigger_volume_changed() + if self.audio: self.audio.set_volume(volume) else: @@ -515,6 +517,10 @@ class PlaybackController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') + def _trigger_volume_changed(self): + logger.debug('Triggering volume changed event') + listener.CoreListener.send('volume_changed') + def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) From ee448d4dd3947d9578c9b535887cc060bbc69696 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 10:27:59 +0100 Subject: [PATCH 27/94] audio: Make get_volume able to return all levels This fixes an issue where applications which changes volume by 1 level at a time could fail to change volume if track.max_volume is less than 100. E.g. get volume could return 44 after volume was set to 43, then the application would just try to set it to 43 again. --- mopidy/audio/actor.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 7de98075..ca115ba1 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -43,6 +43,7 @@ class Audio(pykka.ThreadingActor): self._mixer_track = None self._software_mixing = False self._appsrc = None + self._volume_set = -1 self._notify_source_signal_id = None self._about_to_finish_id = None @@ -388,10 +389,18 @@ class Audio(pykka.ThreadingActor): volumes = self._mixer.get_volume(self._mixer_track) avg_volume = float(sum(volumes)) / len(volumes) - new_scale = (0, 100) - old_scale = ( + internal_scale = (0, 100) + mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - return self._rescale(avg_volume, old=old_scale, new=new_scale) + + volume_set_scaled = self._rescale( + self._volume_set, old=internal_scale, new=mixer_scale) + + if self._volume_set > 0 and volume_set_scaled == avg_volume: + return self._volume_set + else: + return self._rescale( + avg_volume, old=mixer_scale, new=internal_scale) def set_volume(self, volume): """ @@ -408,6 +417,8 @@ class Audio(pykka.ThreadingActor): if self._mixer is None: return False + self._volume_set = volume + old_scale = (0, 100) new_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) From d3e3bef2c0d4040ee865b024284cdcb3a2880fad Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 10:29:41 +0100 Subject: [PATCH 28/94] audio: Rename scales in set_volume for consistency --- mopidy/audio/actor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index ca115ba1..d91022ca 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -419,11 +419,11 @@ class Audio(pykka.ThreadingActor): self._volume_set = volume - old_scale = (0, 100) - new_scale = ( + internal_scale = (0, 100) + mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - volume = self._rescale(volume, old=old_scale, new=new_scale) + volume = self._rescale(volume, old=internal_scale, new=mixer_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) From 58389f31de6a3cd04f185a50aa05596c33e9a216 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 18:08:00 +0100 Subject: [PATCH 29/94] core: Trigger volume changed after actual change --- mopidy/core/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 901976d6..ec51b7ec 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -283,14 +283,14 @@ class PlaybackController(object): return self._volume def set_volume(self, volume): - self._trigger_volume_changed() - if self.audio: self.audio.set_volume(volume) else: # For testing self._volume = volume + self._trigger_volume_changed() + volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" From 88b3bd49345965cefaaedab730c717a84d97de46 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 18:09:00 +0100 Subject: [PATCH 30/94] tests: Test that set volume triggers volume changed --- tests/core/events_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 88f07de6..008d23dd 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -117,3 +117,9 @@ class BackendEventsTest(unittest.TestCase): playlist = playlist.copy(name='bar') self.core.playlists.save(playlist).get() self.assertEqual(send.call_args[0][0], 'playlist_changed') + + def test_set_volume_sends_volume_changed_event(self, send): + self.core.playback.set_volume(10).get() + send.reset_mock() + self.core.playback.set_volume(20).get() + self.assertEqual(send.call_args[0][0], 'volume_changed') From fcda4696bc2b7d13c3e0867de99a8dea068c185f Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 18:18:29 +0100 Subject: [PATCH 31/94] audio: Use None instead of -1 for undefined volume --- mopidy/audio/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index d91022ca..0df8fb2b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -43,7 +43,7 @@ class Audio(pykka.ThreadingActor): self._mixer_track = None self._software_mixing = False self._appsrc = None - self._volume_set = -1 + self._volume_set = None self._notify_source_signal_id = None self._about_to_finish_id = None @@ -396,7 +396,7 @@ class Audio(pykka.ThreadingActor): volume_set_scaled = self._rescale( self._volume_set, old=internal_scale, new=mixer_scale) - if self._volume_set > 0 and volume_set_scaled == avg_volume: + if self._volume_set is not None and volume_set_scaled == avg_volume: return self._volume_set else: return self._rescale( From 200e4d2536484b1063a82aa6a4505f7f33cc0960 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Tue, 18 Dec 2012 19:00:46 +0100 Subject: [PATCH 32/94] audio: Don't try to rescale volume_set if it's None --- mopidy/audio/actor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 0df8fb2b..3910ee80 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -393,10 +393,8 @@ class Audio(pykka.ThreadingActor): mixer_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - volume_set_scaled = self._rescale( - self._volume_set, old=internal_scale, new=mixer_scale) - - if self._volume_set is not None and volume_set_scaled == avg_volume: + if self._volume_set is not None and self._rescale(self._volume_set, + old=internal_scale, new=mixer_scale) == avg_volume: return self._volume_set else: return self._rescale( From 12942631bf4ca38ce847286c85d23f9705978a2c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 18 Dec 2012 23:18:51 +0100 Subject: [PATCH 33/94] mpd: Change name of output from 'None' to 'Default' --- mopidy/frontends/mpd/protocol/audio_output.py | 2 +- tests/frontends/mpd/protocol/audio_output_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index b4d491e5..01982a71 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -39,6 +39,6 @@ def outputs(context): """ return [ ('outputid', 0), - ('outputname', None), + ('outputname', 'Default'), ('outputenabled', 1), ] diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 11cd249e..560e935f 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -15,6 +15,6 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): def test_outputs(self): self.sendRequest('outputs') self.assertInResponse('outputid: 0') - self.assertInResponse('outputname: None') + self.assertInResponse('outputname: Default') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') From ba84af2586e5d0cc70ffd95f8899d28659c36d9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 18 Dec 2012 23:29:57 +0100 Subject: [PATCH 34/94] mpd: Add list of unsupported MPD features --- mopidy/frontends/mpd/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 572192ef..e1edc89d 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -19,6 +19,29 @@ original MPD server. Make sure :attr:`mopidy.settings.FRONTENDS` includes ``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD frontend. + +**Limitations:** + +This is a non exhaustive list of MPD features that Mopidy doesn't support. +Items on this list will probably not be supported in the near future. + +- Toggling of audio outputs is not supported +- Channels for client-to-client communication is not supported +- Stickers is not supported +- Crossfade is not supported +- Replay gain is not supported +- ``count`` does not provide any statistics +- ``stats`` does not provide any statistics +- ``list`` does not support listing tracks by genre +- ``decoders`` does not provide information about available decoders + +The following items are currently not supported, but should be added in the +near future: + +- Modifying stored playlists is not supported +- ``tagtypes`` is not supported +- Browsing the file system is not supported +- Live update of the music database is not supported """ from __future__ import unicode_literals From dd1fad249b565ee6321ff68e0b3c675bc6842f83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 00:05:13 +0100 Subject: [PATCH 35/94] mpd: Fix grammar --- mopidy/frontends/mpd/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e1edc89d..6b4eacc8 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -26,8 +26,8 @@ This is a non exhaustive list of MPD features that Mopidy doesn't support. Items on this list will probably not be supported in the near future. - Toggling of audio outputs is not supported -- Channels for client-to-client communication is not supported -- Stickers is not supported +- Channels for client-to-client communication are not supported +- Stickers are not supported - Crossfade is not supported - Replay gain is not supported - ``count`` does not provide any statistics From 524f22eff44638d00c2e8930b3bd2eeb79a15ee0 Mon Sep 17 00:00:00 2001 From: Wouter van Wijk Date: Wed, 19 Dec 2012 12:48:33 +0100 Subject: [PATCH 36/94] Added lookup for artists, albums --- mopidy/backends/spotify/library.py | 86 ++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index df04058b..1179341f 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -2,11 +2,16 @@ from __future__ import unicode_literals import logging import Queue +import time + +TIME_OUT = 10 from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track +from mopidy.backends.base import BaseLibraryProvider +from mopidy.models import Playlist from . import translator @@ -56,11 +61,82 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return self.search(**query) def lookup(self, uri): - try: - return [SpotifyTrack(uri)] - except SpotifyError as e: - logger.debug('Failed to lookup "%s": %s', uri, e) - return [] + link = Link.from_string(uri) + #uri is an album + if link.type() == Link.LINK_ALBUM: + try: + spotify_album = Link.from_string(uri).as_album() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + browser = self.backend.spotify.session.browse_album(spotify_album) + + #wait 5 seconds + start = time.time() + while not browser.is_loaded(): + time.sleep(0.1) + if time.time() > (start + TIME_OUT): + break + album = translator.to_mopidy_album(spotify_album) + + #for track in browser: + # track = translator.to_mopidy_track(track) + + #from translator + tracks=[translator.to_mopidy_track(t) for t in browser + if str(Link.from_track(t, 0))] + + playlist = Playlist(tracks=tracks, uri=uri, name=album.name) + return playlist + + except SpotifyError as e: + logger.debug(u'Failed to lookup album "%s": %s', uri, e) + return None + + #uri is an album + if link.type() == Link.LINK_ARTIST: + try: + spotify_artist = Link.from_string(uri).as_artist() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + browser = self.backend.spotify.session.browse_artist(spotify_artist) + #wait 5 seconds + start = time.time() + while not browser.is_loaded(): + time.sleep(0.1) + if time.time() > (start + TIME_OUT): + break + artist = translator.to_mopidy_artist(spotify_artist) + + #for track in browser: + # track = translator.to_mopidy_track(track) + + #from translator + tracks=[translator.to_mopidy_track(t) for t in browser + if str(Link.from_track(t, 0))] + + playlist = Playlist(tracks=tracks, uri=uri, name=artist.name) + return playlist + + except SpotifyError as e: + logger.debug(u'Failed to lookup album "%s": %s', uri, e) + return None + + #uri is a playlist of another user + # if l.type() == Link.LINK_PLAYLIST: + # if l.type() == Link.LINK_USER: + + #uri is a track + try: + spotify_track = Link.from_string(uri).as_track() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + return translator.to_mopidy_track(spotify_track) + except SpotifyError as e: + logger.debug(u'Failed to lookup track "%s": %s', uri, e) + return None def refresh(self, uri=None): pass # TODO From 9b9af4b1997e91039c7d4484423be77b2e5d5dea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 14:40:01 +0100 Subject: [PATCH 37/94] Update author list --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 9c9951f8..91a9f6cf 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,3 +11,4 @@ - David C - Christian Johansen - Matt Bray +- Trygve Aaberge From dfda1cb0643dfa0cc82401ac34135e5b0ee99d5b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 14:40:48 +0100 Subject: [PATCH 38/94] docs: Update changelog --- docs/changes.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 30e0c056..0247aa9d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -45,6 +45,8 @@ v0.11.0 (in development) - :meth:`mopidy.core.TracklistController.filter` - :meth:`mopidy.core.TracklistController.remove` +- Actually trigger the :meth:`mopidy.core.CoreListener.volume_changed` event. + v0.10.0 (2012-12-12) ==================== From 0d7b22b745d003948aeb9671566f85298cb058a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 14:59:05 +0100 Subject: [PATCH 39/94] tests: Don't need audio actor for code event tests --- tests/core/events_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 008d23dd..9c10306a 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock import pykka -from mopidy import audio, core +from mopidy import core from mopidy.backends import dummy from mopidy.models import Track @@ -13,8 +13,7 @@ from tests import unittest @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.audio = mock.Mock(spec=audio.Audio) - self.backend = dummy.DummyBackend.start(audio=audio).proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): From c5ac7aeb77db59885acf0b5115a65e3664f930c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 16:31:58 +0100 Subject: [PATCH 40/94] core: Include new volume level in volume_changed event --- docs/changes.rst | 3 +++ mopidy/core/playback.py | 6 +++--- mopidy/frontends/mpd/actor.py | 2 +- mopidy/frontends/mpris/actor.py | 2 +- tests/core/events_test.py | 1 + tests/frontends/mpris/events_test.py | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 0247aa9d..8a7b2d91 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -47,6 +47,9 @@ v0.11.0 (in development) - Actually trigger the :meth:`mopidy.core.CoreListener.volume_changed` event. +- Include the new volume level in the + :meth:`mopidy.core.CoreListener.volume_changed` event. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ec51b7ec..141c2e70 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -289,7 +289,7 @@ class PlaybackController(object): # For testing self._volume = volume - self._trigger_volume_changed() + self._trigger_volume_changed(volume) volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" @@ -517,9 +517,9 @@ class PlaybackController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') - def _trigger_volume_changed(self): + def _trigger_volume_changed(self, volume): logger.debug('Triggering volume changed event') - listener.CoreListener.send('volume_changed') + listener.CoreListener.send('volume_changed', volume=volume) def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 925b15b7..d3c718c4 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -49,5 +49,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def options_changed(self): self.send_idle('options') - def volume_changed(self): + def volume_changed(self, volume): self.send_idle('mixer') diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 795b2694..57bbd790 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -84,7 +84,7 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - def volume_changed(self): + def volume_changed(self, volume): logger.debug('Received volume_changed event') self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 9c10306a..d5f9fc14 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -122,3 +122,4 @@ class BackendEventsTest(unittest.TestCase): send.reset_mock() self.core.playback.set_volume(20).get() self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 20) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 18a9de6f..60c9a783 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -65,7 +65,7 @@ class BackendEventsTest(unittest.TestCase): def test_volume_changed_event_changes_volume(self): self.mpris_object.Get.return_value = 1.0 - self.mpris_frontend.volume_changed() + self.mpris_frontend.volume_changed(volume=100) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'Volume'), {}), ]) From 9602fff8bbf6740ddd40f653d0833ab73b3c2c73 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 16:33:59 +0100 Subject: [PATCH 41/94] tests: Formatting --- tests/core/events_test.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index d5f9fc14..62b142ee 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -20,15 +20,17 @@ class BackendEventsTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_backends_playlists_loaded_forwards_event_to_frontends(self, send): - send.reset_mock() self.core.playlists_loaded().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_pause_sends_track_playback_paused_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play().get() send.reset_mock() + self.core.playback.pause().get() + self.assertEqual(send.call_args[0][0], 'track_playback_paused') def test_resume_sends_track_playback_resumed(self, send): @@ -36,73 +38,97 @@ class BackendEventsTest(unittest.TestCase): self.core.playback.play() self.core.playback.pause().get() send.reset_mock() + self.core.playback.resume().get() + self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]) send.reset_mock() + self.core.playback.play().get() + self.assertEqual(send.call_args[0][0], 'track_playback_started') def test_stop_sends_track_playback_ended_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play().get() send.reset_mock() + self.core.playback.stop().get() + self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') def test_seek_sends_seeked_event(self, send): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play().get() send.reset_mock() + self.core.playback.seek(1000).get() + self.assertEqual(send.call_args[0][0], 'seeked') def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() + self.core.tracklist.add([Track(uri='dummy:a')]).get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_clear_sends_tracklist_changed_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.tracklist.clear().get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_move_sends_tracklist_changed_event(self, send): self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() + self.core.tracklist.move(0, 1, 1).get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_remove_sends_tracklist_changed_event(self, send): self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() + self.core.tracklist.remove(uri='dummy:a').get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_tracklist_shuffle_sends_tracklist_changed_event(self, send): self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() + self.core.tracklist.shuffle().get() + self.assertEqual(send.call_args[0][0], 'tracklist_changed') def test_playlists_refresh_sends_playlists_loaded_event(self, send): send.reset_mock() + self.core.playlists.refresh().get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send): send.reset_mock() + self.core.playlists.refresh(uri_scheme='dummy').get() + self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_playlists_create_sends_playlist_changed_event(self, send): send.reset_mock() + self.core.playlists.create('foo').get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') @unittest.SkipTest @@ -112,14 +138,18 @@ class BackendEventsTest(unittest.TestCase): def test_playlists_save_sends_playlist_changed_event(self, send): playlist = self.core.playlists.create('foo').get() - send.reset_mock() playlist = playlist.copy(name='bar') + send.reset_mock() + self.core.playlists.save(playlist).get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') def test_set_volume_sends_volume_changed_event(self, send): self.core.playback.set_volume(10).get() send.reset_mock() + self.core.playback.set_volume(20).get() + self.assertEqual(send.call_args[0][0], 'volume_changed') self.assertEqual(send.call_args[1]['volume'], 20) From e176425b916d3dbdf1e2cdc02c8467d3571fdeb8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 16:35:52 +0100 Subject: [PATCH 42/94] core: Test that 'seeked' event includes 'time_position' --- tests/core/events_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 62b142ee..bc3cf2e2 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -68,6 +68,7 @@ class BackendEventsTest(unittest.TestCase): self.core.playback.seek(1000).get() self.assertEqual(send.call_args[0][0], 'seeked') + self.assertEqual(send.call_args[1]['time_position'], 1000) def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() From ea37cf1a447acedc1df05c8c79867ab04c18d5c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 17:06:04 +0100 Subject: [PATCH 43/94] core: Include the TlTrack in track_playback_* events --- docs/changes.rst | 3 +++ mopidy/core/listener.py | 24 ++++++++++++------------ mopidy/core/playback.py | 13 +++++++------ mopidy/frontends/lastfm.py | 6 ++++-- mopidy/frontends/mpris/actor.py | 8 ++++---- tests/core/events_test.py | 15 +++++++++++---- tests/core/listener_test.py | 14 +++++++------- tests/frontends/mpris/events_test.py | 10 +++++----- 8 files changed, 53 insertions(+), 40 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8a7b2d91..96056c18 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -50,6 +50,9 @@ v0.11.0 (in development) - Include the new volume level in the :meth:`mopidy.core.CoreListener.volume_changed` event. +- The ``track_playback_{paused,resumed,started,ended}`` events now include a + :class:`mopidy.models.TlTrack` instead of a :class:`mopidy.models.Track`. + v0.10.0 (2012-12-12) ==================== diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 7c4ab093..c93fc39e 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -34,51 +34,51 @@ class CoreListener(object): """ getattr(self, event)(**kwargs) - def track_playback_paused(self, track, time_position): + def track_playback_paused(self, tl_track, time_position): """ Called whenever track playback is paused. *MAY* be implemented by actor. - :param track: the track that was playing when playback paused - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was playing when playback paused + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass - def track_playback_resumed(self, track, time_position): + def track_playback_resumed(self, tl_track, time_position): """ Called whenever track playback is resumed. *MAY* be implemented by actor. - :param track: the track that was playing when playback resumed - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was playing when playback resumed + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ pass - def track_playback_started(self, track): + def track_playback_started(self, tl_track): """ Called whenever a new track starts playing. *MAY* be implemented by actor. - :param track: the track that just started playing - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that just started playing + :type tl_track: :class:`mopidy.models.TlTrack` """ pass - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): """ Called whenever playback of a track ends. *MAY* be implemented by actor. - :param track: the track that was played before playback stopped - :type track: :class:`mopidy.models.Track` + :param tl_track: the track that was played before playback stopped + :type tl_track: :class:`mopidy.models.TlTrack` :param time_position: the time position in milliseconds :type time_position: int """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 141c2e70..21f09ad2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -482,7 +482,7 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_paused', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): logger.debug('Triggering track playback resumed event') @@ -490,22 +490,23 @@ class PlaybackController(object): return listener.CoreListener.send( 'track_playback_resumed', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_track_playback_started(self): logger.debug('Triggering track playback started event') - if self.current_track is None: + if self.current_tl_track is None: return listener.CoreListener.send( - 'track_playback_started', track=self.current_track) + 'track_playback_started', + tl_track=self.current_tl_track) def _trigger_track_playback_ended(self): logger.debug('Triggering track playback ended event') - if self.current_track is None: + if self.current_tl_track is None: return listener.CoreListener.send( 'track_playback_ended', - track=self.current_track, time_position=self.time_position) + tl_track=self.current_tl_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug('Triggering playback state change event') diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 565e5041..61dc306c 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -66,7 +66,8 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): logger.error('Error during Last.fm setup: %s', e) self.stop() - def track_playback_started(self, track): + def track_playback_started(self, tl_track): + track = tl_track.track artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 self.last_start_time = int(time.time()) @@ -83,7 +84,8 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): pylast.MalformedResponseError, pylast.WSError) as e: logger.warning('Error submitting playing track to Last.fm: %s', e) - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): + track = tl_track.track artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 time_position = time_position // 1000 diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 57bbd790..5e171826 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -66,20 +66,20 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.mpris_object.PropertiesChanged( interface, dict(props_with_new_values), []) - def track_playback_paused(self, track, time_position): + def track_playback_paused(self, tl_track, time_position): logger.debug('Received track_playback_paused event') self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - def track_playback_resumed(self, track, time_position): + def track_playback_resumed(self, tl_track, time_position): logger.debug('Received track_playback_resumed event') self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) - def track_playback_started(self, track): + def track_playback_started(self, tl_track): logger.debug('Received track_playback_started event') self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) - def track_playback_ended(self, track, time_position): + def track_playback_ended(self, tl_track, time_position): logger.debug('Received track_playback_ended event') self._emit_properties_changed( objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index bc3cf2e2..11881db7 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -25,16 +25,18 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'playlists_loaded') def test_pause_sends_track_playback_paused_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play().get() send.reset_mock() self.core.playback.pause().get() self.assertEqual(send.call_args[0][0], 'track_playback_paused') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args[1]['time_position'], 0) def test_resume_sends_track_playback_resumed(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play() self.core.playback.pause().get() send.reset_mock() @@ -42,23 +44,28 @@ class BackendEventsTest(unittest.TestCase): self.core.playback.resume().get() self.assertEqual(send.call_args[0][0], 'track_playback_resumed') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args[1]['time_position'], 0) def test_play_sends_track_playback_started_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() send.reset_mock() self.core.playback.play().get() self.assertEqual(send.call_args[0][0], 'track_playback_started') + self.assertEqual(send.call_args[1]['tl_track'], tl_tracks[0]) def test_stop_sends_track_playback_ended_event(self, send): - self.core.tracklist.add([Track(uri='dummy:a')]) + tl_tracks = self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.playback.play().get() send.reset_mock() self.core.playback.stop().get() self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') + self.assertEqual(send.call_args_list[0][1]['tl_track'], tl_tracks[0]) + self.assertEqual(send.call_args_list[0][1]['time_position'], 0) def test_seek_sends_seeked_event(self, send): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 8aaf1234..2d7182d9 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock from mopidy.core import CoreListener, PlaybackState -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack from tests import unittest @@ -16,22 +16,22 @@ class CoreListenerTest(unittest.TestCase): self.listener.track_playback_paused = mock.Mock() self.listener.on_event( - 'track_playback_paused', track=Track(), position=0) + 'track_playback_paused', track=TlTrack(), position=0) self.listener.track_playback_paused.assert_called_with( - track=Track(), position=0) + track=TlTrack(), position=0) def test_listener_has_default_impl_for_track_playback_paused(self): - self.listener.track_playback_paused(Track(), 0) + self.listener.track_playback_paused(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_resumed(self): - self.listener.track_playback_resumed(Track(), 0) + self.listener.track_playback_resumed(TlTrack(), 0) def test_listener_has_default_impl_for_track_playback_started(self): - self.listener.track_playback_started(Track()) + self.listener.track_playback_started(TlTrack()) def test_listener_has_default_impl_for_track_playback_ended(self): - self.listener.track_playback_ended(Track(), 0) + self.listener.track_playback_ended(TlTrack(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): self.listener.playback_state_changed( diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 60c9a783..f1add1b3 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -5,7 +5,7 @@ import sys import mock from mopidy.exceptions import OptionalDependencyError -from mopidy.models import Playlist, Track +from mopidy.models import Playlist, TlTrack try: from mopidy.frontends.mpris import MprisFrontend, objects @@ -25,7 +25,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_paused_event_changes_playback_status(self): self.mpris_object.Get.return_value = 'Paused' - self.mpris_frontend.track_playback_paused(Track(), 0) + self.mpris_frontend.track_playback_paused(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ]) @@ -34,7 +34,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_resumed_event_changes_playback_status(self): self.mpris_object.Get.return_value = 'Playing' - self.mpris_frontend.track_playback_resumed(Track(), 0) + self.mpris_frontend.track_playback_resumed(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ]) @@ -43,7 +43,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_started_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_started(Track()) + self.mpris_frontend.track_playback_started(TlTrack()) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ((objects.PLAYER_IFACE, 'Metadata'), {}), @@ -54,7 +54,7 @@ class BackendEventsTest(unittest.TestCase): def test_track_playback_ended_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' - self.mpris_frontend.track_playback_ended(Track(), 0) + self.mpris_frontend.track_playback_ended(TlTrack(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ ((objects.PLAYER_IFACE, 'PlaybackStatus'), {}), ((objects.PLAYER_IFACE, 'Metadata'), {}), From 6a5ceeec50534193fe54700c41caeeb58da4dba8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 19:49:23 +0100 Subject: [PATCH 44/94] audio: Test setting of volume with mixer volume max below 100 --- tests/audio/actor_test.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 64666d9d..73c8c165 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -4,6 +4,8 @@ import pygst pygst.require('0.10') import gst +import pykka + from mopidy import audio, settings from mopidy.utils.path import path_to_uri @@ -18,7 +20,7 @@ class AudioTest(unittest.TestCase): self.audio = audio.Audio.start().proxy() def tearDown(self): - self.audio.stop() + pykka.ActorRegistry.stop_all() settings.runtime.clear() def prepare_uri(self, uri): @@ -56,6 +58,14 @@ class AudioTest(unittest.TestCase): self.assertTrue(self.audio.set_volume(value).get()) self.assertEqual(value, self.audio.get_volume().get()) + def test_set_volume_with_mixer_max_below_100(self): + settings.MIXER = 'fakemixer track_max_volume=40' + self.audio = audio.Audio.start().proxy() + + for value in range(0, 101): + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) + @unittest.SkipTest def test_set_state_encapsulation(self): pass # TODO From 6d8a8a7902d464948f9302280a79748e654ecc5d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 19:55:15 +0100 Subject: [PATCH 45/94] docs: Update changelog --- docs/changes.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 96056c18..8d614f1d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -53,6 +53,14 @@ v0.11.0 (in development) - The ``track_playback_{paused,resumed,started,ended}`` events now include a :class:`mopidy.models.TlTrack` instead of a :class:`mopidy.models.Track`. +*Audio:* + +- Mixers with fewer than 100 volume levels could report another volume level + than what you just set due to the conversion between Mopidy's 0-100 range and + the mixer's range. Now Mopidy returns the recently set volume if the mixer + reports a volume level that matches the recently set volume, otherwise the + mixer's volume level is rescaled to the 1-100 range and returned. + v0.10.0 (2012-12-12) ==================== From d5c401bd07bbd349c528d171d976ecf3fb0fddd9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 20:29:45 +0100 Subject: [PATCH 46/94] spotify: Fix flake8 warnings in lookup method --- mopidy/backends/spotify/library.py | 59 ++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 1179341f..884e9ac6 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -10,7 +10,6 @@ from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track -from mopidy.backends.base import BaseLibraryProvider from mopidy.models import Playlist from . import translator @@ -66,10 +65,11 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if link.type() == Link.LINK_ALBUM: try: spotify_album = Link.from_string(uri).as_album() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - browser = self.backend.spotify.session.browse_album(spotify_album) + # TODO Block until metadata_updated callback is called. + # Before that the track will be unloaded, unless it's + # already in the stored playlists. + browser = self.backend.spotify.session.browse_album( + spotify_album) #wait 5 seconds start = time.time() @@ -81,26 +81,29 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): #for track in browser: # track = translator.to_mopidy_track(track) - + #from translator - tracks=[translator.to_mopidy_track(t) for t in browser - if str(Link.from_track(t, 0))] - - playlist = Playlist(tracks=tracks, uri=uri, name=album.name) + tracks = [ + translator.to_mopidy_track(t) + for t in browser if str(Link.from_track(t, 0))] + + playlist = Playlist( + tracks=tracks, uri=uri, name=album.name) return playlist - + except SpotifyError as e: logger.debug(u'Failed to lookup album "%s": %s', uri, e) return None - + #uri is an album if link.type() == Link.LINK_ARTIST: try: spotify_artist = Link.from_string(uri).as_artist() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - browser = self.backend.spotify.session.browse_artist(spotify_artist) + # TODO Block until metadata_updated callback is called. + # Before that the track will be unloaded, unless it's + # already in the stored playlists. + browser = self.backend.spotify.session.browse_artist( + spotify_artist) #wait 5 seconds start = time.time() while not browser.is_loaded(): @@ -111,28 +114,30 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): #for track in browser: # track = translator.to_mopidy_track(track) - + #from translator - tracks=[translator.to_mopidy_track(t) for t in browser - if str(Link.from_track(t, 0))] - - playlist = Playlist(tracks=tracks, uri=uri, name=artist.name) + tracks = [ + translator.to_mopidy_track(t) + for t in browser if str(Link.from_track(t, 0))] + + playlist = Playlist( + tracks=tracks, uri=uri, name=artist.name) return playlist - + except SpotifyError as e: logger.debug(u'Failed to lookup album "%s": %s', uri, e) return None - + #uri is a playlist of another user # if l.type() == Link.LINK_PLAYLIST: # if l.type() == Link.LINK_USER: - + #uri is a track try: spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. + # TODO Block until metadata_updated callback is called. Before + # that the track will be unloaded, unless it's already in the + # stored playlists. return translator.to_mopidy_track(spotify_track) except SpotifyError as e: logger.debug(u'Failed to lookup track "%s": %s', uri, e) From 699588b52530601b4d51ca5f35e558cea74c790a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 21:54:09 +0100 Subject: [PATCH 47/94] spotify: Refactor lookup code, add playlist support --- mopidy/backends/spotify/library.py | 126 +++++++++++------------------ 1 file changed, 46 insertions(+), 80 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 884e9ac6..25c58a17 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -4,13 +4,10 @@ import logging import Queue import time -TIME_OUT = 10 - from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track -from mopidy.models import Playlist from . import translator @@ -19,9 +16,14 @@ logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" - def __init__(self, uri): + def __init__(self, uri=None, track=None): super(SpotifyTrack, self).__init__() - self._spotify_track = Link.from_string(uri).as_track() + if uri: + self._spotify_track = Link.from_string(uri).as_track() + elif track: + self._spotify_track = track + else: + raise AttributeError('uri or track must be provided') self._unloaded_track = Track(uri=uri, name='[loading...]') self._track = None @@ -60,88 +62,52 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return self.search(**query) def lookup(self, uri): + try: link = Link.from_string(uri) - #uri is an album + if link.type() == Link.LINK_TRACK: + return self._lookup_track(uri) if link.type() == Link.LINK_ALBUM: - try: - spotify_album = Link.from_string(uri).as_album() - # TODO Block until metadata_updated callback is called. - # Before that the track will be unloaded, unless it's - # already in the stored playlists. - browser = self.backend.spotify.session.browse_album( - spotify_album) + return self._lookup_album(uri) + elif link.type() == Link.LINK_ARTIST: + return self._lookup_artist(uri) + elif link.type() == Link.LINK_PLAYLIST: + return self._lookup_playlist(uri) + else: + return [] + except SpotifyError as error: + logger.debug(u'Failed to lookup "%s": %s', uri, error) + return [] - #wait 5 seconds - start = time.time() - while not browser.is_loaded(): - time.sleep(0.1) - if time.time() > (start + TIME_OUT): - break - album = translator.to_mopidy_album(spotify_album) + def _lookup_track(self, uri): + return [SpotifyTrack(uri)] - #for track in browser: - # track = translator.to_mopidy_track(track) + def _lookup_album(self, uri): + album = Link.from_string(uri).as_album() + album_browser = self.backend.spotify.session.browse_album(album) + self._wait_for_object_to_load(album_browser) + return [SpotifyTrack(track=t) for t in album_browser] - #from translator - tracks = [ - translator.to_mopidy_track(t) - for t in browser if str(Link.from_track(t, 0))] + def _lookup_artist(self, uri): + artist = Link.from_string(uri).as_artist() + artist_browser = self.backend.spotify.session.browse_artist(artist) + self._wait_for_object_to_load(artist_browser) + return [SpotifyTrack(track=t) for t in artist_browser] - playlist = Playlist( - tracks=tracks, uri=uri, name=album.name) - return playlist + def _lookup_playlist(self, uri): + playlist = Link.from_string(uri).as_playlist() + self._wait_for_object_to_load(playlist) + return [SpotifyTrack(track=t) for t in playlist] - except SpotifyError as e: - logger.debug(u'Failed to lookup album "%s": %s', uri, e) - return None - - #uri is an album - if link.type() == Link.LINK_ARTIST: - try: - spotify_artist = Link.from_string(uri).as_artist() - # TODO Block until metadata_updated callback is called. - # Before that the track will be unloaded, unless it's - # already in the stored playlists. - browser = self.backend.spotify.session.browse_artist( - spotify_artist) - #wait 5 seconds - start = time.time() - while not browser.is_loaded(): - time.sleep(0.1) - if time.time() > (start + TIME_OUT): - break - artist = translator.to_mopidy_artist(spotify_artist) - - #for track in browser: - # track = translator.to_mopidy_track(track) - - #from translator - tracks = [ - translator.to_mopidy_track(t) - for t in browser if str(Link.from_track(t, 0))] - - playlist = Playlist( - tracks=tracks, uri=uri, name=artist.name) - return playlist - - except SpotifyError as e: - logger.debug(u'Failed to lookup album "%s": %s', uri, e) - return None - - #uri is a playlist of another user - # if l.type() == Link.LINK_PLAYLIST: - # if l.type() == Link.LINK_USER: - - #uri is a track - try: - spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before - # that the track will be unloaded, unless it's already in the - # stored playlists. - return translator.to_mopidy_track(spotify_track) - except SpotifyError as e: - logger.debug(u'Failed to lookup track "%s": %s', uri, e) - return None + def _wait_for_object_to_load(self, spotify_obj, timeout=10): + # XXX Sleeping to wait for the Spotify object to load is an ugly hack, + # but it works. We should look into other solutions for this. + start = time.time() + while not spotify_obj.is_loaded(): + time.sleep(0.1) + if time.time() > (start + timeout): + logger.debug( + 'Timeout: Spotify object did not load in %ds', timeout) + return def refresh(self, uri=None): pass # TODO From e39d15399b989bd843a173c449a5825474ac3c1d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 21:58:26 +0100 Subject: [PATCH 48/94] Update author list --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 91a9f6cf..d536c059 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,3 +12,4 @@ - Christian Johansen - Matt Bray - Trygve Aaberge +- Wouter van Wijk From e63e6f7bbb2adf1e89961731de417cae62a802f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 21:58:36 +0100 Subject: [PATCH 49/94] docs: Update changelog --- docs/changes.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 8d614f1d..97199291 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,19 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== +**Spotify backend** + +- Add support for looking up albums, artists, and playlists by URI in addition + to tracks. (Fixes: :issue:`67`) + + As an example of how this can be used, you can try the the following MPD + commands which now all adds one or more tracks to your tracklist:: + + add "spotify:track:1mwt9hzaH7idmC5UCoOUkz" + add "spotify:album:3gpHG5MGwnipnap32lFYvI" + add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" + add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" + **MPD frontend** - Add support for the ``findadd`` command. From 81a76bfd92992ebc8a9d6f102c9f7f0e8d107055 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Wed, 19 Dec 2012 21:56:02 +0100 Subject: [PATCH 50/94] audio: Define mixer_scale in _setup_mixer --- mopidy/audio/actor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 3910ee80..78fbd056 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -41,6 +41,7 @@ class Audio(pykka.ThreadingActor): self._playbin = None self._mixer = None self._mixer_track = None + self._mixer_scale = None self._software_mixing = False self._appsrc = None self._volume_set = None @@ -150,6 +151,8 @@ class Audio(pykka.ThreadingActor): self._mixer = mixer self._mixer_track = track + self._mixer_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) logger.info( 'Audio mixer set to "%s" using track "%s"', mixer.get_factory().get_name(), track.label) @@ -390,15 +393,13 @@ class Audio(pykka.ThreadingActor): avg_volume = float(sum(volumes)) / len(volumes) internal_scale = (0, 100) - mixer_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) if self._volume_set is not None and self._rescale(self._volume_set, - old=internal_scale, new=mixer_scale) == avg_volume: + old=internal_scale, new=self._mixer_scale) == avg_volume: return self._volume_set else: return self._rescale( - avg_volume, old=mixer_scale, new=internal_scale) + avg_volume, old=self._mixer_scale, new=internal_scale) def set_volume(self, volume): """ @@ -418,10 +419,9 @@ class Audio(pykka.ThreadingActor): self._volume_set = volume internal_scale = (0, 100) - mixer_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - volume = self._rescale(volume, old=internal_scale, new=mixer_scale) + volume = self._rescale( + volume, old=internal_scale, new=self._mixer_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) From 8be84a1ea409a38f4898bb40ee4d6654a786d5a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 22:40:32 +0100 Subject: [PATCH 51/94] audio: Fix flake8 warning --- mopidy/audio/actor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 78fbd056..1b6c79b3 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -394,8 +394,13 @@ class Audio(pykka.ThreadingActor): internal_scale = (0, 100) - if self._volume_set is not None and self._rescale(self._volume_set, - old=internal_scale, new=self._mixer_scale) == avg_volume: + if self._volume_set is not None: + volume_set_on_mixer_scale = self._rescale( + self._volume_set, old=internal_scale, new=self._mixer_scale) + else: + volume_set_on_mixer_scale = None + + if volume_set_on_mixer_scale == avg_volume: return self._volume_set else: return self._rescale( From d1b2641b863ccee4877eb068df93848d03313a55 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:16:45 +0100 Subject: [PATCH 52/94] spotify: Gather the search functionality in one place --- mopidy/backends/spotify/library.py | 56 ++++++++++++++++------ mopidy/backends/spotify/session_manager.py | 13 ----- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index df04058b..f451a93a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import logging -import Queue +import pykka from spotify import Link, SpotifyError from mopidy.backends import base @@ -67,14 +67,46 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def search(self, **query): if not query: - # Since we can't search for the entire Spotify library, we return - # all tracks in the playlists when the query is empty. - tracks = [] - for playlist in self.backend.playlists.playlists: - tracks += playlist.tracks - return tracks + return self._get_all_tracks() + + spotify_query = self._translate_search_query(query) + logger.debug('Spotify search query: %s' % spotify_query) + + future = pykka.ThreadingFuture() + + def callback(results, userdata=None): + # TODO Include results from results.albums(), etc. too + # TODO Consider launching a second search if results.total_tracks() + # is larger than len(results.tracks()) + tracks = [ + translator.to_mopidy_track(t) for t in results.tracks()] + future.set(tracks) + + self.backend.spotify.connected.wait() + + self.backend.spotify.session.search( + spotify_query, callback, + track_count=100, album_count=0, artist_count=0) + + timeout = 10 # TODO Make this a setting + try: + return future.get(timeout=timeout) + except pykka.Timeout: + logger.debug( + 'Timeout: Spotify search did not return in %ds', timeout) + return [] + + def _get_all_tracks(self): + # Since we can't search for the entire Spotify library, we return + # all tracks in the playlists when the query is empty. + tracks = [] + for playlist in self.backend.playlists.playlists: + tracks += playlist.tracks + return tracks + + def _translate_search_query(self, mopidy_query): spotify_query = [] - for (field, values) in query.iteritems(): + for (field, values) in mopidy_query.iteritems(): if field == 'uri': tracks = [] for value in values: @@ -97,10 +129,4 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): else: spotify_query.append('%s:"%s"' % (field, value)) spotify_query = ' '.join(spotify_query) - logger.debug('Spotify search query: %s' % spotify_query) - queue = Queue.Queue() - self.backend.spotify.search(spotify_query, queue) - try: - return queue.get(timeout=3) # XXX What is an reasonable timeout? - except Queue.Empty: - return [] + return spotify_query diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 288c61f2..f2631406 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -165,19 +165,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.info('Loaded %d Spotify playlist(s)', len(playlists)) BackendListener.send('playlists_loaded') - def search(self, query, queue): - """Search method used by Mopidy backend""" - def callback(results, userdata=None): - # TODO Include results from results.albums(), etc. too - # TODO Consider launching a second search if results.total_tracks() - # is larger than len(results.tracks()) - tracks = [ - translator.to_mopidy_track(t) for t in results.tracks()] - queue.put(tracks) - self.connected.wait() - self.session.search( - query, callback, track_count=100, album_count=0, artist_count=0) - def logout(self): """Log out from spotify""" logger.debug('Logging out from Spotify') From 3cdc9e4e99c3284cc72ccce8a472610237790e95 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:25:20 +0100 Subject: [PATCH 53/94] spotify: Add SPOTIFY_TIMEOUT setting --- docs/changes.rst | 5 +++++ mopidy/settings.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 8d614f1d..b5217200 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,11 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== +**Spotify backend** + +- Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to + control how long we should wait before giving up on Spotify searches, etc. + **MPD frontend** - Add support for the ``findadd`` command. diff --git a/mopidy/settings.py b/mopidy/settings.py index 0a71ccfa..0a272035 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -282,3 +282,12 @@ SPOTIFY_PROXY_USERNAME = None #: #: SPOTIFY_PROXY_PASSWORD = None SPOTIFY_PROXY_PASSWORD = None + +#: Max number of seconds to wait for Spotify operations to complete. +#: +#: Used by :mod:`mopidy.backends.spotify` +#: +#: Default:: +#: +#: SPOTIFY_TIMEOUT = 10 +SPOTIFY_TIMEOUT = 10 From 8baf813fb6dd8858d85a20c016dffb184686cea1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:31:33 +0100 Subject: [PATCH 54/94] spotify: Use SPOTIFY_TIMEOUT in search --- mopidy/backends/spotify/library.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index f451a93a..ca6ee92a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -5,6 +5,7 @@ import logging import pykka from spotify import Link, SpotifyError +from mopidy import settings from mopidy.backends import base from mopidy.models import Track @@ -82,18 +83,20 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): translator.to_mopidy_track(t) for t in results.tracks()] future.set(tracks) - self.backend.spotify.connected.wait() + if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): + logger.debug('Not connected: Spotify search cancelled') + return [] self.backend.spotify.session.search( spotify_query, callback, track_count=100, album_count=0, artist_count=0) - timeout = 10 # TODO Make this a setting try: - return future.get(timeout=timeout) + return future.get(timeout=settings.SPOTIFY_TIMEOUT) except pykka.Timeout: logger.debug( - 'Timeout: Spotify search did not return in %ds', timeout) + 'Timeout: Spotify search did not return in %ds', + settings.SPOTIFY_TIMEOUT) return [] def _get_all_tracks(self): From 4d67dd1353dc56b4dcfae4ba22c2e9440d5c7236 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:37:43 +0100 Subject: [PATCH 55/94] spotify: Use SPOTIFY_TIMEOUT when waiting for objects to load --- mopidy/backends/spotify/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index d171ecae..dec13ced 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -99,7 +99,8 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self._wait_for_object_to_load(playlist) return [SpotifyTrack(track=t) for t in playlist] - def _wait_for_object_to_load(self, spotify_obj, timeout=10): + def _wait_for_object_to_load( + self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): # XXX Sleeping to wait for the Spotify object to load is an ugly hack, # but it works. We should look into other solutions for this. start = time.time() From 79cbdb4fbbd99536501051a3abf4fd3d0121d241 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 19 Dec 2012 23:43:48 +0100 Subject: [PATCH 56/94] mpd: Add MPD_SERVER_CONNECTION_TIMEOUT setting --- docs/changes.rst | 4 ++++ mopidy/frontends/mpd/actor.py | 3 ++- mopidy/settings.py | 10 ++++++++++ mopidy/utils/network.py | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index b5217200..e72abc02 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -15,6 +15,10 @@ v0.11.0 (in development) **MPD frontend** +- Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which + controls how long an MPD client can stay inactive before the connection is + closed by the server. + - Add support for the ``findadd`` command. - Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`): diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index d3c718c4..11e07aa7 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -23,7 +23,8 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): network.Server( hostname, port, protocol=session.MpdSession, protocol_kwargs={'core': core}, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS, + timeout=settings.MPD_SERVER_CONNECTION_TIMEOUT) except IOError as error: logger.error( 'MPD server startup failed: %s', diff --git a/mopidy/settings.py b/mopidy/settings.py index 0a272035..c2081e27 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -174,6 +174,16 @@ MIXER = 'autoaudiomixer' #: MIXER_TRACK = None MIXER_TRACK = None +#: Number of seconds an MPD client can stay inactive before the connection is +#: closed by the server. +#: +#: Used by :mod:`mopidy.frontends.mpd`. +#: +#: Default:: +#: +#: MPD_SERVER_CONNECTION_TIMEOUT = 60 +MPD_SERVER_CONNECTION_TIMEOUT = 60 + #: Which address Mopidy's MPD server should bind to. #: #: Used by :mod:`mopidy.frontends.mpd`. diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 604350d1..1ffb12d6 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -291,7 +291,7 @@ class Connection(object): return True def timeout_callback(self): - self.stop('Client timeout out after %s seconds' % self.timeout) + self.stop('Client inactive for %ds; closing connection' % self.timeout) return False From 30edba0a3e5bc9cab85f7f8313f9e2a336bb6d76 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:25:47 +0100 Subject: [PATCH 57/94] spotify: Unbreak search by URI --- mopidy/backends/spotify/library.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index ca6ee92a..bfdcb4f5 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -70,6 +70,13 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not query: return self._get_all_tracks() + if 'uri' in query.keys(): + result = [] + for uri in query['uri']: + tracks = self.lookup(uri) + result += tracks + return result + spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -110,14 +117,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def _translate_search_query(self, mopidy_query): spotify_query = [] for (field, values) in mopidy_query.iteritems(): - if field == 'uri': - tracks = [] - for value in values: - track = self.lookup(value) - if track: - tracks.append(track) - return tracks - elif field == 'track': + if field == 'track': field = 'title' elif field == 'date': field = 'year' From cb78dc634180d72a6b0ce96b974bb614b5ad62ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:26:29 +0100 Subject: [PATCH 58/94] spotify: Spotify wants 'track', not 'title' (#272) --- mopidy/backends/spotify/library.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index bfdcb4f5..cd6db63d 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -117,9 +117,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def _translate_search_query(self, mopidy_query): spotify_query = [] for (field, values) in mopidy_query.iteritems(): - if field == 'track': - field = 'title' - elif field == 'date': + if field == 'date': field = 'year' if not hasattr(values, '__iter__'): values = [values] From 08f017842560907b15b4e580f70bd25825db3b4c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:46:35 +0100 Subject: [PATCH 59/94] mpd: Extract query translators for direct testing --- mopidy/frontends/mpd/protocol/music_db.py | 93 +++-------------------- mopidy/frontends/mpd/translator.py | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 83 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 7cdfc5e0..393561de 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,11 +1,8 @@ from __future__ import unicode_literals -import re -import shlex - -from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented +from mopidy.frontends.mpd import translator +from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists -from mopidy.frontends.mpd.translator import tracks_to_mpd_format QUERY_RE = ( @@ -13,35 +10,6 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') -def _build_query(mpd_query): - """ - Parses a MPD query string and converts it to the Mopidy query format. - """ - query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') - query_parts = re.findall(query_pattern, mpd_query) - query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' - r'"(?P[^"]+)"') - query = {} - for query_part in query_parts: - m = re.match(query_part_pattern, query_part) - field = m.groupdict()['field'].lower() - if field == 'title': - field = 'track' - elif field in ('file', 'filename'): - field = 'uri' - field = str(field) # Needed for kwargs keys on OS X and Windows - what = m.groupdict()['what'] - if not what: - raise ValueError - if field in query: - query[field].append(what) - else: - query[field] = [what] - return query - - @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -84,11 +52,11 @@ def find(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.find_exact(**query).get() - return tracks_to_mpd_format(result) + return translator.tracks_to_mpd_format(result) @handle_request(r'^findadd ' + QUERY_RE) @@ -102,7 +70,7 @@ def findadd(context, mpd_query): current playlist. Parameters have the same meaning as for ``find``. """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.find_exact(**query).get() @@ -196,7 +164,7 @@ def list_(context, field, mpd_query=None): """ field = field.lower() try: - query = _list_build_query(field, mpd_query) + query = translator.query_from_mpd_list_format(field, mpd_query) except ValueError: return if field == 'artist': @@ -209,47 +177,6 @@ def list_(context, field, mpd_query=None): pass # TODO We don't have genre in our internal data structures yet -def _list_build_query(field, mpd_query): - """Converts a ``list`` query to a Mopidy query.""" - if mpd_query is None: - return {} - try: - # shlex does not seem to be friends with unicode objects - tokens = shlex.split(mpd_query.encode('utf-8')) - except ValueError as error: - if str(error) == 'No closing quotation': - raise MpdArgError('Invalid unquoted character', command='list') - else: - raise - tokens = [t.decode('utf-8') for t in tokens] - if len(tokens) == 1: - if field == 'album': - if not tokens[0]: - raise ValueError - return {'artist': [tokens[0]]} - else: - raise MpdArgError( - 'should be "Album" for 3 arguments', command='list') - elif len(tokens) % 2 == 0: - query = {} - while tokens: - key = tokens[0].lower() - key = str(key) # Needed for kwargs keys on OS X and Windows - value = tokens[1] - tokens = tokens[2:] - if key not in ('artist', 'album', 'date', 'genre'): - raise MpdArgError('not able to parse args', command='list') - if not value: - raise ValueError - if key in query: - query[key].append(value) - else: - query[key] = [value] - return query - else: - raise MpdArgError('not able to parse args', command='list') - - def _list_artist(context, query): artists = set() tracks = context.core.library.find_exact(**query).get() @@ -367,11 +294,11 @@ def search(context, mpd_query): - uses "file" instead of "filename". """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.search(**query).get() - return tracks_to_mpd_format(result) + return translator.tracks_to_mpd_format(result) @handle_request(r'^searchadd ' + QUERY_RE) @@ -388,7 +315,7 @@ def searchadd(context, mpd_query): not case sensitive. """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.search(**query).get() @@ -411,7 +338,7 @@ def searchaddpl(context, playlist_name, mpd_query): not case sensitive. """ try: - query = _build_query(mpd_query) + query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return result = context.core.library.search(**query).get() diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 0c95f044..ef7c8a1c 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -2,10 +2,12 @@ from __future__ import unicode_literals import os import re +import shlex import urllib from mopidy import settings from mopidy.frontends.mpd import protocol +from mopidy.frontends.mpd.exceptions import MpdArgError from mopidy.models import TlTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path @@ -134,6 +136,82 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) +def query_from_mpd_list_format(field, mpd_query): + """ + Converts an MPD ``list`` query to a Mopidy query. + """ + if mpd_query is None: + return {} + try: + # shlex does not seem to be friends with unicode objects + tokens = shlex.split(mpd_query.encode('utf-8')) + except ValueError as error: + if str(error) == 'No closing quotation': + raise MpdArgError('Invalid unquoted character', command='list') + else: + raise + tokens = [t.decode('utf-8') for t in tokens] + if len(tokens) == 1: + if field == 'album': + if not tokens[0]: + raise ValueError + return {'artist': [tokens[0]]} + else: + raise MpdArgError( + 'should be "Album" for 3 arguments', command='list') + elif len(tokens) % 2 == 0: + query = {} + while tokens: + key = tokens[0].lower() + key = str(key) # Needed for kwargs keys on OS X and Windows + value = tokens[1] + tokens = tokens[2:] + if key not in ('artist', 'album', 'date', 'genre'): + raise MpdArgError('not able to parse args', command='list') + if not value: + raise ValueError + if key in query: + query[key].append(value) + else: + query[key] = [value] + return query + else: + raise MpdArgError('not able to parse args', command='list') + + +def query_from_mpd_search_format(mpd_query): + """ + Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy + query format. + + :param mpd_query: the MPD search query + :type mpd_query: string + """ + query_pattern = ( + r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') + query_parts = re.findall(query_pattern, mpd_query) + query_part_pattern = ( + r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' + r'"(?P[^"]+)"') + query = {} + for query_part in query_parts: + m = re.match(query_part_pattern, query_part) + field = m.groupdict()['field'].lower() + if field == 'title': + field = 'track' + elif field in ('file', 'filename'): + field = 'uri' + field = str(field) # Needed for kwargs keys on OS X and Windows + what = m.groupdict()['what'] + if not what: + raise ValueError + if field in query: + query[field].append(what) + else: + query[field] = [what] + return query + + def tracks_to_tag_cache_format(tracks): """ Format list of tracks for output to MPD tag cache From f9dc3e3d81bb6b578fe055f22cf6210f96b18097 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 00:48:04 +0100 Subject: [PATCH 60/94] mpd: Rename test file to match src file --- tests/frontends/mpd/{serializer_test.py => translator_test.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/frontends/mpd/{serializer_test.py => translator_test.py} (100%) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/translator_test.py similarity index 100% rename from tests/frontends/mpd/serializer_test.py rename to tests/frontends/mpd/translator_test.py From 42faec8a3c8c2faaecdf90fddbf8bf79fcc8f357 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 18:59:01 +0100 Subject: [PATCH 61/94] spotify: SpotifyTrack fails when both uri and track is provided --- mopidy/backends/spotify/library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index dec13ced..28e9c61f 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -19,12 +19,12 @@ class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" def __init__(self, uri=None, track=None): super(SpotifyTrack, self).__init__() - if uri: + if (uri and track) or (not uri and not track): + raise AttributeError('uri or track must be provided') + elif uri: self._spotify_track = Link.from_string(uri).as_track() elif track: self._spotify_track = track - else: - raise AttributeError('uri or track must be provided') self._unloaded_track = Track(uri=uri, name='[loading...]') self._track = None From e118c73aa36506e1f66eda027aedfdda98758821 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 19:01:04 +0100 Subject: [PATCH 62/94] spotify: Refactor loading timeout logic --- mopidy/backends/spotify/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 28e9c61f..bde1e3fb 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -103,10 +103,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): # XXX Sleeping to wait for the Spotify object to load is an ugly hack, # but it works. We should look into other solutions for this. - start = time.time() + wait_until = time.time() + timeout while not spotify_obj.is_loaded(): time.sleep(0.1) - if time.time() > (start + timeout): + if time.time() > wait_until: logger.debug( 'Timeout: Spotify object did not load in %ds', timeout) return From a3ab9567331f1072a79e3d1869a38e338ff8d2f9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 19:08:01 +0100 Subject: [PATCH 63/94] spotify: Block track lookups until we get data This makes track lookup behave consistently with lookup of artists, albums and playlists. I consider this "safe", since track lookup is only used for lookup of single tracks by URI. If you're e.g. loading a playlist full of unloaded tracks, you should still use SpotifyTrack to avoid blocking on track loading. --- mopidy/backends/spotify/library.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index bde1e3fb..af25fab2 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -80,7 +80,9 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return [] def _lookup_track(self, uri): - return [SpotifyTrack(uri)] + track = Link.from_string(uri).as_track() + self._wait_for_object_to_load(track) + return [SpotifyTrack(track=track)] def _lookup_album(self, uri): album = Link.from_string(uri).as_album() From 147bb5e983d059862dce55bd807c4662376fdac7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:09:11 +0100 Subject: [PATCH 64/94] local: Read track date from tag cache --- docs/changes.rst | 4 ++++ mopidy/backends/local/translator.py | 3 +++ tests/backends/local/translator_test.py | 12 ++++++------ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3caa02df..2a55e73e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -24,6 +24,10 @@ v0.11.0 (in development) add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" +**Local backend** + +- Load track dates from tag cache. + **MPD frontend** - Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index ff58a16e..390fd92a 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -123,6 +123,9 @@ def _convert_mpd_data(data, tracks, music_dir): if 'title' in data: track_kwargs['name'] = data['title'] + if 'date' in data: + track_kwargs['date'] = data['date'] + if 'musicbrainz_trackid' in data: track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 90ee849d..61a86672 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -99,8 +99,8 @@ expected_tracks = [] def generate_track(path, ident): uri = path_to_uri(path_to_data_dir(path)) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, + album=expected_albums[0], track_no=1, date='2006', length=4000) expected_tracks.append(track) @@ -126,8 +126,8 @@ class MPDTagCacheToTracksTest(unittest.TestCase): path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=expected_albums[0], length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, track_no=1, + album=expected_albums[0], date='2006', length=4000) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): @@ -182,6 +182,6 @@ class MPDTagCacheToTracksTest(unittest.TestCase): artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) track = Track( - name='trackname', artists=expected_artists, track_no=1, - album=album, length=4000, uri=uri) + uri=uri, name='trackname', artists=expected_artists, track_no=1, + album=album, date='2006', length=4000) self.assertEqual(track, list(tracks)[0]) From 53f3ef488c0176712670f92f61c0e9bb731a0e92 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:26:55 +0100 Subject: [PATCH 65/94] local: Reorder search filters and tests --- mopidy/backends/local/library.py | 16 ++++++------ tests/backends/base/library.py | 44 ++++++++++++++++---------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index e0e6f423..65c45376 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -46,23 +46,23 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip() + uri_filter = lambda t: q == t.uri track_filter = lambda t: q == t.name album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) - uri_filter = lambda t: q == t.uri any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) - if field == 'track': + if field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'track': result_tracks = filter(track_filter, result_tracks) elif field == 'album': result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -80,23 +80,23 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip().lower() + uri_filter = lambda t: q in t.uri.lower() track_filter = lambda t: q in t.name.lower() album_filter = lambda t: q in getattr( t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) - uri_filter = lambda t: q in t.uri.lower() any_filter = lambda t: track_filter(t) or album_filter(t) or \ artist_filter(t) or uri_filter(t) - if field == 'track': + if field == 'uri': + result_tracks = filter(uri_filter, result_tracks) + elif field == 'track': result_tracks = filter(track_filter, result_tracks) elif field == 'album': result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4e9232e5..c9db7767 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -60,11 +60,13 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['unknown artist']) self.assertEqual(result, []) - def test_find_exact_artist(self): - result = self.library.find_exact(artist=['artist1']) + def test_find_exact_uri(self): + track_1_uri = 'file://' + path_to_data_dir('uri1') + result = self.library.find_exact(uri=track_1_uri) self.assertEqual(result, self.tracks[:1]) - result = self.library.find_exact(artist=['artist2']) + track_2_uri = 'file://' + path_to_data_dir('uri2') + result = self.library.find_exact(uri=track_2_uri) self.assertEqual(result, self.tracks[1:2]) def test_find_exact_track(self): @@ -74,6 +76,13 @@ class LibraryControllerTest(object): result = self.library.find_exact(track=['track2']) self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_artist(self): + result = self.library.find_exact(artist=['artist1']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.find_exact(artist=['artist2']) + self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_album(self): result = self.library.find_exact(album=['album1']) self.assertEqual(result, self.tracks[:1]) @@ -81,15 +90,6 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, self.tracks[1:2]) - def test_find_exact_uri(self): - track_1_uri = 'file://' + path_to_data_dir('uri1') - result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(result, self.tracks[:1]) - - track_2_uri = 'file://' + path_to_data_dir('uri2') - result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(result, self.tracks[1:2]) - def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -120,11 +120,11 @@ class LibraryControllerTest(object): result = self.library.search(any=['unknown']) self.assertEqual(result, []) - def test_search_artist(self): - result = self.library.search(artist=['Tist1']) + def test_search_uri(self): + result = self.library.search(uri=['RI1']) self.assertEqual(result, self.tracks[:1]) - result = self.library.search(artist=['Tist2']) + result = self.library.search(uri=['RI2']) self.assertEqual(result, self.tracks[1:2]) def test_search_track(self): @@ -134,6 +134,13 @@ class LibraryControllerTest(object): result = self.library.search(track=['Rack2']) self.assertEqual(result, self.tracks[1:2]) + def test_search_artist(self): + result = self.library.search(artist=['Tist1']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.search(artist=['Tist2']) + self.assertEqual(result, self.tracks[1:2]) + def test_search_album(self): result = self.library.search(album=['Bum1']) self.assertEqual(result, self.tracks[:1]) @@ -141,13 +148,6 @@ class LibraryControllerTest(object): result = self.library.search(album=['Bum2']) self.assertEqual(result, self.tracks[1:2]) - def test_search_uri(self): - result = self.library.search(uri=['RI1']) - self.assertEqual(result, self.tracks[:1]) - - result = self.library.search(uri=['RI2']) - self.assertEqual(result, self.tracks[1:2]) - def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, self.tracks[:1]) From 02c8ea53d7eb54c25deec60425d1e4703b890c7e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:28:36 +0100 Subject: [PATCH 66/94] local: Add search-by-date support --- docs/changes.rst | 2 ++ mopidy/backends/local/library.py | 6 ++++++ tests/backends/base/library.py | 32 ++++++++++++++++++++++++++++---- tests/data/library_tag_cache | 2 ++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2a55e73e..bc709be2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -28,6 +28,8 @@ v0.11.0 (in development) - Load track dates from tag cache. +- Add support for searching by track date. + **MPD frontend** - Add :attr:`mopidy.settings.MPD_SERVER_CONNECTION_TIMEOUT` setting which diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 65c45376..143c6d84 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -51,6 +51,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): album_filter = lambda t: q == getattr(t, 'album', Album()).name artist_filter = lambda t: filter( lambda a: q == a.name, t.artists) + date_filter = lambda t: q == t.date any_filter = lambda t: ( track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) @@ -63,6 +64,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -86,6 +89,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): t, 'album', Album()).name.lower() artist_filter = lambda t: filter( lambda a: q in a.name.lower(), t.artists) + date_filter = lambda t: t.date and t.date.startswith(q) any_filter = lambda t: track_filter(t) or album_filter(t) or \ artist_filter(t) or uri_filter(t) @@ -97,6 +101,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) + elif field == 'date': + result_tracks = filter(date_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index c9db7767..57aec3c6 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -16,11 +16,12 @@ class LibraryControllerTest(object): Album()] tracks = [ Track( - name='track1', length=4000, artists=artists[:1], - album=albums[0], uri='file://' + path_to_data_dir('uri1')), + uri='file://' + path_to_data_dir('uri1'), name='track1', + artists=artists[:1], album=albums[0], date='2001-02-03', + length=4000), Track( - name='track2', length=4000, artists=artists[1:2], - album=albums[1], uri='file://' + path_to_data_dir('uri2')), + uri='file://' + path_to_data_dir('uri2'), name='track2', + artists=artists[1:2], album=albums[1], date='2002', length=4000), Track()] def setUp(self): @@ -90,6 +91,16 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_date(self): + result = self.library.find_exact(date=['2001']) + self.assertEqual(result, []) + + result = self.library.find_exact(date=['2001-02-03']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.find_exact(date=['2002']) + self.assertEqual(result, self.tracks[1:2]) + def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -148,6 +159,19 @@ class LibraryControllerTest(object): result = self.library.search(album=['Bum2']) self.assertEqual(result, self.tracks[1:2]) + def test_search_date(self): + result = self.library.search(date=['2001']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.search(date=['2001-02-03']) + self.assertEqual(result, self.tracks[:1]) + + result = self.library.search(date=['2001-02-04']) + self.assertEqual(result, []) + + result = self.library.search(date=['2002']) + self.assertEqual(result, self.tracks[1:2]) + def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, self.tracks[:1]) diff --git a/tests/data/library_tag_cache b/tests/data/library_tag_cache index e090fcbd..50771a0a 100644 --- a/tests/data/library_tag_cache +++ b/tests/data/library_tag_cache @@ -8,12 +8,14 @@ file: /uri1 Artist: artist1 Title: track1 Album: album1 +Date: 2001-02-03 Time: 4 key: uri2 file: /uri2 Artist: artist2 Title: track2 Album: album2 +Date: 2002 Time: 4 key: uri3 file: /uri3 From 242df281149c4743fd8d202649b3216d96b0e611 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 21:34:06 +0100 Subject: [PATCH 67/94] mpd: Support search by date (#272) --- docs/changes.rst | 2 ++ mopidy/frontends/mpd/translator.py | 9 ++++++--- tests/frontends/mpd/translator_test.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index bc709be2..8c2087e5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -53,6 +53,8 @@ v0.11.0 (in development) - Add empty stubs for channel commands for client to client communication. +- Add support for search by date. + **Internal changes** *Models:* diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index ef7c8a1c..e26d7dce 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -187,12 +187,15 @@ def query_from_mpd_search_format(mpd_query): :param mpd_query: the MPD search query :type mpd_query: string """ + # XXX The regexps below should be refactored to reuse common patterns here + # and in mopidy.frontends.mpd.protocol.music_db. query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny)"? "[^"]+"') + r'"?(?:[Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"') query_parts = re.findall(query_pattern, mpd_query) query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' - r'"(?P[^"]+)"') + r'"?(?P([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|' + r'[Tt]itle|[Aa]ny))"? "(?P[^"]+)"') query = {} for query_part in query_parts: m = re.match(query_part_pattern, query_part) diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index aa3b77bb..088ae137 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -121,6 +121,20 @@ class PlaylistMpdFormatTest(unittest.TestCase): self.assertEqual(dict(result[0])['Track'], 2) +class QueryFromMpdSearchFormatTest(unittest.TestCase): + def test_dates_are_extracted(self): + result = translator.query_from_mpd_search_format( + 'Date "1974-01-02" Date "1975"') + self.assertEqual(result['date'][0], '1974-01-02') + self.assertEqual(result['date'][1], '1975') + + # TODO Test more mappings + + +class QueryFromMpdListFormatTest(unittest.TestCase): + pass # TODO + + class TracksToTagCacheFormatTest(unittest.TestCase): def setUp(self): settings.LOCAL_MUSIC_PATH = '/dir/subdir' From 4b94a5a8efccf0ee0661a2dea06cbe6b0d90c613 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 22:15:46 +0100 Subject: [PATCH 68/94] spotify: Increase max search results from 100 to 200 --- docs/changes.rst | 3 +++ mopidy/backends/spotify/library.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8c2087e5..266f73f2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -24,6 +24,9 @@ v0.11.0 (in development) add "spotify:artist:5TgQ66WuWkoQ2xYxaSTnVP" add "spotify:user:p3.no:playlist:0XX6tamRiqEgh3t6FPFEkw" +- Increase max number of tracks returned by searches from 100 to 200, which + seems to be Spotify's current max limit. + **Local backend** - Load track dates from tag cache. diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 7fcf286f..81587e00 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -146,7 +146,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self.backend.spotify.session.search( spotify_query, callback, - track_count=100, album_count=0, artist_count=0) + track_count=200, album_count=0, artist_count=0) try: return future.get(timeout=settings.SPOTIFY_TIMEOUT) From 52b20b3297557d6bebd395419fe34da74fd18fe8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Dec 2012 23:36:45 +0100 Subject: [PATCH 69/94] models: Add SearchResult model --- docs/changes.rst | 3 +++ mopidy/models.py | 31 +++++++++++++++++++++++++++ tests/models_test.py | 50 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 266f73f2..8df821f1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -64,6 +64,9 @@ v0.11.0 (in development) - Specified that :attr:`mopidy.models.Playlist.last_modified` should be in UTC. +- Added :class:`mopidy.models.SearchResult` model to encapsulate search results + consisting of more than just tracks. + *Core API:* - Change the following methods to accept either a dict with filters or kwargs. diff --git a/mopidy/models.py b/mopidy/models.py index e47ed3be..73209b6e 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -318,3 +318,34 @@ class Playlist(ImmutableObject): def length(self): """The number of tracks in the playlist. Read-only.""" return len(self.tracks) + + +class SearchResult(ImmutableObject): + """ + :param uri: search result URI + :type uri: string + :param tracks: matching tracks + :type tracks: list of :class:`Track` elements + :param artists: matching artists + :type artists: list of :class:`Artist` elements + :param albums: matching albums + :type albums: list of :class:`Album` elements + """ + + # The search result URI. Read-only. + uri = None + + # The tracks matching the search query. Read-only. + tracks = tuple() + + # The artists matching the search query. Read-only. + artists = tuple() + + # The albums matching the search query. Read-only. + albums = tuple() + + def __init__(self, *args, **kwargs): + self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) + self.__dict__['artists'] = tuple(kwargs.pop('artists', [])) + self.__dict__['albums'] = tuple(kwargs.pop('albums', [])) + super(SearchResult, self).__init__(*args, **kwargs) diff --git a/tests/models_test.py b/tests/models_test.py index 1a4d869a..89d0b132 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -4,7 +4,7 @@ import datetime import json from mopidy.models import ( - Artist, Album, TlTrack, Track, Playlist, + Artist, Album, TlTrack, Track, Playlist, SearchResult, ModelJSONEncoder, model_json_decoder) from tests import unittest @@ -862,10 +862,56 @@ class PlaylistTest(unittest.TestCase): def test_ne(self): playlist1 = Playlist( - uri='uri1', name='name2', tracks=[Track(uri='uri1')], + uri='uri1', name='name1', tracks=[Track(uri='uri1')], last_modified=1) playlist2 = Playlist( uri='uri2', name='name2', tracks=[Track(uri='uri2')], last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) + + +class SearchResultTest(unittest.TestCase): + def test_uri(self): + uri = 'an_uri' + result = SearchResult(uri=uri) + self.assertEqual(result.uri, uri) + self.assertRaises(AttributeError, setattr, result, 'uri', None) + + def test_tracks(self): + tracks = [Track(), Track(), Track()] + result = SearchResult(tracks=tracks) + self.assertEqual(list(result.tracks), tracks) + self.assertRaises(AttributeError, setattr, result, 'tracks', None) + + def test_artists(self): + artists = [Artist(), Artist(), Artist()] + result = SearchResult(artists=artists) + self.assertEqual(list(result.artists), artists) + self.assertRaises(AttributeError, setattr, result, 'artists', None) + + def test_albums(self): + albums = [Album(), Album(), Album()] + result = SearchResult(albums=albums) + self.assertEqual(list(result.albums), albums) + self.assertRaises(AttributeError, setattr, result, 'albums', None) + + def test_invalid_kwarg(self): + test = lambda: SearchResult(foo='baz') + self.assertRaises(TypeError, test) + + def test_repr_without_results(self): + self.assertEquals( + "SearchResult(albums=[], artists=[], tracks=[], uri=u'uri')", + repr(SearchResult(uri='uri'))) + + def test_serialize_without_results(self): + self.assertDictEqual( + {'__model__': 'SearchResult', 'uri': 'uri'}, + SearchResult(uri='uri').serialize()) + + def test_to_json_and_back(self): + result1 = SearchResult(uri='uri') + serialized = json.dumps(result1, cls=ModelJSONEncoder) + result2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result1, result2) From b0ba2040dfc387ec55163a5462d732d14ca00380 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 00:28:24 +0100 Subject: [PATCH 70/94] Return SearchResult objects from find_exact() and search() This applies to both backends and core. --- docs/changes.rst | 6 ++ mopidy/backends/dummy.py | 6 +- mopidy/backends/local/library.py | 6 +- mopidy/backends/spotify/library.py | 27 +++---- mopidy/core/library.py | 11 ++- mopidy/frontends/mpd/protocol/music_db.py | 38 +++++----- tests/backends/base/library.py | 70 +++++++++---------- tests/core/library_test.py | 46 +++++++----- tests/frontends/mpd/protocol/music_db_test.py | 33 ++++----- 9 files changed, 131 insertions(+), 112 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8df821f1..8f887ed5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -69,6 +69,12 @@ v0.11.0 (in development) *Core API:* +- Change the following methods to return :class:`mopidy.models.SearchResult` + objects which can include both track results and other results: + + - :meth:`mopidy.core.LibraryController.find_exact` + - :meth:`mopidy.core.LibraryController.search` + - Change the following methods to accept either a dict with filters or kwargs. Previously they only accepted kwargs, which made them impossible to use from the Mopidy.js through JSON-RPC, which doesn't support kwargs. diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 39180bbb..c6997b12 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -19,7 +19,7 @@ from __future__ import unicode_literals import pykka from mopidy.backends import base -from mopidy.models import Playlist +from mopidy.models import Playlist, SearchResult class DummyBackend(pykka.ThreadingActor, base.Backend): @@ -37,8 +37,8 @@ class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] - self.dummy_find_exact_result = [] - self.dummy_search_result = [] + self.dummy_find_exact_result = SearchResult() + self.dummy_search_result = SearchResult() def find_exact(self, **query): return self.dummy_find_exact_result diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 143c6d84..ad81efea 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -4,7 +4,7 @@ import logging from mopidy import settings from mopidy.backends import base -from mopidy.models import Album +from mopidy.models import Album, SearchResult from .translator import parse_mpd_tag_cache @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return result_tracks + return SearchResult(tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 81587e00..0e009fd9 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -8,7 +8,7 @@ from spotify import Link, SpotifyError from mopidy import settings from mopidy.backends import base -from mopidy.models import Track +from mopidy.models import Track, SearchResult from . import translator @@ -121,11 +121,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return self._get_all_tracks() if 'uri' in query.keys(): - result = [] + tracks = [] for uri in query['uri']: - tracks = self.lookup(uri) - result += tracks - return result + tracks += self.lookup(uri) + return SearchResult(tracks=tracks) spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -133,12 +132,14 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): future = pykka.ThreadingFuture() def callback(results, userdata=None): - # TODO Include results from results.albums(), etc. too - # TODO Consider launching a second search if results.total_tracks() - # is larger than len(results.tracks()) - tracks = [ - translator.to_mopidy_track(t) for t in results.tracks()] - future.set(tracks) + search_result = SearchResult( + albums=[ + translator.to_mopidy_album(a) for a in results.albums()], + artists=[ + translator.to_mopidy_artist(a) for a in results.artists()], + tracks=[ + translator.to_mopidy_track(t) for t in results.tracks()]) + future.set(search_result) if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): logger.debug('Not connected: Spotify search cancelled') @@ -146,7 +147,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self.backend.spotify.session.search( spotify_query, callback, - track_count=200, album_count=0, artist_count=0) + album_count=200, artist_count=200, track_count=200) try: return future.get(timeout=settings.SPOTIFY_TIMEOUT) @@ -154,7 +155,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): logger.debug( 'Timeout: Spotify search did not return in %ds', settings.SPOTIFY_TIMEOUT) - return [] + return SearchResult(uri='spotify:search') def _get_all_tracks(self): # Since we can't search for the entire Spotify library, we return diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 3c596a3a..39a1e99c 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import itertools import urlparse import pykka @@ -37,13 +36,12 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return pykka.get_all(futures) def lookup(self, uri): """ @@ -98,10 +96,9 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: list of :class:`mopidy.models.Track` + :rtype: list of :class:`mopidy.models.SearchResult` """ query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] - results = pykka.get_all(futures) - return list(itertools.chain(*results)) + return pykka.get_all(futures) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 393561de..f9149a50 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import itertools + from mopidy.frontends.mpd import translator from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists @@ -10,6 +12,10 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') +def _get_tracks(search_results): + return list(itertools.chain(*[r.tracks for r in search_results])) + + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -55,8 +61,8 @@ def find(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(result) + results = context.core.library.find_exact(**query).get() + return translator.tracks_to_mpd_format(_get_tracks(results)) @handle_request(r'^findadd ' + QUERY_RE) @@ -73,8 +79,8 @@ def findadd(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.find_exact(**query).get() - context.core.tracklist.add(result) + results = context.core.library.find_exact(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request( @@ -179,8 +185,8 @@ def list_(context, field, mpd_query=None): def _list_artist(context, query): artists = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): for artist in track.artists: if artist.name: artists.add(('Artist', artist.name)) @@ -189,8 +195,8 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.album and track.album.name: albums.add(('Album', track.album.name)) return albums @@ -198,8 +204,8 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - tracks = context.core.library.find_exact(**query).get() - for track in tracks: + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): if track.date: dates.add(('Date', track.date)) return dates @@ -297,8 +303,8 @@ def search(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(result) + results = context.core.library.search(**query).get() + return translator.tracks_to_mpd_format(_get_tracks(results)) @handle_request(r'^searchadd ' + QUERY_RE) @@ -318,8 +324,8 @@ def searchadd(context, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() - context.core.tracklist.add(result) + results = context.core.library.search(**query).get() + context.core.tracklist.add(_get_tracks(results)) @handle_request(r'^searchaddpl "(?P[^"]+)" ' + QUERY_RE) @@ -341,14 +347,14 @@ def searchaddpl(context, playlist_name, mpd_query): query = translator.query_from_mpd_search_format(mpd_query) except ValueError: return - result = context.core.library.search(**query).get() + results = context.core.library.search(**query).get() playlists = context.core.playlists.filter(name=playlist_name).get() if playlists: playlist = playlists[0] else: playlist = context.core.playlists.create(playlist_name).get() - tracks = list(playlist.tracks) + result + tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.copy(tracks=tracks) context.core.playlists.save(playlist) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 57aec3c6..c75bec74 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -53,53 +53,53 @@ class LibraryControllerTest(object): def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) def test_find_exact_uri(self): track_1_uri = 'file://' + path_to_data_dir('uri1') result = self.library.find_exact(uri=track_1_uri) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) track_2_uri = 'file://' + path_to_data_dir('uri2') result = self.library.find_exact(uri=track_2_uri) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_track(self): result = self.library.find_exact(track=['track1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(track=['track2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_artist(self): result = self.library.find_exact(artist=['artist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(artist=['artist2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_album(self): result = self.library.find_exact(album=['album1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(album=['album2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_date(self): result = self.library.find_exact(date=['2001']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.find_exact(date=['2001-02-03']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.find_exact(date=['2002']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) @@ -117,70 +117,70 @@ class LibraryControllerTest(object): def test_search_no_hits(self): result = self.library.search(track=['unknown track']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(artist=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(album=['unknown artist']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(uri=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(any=['unknown']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) def test_search_uri(self): result = self.library.search(uri=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(uri=['RI2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_track(self): result = self.library.search(track=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(track=['Rack2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_artist(self): result = self.library.search(artist=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(artist=['Tist2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_album(self): result = self.library.search(album=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(album=['Bum2']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_date(self): result = self.library.search(date=['2001']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(date=['2001-02-03']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(date=['2001-02-04']) - self.assertEqual(result, []) + self.assertEqual(list(result[0].tracks), []) result = self.library.search(date=['2002']) - self.assertEqual(result, self.tracks[1:2]) + self.assertEqual(list(result[0].tracks), self.tracks[1:2]) def test_search_any(self): result = self.library.search(any=['Tist1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Rack1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['Bum1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) result = self.library.search(any=['RI1']) - self.assertEqual(result, self.tracks[:1]) + self.assertEqual(list(result[0].tracks), self.tracks[:1]) def test_search_wrong_type(self): test = lambda: self.library.search(wrong=['test']) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index a2c358d7..32e618d2 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -4,7 +4,7 @@ import mock from mopidy.backends import base from mopidy.core import Core -from mopidy.models import Track +from mopidy.models import SearchResult, Track from tests import unittest @@ -75,59 +75,71 @@ class CoreLibraryTest(unittest.TestCase): def test_find_exact_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.find_exact().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.find_exact().get.return_value = result1 self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = [track2] + self.library2.find_exact().get.return_value = result2 self.library2.find_exact.reset_mock() result = self.core.library.find_exact(dict(any=['a'])) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) def test_search_combines_results_from_all_backends(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(any=['a']) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') - self.library1.search().get.return_value = [track1] + result1 = SearchResult(tracks=[track1]) + result2 = SearchResult(tracks=[track2]) + + self.library1.search().get.return_value = result1 self.library1.search.reset_mock() - self.library2.search().get.return_value = [track2] + self.library2.search().get.return_value = result2 self.library2.search.reset_mock() result = self.core.library.search(dict(any=['a'])) - self.assertIn(track1, result) - self.assertIn(track2, result) + self.assertIn(result1, result) + self.assertIn(result2, result) self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 5c887958..86fd8ad7 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, SearchResult, Track from tests.frontends.mpd import protocol @@ -13,9 +13,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_findadd(self): - self.backend.library.dummy_find_exact_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.sendRequest('findadd "title" "A"') @@ -25,9 +24,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_searchadd(self): - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual(self.core.tracklist.length.get(), 0) self.sendRequest('searchadd "title" "a"') @@ -43,9 +41,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): Track(uri='dummy:y', name='y'), ]) self.core.playlists.save(playlist) - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) playlists = self.core.playlists.filter(name='my favs').get() self.assertEqual(len(playlists), 1) self.assertEqual(len(playlists[0].tracks), 2) @@ -61,9 +58,8 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_searchaddpl_creates_missing_playlist(self): - self.backend.library.dummy_search_result = [ - Track(uri='dummy:a', name='A'), - ] + self.backend.library.dummy_search_result = SearchResult( + tracks=[Track(uri='dummy:a', name='A')]) self.assertEqual( len(self.core.playlists.filter(name='my favs').get()), 0) @@ -242,8 +238,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_artist_should_not_return_artists_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(artists=[Artist(name='')])] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(artists=[Artist(name='')])]) self.sendRequest('list "artist"') self.assertNotInResponse('Artist: ') @@ -301,8 +297,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_album_should_not_return_albums_without_names(self): - self.backend.library.dummy_find_exact_result = [ - Track(album=Album(name=''))] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(album=Album(name=''))]) self.sendRequest('list "album"') self.assertNotInResponse('Album: ') @@ -356,7 +352,8 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_list_date_should_not_return_blank_dates(self): - self.backend.library.dummy_find_exact_result = [Track(date='')] + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[Track(date='')]) self.sendRequest('list "date"') self.assertNotInResponse('Date: ') From 71f27d5625328205e4d1158c20d56a22fce89fb3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 11:13:25 +0100 Subject: [PATCH 71/94] local: Add uri to SearchResults --- mopidy/backends/local/actor.py | 2 +- mopidy/backends/local/library.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 75baeab2..c664fb99 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -20,4 +20,4 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - self.uri_schemes = ['file'] + self.uri_schemes = ['file', 'local'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index ad81efea..2295dfb5 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(tracks=result_tracks) + return SearchResult(uri='local:search', tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(tracks=result_tracks) + return SearchResult(uri='local:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): From e804333897b53379ac7f6cbd6f62fbf78fe4f92a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 21:57:20 +0100 Subject: [PATCH 72/94] spotify: Add uri to SearchResult --- mopidy/backends/spotify/library.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 0e009fd9..55f704f7 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging import time +import urllib import pykka from spotify import Link, SpotifyError @@ -124,7 +125,11 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): tracks = [] for uri in query['uri']: tracks += self.lookup(uri) - return SearchResult(tracks=tracks) + if len(query['uri']) == 1: + uri = query['uri'] + else: + uri = 'spotify:search' + return SearchResult(uri=uri, tracks=tracks) spotify_query = self._translate_search_query(query) logger.debug('Spotify search query: %s' % spotify_query) @@ -133,6 +138,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def callback(results, userdata=None): search_result = SearchResult( + uri='spotify:search:' + urllib.quote(results.query()), albums=[ translator.to_mopidy_album(a) for a in results.albums()], artists=[ From a8c0f6baa808dea2d13a5387dc887ef1082f806c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 22:25:21 +0100 Subject: [PATCH 73/94] spotify: Make query a bytestring before urlencoding it --- mopidy/backends/spotify/library.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 55f704f7..5dccc25e 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -138,7 +138,8 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def callback(results, userdata=None): search_result = SearchResult( - uri='spotify:search:' + urllib.quote(results.query()), + uri='spotify:search:%s' % ( + urllib.quote(results.query().encode('utf-8'))), albums=[ translator.to_mopidy_album(a) for a in results.albums()], artists=[ From 455f0145e71e77121502556b05c2c98bc33b179c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 23:08:18 +0100 Subject: [PATCH 74/94] mpd: Include artists and albums in search results --- docs/changes.rst | 5 +++ mopidy/frontends/mpd/protocol/music_db.py | 36 ++++++++++++++++--- tests/frontends/mpd/protocol/music_db_test.py | 34 ++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8f887ed5..3bc68948 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -58,6 +58,11 @@ v0.11.0 (in development) - Add support for search by date. +- Include fake tracks representing albums and artists in the search results. + When these are added to the tracklist, they expand to either all tracks in + the album or all tracks by the artist. This makes it easy to play full albums + in proper order, which is a feature that have been frequently requested. + **Internal changes** *Models:* diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index f9149a50..ca5ef730 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals +import functools import itertools +from mopidy.models import Track from mopidy.frontends.mpd import translator from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists @@ -12,8 +14,28 @@ QUERY_RE = ( r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') -def _get_tracks(search_results): - return list(itertools.chain(*[r.tracks for r in search_results])) +def _get_field(field, search_results): + return list(itertools.chain(*[getattr(r, field) for r in search_results])) + + +_get_albums = functools.partial(_get_field, 'albums') +_get_artists = functools.partial(_get_field, 'artists') +_get_tracks = functools.partial(_get_field, 'tracks') + + +def _album_as_track(album): + return Track( + uri=album.uri, + name='Album: ' + album.name, + album=album, + artists=album.artists) + + +def _artist_as_track(artist): + return Track( + uri=artist.uri, + name='Artist: ' + artist.name, + artists=[artist]) @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') @@ -62,7 +84,10 @@ def find(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(_get_tracks(results)) + albums = [_album_as_track(a) for a in _get_albums(results)] + artists = [_artist_as_track(a) for a in _get_artists(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^findadd ' + QUERY_RE) @@ -304,7 +329,10 @@ def search(context, mpd_query): except ValueError: return results = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(_get_tracks(results)) + albums = [_album_as_track(a) for a in _get_albums(results)] + artists = [_artist_as_track(a) for a in _get_artists(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^searchadd ' + QUERY_RE) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 86fd8ad7..fedc34a1 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -115,6 +115,23 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): + def test_find(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "any" "foo"') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_find_album(self): self.sendRequest('find "album" "what"') self.assertInResponse('OK') @@ -409,6 +426,23 @@ class MusicDatabaseListTest(protocol.BaseTestCase): class MusicDatabaseSearchTest(protocol.BaseTestCase): + def test_search(self): + self.backend.library.dummy_search_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('search "any" "foo"') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + def test_search_album(self): self.sendRequest('search "album" "analbum"') self.assertInResponse('OK') From e5c0bcd110781c0d81bf178d5d5eb8fc066a8749 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 23:28:59 +0100 Subject: [PATCH 75/94] docs: Add issue references --- docs/changes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changes.rst b/docs/changes.rst index 3bc68948..f373832d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -62,6 +62,7 @@ v0.11.0 (in development) When these are added to the tracklist, they expand to either all tracks in the album or all tracks by the artist. This makes it easy to play full albums in proper order, which is a feature that have been frequently requested. + (Fixes: :issue:`67`, :issue:`148`) **Internal changes** From 5060db48f2ccf04f3c7c5f9908840f2891faa7e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 21 Dec 2012 23:51:40 +0100 Subject: [PATCH 76/94] mpd: Refactor search result to (fake) tracks functionality --- mopidy/frontends/mpd/protocol/music_db.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index ca5ef730..b346d714 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -38,6 +38,13 @@ def _artist_as_track(artist): artists=[artist]) +def _search_results_as_tracks(results): + albums = [_album_as_track(a) for a in _get_albums(results)] + artists = [_artist_as_track(a) for a in _get_artists(results)] + tracks = _get_tracks(results) + return artists + albums + tracks + + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -84,10 +91,7 @@ def find(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - albums = [_album_as_track(a) for a in _get_albums(results)] - artists = [_artist_as_track(a) for a in _get_artists(results)] - tracks = _get_tracks(results) - return translator.tracks_to_mpd_format(artists + albums + tracks) + return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) @handle_request(r'^findadd ' + QUERY_RE) @@ -329,10 +333,7 @@ def search(context, mpd_query): except ValueError: return results = context.core.library.search(**query).get() - albums = [_album_as_track(a) for a in _get_albums(results)] - artists = [_artist_as_track(a) for a in _get_artists(results)] - tracks = _get_tracks(results) - return translator.tracks_to_mpd_format(artists + albums + tracks) + return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) @handle_request(r'^searchadd ' + QUERY_RE) From 357a26d7f9c39fe83c418a634b2f86fe858bb8b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 01:40:19 +0100 Subject: [PATCH 77/94] spotify: Fix improper search() return value --- mopidy/backends/spotify/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 5dccc25e..0af76e4b 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -170,7 +170,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): tracks = [] for playlist in self.backend.playlists.playlists: tracks += playlist.tracks - return tracks + return SearchResult(uri='spotify:search', tracks=tracks) def _translate_search_query(self, mopidy_query): spotify_query = [] From 4f4754c57396d183ab8d94c36d57f279121d6c6e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 01:40:51 +0100 Subject: [PATCH 78/94] mpd: Test 'list' response content --- tests/frontends/mpd/protocol/music_db_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 86fd8ad7..58bb33e8 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -181,6 +181,17 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): class MusicDatabaseListTest(protocol.BaseTestCase): + def test_list(self): + self.backend.library.dummy_find_exact_result = SearchResult( + tracks=[ + Track(uri='dummy:a', name='A', artists=[ + Artist(name='A Artist')])]) + + self.sendRequest('list "artist" "artist" "foo"') + + self.assertInResponse('Artist: A Artist') + self.assertInResponse('OK') + def test_list_foo_returns_ack(self): self.sendRequest('list "foo"') self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') From 04be75ed97ed74cdb70d0aec165a547bf5f4a355 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 02:12:07 +0100 Subject: [PATCH 79/94] mpd: Add album date to 'fake' tracks --- mopidy/frontends/mpd/protocol/music_db.py | 3 ++- tests/frontends/mpd/protocol/music_db_test.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index b346d714..bbacaacd 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -27,8 +27,9 @@ def _album_as_track(album): return Track( uri=album.uri, name='Album: ' + album.name, + artists=album.artists, album=album, - artists=album.artists) + date=album.date) def _artist_as_track(artist): diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index fedc34a1..a641cb27 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -117,7 +117,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): def test_find(self): self.backend.library.dummy_find_exact_result = SearchResult( - albums=[Album(uri='dummy:album:a', name='A')], + albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], tracks=[Track(uri='dummy:track:c', name='C')]) @@ -125,8 +125,11 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + self.assertInResponse('file: dummy:artist:b') self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') From 54662479ef4dbd2dbbd9765c839a42316ec1d37b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 22 Dec 2012 12:49:27 +0100 Subject: [PATCH 80/94] mpd: Limit use of fake tracks in 'find` responses If searching for exact artist, don't include fake artist tracks. If searching for exact album, don't include fake album tracks. This makes sure that ncmpcpp's media library doesn't include the magic artist-track in an artist's album listing, and that it doesn't include the magic album-track in an album's track listing. --- mopidy/frontends/mpd/protocol/music_db.py | 20 ++++---- tests/frontends/mpd/protocol/music_db_test.py | 46 +++++++++++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index bbacaacd..c457ee02 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -39,13 +39,6 @@ def _artist_as_track(artist): artists=[artist]) -def _search_results_as_tracks(results): - albums = [_album_as_track(a) for a in _get_albums(results)] - artists = [_artist_as_track(a) for a in _get_artists(results)] - tracks = _get_tracks(results) - return artists + albums + tracks - - @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -92,7 +85,13 @@ def find(context, mpd_query): except ValueError: return results = context.core.library.find_exact(**query).get() - return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) + result_tracks = [] + if 'artist' not in query: + result_tracks += [_artist_as_track(a) for a in _get_artists(results)] + if 'album' not in query: + result_tracks += [_album_as_track(a) for a in _get_albums(results)] + result_tracks += _get_tracks(results) + return translator.tracks_to_mpd_format(result_tracks) @handle_request(r'^findadd ' + QUERY_RE) @@ -334,7 +333,10 @@ def search(context, mpd_query): except ValueError: return results = context.core.library.search(**query).get() - return translator.tracks_to_mpd_format(_search_results_as_tracks(results)) + artists = [_artist_as_track(a) for a in _get_artists(results)] + albums = [_album_as_track(a) for a in _get_albums(results)] + tracks = _get_tracks(results) + return translator.tracks_to_mpd_format(artists + albums + tracks) @handle_request(r'^searchadd ' + QUERY_RE) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index a641cb27..0a69b7cf 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -115,7 +115,7 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseFindTest(protocol.BaseTestCase): - def test_find(self): + def test_find_includes_fake_artist_and_album_tracks(self): self.backend.library.dummy_find_exact_result = SearchResult( albums=[Album(uri='dummy:album:a', name='A', date='2001')], artists=[Artist(uri='dummy:artist:b', name='B')], @@ -123,12 +123,52 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.sendRequest('find "any" "foo"') + self.assertInResponse('file: dummy:artist:b') + self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:album:a') self.assertInResponse('Title: Album: A') self.assertInResponse('Date: 2001') - self.assertInResponse('file: dummy:artist:b') - self.assertInResponse('Title: Artist: B') + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_does_not_include_fake_artist_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertInResponse('file: dummy:album:a') + self.assertInResponse('Title: Album: A') + self.assertInResponse('Date: 2001') + + self.assertInResponse('file: dummy:track:c') + self.assertInResponse('Title: C') + + self.assertInResponse('OK') + + def test_find_artist_and_album_does_not_include_fake_tracks(self): + self.backend.library.dummy_find_exact_result = SearchResult( + albums=[Album(uri='dummy:album:a', name='A', date='2001')], + artists=[Artist(uri='dummy:artist:b', name='B')], + tracks=[Track(uri='dummy:track:c', name='C')]) + + self.sendRequest('find "artist" "foo" "album" "bar"') + + self.assertNotInResponse('file: dummy:artist:b') + self.assertNotInResponse('Title: Artist: B') + + self.assertNotInResponse('file: dummy:album:a') + self.assertNotInResponse('Title: Album: A') + self.assertNotInResponse('Date: 2001') self.assertInResponse('file: dummy:track:c') self.assertInResponse('Title: C') From ce318316a3d74a83a0aa9f40e9de21849afd3dc5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 12:20:30 +0100 Subject: [PATCH 81/94] mpd: Don't restart current track before seek --- docs/changes.rst | 3 ++ mopidy/frontends/mpd/protocol/playback.py | 2 ++ tests/frontends/mpd/protocol/playback_test.py | 29 ++++++++++++++----- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 266f73f2..2dd6d940 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -58,6 +58,9 @@ v0.11.0 (in development) - Add support for search by date. +- Make ``seek`` and ``seekid`` not restart the current track before seeking in + it. + **Internal changes** *Models:* diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 68c49ca0..b8153dc9 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,6 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ + songpos = int(songpos) if context.core.playback.tracklist_position.get() != songpos: playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -343,6 +344,7 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ + tlid = int(tlid) tl_track = context.core.playback.current_tl_track.get() if not tl_track or tl_track.tlid != tlid: playid(context, tlid) diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 063493ec..cc49a8cd 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -371,45 +371,58 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('previous') self.assertInResponse('OK') - def test_seek(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + def test_seek_in_current_track(self): + seek_track = Track(uri='dummy:a', length=40000) + self.core.tracklist.add([seek_track]) + self.core.playback.play() - self.sendRequest('seek "0"') self.sendRequest('seek "0" "30"') + + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse('OK') - def test_seek_with_songpos(self): + def test_seek_in_another_track(self): seek_track = Track(uri='dummy:b', length=40000) self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) + self.core.playback.play() + self.assertNotEqual(self.core.playback.current_track.get(), seek_track) self.sendRequest('seek "1" "30"') + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse('OK') def test_seek_without_quotes(self): self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - self.sendRequest('seek 0') self.sendRequest('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid(self): - self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) + def test_seekid_in_current_track(self): + seek_track = Track(uri='dummy:a', length=40000) + self.core.tracklist.add([seek_track]) + self.core.playback.play() + self.sendRequest('seekid "0" "30"') + + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid_with_tlid(self): + def test_seekid_in_another_track(self): seek_track = Track(uri='dummy:b', length=40000) self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) + self.core.playback.play() self.sendRequest('seekid "1" "30"') + self.assertEqual(1, self.core.playback.current_tl_track.get().tlid) self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse('OK') From 8fcc7966b2222a44a22053801ed9f50112da64fb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 12:25:20 +0100 Subject: [PATCH 82/94] spotify: Create SpotifyTrack with uri if lookup track isn't loaded --- mopidy/backends/spotify/library.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 81587e00..db4c5d7e 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -82,7 +82,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def _lookup_track(self, uri): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) - return [SpotifyTrack(track=track)] + if track.is_loaded(): + return [SpotifyTrack(track=track)] + else: + return [SpotifyTrack(uri=uri)] def _lookup_album(self, uri): album = Link.from_string(uri).as_album() From 5d707e39186d60ee1db1468ecc4ba40ea45feded Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 15:42:49 +0100 Subject: [PATCH 83/94] settings: Fail if BACKENDS/FRONTENDS setting isn't iterable (fixes #278) --- docs/changes.rst | 9 +++++++++ mopidy/utils/settings.py | 4 ++++ tests/utils/settings_test.py | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 2dd6d940..1da3dacc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,15 @@ This change log is used to track all major changes to Mopidy. v0.11.0 (in development) ======================== +**Settings** + +- The settings validator now complains if a setting which expects a tuple of + values (e.g. :attr:`mopidy.settings.BACKENDS`, + :attr:`mopidy.settings.FRONTENDS`) has a non-iterable value. This typically + happens because the setting value contains a single value and one has + forgotten to add a comma after the string, making the value a tuple. (Fixes: + :issue:`278`) + **Spotify backend** - Add :attr:`mopidy.settings.SPOTIFY_TIMEOUT` setting which allows you to diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index fee5252d..6eb462ce 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -172,6 +172,10 @@ def validate_settings(defaults, settings): 'bin in OUTPUT.') elif setting in list_of_one_or_more: + if not hasattr(value, '__iter__'): + errors[setting] = ( + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") if not value: errors[setting] = 'Must contain at least one value.' diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 0ecbb90f..1dcac1bb 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -87,6 +87,14 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual( result['BACKENDS'], 'Must contain at least one value.') + def test_noniterable_multivalue_setting_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': ('this is not a tuple')}) + self.assertEqual( + result['FRONTENDS'], + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") + class SettingsProxyTest(unittest.TestCase): def setUp(self): From c81d1d77bff2cb660d59c8d937b54307c8f49146 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 16:30:59 +0100 Subject: [PATCH 84/94] fab: Make 'test' and 'autotest' able to run a subset of the tests --- fabfile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fabfile.py b/fabfile.py index 267bdc23..370c81be 100644 --- a/fabfile.py +++ b/fabfile.py @@ -1,14 +1,15 @@ from fabric.api import local -def test(): - local('nosetests tests/') +def test(path=None): + path = path or 'tests/' + local('nosetests ' + path) -def autotest(): +def autotest(path=None): while True: local('clear') - test() + test(path) local( 'inotifywait -q -e create -e modify -e delete ' '--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/') From 524bfc931797c35e5371e37f4e699ee5ffbca51d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 18:32:52 +0100 Subject: [PATCH 85/94] local: Use 'file:search' as uri for search results for now --- mopidy/backends/local/actor.py | 2 +- mopidy/backends/local/library.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index c664fb99..75baeab2 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -20,4 +20,4 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) - self.uri_schemes = ['file', 'local'] + self.uri_schemes = ['file'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 2295dfb5..eb328ce2 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -70,7 +70,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='local:search', tracks=result_tracks) + return SearchResult(uri='file:search', tracks=result_tracks) def search(self, **query): self._validate_query(query) @@ -107,7 +107,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return SearchResult(uri='local:search', tracks=result_tracks) + return SearchResult(uri='file:search', tracks=result_tracks) def _validate_query(self, query): for (_, values) in query.iteritems(): From eec6c271c2b2c9ab3ff46c3952e66cbe1b68f234 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 18:41:07 +0100 Subject: [PATCH 86/94] spotify: Refactor URI lookup --- mopidy/backends/spotify/library.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 0af76e4b..45ec0563 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -121,12 +121,13 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not query: return self._get_all_tracks() - if 'uri' in query.keys(): + uris = query.get('uri', []) + if uris: tracks = [] - for uri in query['uri']: + for uri in uris: tracks += self.lookup(uri) - if len(query['uri']) == 1: - uri = query['uri'] + if len(uris) == 1: + uri = uris[0] else: uri = 'spotify:search' return SearchResult(uri=uri, tracks=tracks) From 8da2495e833ebb1115a1d0f59a660cd1d80f2f98 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 00:29:37 +0100 Subject: [PATCH 87/94] spotify: Only return available tracks from lookups --- mopidy/backends/spotify/library.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a42fc21f..8e8e47f9 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -84,7 +84,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) if track.is_loaded(): - return [SpotifyTrack(track=track)] + if track.availability() == 1: + return [SpotifyTrack(track=track)] + else: + return None else: return [SpotifyTrack(uri=uri)] @@ -92,18 +95,18 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): album = Link.from_string(uri).as_album() album_browser = self.backend.spotify.session.browse_album(album) self._wait_for_object_to_load(album_browser) - return [SpotifyTrack(track=t) for t in album_browser] + return [SpotifyTrack(track=t) for t in album_browser if t.availability() == 1] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() artist_browser = self.backend.spotify.session.browse_artist(artist) self._wait_for_object_to_load(artist_browser) - return [SpotifyTrack(track=t) for t in artist_browser] + return [SpotifyTrack(track=t) for t in artist_browser if t.availability() == 1] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) - return [SpotifyTrack(track=t) for t in playlist] + return [SpotifyTrack(track=t) for t in playlist if t.availability() == 1] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): From 30a78ba84b1fd819c8b03fd2fe2b3bf57ce49b4d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 23 Dec 2012 20:56:49 +0100 Subject: [PATCH 88/94] mpd: Minor refactoring --- mopidy/frontends/mpd/protocol/playback.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b8153dc9..8e08585f 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,8 +329,7 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - songpos = int(songpos) - if context.core.playback.tracklist_position.get() != songpos: + if context.core.playback.tracklist_position.get() != int(songpos): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000).get() @@ -344,9 +343,8 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - tlid = int(tlid) tl_track = context.core.playback.current_tl_track.get() - if not tl_track or tl_track.tlid != tlid: + if not tl_track or tl_track.tlid != int(tlid): playid(context, tlid) context.core.playback.seek(int(seconds) * 1000).get() From fdd4ac19ae630d679f6f5e7e22eab56415f5c3da Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 01:21:08 +0100 Subject: [PATCH 89/94] spotify: Fix wrong search return type --- mopidy/backends/spotify/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a42fc21f..45835d04 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -154,7 +154,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): logger.debug('Not connected: Spotify search cancelled') - return [] + return SearchResult(uri='spotify:search') self.backend.spotify.session.search( spotify_query, callback, From 2a487ecd303e09f4471a887ed02801c5f29d901b Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 01:39:56 +0100 Subject: [PATCH 90/94] spotify: Fix flake8 warnings --- mopidy/backends/spotify/library.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 8e8e47f9..f3406821 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -95,18 +95,24 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): album = Link.from_string(uri).as_album() album_browser = self.backend.spotify.session.browse_album(album) self._wait_for_object_to_load(album_browser) - return [SpotifyTrack(track=t) for t in album_browser if t.availability() == 1] + return [ + SpotifyTrack(track=t) + for t in album_browser if t.availability() == 1] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() artist_browser = self.backend.spotify.session.browse_artist(artist) self._wait_for_object_to_load(artist_browser) - return [SpotifyTrack(track=t) for t in artist_browser if t.availability() == 1] + return [ + SpotifyTrack(track=t) + for t in artist_browser if t.availability() == 1] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) - return [SpotifyTrack(track=t) for t in playlist if t.availability() == 1] + return [ + SpotifyTrack(track=t) + for t in playlist if t.availability() == 1] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): From 75279721fb41d88bf27fe3dd8629f49a8485cb57 Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 01:41:08 +0100 Subject: [PATCH 91/94] spotify: Return [] instead of None in _lookup_track --- mopidy/backends/spotify/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index f3406821..a39d674a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -87,7 +87,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): if track.availability() == 1: return [SpotifyTrack(track=track)] else: - return None + return [] else: return [SpotifyTrack(uri=uri)] From 31ddbbc0171d2daea85d891d81de762f1dcec59e Mon Sep 17 00:00:00 2001 From: Trygve Aaberge Date: Mon, 24 Dec 2012 02:07:34 +0100 Subject: [PATCH 92/94] spotify: Use TRACK_AVAILABLE constant --- mopidy/backends/spotify/library.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a39d674a..044e51d9 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -15,6 +15,8 @@ from . import translator logger = logging.getLogger('mopidy.backends.spotify') +TRACK_AVAILABLE = 1 + class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" @@ -84,7 +86,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): track = Link.from_string(uri).as_track() self._wait_for_object_to_load(track) if track.is_loaded(): - if track.availability() == 1: + if track.availability() == TRACK_AVAILABLE: return [SpotifyTrack(track=track)] else: return [] @@ -97,7 +99,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self._wait_for_object_to_load(album_browser) return [ SpotifyTrack(track=t) - for t in album_browser if t.availability() == 1] + for t in album_browser if t.availability() == TRACK_AVAILABLE] def _lookup_artist(self, uri): artist = Link.from_string(uri).as_artist() @@ -105,14 +107,14 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): self._wait_for_object_to_load(artist_browser) return [ SpotifyTrack(track=t) - for t in artist_browser if t.availability() == 1] + for t in artist_browser if t.availability() == TRACK_AVAILABLE] def _lookup_playlist(self, uri): playlist = Link.from_string(uri).as_playlist() self._wait_for_object_to_load(playlist) return [ SpotifyTrack(track=t) - for t in playlist if t.availability() == 1] + for t in playlist if t.availability() == TRACK_AVAILABLE] def _wait_for_object_to_load( self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): From f0ba9dd31c615448a74a8d96d5101af8a07b5e85 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 10:48:29 +0100 Subject: [PATCH 93/94] Bump version number to 0.11.0 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 049db682..2e5aeeba 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.10.0' +__version__ = '0.11.0' from mopidy import settings as default_settings_module diff --git a/tests/version_test.py b/tests/version_test.py index 271f004a..f353f201 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -32,5 +32,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.3'), SV('0.8.0')) self.assertLess(SV('0.8.0'), SV('0.8.1')) self.assertLess(SV('0.8.1'), SV('0.9.0')) - self.assertLess(SV('0.9.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.10.1')) + self.assertLess(SV('0.9.0'), SV('0.10.0')) + self.assertLess(SV('0.10.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.11.1')) From e7d9a1bcdb9e5acd588a70024b111da7d9f48ac6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 24 Dec 2012 10:54:50 +0100 Subject: [PATCH 94/94] docs: Update changelog for v0.11.0 --- docs/changes.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 2357590d..e705444b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,8 +5,13 @@ Changes This change log is used to track all major changes to Mopidy. -v0.11.0 (in development) -======================== +v0.11.0 (2012-12-24) +==================== + +In celebration of Mopidy's three year anniversary December 23, we're releasing +Mopidy 0.11. This release brings several improvements, most notably better +search which now includes matching artists and albums from Spotify in the +search results. **Settings**