From 88f0ffb9f2b01b9d32c6f7a2d06a4987249d805b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 9 Sep 2012 22:48:08 +0200 Subject: [PATCH 001/323] Add a debug thread that dumps tracebacks. Start and extra thread that blocks on a threading event until SIGUSR1 is received. Then dump the tracebacks for all threads except itself. This is only really useful as a debug tool for deadlocks, so we might want to hide it behind a flag? --- mopidy/__main__.py | 7 ++++++- mopidy/utils/process.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 9bee390e..64465c19 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -36,7 +36,7 @@ from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.process import (exit_handler, stop_remaining_actors, - stop_actors_by_class) + stop_actors_by_class, DebugThread) from mopidy.utils.settings import list_settings_optparse_callback @@ -44,7 +44,12 @@ logger = logging.getLogger('mopidy.main') def main(): + debug_thread = DebugThread() + debug_thread.start() + + signal.signal(signal.SIGUSR1, debug_thread.handler) signal.signal(signal.SIGTERM, exit_handler) + loop = gobject.MainLoop() try: options = parse_options() diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 80d850fe..fdeb8e8c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,7 +1,9 @@ import logging import signal +import sys import thread import threading +import traceback from pykka import ActorDeadError from pykka.registry import ActorRegistry @@ -10,6 +12,9 @@ from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') +signals = dict((k, v) for v, k in signal.__dict__.iteritems() + if v.startswith('SIG') and not v.startswith('SIG_')) + def exit_process(): logger.debug(u'Interrupting main...') thread.interrupt_main() @@ -17,8 +22,6 @@ def exit_process(): def exit_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal.""" - signals = dict((k, v) for v, k in signal.__dict__.iteritems() - if v.startswith('SIG') and not v.startswith('SIG_')) logger.info(u'Got %s signal', signals[signum]) exit_process() @@ -65,3 +68,27 @@ class BaseThread(threading.Thread): def run_inside_try(self): raise NotImplementedError + +class DebugThread(threading.Thread): + daemon = True + name = 'DebugThread' + + event = threading.Event() + + def handler(self, signum, frame): + logger.info(u'Got %s signal', signals[signum]) + self.event.set() + + def run(self): + while True: + self.event.wait() + threads = dict((t.ident, t.name) for t in threading.enumerate()) + + for ident, frame in sys._current_frames().items(): + if self.ident == ident: + continue + + print "## Thread: %s (%s) ##" % (threads[ident], ident) + print ''.join(traceback.format_stack(frame)) + + self.event.clear() From 6bee352f477967b0b27591d796b75ab22352dba6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 16 Sep 2012 16:24:36 +0200 Subject: [PATCH 002/323] Move trackback dumping to logger. --- mopidy/utils/process.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index fdeb8e8c..909fe7c1 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -85,10 +85,10 @@ class DebugThread(threading.Thread): threads = dict((t.ident, t.name) for t in threading.enumerate()) for ident, frame in sys._current_frames().items(): - if self.ident == ident: - continue - - print "## Thread: %s (%s) ##" % (threads[ident], ident) - print ''.join(traceback.format_stack(frame)) + if self.ident != ident: + stack = ''.join(traceback.format_stack(frame)) + logger.debug('Current state of %s (%s):\n%s', + threads[ident], ident, stack) + del frame self.event.clear() From 84f55d6853ba9260b9e57ff003aa335ce3feed5c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:07:38 +0200 Subject: [PATCH 003/323] Start changelog for v0.9 --- docs/changes.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index bd90111e..caec53ba 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,12 @@ Changes This change log is used to track all major changes to Mopidy. +v0.9.0 (in development) +======================= + +- Nothing so far. + + v0.8.0 (2012-09-20) =================== From 1ed78c5ceb4ccdb08dd86eb469f69a163a8b8b2e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:22:28 +0200 Subject: [PATCH 004/323] docs: Avoid frequent repetition of 'most' word --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0b0f6965..72a60a45 100644 --- a/README.rst +++ b/README.rst @@ -6,8 +6,8 @@ Mopidy Mopidy is a music server which can play music from `Spotify `_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use most -`MPD clients `_. MPD clients are available for most +in Spotify's vast archive, manage playlists, and play music, you can use any +`MPD client `_. MPD clients are available for most platforms, including Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, check out From 5a6fe0eb0af847845c1672dbf40c60e22bc9973e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 01:22:38 +0200 Subject: [PATCH 005/323] docs: Add link to CI server --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 72a60a45..e7ecd614 100644 --- a/README.rst +++ b/README.rst @@ -16,5 +16,6 @@ To install Mopidy, check out - `Documentation `_ - `Source code `_ - `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - `Download development snapshot `_ From b3f3cfe2a08f4d3852de24f2c464c4fb2a00117f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 11:11:15 +0200 Subject: [PATCH 006/323] Use assert{Less,Greater}[EEqual] in tests --- tests/backends/base/current_playlist.py | 4 +- tests/backends/base/playback.py | 10 +-- .../mpd/protocol/current_playlist_test.py | 6 +- tests/frontends/mpd/protocol/playback_test.py | 32 +++++--- tests/frontends/mpd/status_test.py | 24 +++--- .../frontends/mpris/player_interface_test.py | 78 +++++++++---------- tests/version_test.py | 38 ++++----- 7 files changed, 101 insertions(+), 91 deletions(-) diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 430e4c40..a42e7eac 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -205,7 +205,7 @@ class CurrentPlaylistControllerTest(object): track2 = self.controller.tracks[2] version = self.controller.version self.controller.remove(uri=track1.uri) - self.assert_(version < self.controller.version) + self.assertLess(version, self.controller.version) self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @@ -281,4 +281,4 @@ class CurrentPlaylistControllerTest(object): def test_version_increases_when_appending_something(self): version = self.controller.version self.controller.append([Track()]) - self.assert_(version < self.controller.version) + self.assertLess(version, self.controller.version) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 1e434e35..e052a907 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -618,7 +618,7 @@ class PlaybackControllerTest(object): def test_seek_when_stopped_updates_position(self): self.playback.seek(1000) position = self.playback.time_position - self.assert_(position >= 990, position) + self.assertGreaterEqual(position, 990) def test_seek_on_empty_playlist(self): self.assertFalse(self.playback.seek(0)) @@ -644,7 +644,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.seek(length - 1000) position = self.playback.time_position - self.assert_(position >= length - 1010, position) + self.assertGreaterEqual(position, length - 1010) @populate_playlist def test_seek_when_paused(self): @@ -660,7 +660,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.playback.seek(length - 1000) position = self.playback.time_position - self.assert_(position >= length - 1010, position) + self.assertGreaterEqual(position, length - 1010) @populate_playlist def test_seek_when_paused_triggers_play(self): @@ -702,7 +702,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.seek(-1000) position = self.playback.time_position - self.assert_(position >= 0, position) + self.assertGreaterEqual(position, 0) self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist @@ -749,7 +749,7 @@ class PlaybackControllerTest(object): first = self.playback.time_position time.sleep(1) second = self.playback.time_position - self.assert_(second > first, '%s - %s' % (first, second)) + self.assertGreater(second, first) @unittest.SkipTest # Uses sleep @populate_playlist diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 21889e82..4aed5de1 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -415,7 +415,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): version = self.backend.current_playlist.version.get() self.sendRequest(u'shuffle') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.backend.current_playlist.version.get()) self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): @@ -426,7 +426,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): version = self.backend.current_playlist.version.get() self.sendRequest(u'shuffle "4:"') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -442,7 +442,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): version = self.backend.current_playlist.version.get() self.sendRequest(u'shuffle "1:3"') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.backend.current_playlist.version.get()) tracks = self.backend.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 4f8f7430..112a13ae 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -259,25 +259,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_playid(self): @@ -327,25 +331,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_is_ignored_if_playing(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): self.backend.current_playlist.append([Track(length=40000)]) self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertEquals(PLAYING, self.backend.playback.state.get()) self.backend.playback.pause() self.assertEquals(PAUSED, self.backend.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): @@ -363,7 +371,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') - self.assert_(self.backend.playback.time_position >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position, 30000) self.assertInResponse(u'OK') def test_seek_with_songpos(self): @@ -380,13 +388,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_seekid(self): self.backend.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual(self.backend.playback.time_position.get(), + 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 2bc3488b..59418a3b 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -27,19 +27,19 @@ class StatusHandlerTest(unittest.TestCase): def test_stats_method(self): result = status.stats(self.context) self.assertIn('artists', result) - self.assert_(int(result['artists']) >= 0) + self.assertGreaterEqual(int(result['artists']), 0) self.assertIn('albums', result) - self.assert_(int(result['albums']) >= 0) + self.assertGreaterEqual(int(result['albums']), 0) self.assertIn('songs', result) - self.assert_(int(result['songs']) >= 0) + self.assertGreaterEqual(int(result['songs']), 0) self.assertIn('uptime', result) - self.assert_(int(result['uptime']) >= 0) + self.assertGreaterEqual(int(result['uptime']), 0) self.assertIn('db_playtime', result) - self.assert_(int(result['db_playtime']) >= 0) + self.assertGreaterEqual(int(result['db_playtime']), 0) self.assertIn('db_update', result) - self.assert_(int(result['db_update']) >= 0) + self.assertGreaterEqual(int(result['db_update']), 0) self.assertIn('playtime', result) - self.assert_(int(result['playtime']) >= 0) + self.assertGreaterEqual(int(result['playtime']), 0) def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) @@ -98,12 +98,12 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) self.assertIn('playlistlength', result) - self.assert_(int(result['playlistlength']) >= 0) + self.assertGreaterEqual(int(result['playlistlength']), 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) self.assertIn('xfade', result) - self.assert_(int(result['xfade']) >= 0) + self.assertGreaterEqual(int(result['xfade']), 0) def test_status_method_contains_state_is_play(self): self.backend.playback.state = PLAYING @@ -129,7 +129,7 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) - self.assert_(int(result['song']) >= 0) + self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): self.backend.current_playlist.append([Track()]) @@ -146,7 +146,7 @@ class StatusHandlerTest(unittest.TestCase): (position, total) = result['time'].split(':') position = int(position) total = int(total) - self.assert_(position <= total) + self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): self.backend.current_playlist.append([Track(length=10000)]) @@ -156,7 +156,7 @@ class StatusHandlerTest(unittest.TestCase): (position, total) = result['time'].split(':') position = int(position) total = int(total) - self.assert_(position <= total) + self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): self.backend.playback.state = PAUSED diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index db7f9265..89f7f1d4 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -89,12 +89,12 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assert_(rate >= minimum_rate) + self.assertGreaterEqual(rate, minimum_rate) def test_get_rate_is_less_or_equal_than_maximum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assert_(rate >= maximum_rate) + self.assertGreaterEqual(rate, maximum_rate) def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False @@ -246,7 +246,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(10000) result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 - self.assert_(result_in_milliseconds >= 10000) + self.assertGreaterEqual(result_in_milliseconds, 10000) def test_get_position_when_no_current_track_should_be_zero(self): result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') @@ -255,11 +255,11 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_minimum_rate_is_one_or_less(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assert_(result <= 1.0) + self.assertLessEqual(result, 1.0) def test_get_maximum_rate_is_one_or_more(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assert_(result >= 1.0) + self.assertGreaterEqual(result, 1.0) def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True @@ -490,13 +490,13 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PAUSED) at_pause = self.backend.playback.time_position.get() - self.assert_(at_pause >= 0) + self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() self.assertEquals(self.backend.playback.state.get(), PLAYING) after_pause = self.backend.playback.time_position.get() - self.assert_(after_pause >= at_pause) + self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -545,17 +545,17 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.play() before_pause = self.backend.playback.time_position.get() - self.assert_(before_pause >= 0) + self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() self.assertEquals(self.backend.playback.state.get(), PAUSED) at_pause = self.backend.playback.time_position.get() - self.assert_(at_pause >= before_pause) + self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() self.assertEquals(self.backend.playback.state.get(), PLAYING) after_pause = self.backend.playback.time_position.get() - self.assert_(after_pause >= at_pause) + self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): self.backend.current_playlist.clear() @@ -569,7 +569,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.play() before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 0) + self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -577,15 +577,15 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) after_seek = self.backend.playback.time_position.get() - self.assert_(before_seek <= after_seek < ( - before_seek + milliseconds_to_seek)) + self.assertLessEqual(before_seek, after_seek) + self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) self.backend.playback.play() before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 0) + self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -595,7 +595,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) @@ -603,7 +603,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -10000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -613,8 +613,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) - self.assert_(after_seek < before_seek) + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) + self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) @@ -622,7 +622,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -30000 microseconds_to_seek = milliseconds_to_seek * 1000 @@ -632,9 +632,9 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) - self.assert_(after_seek < before_seek) - self.assert_(after_seek >= 0) + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) + self.assertLess(after_seek, before_seek) + self.assertGreaterEqual(after_seek, 0) def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): self.backend.current_playlist.append([Track(uri='a', length=40000), @@ -643,7 +643,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + self.assertGreaterEqual(before_seek, 20000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -656,8 +656,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.current_track.get().uri, 'b') after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= 0) - self.assert_(after_seek < before_seek) + self.assertGreaterEqual(after_seek, 0) + self.assertLess(after_seek, before_seek) def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False @@ -665,7 +665,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.play() before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= 5000) + self.assertLessEqual(before_set_position, 5000) track_id = 'a' @@ -675,15 +675,15 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= after_set_position < - position_to_set_in_milliseconds) + self.assertLessEqual(before_set_position, after_set_position) + self.assertLess(after_set_position, position_to_set_in_milliseconds) def test_set_position_sets_the_current_track_position_in_microsecs(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) self.backend.playback.play() before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= 5000) + self.assertLessEqual(before_set_position, 5000) self.assertEquals(self.backend.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' @@ -696,7 +696,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.backend.playback.state.get(), PLAYING) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= position_to_set_in_milliseconds) + self.assertGreaterEqual(after_set_position, position_to_set_in_milliseconds) def test_set_position_does_nothing_if_the_position_is_negative(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) @@ -704,8 +704,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -717,7 +717,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) + self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -727,8 +727,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -740,7 +740,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) + self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -750,8 +750,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.playback.seek(20000) before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') @@ -763,7 +763,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) + self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.backend.playback.state.get(), PLAYING) self.assertEquals(self.backend.playback.current_track.get().uri, 'a') diff --git a/tests/version_test.py b/tests/version_test.py index c3eb00c1..678dc221 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -11,25 +11,25 @@ class VersionTest(unittest.TestCase): SV(__version__) def test_versions_can_be_strictly_ordered(self): - self.assert_(SV('0.1.0a0') < SV('0.1.0a1')) - self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) - self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) - self.assert_(SV('0.1.0a3') < SV('0.1.0')) - self.assert_(SV('0.1.0') < SV('0.2.0')) - self.assert_(SV('0.1.0') < SV('1.0.0')) - self.assert_(SV('0.2.0') < SV('0.3.0')) - self.assert_(SV('0.3.0') < SV('0.3.1')) - self.assert_(SV('0.3.1') < SV('0.4.0')) - self.assert_(SV('0.4.0') < SV('0.4.1')) - self.assert_(SV('0.4.1') < SV('0.5.0')) - self.assert_(SV('0.5.0') < SV('0.6.0')) - self.assert_(SV('0.6.0') < SV('0.6.1')) - self.assert_(SV('0.6.1') < SV('0.7.0')) - self.assert_(SV('0.7.0') < SV('0.7.1')) - self.assert_(SV('0.7.1') < SV('0.7.2')) - self.assert_(SV('0.7.2') < SV('0.7.3')) - self.assert_(SV('0.7.3') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.1')) + self.assertLess(SV('0.1.0a0'), SV('0.1.0a1')) + self.assertLess(SV('0.1.0a1'), SV('0.1.0a2')) + self.assertLess(SV('0.1.0a2'), SV('0.1.0a3')) + self.assertLess(SV('0.1.0a3'), SV('0.1.0')) + self.assertLess(SV('0.1.0'), SV('0.2.0')) + self.assertLess(SV('0.1.0'), SV('1.0.0')) + self.assertLess(SV('0.2.0'), SV('0.3.0')) + self.assertLess(SV('0.3.0'), SV('0.3.1')) + self.assertLess(SV('0.3.1'), SV('0.4.0')) + self.assertLess(SV('0.4.0'), SV('0.4.1')) + self.assertLess(SV('0.4.1'), SV('0.5.0')) + self.assertLess(SV('0.5.0'), SV('0.6.0')) + self.assertLess(SV('0.6.0'), SV('0.6.1')) + self.assertLess(SV('0.6.1'), SV('0.7.0')) + self.assertLess(SV('0.7.0'), SV('0.7.1')) + self.assertLess(SV('0.7.1'), SV('0.7.2')) + self.assertLess(SV('0.7.2'), SV('0.7.3')) + self.assertLess(SV('0.7.3'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.8.1')) def test_get_platform_contains_platform(self): self.assertIn(platform.platform(), get_platform()) From 28e5ed8b2e29124d303ac5010a68509fbc8e827d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 23:23:58 +0200 Subject: [PATCH 007/323] Send old and new state to playback_state_changed listeners --- mopidy/core/playback.py | 7 ++++--- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/listeners.py | 7 ++++++- tests/listeners_test.py | 4 +++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 31a1acc5..73866f16 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -290,7 +290,7 @@ class PlaybackController(object): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) - self._trigger_playback_state_changed() + self._trigger_playback_state_changed(old_state, new_state) # FIXME play_time stuff assumes backend does not have a better way of # handeling this stuff :/ @@ -544,9 +544,10 @@ class PlaybackController(object): track=self.current_track, time_position=self.time_position) - def _trigger_playback_state_changed(self): + def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') + BackendListener.send('playback_state_changed', + old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e8b2aabe..3d739c51 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -51,7 +51,7 @@ class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): 'kwargs': {}, }, target_class=MpdSession) - def playback_state_changed(self): + def playback_state_changed(self, old_state, new_state): self.send_idle('player') def playlist_changed(self): diff --git a/mopidy/listeners.py b/mopidy/listeners.py index ee360bf3..8958ac2c 100644 --- a/mopidy/listeners.py +++ b/mopidy/listeners.py @@ -74,11 +74,16 @@ class BackendListener(object): """ pass - def playback_state_changed(self): + def playback_state_changed(self, old_state, new_state): """ Called whenever playback state is changed. *MAY* be implemented by actor. + + :param old_state: the state before the change + :type old_state: string from :class:`mopidy.core.PlaybackState` field + :param new_state: the state after the change + :type new_state: string from :class:`mopidy.core.PlaybackState` field """ pass diff --git a/tests/listeners_test.py b/tests/listeners_test.py index 486dcf9c..7a1c6fb9 100644 --- a/tests/listeners_test.py +++ b/tests/listeners_test.py @@ -1,3 +1,4 @@ +from mopidy.core import PlaybackState from mopidy.listeners import BackendListener from mopidy.models import Track @@ -21,7 +22,8 @@ class BackendListenerTest(unittest.TestCase): self.listener.track_playback_ended(Track(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): - self.listener.playback_state_changed() + self.listener.playback_state_changed( + PlaybackState.STOPPED, PlaybackState.PLAYING) def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed() From b60e6806ced7a5f3059dc6ea256b2e5b80895b1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 13:38:40 +0200 Subject: [PATCH 008/323] Add get_time_position() to playback provider interface --- mopidy/backends/base/playback.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index ae5a4383..197ba90e 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -65,6 +65,16 @@ class BasePlaybackProvider(object): """ return self.backend.audio.stop_playback().get() + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.backend.audio.get_position().get() + def get_volume(self): """ Get current volume From f0613753160ad985a37ec054e7dfaee9729070d6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 13:39:12 +0200 Subject: [PATCH 009/323] Override get_time_position() in the dummy backend --- mopidy/backends/dummy/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 3ada0052..9c4a0e69 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -56,6 +56,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._time_position = 0 self._volume = None def pause(self): @@ -63,17 +64,22 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): def play(self, track): """Pass None as track to force failure""" + self._time_position = 0 return track is not None def resume(self): return True def seek(self, time_position): + self._time_position = time_position return True def stop(self): return True + def get_time_position(self): + return self._time_position + def get_volume(self): return self._volume From 81fca7d68674c4784c35cd770423549cd429934b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 20 Sep 2012 19:33:34 +0200 Subject: [PATCH 010/323] Switch to time position from provider --- mopidy/core/playback.py | 3 +++ tests/frontends/mpd/status_test.py | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 9f6030c1..1bebd270 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -307,6 +307,9 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" + return self.provider.get_time_position() + + def _wall_clock_based_time_position(): if self.state == PlaybackState.PLAYING: time_since_started = (self._current_wall_time - self.play_time_started) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 59418a3b..2397b96f 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -159,18 +159,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.backend.playback.state = PAUSED - self.backend.playback.play_time_accumulated = 59123 + self.backend.current_playlist.append([Track(length=60000)]) + self.backend.playback.play() + self.backend.playback.pause() + self.backend.playback.seek(59123) result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.backend.playback.state = PAUSED - self.backend.playback.play_time_accumulated = 123 # Less than 1000ms + self.backend.current_playlist.append([Track(length=10000)]) + self.backend.playback.play() + self.backend.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) - self.assertEqual(result['elapsed'], '0.123') + self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) From ef17e36a1a641edc5605ba8520a4fd5700776f0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 11:11:59 +0200 Subject: [PATCH 011/323] Remove LocalPlaybackController --- mopidy/backends/local/__init__.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index db86e56f..c321c6e9 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -41,7 +41,7 @@ class LocalBackend(ThreadingActor, base.Backend): provider=library_provider) playback_provider = base.BasePlaybackProvider(backend=self) - self.playback = LocalPlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) @@ -59,18 +59,6 @@ class LocalBackend(ThreadingActor, base.Backend): self.audio = audio_refs[0].proxy() -class LocalPlaybackController(core.PlaybackController): - def __init__(self, *args, **kwargs): - super(LocalPlaybackController, self).__init__(*args, **kwargs) - - # XXX Why do we call stop()? Is it to set GStreamer state to 'READY'? - self.stop() - - @property - def time_position(self): - return self.backend.audio.get_position().get() - - class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) From 12d6ce53dd97593c4a5995783ea7bef8bc898b1e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 14 Sep 2012 23:32:19 +0200 Subject: [PATCH 012/323] Send new time position to 'seeked' listeners --- mopidy/core/playback.py | 6 +++--- mopidy/listeners.py | 5 ++++- tests/listeners_test.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 73866f16..9f6030c1 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -495,7 +495,7 @@ class PlaybackController(object): success = self.provider.seek(time_position) if success: - self._trigger_seeked() + self._trigger_seeked(time_position) return success def stop(self, clear_current_track=False): @@ -553,6 +553,6 @@ class PlaybackController(object): logger.debug(u'Triggering options changed event') BackendListener.send('options_changed') - def _trigger_seeked(self): + def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') + BackendListener.send('seeked', time_position=time_position) diff --git a/mopidy/listeners.py b/mopidy/listeners.py index 8958ac2c..a8794232 100644 --- a/mopidy/listeners.py +++ b/mopidy/listeners.py @@ -111,11 +111,14 @@ class BackendListener(object): """ pass - def seeked(self): + def seeked(self, time_position): """ Called whenever the time position changes by an unexpected amount, e.g. at seek to a new time position. *MAY* be implemented by actor. + + :param time_position: the position that was seeked to in milliseconds + :type time_position: int """ pass diff --git a/tests/listeners_test.py b/tests/listeners_test.py index 7a1c6fb9..896fedf0 100644 --- a/tests/listeners_test.py +++ b/tests/listeners_test.py @@ -35,4 +35,4 @@ class BackendListenerTest(unittest.TestCase): self.listener.volume_changed() def test_listener_has_default_impl_for_seeked(self): - self.listener.seeked() + self.listener.seeked(0) From 2237e4f5a1e2e79d50485f0d1b97a5774af0ed40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 12:10:25 +0200 Subject: [PATCH 013/323] Move optional wall clock-based position tracking down to the playback provider --- mopidy/backends/base/playback.py | 45 +++++++++++++++++++++++++++ mopidy/backends/spotify/playback.py | 9 ++++++ mopidy/core/playback.py | 48 +++-------------------------- 3 files changed, 58 insertions(+), 44 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 197ba90e..b3b9959e 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,3 +1,8 @@ +import time + +from mopidy.core.playback import PlaybackState + + class BasePlaybackProvider(object): """ :param backend: the backend @@ -9,6 +14,9 @@ class BasePlaybackProvider(object): def __init__(self, backend): self.backend = backend + self._play_time_accumulated = 0 + self._play_time_started = 0 + def pause(self): """ Pause playback. @@ -95,3 +103,40 @@ class BasePlaybackProvider(object): :type volume: int [0..100] """ self.backend.audio.set_volume(volume) + + def wall_clock_based_time_position(self): + """ + Helper method that tracks track time position using the wall clock. + + To use this helper you must call the helper from your implementation of + :meth:`get_time_position` and return its return value. + + :rtype: int + """ + state = self.backend.playback.state + if state == PlaybackState.PLAYING: + time_since_started = (self._wall_time() - + self._play_time_started) + return self._play_time_accumulated + time_since_started + elif state == PlaybackState.PAUSED: + return self._play_time_accumulated + elif state == PlaybackState.STOPPED: + return 0 + + def update_play_time_on_play(self): + self._play_time_accumulated = 0 + self._play_time_started = self._wall_time() + + def update_play_time_on_pause(self): + time_since_started = self._wall_time() - self._play_time_started + self._play_time_accumulated += time_since_started + + def update_play_time_on_resume(self): + self._play_time_started = self._wall_time() + + def update_play_time_on_seek(self, time_position): + self._play_time_started = self._wall_time() + self._play_time_accumulated = time_position + + def _wall_time(self): + return int(time.time() * 1000) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 1c20da87..cd5b0689 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -5,8 +5,10 @@ from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackProvider from mopidy.core import PlaybackState + logger = logging.getLogger('mopidy.backends.spotify.playback') + class SpotifyPlaybackProvider(BasePlaybackProvider): def play(self, track): if self.backend.playback.state == PlaybackState.PLAYING: @@ -38,3 +40,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): def stop(self): self.backend.spotify.session.play(0) return super(SpotifyPlaybackProvider, self).stop() + + def get_time_position(self): + # XXX: The default implementation of get_time_position hangs/times out + # when used with the Spotify backend and GStreamer appsrc. If this can + # be resolved, we no longer need to use a wall clock based time + # position for Spotify playback. + return self.wall_clock_based_time_position() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 1bebd270..b32f5b62 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,6 +1,5 @@ import logging import random -import time from mopidy.listeners import BackendListener @@ -20,7 +19,6 @@ def option_wrapper(name, default): return property(get_option, set_option) - class PlaybackState(object): """ Enum of playback states. @@ -87,8 +85,6 @@ class PlaybackController(object): self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = 0 def _get_cpid(self, cp_track): if cp_track is None: @@ -292,48 +288,11 @@ class PlaybackController(object): self._trigger_playback_state_changed(old_state, new_state) - # FIXME play_time stuff assumes backend does not have a better way of - # handeling this stuff :/ - if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED) - and new_state == PlaybackState.PLAYING): - self._play_time_start() - elif (old_state == PlaybackState.PLAYING - and new_state == PlaybackState.PAUSED): - self._play_time_pause() - elif (old_state == PlaybackState.PAUSED - and new_state == PlaybackState.PLAYING): - self._play_time_resume() - @property def time_position(self): """Time position in milliseconds.""" return self.provider.get_time_position() - def _wall_clock_based_time_position(): - if self.state == PlaybackState.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == PlaybackState.PAUSED: - return self.play_time_accumulated - elif self.state == PlaybackState.STOPPED: - return 0 - - def _play_time_start(self): - self.play_time_accumulated = 0 - self.play_time_started = self._current_wall_time - - def _play_time_pause(self): - time_since_started = self._current_wall_time - self.play_time_started - self.play_time_accumulated += time_since_started - - def _play_time_resume(self): - self.play_time_started = self._current_wall_time - - @property - def _current_wall_time(self): - return int(time.time() * 1000) - @property def volume(self): return self.provider.get_volume() @@ -411,6 +370,7 @@ class PlaybackController(object): """Pause playback.""" if self.provider.pause(): self.state = PlaybackState.PAUSED + self.provider.update_play_time_on_pause() self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): @@ -453,6 +413,7 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) + self.provider.update_play_time_on_play() self._trigger_track_playback_started() def previous(self): @@ -469,6 +430,7 @@ class PlaybackController(object): """If paused, resume playing the current track.""" if self.state == PlaybackState.PAUSED and self.provider.resume(): self.state = PlaybackState.PLAYING + self.provider.update_play_time_on_resume() self._trigger_track_playback_resumed() def seek(self, time_position): @@ -493,11 +455,9 @@ class PlaybackController(object): self.next() return True - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - success = self.provider.seek(time_position) if success: + self.provider.update_play_time_on_seek(time_position) self._trigger_seeked(time_position) return success From 90a538c5954e6845080d93a6aeb970a5d8078e89 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 15:43:08 +0200 Subject: [PATCH 014/323] Move wall clock-based time position into Spotify backend --- mopidy/backends/base/playback.py | 45 ----------------------------- mopidy/backends/spotify/playback.py | 37 ++++++++++++++++++++++-- mopidy/core/playback.py | 4 --- 3 files changed, 34 insertions(+), 52 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index b3b9959e..197ba90e 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,8 +1,3 @@ -import time - -from mopidy.core.playback import PlaybackState - - class BasePlaybackProvider(object): """ :param backend: the backend @@ -14,9 +9,6 @@ class BasePlaybackProvider(object): def __init__(self, backend): self.backend = backend - self._play_time_accumulated = 0 - self._play_time_started = 0 - def pause(self): """ Pause playback. @@ -103,40 +95,3 @@ class BasePlaybackProvider(object): :type volume: int [0..100] """ self.backend.audio.set_volume(volume) - - def wall_clock_based_time_position(self): - """ - Helper method that tracks track time position using the wall clock. - - To use this helper you must call the helper from your implementation of - :meth:`get_time_position` and return its return value. - - :rtype: int - """ - state = self.backend.playback.state - if state == PlaybackState.PLAYING: - time_since_started = (self._wall_time() - - self._play_time_started) - return self._play_time_accumulated + time_since_started - elif state == PlaybackState.PAUSED: - return self._play_time_accumulated - elif state == PlaybackState.STOPPED: - return 0 - - def update_play_time_on_play(self): - self._play_time_accumulated = 0 - self._play_time_started = self._wall_time() - - def update_play_time_on_pause(self): - time_since_started = self._wall_time() - self._play_time_started - self._play_time_accumulated += time_since_started - - def update_play_time_on_resume(self): - self._play_time_started = self._wall_time() - - def update_play_time_on_seek(self, time_position): - self._play_time_started = self._wall_time() - self._play_time_accumulated = time_position - - def _wall_time(self): - return int(time.time() * 1000) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index cd5b0689..61696bd8 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,4 +1,5 @@ import logging +import time from spotify import Link, SpotifyError @@ -10,11 +11,25 @@ logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): + def __init__(self, *args, **kwargs): + super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) + + self._play_time_accumulated = 0 + self._play_time_started = 0 + + def pause(self): + time_since_started = self._wall_time() - self._play_time_started + self._play_time_accumulated += time_since_started + + return super(SpotifyPlaybackProvider, self).pause() + def play(self, track): - if self.backend.playback.state == PlaybackState.PLAYING: - self.backend.spotify.session.play(0) if track.uri is None: return False + + self._play_time_accumulated = 0 + self._play_time_started = self._wall_time() + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) @@ -29,12 +44,17 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return False def resume(self): + self._play_time_started = self._wall_time() return self.seek(self.backend.playback.time_position) def seek(self, time_position): + self._play_time_started = self._wall_time() + self._play_time_accumulated = time_position + self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) self.backend.audio.start_playback() + return True def stop(self): @@ -46,4 +66,15 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): # when used with the Spotify backend and GStreamer appsrc. If this can # be resolved, we no longer need to use a wall clock based time # position for Spotify playback. - return self.wall_clock_based_time_position() + state = self.backend.playback.state + if state == PlaybackState.PLAYING: + time_since_started = (self._wall_time() - + self._play_time_started) + return self._play_time_accumulated + time_since_started + elif state == PlaybackState.PAUSED: + return self._play_time_accumulated + elif state == PlaybackState.STOPPED: + return 0 + + def _wall_time(self): + return int(time.time() * 1000) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b32f5b62..82a11064 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -370,7 +370,6 @@ class PlaybackController(object): """Pause playback.""" if self.provider.pause(): self.state = PlaybackState.PAUSED - self.provider.update_play_time_on_pause() self._trigger_track_playback_paused() def play(self, cp_track=None, on_error_step=1): @@ -413,7 +412,6 @@ class PlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - self.provider.update_play_time_on_play() self._trigger_track_playback_started() def previous(self): @@ -430,7 +428,6 @@ class PlaybackController(object): """If paused, resume playing the current track.""" if self.state == PlaybackState.PAUSED and self.provider.resume(): self.state = PlaybackState.PLAYING - self.provider.update_play_time_on_resume() self._trigger_track_playback_resumed() def seek(self, time_position): @@ -457,7 +454,6 @@ class PlaybackController(object): success = self.provider.seek(time_position) if success: - self.provider.update_play_time_on_seek(time_position) self._trigger_seeked(time_position) return success From b913dc48737b19a0b3d757bffe2a36e1b05f9bc1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 25 Sep 2012 15:49:00 +0200 Subject: [PATCH 015/323] Turn on IRC notification when Travis build status changes --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.travis.yml b/.travis.yml index a57f7474..6120e2de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,3 +10,10 @@ before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" script: nosetests + +notifications: + irc: + channels: + - "irc.freenode.org#mopidy" + on_success: change + on_failure: change From 66f476e85ae667d1f401593c33b7b542ec63ddb7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 10:08:59 +0200 Subject: [PATCH 016/323] Fix typo --- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/utils/network.py | 10 ++++---- tests/utils/network/lineprotocol_test.py | 32 ++++++++++++------------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 3d739c51..5d287d03 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -72,7 +72,7 @@ class MpdSession(network.LineProtocol): terminator = protocol.LINE_TERMINATOR encoding = protocol.ENCODING - delimeter = r'\r?\n' + delimiter = r'\r?\n' def __init__(self, connection): super(MpdSession, self).__init__(connection) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 7d97daf8..9cb8d74c 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -293,7 +293,7 @@ class LineProtocol(ThreadingActor): #: Regex to use for spliting lines, will be set compiled version of its #: own value, or to ``terminator``s value if it is not set itself. - delimeter = None + delimiter = None #: What encoding to expect incomming data to be in, can be :class:`None`. encoding = 'utf-8' @@ -304,10 +304,10 @@ class LineProtocol(ThreadingActor): self.prevent_timeout = False self.recv_buffer = '' - if self.delimeter: - self.delimeter = re.compile(self.delimeter) + if self.delimiter: + self.delimiter = re.compile(self.delimiter) else: - self.delimeter = re.compile(self.terminator) + self.delimiter = re.compile(self.terminator) @property def host(self): @@ -348,7 +348,7 @@ class LineProtocol(ThreadingActor): def parse_lines(self): """Consume new data and yield any lines found.""" while re.search(self.terminator, self.recv_buffer): - line, self.recv_buffer = self.delimeter.split( + line, self.recv_buffer = self.delimiter.split( self.recv_buffer, 1) yield line diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index b323de09..4ba62b8f 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -14,23 +14,23 @@ class LineProtocolTest(unittest.TestCase): self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding - self.mock.delimeter = network.LineProtocol.delimeter + self.mock.delimiter = network.LineProtocol.delimiter self.mock.prevent_timeout = False def test_init_stores_values_in_attributes(self): - delimeter = re.compile(network.LineProtocol.terminator) + delimiter = re.compile(network.LineProtocol.terminator) network.LineProtocol.__init__(self.mock, sentinel.connection) self.assertEqual(sentinel.connection, self.mock.connection) self.assertEqual('', self.mock.recv_buffer) - self.assertEqual(delimeter, self.mock.delimeter) + self.assertEqual(delimiter, self.mock.delimiter) self.assertFalse(self.mock.prevent_timeout) - def test_init_compiles_delimeter(self): - self.mock.delimeter = '\r?\n' - delimeter = re.compile('\r?\n') + def test_init_compiles_delimiter(self): + self.mock.delimiter = '\r?\n' + delimiter = re.compile('\r?\n') network.LineProtocol.__init__(self.mock, sentinel.connection) - self.assertEqual(delimeter, self.mock.delimeter) + self.assertEqual(delimiter, self.mock.delimiter) def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.mock.connection = Mock(spec=network.Connection) @@ -108,21 +108,21 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual(2, self.mock.on_line_received.call_count) def test_parse_lines_emtpy_buffer(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '' lines = network.LineProtocol.parse_lines(self.mock) self.assertRaises(StopIteration, lines.next) def test_parse_lines_no_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data' lines = network.LineProtocol.parse_lines(self.mock) self.assertRaises(StopIteration, lines.next) def test_parse_lines_termintor(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -131,7 +131,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_termintor_with_carriage_return(self): - self.mock.delimeter = re.compile(r'\r?\n') + self.mock.delimiter = re.compile(r'\r?\n') self.mock.recv_buffer = 'data\r\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -140,7 +140,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_no_data_before_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -149,7 +149,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_extra_data_after_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1\ndata2' lines = network.LineProtocol.parse_lines(self.mock) @@ -158,7 +158,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('data2', self.mock.recv_buffer) def test_parse_lines_unicode(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = u'æøå\n'.encode('utf-8') lines = network.LineProtocol.parse_lines(self.mock) @@ -167,7 +167,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_multiple_lines(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'abc\ndef\nghi\njkl' lines = network.LineProtocol.parse_lines(self.mock) @@ -178,7 +178,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('jkl', self.mock.recv_buffer) def test_parse_lines_multiple_calls(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1' lines = network.LineProtocol.parse_lines(self.mock) From f88b7115d9aac100a4681d865c947fd0a27771af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 10:11:33 +0200 Subject: [PATCH 017/323] Give the backends an audio proxy on construction --- mopidy/__main__.py | 11 ++++++----- mopidy/backends/base/__init__.py | 9 +++++++++ mopidy/backends/local/__init__.py | 10 +--------- mopidy/backends/spotify/__init__.py | 8 +------- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 35518874..c82510d9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -51,8 +51,8 @@ def main(): setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - setup_audio() - setup_backend() + audio = setup_audio() + setup_backend(audio) setup_frontends() loop.run() except SettingsError as e: @@ -118,14 +118,15 @@ def setup_settings(interactive): def setup_audio(): - Audio.start() + return Audio.start().proxy() def stop_audio(): stop_actors_by_class(Audio) -def setup_backend(): - get_class(settings.BACKENDS[0]).start() + +def setup_backend(audio): + get_class(settings.BACKENDS[0]).start(audio=audio) def stop_backend(): diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index e6c8b70a..67a3c5ba 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -4,6 +4,12 @@ from .stored_playlists import BaseStoredPlaylistsProvider class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + #: The current playlist controller. An instance of #: :class:`mopidy.backends.base.CurrentPlaylistController`. current_playlist = None @@ -22,3 +28,6 @@ class Backend(object): #: List of URI schemes this backend can handle. uri_schemes = [] + + def __init__(self, audio=None): + self.audio = audio diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index c321c6e9..5d6ab8e1 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -32,7 +32,7 @@ class LocalBackend(ThreadingActor, base.Backend): """ def __init__(self, *args, **kwargs): - super(LocalBackend, self).__init__(*args, **kwargs) + base.Backend.__init__(self, *args, **kwargs) self.current_playlist = core.CurrentPlaylistController(backend=self) @@ -50,14 +50,6 @@ class LocalBackend(ThreadingActor, base.Backend): self.uri_schemes = [u'file'] - self.audio = None - - def on_start(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 1feb1c65..039295b6 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -47,7 +47,7 @@ class SpotifyBackend(ThreadingActor, base.Backend): from .playback import SpotifyPlaybackProvider from .stored_playlists import SpotifyStoredPlaylistsProvider - super(SpotifyBackend, self).__init__(*args, **kwargs) + base.Backend.__init__(self, *args, **kwargs) self.current_playlist = core.CurrentPlaylistController(backend=self) @@ -66,7 +66,6 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.uri_schemes = [u'spotify'] - self.audio = None self.spotify = None # Fail early if settings are not present @@ -74,11 +73,6 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.password = settings.SPOTIFY_PASSWORD def on_start(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() From 53d615622744939459aaaa9fb52e47926781d036 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 10:12:17 +0200 Subject: [PATCH 018/323] Give SpotifySessionManager audio and backend proxies on construction --- mopidy/backends/spotify/__init__.py | 3 ++- mopidy/backends/spotify/session_manager.py | 17 +++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 039295b6..fccdd3f1 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -83,6 +83,7 @@ class SpotifyBackend(ThreadingActor, base.Backend): from .session_manager import SpotifySessionManager logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager(self.username, self.password) + spotify = SpotifySessionManager(self.username, self.password, + audio=self.audio, backend=self.actor_ref.proxy()) spotify.start() return spotify diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index ce1226d8..382f65f6 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -27,13 +27,13 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() - def __init__(self, username, password): + def __init__(self, username, password, audio, backend): PyspotifySessionManager.__init__(self, username, password) BaseThread.__init__(self) self.name = 'SpotifyThread' - self.audio = None - self.backend = None + self.audio = audio + self.backend = backend self.connected = threading.Event() self.session = None @@ -44,19 +44,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self._initial_data_receive_completed = False def run_inside_try(self): - self.setup() self.connect() - def setup(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - self.backend = backend_refs[0].proxy() - def logged_in(self, session, error): """Callback used by pyspotify""" if error: From 4ba5395cc01aa1cbd800fe8f083c35e1f1e6aff5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 14:39:22 +0200 Subject: [PATCH 019/323] Remove unused imports --- mopidy/backends/local/__init__.py | 1 - mopidy/backends/spotify/__init__.py | 1 - mopidy/backends/spotify/session_manager.py | 2 -- 3 files changed, 4 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5d6ab8e1..363c1b36 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -5,7 +5,6 @@ import os import shutil from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry from mopidy import audio, core, settings from mopidy.backends import base diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index fccdd3f1..d41c70b4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,7 +1,6 @@ import logging from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry from mopidy import audio, core, settings from mopidy.backends import base diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 382f65f6..9fb6adcb 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,8 +4,6 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from pykka.registry import ActorRegistry - from mopidy import audio, get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES From 52656096103822fa18009913a15703ad2916e0d3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 21:51:29 +0200 Subject: [PATCH 020/323] MPRIS: New BackendListener.seeked() signature --- mopidy/frontends/mpris/__init__.py | 7 ++----- tests/frontends/mpris/events_test.py | 6 +----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 0f5d35c5..4d4d5edb 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -123,9 +123,6 @@ class MprisFrontend(ThreadingActor, BackendListener): logger.debug(u'Received volume changed event') self._emit_properties_changed('Volume') - def seeked(self): + def seeked(self, time_position_in_ms): logger.debug(u'Received seeked event') - if self.mpris_object is None: - return - self.mpris_object.Seeked( - self.mpris_object.Get(objects.PLAYER_IFACE, 'Position')) + self.mpris_object.Seeked(time_position_in_ms * 1000) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 49e56226..3db03ccf 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -70,9 +70,5 @@ class BackendEventsTest(unittest.TestCase): objects.PLAYER_IFACE, {'Volume': 1.0}, []) def test_seeked_event_causes_mpris_seeked_event(self): - self.mpris_object.Get.return_value = 31000000 - self.mpris_frontend.seeked() - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'Position'), {}), - ]) + self.mpris_frontend.seeked(31000) self.mpris_object.Seeked.assert_called_with(31000000) From f80979517daaf841dd0dcd836f709d9a08b2a7b5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 22:10:20 +0200 Subject: [PATCH 021/323] Refactor Spotify track position tracking - Moved to its own class, so it can easily be removed in the future if we get GStreamer based track position working for appsrc. - Now tracks playback state itself, to not depend on the playback controller. --- mopidy/backends/spotify/playback.py | 74 +++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 61696bd8..94d57f56 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -14,12 +14,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) - self._play_time_accumulated = 0 - self._play_time_started = 0 + self._timer = TrackPositionTimer() def pause(self): - time_since_started = self._wall_time() - self._play_time_started - self._play_time_accumulated += time_since_started + self._timer.pause() return super(SpotifyPlaybackProvider, self).pause() @@ -27,38 +25,42 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): if track.uri is None: return False - self._play_time_accumulated = 0 - self._play_time_started = self._wall_time() - try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) + self.backend.audio.prepare_change() self.backend.audio.set_uri('appsrc://') self.backend.audio.start_playback() self.backend.audio.set_metadata(track) + + self._timer.play() + return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) return False def resume(self): - self._play_time_started = self._wall_time() - return self.seek(self.backend.playback.time_position) + time_position = self.get_time_position() + + self._timer.resume() + + return self.seek(time_position) def seek(self, time_position): - self._play_time_started = self._wall_time() - self._play_time_accumulated = time_position - self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) self.backend.audio.start_playback() + self._timer.seek(time_position) + return True def stop(self): self.backend.spotify.session.play(0) + return super(SpotifyPlaybackProvider, self).stop() def get_time_position(self): @@ -66,14 +68,46 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): # when used with the Spotify backend and GStreamer appsrc. If this can # be resolved, we no longer need to use a wall clock based time # position for Spotify playback. - state = self.backend.playback.state - if state == PlaybackState.PLAYING: - time_since_started = (self._wall_time() - - self._play_time_started) - return self._play_time_accumulated + time_since_started - elif state == PlaybackState.PAUSED: - return self._play_time_accumulated - elif state == PlaybackState.STOPPED: + return self._timer.get_time_position() + + +class TrackPositionTimer(object): + """ + Keeps track of time position in a track using the wall clock and playback + events. + + To not introduce a reverse dependency on the playback controller, this + class keeps track of playback state itself. + """ + + def __init__(self): + self._state = PlaybackState.STOPPED + self._accumulated = 0 + self._started = 0 + + def play(self): + self._state = PlaybackState.PLAYING + self._accumulated = 0 + self._started = self._wall_time() + + def pause(self): + self._state = PlaybackState.PAUSED + self._accumulated += self._wall_time() - self._started + + def resume(self): + self._state = PlaybackState.PLAYING + + def seek(self, time_position): + self._started = self._wall_time() + self._accumulated = time_position + + def get_time_position(self): + if self._state == PlaybackState.PLAYING: + time_since_started = self._wall_time() - self._started + return self._accumulated + time_since_started + elif self._state == PlaybackState.PAUSED: + return self._accumulated + elif self._state == PlaybackState.STOPPED: return 0 def _wall_time(self): From 061c155f1e4e17621d454754e001755469ed46ad Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 02:03:48 +0200 Subject: [PATCH 022/323] Remove reverse dependency on the library controller --- mopidy/backends/local/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 363c1b36..73e10918 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -35,9 +35,9 @@ class LocalBackend(ThreadingActor, base.Backend): self.current_playlist = core.CurrentPlaylistController(backend=self) - library_provider = LocalLibraryProvider(backend=self) + self.library_provider = LocalLibraryProvider(backend=self) self.library = core.LibraryController(backend=self, - provider=library_provider) + provider=self.library_provider) playback_provider = base.BasePlaybackProvider(backend=self) self.playback = core.PlaybackController(backend=self, @@ -69,7 +69,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): tracks = [] for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: - tracks.append(self.backend.library.lookup(uri)) + tracks.append(self.backend.library_provider.lookup(uri)) except LookupError, e: logger.error('Playlist item could not be added: %s', e) playlist = Playlist(tracks=tracks, name=name) From 5dd67fa7a762e7be2ab351d4185578336d94e44c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 02:10:03 +0200 Subject: [PATCH 023/323] Remove reverse dependency on the stored playlists controller --- mopidy/backends/spotify/__init__.py | 4 ++-- mopidy/backends/spotify/library.py | 2 +- mopidy/backends/spotify/session_manager.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index d41c70b4..4320d723 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -58,10 +58,10 @@ class SpotifyBackend(ThreadingActor, base.Backend): self.playback = core.PlaybackController(backend=self, provider=playback_provider) - stored_playlists_provider = SpotifyStoredPlaylistsProvider( + self.stored_playlists_provider = SpotifyStoredPlaylistsProvider( backend=self) self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + provider=self.stored_playlists_provider) self.uri_schemes = [u'spotify'] diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 18276ecd..3931aece 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -66,7 +66,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): # Since we can't search for the entire Spotify library, we return # all tracks in the stored playlists when the query is empty. tracks = [] - for playlist in self.backend.stored_playlists.playlists: + for playlist in self.backend.stored_playlists_provider.playlists: tracks += playlist.tracks return Playlist(tracks=tracks) spotify_query = [] diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 9fb6adcb..577d48c9 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -139,7 +139,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): playlists = map(SpotifyTranslator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) - self.backend.stored_playlists.playlists = playlists + self.backend.stored_playlists_provider.playlists = playlists logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): From c5ef8431c3590cdb296160c6f2a44c22705a77de Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:09:31 +0200 Subject: [PATCH 024/323] Remove unused imports --- mopidy/backends/spotify/session_manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 577d48c9..a6389048 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,8 +4,7 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from mopidy import audio, get_version, settings -from mopidy.backends.base import Backend +from mopidy import get_version, settings from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager From 2fdeec9f5af23315a9695568abce6ec24756c09c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 26 Sep 2012 14:54:44 +0200 Subject: [PATCH 025/323] Move controllers to a new core actor The frontends use the new core actor, while the core actor uses the backend. This is a step towards supporting multiple backends, where the core actor will coordinate the backends. --- mopidy/__main__.py | 15 +- mopidy/audio/__init__.py | 19 +- mopidy/backends/base/__init__.py | 18 +- mopidy/backends/dummy/__init__.py | 19 +- mopidy/backends/local/__init__.py | 20 +- mopidy/backends/spotify/__init__.py | 19 +- mopidy/backends/spotify/library.py | 2 +- mopidy/backends/spotify/session_manager.py | 2 +- mopidy/core/__init__.py | 1 + mopidy/core/actor.py | 42 ++ mopidy/core/current_playlist.py | 6 +- mopidy/core/library.py | 12 +- mopidy/core/playback.py | 43 +- mopidy/core/stored_playlists.py | 20 +- mopidy/frontends/mpd/dispatcher.py | 20 +- mopidy/frontends/mpris/objects.py | 18 +- tests/backends/base/__init__.py | 2 +- tests/backends/base/current_playlist.py | 16 +- tests/backends/base/library.py | 13 +- tests/backends/base/playback.py | 55 +- tests/backends/base/stored_playlists.py | 10 +- tests/backends/events_test.py | 37 +- tests/backends/local/playback_test.py | 7 +- tests/backends/local/stored_playlists_test.py | 3 +- tests/frontends/mpd/dispatcher_test.py | 10 +- tests/frontends/mpd/protocol/__init__.py | 11 +- .../mpd/protocol/current_playlist_test.py | 166 ++--- tests/frontends/mpd/protocol/playback_test.py | 230 +++---- .../frontends/mpd/protocol/regression_test.py | 28 +- tests/frontends/mpd/protocol/status_test.py | 4 +- .../mpd/protocol/stored_playlists_test.py | 16 +- tests/frontends/mpd/status_test.py | 58 +- .../frontends/mpris/player_interface_test.py | 621 +++++++++--------- tests/frontends/mpris/root_interface_test.py | 11 +- 34 files changed, 815 insertions(+), 759 deletions(-) create mode 100644 mopidy/core/actor.py diff --git a/mopidy/__main__.py b/mopidy/__main__.py index c82510d9..ee2e21b6 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,6 +31,7 @@ sys.path.insert(0, from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.audio import Audio +from mopidy.core import Core from mopidy.utils import get_class from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging @@ -52,7 +53,8 @@ def main(): check_old_folders() setup_settings(options.interactive) audio = setup_audio() - setup_backend(audio) + backend = setup_backend(audio) + setup_core(audio, backend) setup_frontends() loop.run() except SettingsError as e: @@ -64,6 +66,7 @@ def main(): finally: loop.quit() stop_frontends() + stop_core() stop_backend() stop_audio() stop_remaining_actors() @@ -126,13 +129,21 @@ def stop_audio(): def setup_backend(audio): - get_class(settings.BACKENDS[0]).start(audio=audio) + return get_class(settings.BACKENDS[0]).start(audio=audio).proxy() def stop_backend(): stop_actors_by_class(get_class(settings.BACKENDS[0])) +def setup_core(audio, backend): + return Core.start(audio, backend).proxy() + + +def stop_core(): + stop_actors_by_class(Core) + + def setup_frontends(): for frontend_class_name in settings.FRONTENDS: try: diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index df5efb92..3ce459dd 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -8,8 +8,7 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, utils -from mopidy.backends.base import Backend +from mopidy import core, settings, utils from mopidy.utils import process # Trigger install of gst mixer plugins @@ -150,7 +149,7 @@ class Audio(ThreadingActor): def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: - self._notify_backend_of_eos() + self._notify_core_of_eos() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() logger.error(u'%s %s', error, debug) @@ -159,14 +158,14 @@ class Audio(ThreadingActor): error, debug = message.parse_warning() logger.warning(u'%s %s', error, debug) - def _notify_backend_of_eos(self): - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) <= 1, 'Expected at most one running backend.' - if backend_refs: - logger.debug(u'Notifying backend of end-of-stream.') - backend_refs[0].proxy().playback.on_end_of_track() + def _notify_core_of_eos(self): + core_refs = ActorRegistry.get_by_class(core.Core) + assert len(core_refs) <= 1, 'Expected at most one running core instance' + if core_refs: + logger.debug(u'Notifying core of end-of-stream') + core_refs[0].proxy().playback.on_end_of_track() else: - logger.debug(u'No backend to notify of end-of-stream found.') + logger.debug(u'No core instance to notify of end-of-stream found') def set_uri(self, uri): """ diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 67a3c5ba..4e0f0b08 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -10,24 +10,20 @@ class Backend(object): #: which will then set this field. audio = None - #: The current playlist controller. An instance of - #: :class:`mopidy.backends.base.CurrentPlaylistController`. - current_playlist = None - - #: The library controller. An instance of - # :class:`mopidy.backends.base.LibraryController`. + #: The library provider. An instance of + # :class:`mopidy.backends.base.BaseLibraryProvider`. library = None - #: The playback controller. An instance of - #: :class:`mopidy.backends.base.PlaybackController`. + #: The playback provider. An instance of + #: :class:`mopidy.backends.base.BasePlaybackProvider`. playback = None - #: The stored playlists controller. An instance of - #: :class:`mopidy.backends.base.StoredPlaylistsController`. + #: The stored playlists provider. An instance of + #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. stored_playlists = None #: List of URI schemes this backend can handle. uri_schemes = [] - def __init__(self, audio=None): + def __init__(self, audio): self.audio = audio diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 9c4a0e69..1d69ed7c 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,6 +1,5 @@ from pykka.actor import ThreadingActor -from mopidy import core from mopidy.backends import base from mopidy.models import Playlist @@ -14,21 +13,11 @@ class DummyBackend(ThreadingActor, base.Backend): """ def __init__(self, *args, **kwargs): - super(DummyBackend, self).__init__(*args, **kwargs) + base.Backend.__init__(self, *args, **kwargs) - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = DummyLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = DummyPlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + self.library = DummyLibraryProvider(backend=self) + self.playback = DummyPlaybackProvider(backend=self) + self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'dummy'] diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 73e10918..f3e86679 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -6,7 +6,7 @@ import shutil from pykka.actor import ThreadingActor -from mopidy import audio, core, settings +from mopidy import settings from mopidy.backends import base from mopidy.models import Playlist, Track, Album @@ -33,19 +33,9 @@ class LocalBackend(ThreadingActor, base.Backend): def __init__(self, *args, **kwargs): base.Backend.__init__(self, *args, **kwargs) - self.current_playlist = core.CurrentPlaylistController(backend=self) - - self.library_provider = LocalLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=self.library_provider) - - playback_provider = base.BasePlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + self.library = LocalLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(backend=self) + self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'file'] @@ -69,7 +59,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): tracks = [] for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: - tracks.append(self.backend.library_provider.lookup(uri)) + tracks.append(self.backend.library.lookup(uri)) except LookupError, e: logger.error('Playlist item could not be added: %s', e) playlist = Playlist(tracks=tracks, name=name) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 4320d723..a79168f5 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -2,7 +2,7 @@ import logging from pykka.actor import ThreadingActor -from mopidy import audio, core, settings +from mopidy import settings from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') @@ -48,20 +48,9 @@ class SpotifyBackend(ThreadingActor, base.Backend): base.Backend.__init__(self, *args, **kwargs) - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = SpotifyLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - self.stored_playlists_provider = SpotifyStoredPlaylistsProvider( - backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=self.stored_playlists_provider) + self.library = SpotifyLibraryProvider(backend=self) + self.playback = SpotifyPlaybackProvider(backend=self) + self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'spotify'] diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 3931aece..18276ecd 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -66,7 +66,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): # Since we can't search for the entire Spotify library, we return # all tracks in the stored playlists when the query is empty. tracks = [] - for playlist in self.backend.stored_playlists_provider.playlists: + for playlist in self.backend.stored_playlists.playlists: tracks += playlist.tracks return Playlist(tracks=tracks) spotify_query = [] diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index a6389048..52769d84 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -138,7 +138,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): playlists = map(SpotifyTranslator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) - self.backend.stored_playlists_provider.playlists = playlists + self.backend.stored_playlists.playlists = playlists logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 87df96c9..6070dcc8 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,3 +1,4 @@ +from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController from .playback import PlaybackController, PlaybackState diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py new file mode 100644 index 00000000..4ff378c4 --- /dev/null +++ b/mopidy/core/actor.py @@ -0,0 +1,42 @@ +from pykka.actor import ThreadingActor + +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController +from .stored_playlists import StoredPlaylistsController + + +class Core(ThreadingActor): + #: The current playlist controller. An instance of + #: :class:`mopidy.core.CurrentPlaylistController`. + current_playlist = None + + #: The library controller. An instance of + # :class:`mopidy.core.LibraryController`. + library = None + + #: The playback controller. An instance of + #: :class:`mopidy.core.PlaybackController`. + playback = None + + #: The stored playlists controller. An instance of + #: :class:`mopidy.core.StoredPlaylistsController`. + stored_playlists = None + + def __init__(self, audio=None, backend=None): + self._backend = backend + + self.current_playlist = CurrentPlaylistController(core=self) + + self.library = LibraryController(backend=backend, core=self) + + self.playback = PlaybackController( + audio=audio, backend=backend, core=self) + + self.stored_playlists = StoredPlaylistsController( + backend=backend, core=self) + + @property + def uri_schemes(self): + """List of URI schemes we can handle""" + return self._backend.uri_schemes.get() diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index af06e05e..a39b4c39 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -17,8 +17,8 @@ class CurrentPlaylistController(object): pykka_traversable = True - def __init__(self, backend): - self.backend = backend + def __init__(self, core): + self.core = core self.cp_id = 0 self._cp_tracks = [] self._version = 0 @@ -59,7 +59,7 @@ class CurrentPlaylistController(object): @version.setter def version(self, version): self._version = version - self.backend.playback.on_current_playlist_change() + self.core.playback.on_current_playlist_change() self._trigger_playlist_changed() def add(self, track, at_position=None, increase_version=True): diff --git a/mopidy/core/library.py b/mopidy/core/library.py index fc55aaeb..52f85b55 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -8,9 +8,9 @@ class LibraryController(object): pykka_traversable = True - def __init__(self, backend, provider): + def __init__(self, backend, core): self.backend = backend - self.provider = provider + self.core = core def find_exact(self, **query): """ @@ -29,7 +29,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.find_exact(**query) + return self.backend.library.find_exact(**query).get() def lookup(self, uri): """ @@ -39,7 +39,7 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.provider.lookup(uri) + return self.backend.library.lookup(uri).get() def refresh(self, uri=None): """ @@ -48,7 +48,7 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.provider.refresh(uri) + return self.backend.library.refresh(uri).get() def search(self, **query): """ @@ -67,4 +67,4 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.search(**query) + return self.backend.library.search(**query).get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 82a11064..efba03dd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -79,9 +79,10 @@ class PlaybackController(object): #: Playback continues after current song. single = option_wrapper('_single', False) - def __init__(self, backend, provider): + def __init__(self, audio, backend, core): + self.audio = audio self.backend = backend - self.provider = provider + self.core = core self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True @@ -125,7 +126,7 @@ class PlaybackController(object): if self.current_cp_track is None: return None try: - return self.backend.current_playlist.cp_tracks.index( + return self.core.current_playlist.cp_tracks.index( self.current_cp_track) except ValueError: return None @@ -152,7 +153,7 @@ class PlaybackController(object): # pylint: disable = R0911 # Too many return statements - cp_tracks = self.backend.current_playlist.cp_tracks + cp_tracks = self.core.current_playlist.cp_tracks if not cp_tracks: return None @@ -204,7 +205,7 @@ class PlaybackController(object): enabled this should be a random track, all tracks should be played once before the list repeats. """ - cp_tracks = self.backend.current_playlist.cp_tracks + cp_tracks = self.core.current_playlist.cp_tracks if not cp_tracks: return None @@ -258,7 +259,7 @@ class PlaybackController(object): if self.current_playlist_position in (None, 0): return None - return self.backend.current_playlist.cp_tracks[ + return self.core.current_playlist.cp_tracks[ self.current_playlist_position - 1] @property @@ -291,15 +292,16 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.provider.get_time_position() + return self.backend.playback.get_time_position().get() @property def volume(self): - return self.provider.get_volume() + """Volume as int in range [0..100].""" + return self.backend.playback.get_volume().get() @volume.setter def volume(self, volume): - self.provider.set_volume(volume) + self.backend.playback.set_volume(volume).get() def change_track(self, cp_track, on_error_step=1): """ @@ -337,20 +339,20 @@ class PlaybackController(object): self.stop(clear_current_track=True) if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track.cpid) + self.core.current_playlist.remove(cpid=original_cp_track.cpid) def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. - Used by :class:`mopidy.backends.base.CurrentPlaylistController`. + Used by :class:`mopidy.core.CurrentPlaylistController`. """ self._first_shuffle = True self._shuffled = [] - if (not self.backend.current_playlist.cp_tracks or + if (not self.core.current_playlist.cp_tracks or self.current_cp_track not in - self.backend.current_playlist.cp_tracks): + self.core.current_playlist.cp_tracks): self.stop(clear_current_track=True) def next(self): @@ -368,7 +370,7 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.provider.pause(): + if self.backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -386,7 +388,7 @@ class PlaybackController(object): """ if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks + assert cp_track in self.core.current_playlist.cp_tracks elif cp_track is None: if self.state == PlaybackState.PAUSED: return self.resume() @@ -400,7 +402,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.provider.play(cp_track.track): + if not self.backend.playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -426,7 +428,8 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if self.state == PlaybackState.PAUSED and self.provider.resume(): + if (self.state == PlaybackState.PAUSED and + self.backend.playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -438,7 +441,7 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - if not self.backend.current_playlist.tracks: + if not self.core.current_playlist.tracks: return False if self.state == PlaybackState.STOPPED: @@ -452,7 +455,7 @@ class PlaybackController(object): self.next() return True - success = self.provider.seek(time_position) + success = self.backend.playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -466,7 +469,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.provider.stop(): + if self.backend.playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index a29e34fc..6ea9b1d3 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -8,9 +8,9 @@ class StoredPlaylistsController(object): pykka_traversable = True - def __init__(self, backend, provider): + def __init__(self, backend, core): self.backend = backend - self.provider = provider + self.core = core @property def playlists(self): @@ -19,11 +19,11 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.provider.playlists + return self.backend.stored_playlists.playlists.get() @playlists.setter def playlists(self, playlists): - self.provider.playlists = playlists + self.backend.stored_playlists.playlists = playlists def create(self, name): """ @@ -33,7 +33,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.create(name) + return self.backend.stored_playlists.create(name).get() def delete(self, playlist): """ @@ -42,7 +42,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ - return self.provider.delete(playlist) + return self.backend.stored_playlists.delete(playlist).get() def get(self, **criteria): """ @@ -83,14 +83,14 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.lookup(uri) + return self.backend.stored_playlists.lookup(uri).get() def refresh(self): """ Refresh the stored playlists in :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. """ - return self.provider.refresh() + return self.backend.stored_playlists.refresh().get() def rename(self, playlist, new_name): """ @@ -101,7 +101,7 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ - return self.provider.rename(playlist, new_name) + return self.backend.stored_playlists.rename(playlist, new_name).get() def save(self, playlist): """ @@ -110,4 +110,4 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ - return self.provider.save(playlist) + return self.backend.stored_playlists.save(playlist).get() diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 94ac6bf9..c9dee576 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -4,8 +4,7 @@ import re from pykka import ActorDeadError from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import Backend +from mopidy import core, settings from mopidy.frontends.mpd import exceptions from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to @@ -233,16 +232,17 @@ class MpdContext(object): self.session = session self.events = set() self.subscriptions = set() - self._backend = None + self._core = None @property def backend(self): """ - The backend. An instance of :class:`mopidy.backends.base.Backend`. + The Mopidy core. An instance of :class:`mopidy.core.Core`. """ - if self._backend is None: - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, \ - 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() - return self._backend + # TODO: Rename property to 'core' + if self._core is None: + core_refs = ActorRegistry.get_by_class(core.Core) + assert len(core_refs) == 1, \ + 'Expected exactly one running core instance.' + self._core = core_refs[0].proxy() + return self._core diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 93669977..c2c9f527 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -14,8 +14,7 @@ except ImportError as import_error: from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import Backend +from mopidy import core, settings from mopidy.core import PlaybackState from mopidy.utils.process import exit_process @@ -35,7 +34,7 @@ class MprisObject(dbus.service.Object): properties = None def __init__(self): - self._backend = None + self._core = None self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), @@ -86,12 +85,13 @@ class MprisObject(dbus.service.Object): @property def backend(self): - if self._backend is None: - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, \ - 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() - return self._backend + # TODO: Rename property to 'core' + if self._core is None: + core_refs = ActorRegistry.get_by_class(core.Core) + assert len(core_refs) == 1, \ + 'Expected exactly one running core instance.' + self._core = core_refs[0].proxy() + return self._core def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index 29f010e1..84eee193 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -1,7 +1,7 @@ def populate_playlist(func): def wrapper(self): for track in self.tracks: - self.backend.current_playlist.add(track) + self.core.current_playlist.add(track) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index a42e7eac..db4473bb 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,7 +1,9 @@ import mock import random -from mopidy import audio +from pykka.registry import ActorRegistry + +from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import CpTrack, Playlist, Track @@ -12,13 +14,17 @@ class CurrentPlaylistControllerTest(object): tracks = [] def setUp(self): - self.backend = self.backend_class() - self.backend.audio = mock.Mock(spec=audio.Audio) - self.controller = self.backend.current_playlist - self.playback = self.backend.playback + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(audio=audio, backend=self.backend) + self.controller = self.core.current_playlist + self.playback = self.core.playback assert len(self.tracks) == 3, 'Need three tracks to run tests.' + def tearDown(self): + ActorRegistry.stop_all() + def test_length(self): self.assertEqual(0, len(self.controller.cp_tracks)) self.assertEqual(0, self.controller.length) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index f76d9d75..99dce78e 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,3 +1,8 @@ +import mock + +from pykka.registry import ActorRegistry + +from mopidy import core from mopidy.models import Playlist, Track, Album, Artist from tests import unittest, path_to_data_dir @@ -15,8 +20,12 @@ class LibraryControllerTest(object): Track()] def setUp(self): - self.backend = self.backend_class() - self.library = self.backend.library + self.backend = self.backend_class.start(audio=None).proxy() + self.core = core.Core(backend=self.backend) + self.library = self.core.library + + def tearDown(self): + ActorRegistry.stop_all() def test_refresh(self): self.library.refresh() diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index e052a907..46863f03 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -2,7 +2,7 @@ import mock import random import time -from mopidy import audio +from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import Track @@ -16,10 +16,11 @@ class PlaybackControllerTest(object): tracks = [] def setUp(self): - self.backend = self.backend_class() - self.backend.audio = mock.Mock(spec=audio.Audio) - self.playback = self.backend.playback - self.current_playlist = self.backend.current_playlist + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(backend=self.backend) + self.playback = self.core.playback + self.current_playlist = self.core.current_playlist assert len(self.tracks) >= 3, \ 'Need at least three tracks to run tests.' @@ -97,8 +98,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[0] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[0] self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -157,8 +158,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_skips_to_previous_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play(self.current_playlist.cp_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -221,8 +222,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -274,7 +275,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assertIn(self.tracks[0], self.backend.current_playlist.tracks) + self.assertIn(self.tracks[0], self.current_playlist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -298,7 +299,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.backend.current_playlist.append(self.tracks[:1]) + self.current_playlist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -357,8 +358,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() @@ -411,7 +412,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.current_playlist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -427,7 +428,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.backend.current_playlist.append(self.tracks[:1]) + self.current_playlist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -517,7 +518,7 @@ class PlaybackControllerTest(object): wrapper.called = False self.playback.on_current_playlist_change = wrapper - self.backend.current_playlist.append([Track()]) + self.current_playlist.append([Track()]) self.assert_(wrapper.called) @@ -534,13 +535,13 @@ class PlaybackControllerTest(object): def test_on_current_playlist_change_when_playing(self): self.playback.play() current_track = self.playback.current_track - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_on_current_playlist_change_when_stopped(self): - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -549,7 +550,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() current_track = self.playback.current_track - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @@ -640,7 +641,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_playing_updates_position(self): - length = self.backend.current_playlist.tracks[0].length + length = self.current_playlist.tracks[0].length self.playback.play() self.playback.seek(length - 1000) position = self.playback.time_position @@ -655,7 +656,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_paused_updates_position(self): - length = self.backend.current_playlist.tracks[0].length + length = self.current_playlist.tracks[0].length self.playback.play() self.playback.pause() self.playback.seek(length - 1000) @@ -730,7 +731,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.audio.get_position = mock.Mock(return_value=future) + self.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -738,7 +739,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.audio.get_position = mock.Mock(return_value=future) + self.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -772,9 +773,9 @@ class PlaybackControllerTest(object): def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.playback.consume = True self.playback.play() - for _ in range(len(self.backend.current_playlist.tracks)): + for _ in range(len(self.current_playlist.tracks)): self.playback.on_end_of_track() - self.assertEqual(len(self.backend.current_playlist.tracks), 0) + self.assertEqual(len(self.current_playlist.tracks), 0) @populate_playlist def test_play_with_random(self): diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 1e575b9e..4e65c034 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -2,7 +2,9 @@ import os import shutil import tempfile -from mopidy import settings +import mock + +from mopidy import audio, core, settings from mopidy.models import Playlist from tests import unittest, path_to_data_dir @@ -14,8 +16,10 @@ class StoredPlaylistsControllerTest(object): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') settings.LOCAL_MUSIC_PATH = path_to_data_dir('') - self.backend = self.backend_class() - self.stored = self.backend.stored_playlists + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(backend=self.backend) + self.stored = self.core.stored_playlists def tearDown(self): if os.path.exists(settings.LOCAL_PLAYLIST_PATH): diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index d761676d..5408d71f 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -2,7 +2,8 @@ import mock from pykka.registry import ActorRegistry -from mopidy.backends.dummy import DummyBackend +from mopidy import audio, core +from mopidy.backends import dummy from mopidy.listeners import BackendListener from mopidy.models import Track @@ -12,42 +13,44 @@ from tests import unittest @mock.patch.object(BackendListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() + self.audio = mock.Mock(spec=audio.Audio) + self.backend = dummy.DummyBackend.start(audio=audio).proxy() + self.core = core.Core.start(backend=self.backend).proxy() def tearDown(self): ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='a')) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.pause().get() + self.core.playback.pause().get() self.assertEqual(send.call_args[0][0], 'track_playback_paused') def test_resume_sends_track_playback_resumed(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play() - self.backend.playback.pause().get() + self.core.current_playlist.add(Track(uri='a')) + self.core.playback.play() + self.core.playback.pause().get() send.reset_mock() - self.backend.playback.resume().get() + 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.backend.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='a')) send.reset_mock() - self.backend.playback.play().get() + 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.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='a')) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.stop().get() + 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.backend.current_playlist.add(Track(uri='a', length=40000)) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='a', length=40000)) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.seek(1000).get() + self.core.playback.seek(1000).get() self.assertEqual(send.call_args[0][0], 'seeked') diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index c167fbcc..fe5fee32 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -20,10 +20,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - super(LocalPlaybackControllerTest, self).setUp() - # Two tests does not work at all when using the fake sink - #self.backend.playback.use_fake_sink() def tearDown(self): super(LocalPlaybackControllerTest, self).tearDown() @@ -32,10 +29,10 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def add_track(self, path): uri = path_to_uri(path_to_data_dir(path)) track = Track(uri=uri, length=4464) - self.backend.current_playlist.add(track) + self.current_playlist.add(track) def test_uri_scheme(self): - self.assertIn('file', self.backend.uri_schemes) + self.assertIn('file', self.core.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 56be92c4..3f3d9c58 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -65,8 +65,7 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.stored.save(playlist) - self.backend = self.backend_class() - self.stored = self.backend.stored_playlists + self.backend = self.backend_class.start(audio=self.audio).proxy() self.assert_(self.stored.playlists) self.assertEqual('test', self.stored.playlists[0].name) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 9f05d7dd..0bff04e7 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,4 +1,7 @@ -from mopidy.backends.dummy import DummyBackend +from pykka.registry import ActorRegistry + +from mopidy import core +from mopidy.backends import dummy from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request @@ -8,11 +11,12 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.dispatcher = MpdDispatcher() def tearDown(self): - self.backend.stop().get() + ActorRegistry.stop_all() def test_register_same_pattern_twice_fails(self): func = lambda: None diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 3b8fbe33..a2dafb9b 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -1,7 +1,9 @@ import mock -from mopidy import settings -from mopidy.backends import dummy as backend +from pykka.registry import ActorRegistry + +from mopidy import core, settings +from mopidy.backends import dummy from mopidy.frontends import mpd from tests import unittest @@ -21,7 +23,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.connection = MockConnection() self.session = mpd.MpdSession(self.connection) @@ -29,7 +32,7 @@ class BaseTestCase(unittest.TestCase): self.context = self.dispatcher.context def tearDown(self): - self.backend.stop().get() + ActorRegistry.stop_all() settings.runtime.clear() def sendRequest(self, request): diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 4aed5de1..63c4a42b 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -6,15 +6,15 @@ from tests.frontends.mpd import protocol class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'add "dummy://foo"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) self.assertEqualResponse(u'OK') def test_add_with_uri_not_found_in_library_should_ack(self): @@ -29,17 +29,17 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) self.assertInResponse(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[5][0]) + self.core.current_playlist.cp_tracks.get()[5][0]) self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): @@ -48,26 +48,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo" "3"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) self.assertInResponse(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[3][0]) + self.core.current_playlist.cp_tracks.get()[3][0]) self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo" "6"') self.assertEqualResponse(u'ACK [2@0] {addid} Bad song index') @@ -77,85 +77,85 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_clear(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'clear') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse(u'OK') def test_delete_songpos(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "%d"' % - self.backend.current_playlist.cp_tracks.get()[2][0]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) + self.core.current_playlist.cp_tracks.get()[2][0]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) self.assertInResponse(u'OK') def test_delete_songpos_out_of_bounds(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "5"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "1:"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) self.assertInResponse(u'OK') def test_delete_closed_range(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "1:3"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 3) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 3) self.assertInResponse(u'OK') def test_delete_range_out_of_bounds(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "5:7"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.backend.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.core.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.sendRequest(u'deleteid "1"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) self.assertInResponse(u'OK') def test_deleteid_does_not_exist(self): - self.backend.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.core.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.sendRequest(u'deleteid "12345"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.assertEqualResponse(u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "1" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') self.assertEqual(tracks[2].name, 'c') @@ -165,13 +165,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_move_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "2:" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') self.assertEqual(tracks[2].name, 'e') @@ -181,13 +181,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_move_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "1:3" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') self.assertEqual(tracks[2].name, 'a') @@ -197,13 +197,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_moveid(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'moveid "4" "2"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'e') @@ -230,7 +230,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_in_current_playlist(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='file:///exists')]) self.sendRequest( u'playlistfind filename "file:///exists"') @@ -240,7 +240,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_without_songid(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid') self.assertInResponse(u'Title: a') @@ -248,7 +248,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_with_songid(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid "1"') self.assertNotInResponse(u'Title: a') @@ -258,13 +258,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_with_not_existing_songid_fails(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid "25"') self.assertEqualResponse(u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -286,8 +286,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position - self.backend.current_playlist.cp_id = 17 - self.backend.current_playlist.append([ + self.core.current_playlist.cp_id = 17 + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -313,7 +313,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -334,7 +334,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistinfo_with_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -365,7 +365,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_plchanges(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges "0"') @@ -375,7 +375,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges "-1"') @@ -385,7 +385,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchanges_without_quotes_works(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges 0') @@ -395,10 +395,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchangesposid(self): - self.backend.current_playlist.append([Track(), Track(), Track()]) + self.core.current_playlist.append([Track(), Track(), Track()]) self.sendRequest(u'plchangesposid "0"') - cp_tracks = self.backend.current_playlist.cp_tracks.get() + cp_tracks = self.core.current_playlist.cp_tracks.get() self.assertInResponse(u'cpos: 0') self.assertInResponse(u'Id: %d' % cp_tracks[0][0]) self.assertInResponse(u'cpos: 2') @@ -408,26 +408,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_shuffle_without_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle') - self.assertLess(version, self.backend.current_playlist.version.get()) + self.assertLess(version, self.core.current_playlist.version.get()) self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle "4:"') - self.assertLess(version, self.backend.current_playlist.version.get()) - tracks = self.backend.current_playlist.tracks.get() + self.assertLess(version, self.core.current_playlist.version.get()) + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') @@ -435,15 +435,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_shuffle_with_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle "1:3"') - self.assertLess(version, self.backend.current_playlist.version.get()) - tracks = self.backend.current_playlist.tracks.get() + self.assertLess(version, self.core.current_playlist.version.get()) + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') @@ -451,13 +451,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_swap(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'swap "1" "4"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') @@ -467,13 +467,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_swapid(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'swapid "1" "4"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 112a13ae..2380c7bc 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -13,22 +13,22 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.sendRequest(u'consume "0"') - self.assertFalse(self.backend.playback.consume.get()) + self.assertFalse(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_off_without_quotes(self): self.sendRequest(u'consume 0') - self.assertFalse(self.backend.playback.consume.get()) + self.assertFalse(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_on(self): self.sendRequest(u'consume "1"') - self.assertTrue(self.backend.playback.consume.get()) + self.assertTrue(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_on_without_quotes(self): self.sendRequest(u'consume 1') - self.assertTrue(self.backend.playback.consume.get()) + self.assertTrue(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_crossfade(self): @@ -37,97 +37,97 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_random_off(self): self.sendRequest(u'random "0"') - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_off_without_quotes(self): self.sendRequest(u'random 0') - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_on(self): self.sendRequest(u'random "1"') - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_on_without_quotes(self): self.sendRequest(u'random 1') - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_repeat_off(self): self.sendRequest(u'repeat "0"') - self.assertFalse(self.backend.playback.repeat.get()) + self.assertFalse(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_off_without_quotes(self): self.sendRequest(u'repeat 0') - self.assertFalse(self.backend.playback.repeat.get()) + self.assertFalse(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_on(self): self.sendRequest(u'repeat "1"') - self.assertTrue(self.backend.playback.repeat.get()) + self.assertTrue(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_on_without_quotes(self): self.sendRequest(u'repeat 1') - self.assertTrue(self.backend.playback.repeat.get()) + self.assertTrue(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_setvol_below_min(self): self.sendRequest(u'setvol "-10"') - self.assertEqual(0, self.backend.playback.volume.get()) + self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_min(self): self.sendRequest(u'setvol "0"') - self.assertEqual(0, self.backend.playback.volume.get()) + self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_middle(self): self.sendRequest(u'setvol "50"') - self.assertEqual(50, self.backend.playback.volume.get()) + self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_max(self): self.sendRequest(u'setvol "100"') - self.assertEqual(100, self.backend.playback.volume.get()) + self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_above_max(self): self.sendRequest(u'setvol "110"') - self.assertEqual(100, self.backend.playback.volume.get()) + self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): self.sendRequest(u'setvol "+10"') - self.assertEqual(10, self.backend.playback.volume.get()) + self.assertEqual(10, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_without_quotes(self): self.sendRequest(u'setvol 50') - self.assertEqual(50, self.backend.playback.volume.get()) + self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_single_off(self): self.sendRequest(u'single "0"') - self.assertFalse(self.backend.playback.single.get()) + self.assertFalse(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_off_without_quotes(self): self.sendRequest(u'single 0') - self.assertFalse(self.backend.playback.single.get()) + self.assertFalse(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_on(self): self.sendRequest(u'single "1"') - self.assertTrue(self.backend.playback.single.get()) + self.assertTrue(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_on_without_quotes(self): self.sendRequest(u'single 1') - self.assertTrue(self.backend.playback.single.get()) + self.assertTrue(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_replay_gain_mode_off(self): @@ -166,198 +166,198 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_off(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') self.sendRequest(u'pause "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_pause_on(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') - self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_pause_toggle(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') self.sendRequest(u'pause') - self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse(u'OK') self.sendRequest(u'pause') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_without_pos(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.state = PAUSED + self.core.current_playlist.append([Track()]) + self.core.playback.state = PAUSED self.sendRequest(u'play') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'play 0') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_out_of_bounds(self): - self.backend.current_playlist.append([]) + self.core.current_playlist.append([]) self.sendRequest(u'play "0"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse(u'ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('a', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertNotEqual(self.backend.playback.current_track.get(), None) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertNotEqual(self.core.playback.current_track.get(), None) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('b', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): - self.backend.current_playlist.clear() + self.core.current_playlist.clear() self.sendRequest(u'play "-1"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) + self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) - self.backend.playback.pause() - self.assertEquals(PAUSED, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) + self.core.playback.pause() + self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'playid "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_playid_without_quotes(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'playid 0') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('a', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertNotEqual(None, self.backend.playback.current_track.get()) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertNotEqual(None, self.core.playback.current_track.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('b', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): - self.backend.current_playlist.clear() + self.core.current_playlist.clear() self.sendRequest(u'playid "-1"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) + self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.core.current_playlist.append([Track(length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) - self.backend.playback.pause() - self.assertEquals(PAUSED, self.backend.playback.state.get()) + self.assertEquals(PLAYING, self.core.playback.state.get()) + self.core.playback.pause() + self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track()]) self.sendRequest(u'playid "12345"') self.assertInResponse(u'ACK [50@0] {playid} No such song') @@ -367,49 +367,49 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') - self.assertGreaterEqual(self.backend.playback.time_position, 30000) + self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse(u'OK') def test_seek_with_songpos(self): seek_track = Track(uri='2', length=40000) - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(uri='1', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') - self.assertEqual(self.backend.playback.current_track.get(), seek_track) + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse(u'OK') def test_seek_without_quotes(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assertGreaterEqual(self.backend.playback.time_position.get(), + self.assertGreaterEqual(self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): seek_track = Track(uri='2', length=40000) - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') - self.assertEqual(1, self.backend.playback.current_cpid.get()) - self.assertEqual(seek_track, self.backend.playback.current_track.get()) + self.assertEqual(1, self.core.playback.current_cpid.get()) + self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_stop(self): self.sendRequest(u'stop') - self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 7f214efa..90bcaf60 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -16,23 +16,23 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Press next until you get to the unplayable track """ def test(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), None, Track(uri='d'), Track(uri='e'), Track(uri='f')]) random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') - self.assertEquals('a', self.backend.playback.current_track.get().uri) + self.assertEquals('a', self.core.playback.current_track.get().uri) self.sendRequest(u'random "1"') self.sendRequest(u'next') - self.assertEquals('b', self.backend.playback.current_track.get().uri) + self.assertEquals('b', self.core.playback.current_track.get().uri) self.sendRequest(u'next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.backend.playback.current_track.get().uri) + self.assertEquals('f', self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('d', self.backend.playback.current_track.get().uri) + self.assertEquals('d', self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('e', self.backend.playback.current_track.get().uri) + self.assertEquals('e', self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): @@ -47,7 +47,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), Track(uri='c'), Track(uri='d'), Track(uri='e'), Track(uri='f')]) random.seed(1) @@ -59,11 +59,11 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): self.sendRequest(u'next') self.sendRequest(u'next') - cp_track_1 = self.backend.playback.current_cp_track.get() + cp_track_1 = self.core.playback.current_cp_track.get() self.sendRequest(u'next') - cp_track_2 = self.backend.playback.current_cp_track.get() + cp_track_2 = self.core.playback.current_cp_track.get() self.sendRequest(u'next') - cp_track_3 = self.backend.playback.current_cp_track.get() + cp_track_3 = self.core.playback.current_cp_track.get() self.assertNotEqual(cp_track_1, cp_track_2) self.assertNotEqual(cp_track_2, cp_track_3) @@ -83,7 +83,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), Track(uri='c'), Track(uri='d'), Track(uri='e'), Track(uri='f')]) random.seed(1) @@ -111,8 +111,8 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.stored_playlists.create('foo') - self.backend.current_playlist.append([ + self.core.stored_playlists.create('foo') + self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), Track(uri='c'), Track(uri='d'), Track(uri='e'), Track(uri='f')]) @@ -136,7 +136,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.stored_playlists.create( + self.core.stored_playlists.create( u'all lart spotify:track:\w\{22\} pastes') self.sendRequest(u'lsinfo "/"') diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index e6572eab..e2f0df9c 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -10,8 +10,8 @@ class StatusHandlerTest(protocol.BaseTestCase): def test_currentsong(self): track = Track() - self.backend.current_playlist.append([track]) - self.backend.playback.play() + self.core.current_playlist.append([track]) + self.core.playback.play() self.sendRequest(u'currentsong') self.assertInResponse(u'file: ') self.assertInResponse(u'Time: 0') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 45d6a09a..0bf9756f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -7,7 +7,7 @@ from tests.frontends.mpd import protocol class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.stored_playlists.playlists = [ + self.core.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylist "name"') @@ -19,7 +19,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): - self.backend.stored_playlists.playlists = [ + self.core.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylistinfo "name"') @@ -35,7 +35,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.backend.stored_playlists.playlists = [Playlist(name='a', + self.core.stored_playlists.playlists = [Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') @@ -45,13 +45,13 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_load_known_playlist_appends_to_current_playlist(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.backend.stored_playlists.playlists = [Playlist(name='A-list', + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + self.core.stored_playlists.playlists = [Playlist(name='A-list', tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest(u'load "A-list"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) self.assertEqual('b', tracks[1].uri) @@ -62,7 +62,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_unknown_playlist_acks(self): self.sendRequest(u'load "unknown playlist"') - self.assertEqual(0, len(self.backend.current_playlist.tracks.get())) + self.assertEqual(0, len(self.core.current_playlist.tracks.get())) self.assertEqualResponse(u'ACK [50@0] {load} No such playlist') def test_playlistadd(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 2397b96f..3a5bdcbe 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,3 +1,6 @@ +from pykka.registry import ActorRegistry + +from mopidy import audio, core from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher @@ -17,12 +20,13 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): - self.backend.stop().get() + ActorRegistry.stop_all() def test_stats_method(self): result = status.stats(self.context) @@ -47,7 +51,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.backend.playback.volume = 17 + self.core.playback.volume = 17 result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) @@ -58,7 +62,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.backend.playback.repeat = 1 + self.core.playback.repeat = 1 result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -69,7 +73,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.backend.playback.random = 1 + self.core.playback.random = 1 result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -85,7 +89,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.backend.playback.consume = 1 + self.core.playback.consume = 1 result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) @@ -106,41 +110,41 @@ class StatusHandlerTest(unittest.TestCase): self.assertGreaterEqual(int(result['xfade']), 0) def test_status_method_contains_state_is_play(self): - self.backend.playback.state = PLAYING + self.core.playback.state = PLAYING result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): - self.backend.playback.state = STOPPED + self.core.playback.state = STOPPED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): - self.backend.playback.state = PLAYING - self.backend.playback.state = PAUSED + self.core.playback.state = PLAYING + self.core.playback.state = PAUSED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.play() + self.core.current_playlist.append([Track()]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.play() + self.core.current_playlist.append([Track()]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.backend.current_playlist.append([Track(length=None)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(length=None)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') @@ -149,8 +153,8 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.backend.current_playlist.append([Track(length=10000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(length=10000)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') @@ -159,25 +163,25 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.backend.current_playlist.append([Track(length=60000)]) - self.backend.playback.play() - self.backend.playback.pause() - self.backend.playback.seek(59123) + self.core.current_playlist.append([Track(length=60000)]) + self.core.playback.play() + self.core.playback.pause() + self.core.playback.seek(59123) result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.backend.current_playlist.append([Track(length=10000)]) - self.backend.playback.play() - self.backend.playback.pause() + self.core.current_playlist.append([Track(length=10000)]) + self.core.playback.play() + self.core.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.backend.current_playlist.append([Track(bitrate=320)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(bitrate=320)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 89f7f1d4..236ec645 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -2,8 +2,10 @@ import sys import mock -from mopidy import OptionalDependencyError -from mopidy.backends.dummy import DummyBackend +from pykka.registry import ActorRegistry + +from mopidy import core, OptionalDependencyError +from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track @@ -23,68 +25,69 @@ STOPPED = PlaybackState.STOPPED class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.mpris = objects.MprisObject() - self.mpris._backend = self.backend + self.mpris._core = self.core def tearDown(self): - self.backend.stop() + ActorRegistry.stop_all() def test_get_playback_status_is_playing_when_playing(self): - self.backend.playback.state = PLAYING + self.core.playback.state = PLAYING result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Playing', result) def test_get_playback_status_is_paused_when_paused(self): - self.backend.playback.state = PAUSED + self.core.playback.state = PAUSED result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Paused', result) def test_get_playback_status_is_stopped_when_stopped(self): - self.backend.playback.state = STOPPED + self.core.playback.state = STOPPED result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Stopped', result) def test_get_loop_status_is_none_when_not_looping(self): - self.backend.playback.repeat = False - self.backend.playback.single = False + self.core.playback.repeat = False + self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('None', result) def test_get_loop_status_is_track_when_looping_a_single_track(self): - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Track', result) def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): - self.backend.playback.repeat = True - self.backend.playback.single = False + self.core.playback.repeat = True + self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Playlist', result) def test_set_loop_status_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), True) + self.assertEquals(self.core.playback.repeat.get(), True) + self.assertEquals(self.core.playback.single.get(), True) def test_set_loop_status_to_none_unsets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.backend.playback.repeat.get(), False) - self.assertEquals(self.backend.playback.single.get(), False) + self.assertEquals(self.core.playback.repeat.get(), False) + self.assertEquals(self.core.playback.single.get(), False) def test_set_loop_status_to_track_sets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), True) + self.assertEquals(self.core.playback.repeat.get(), True) + self.assertEquals(self.core.playback.single.get(), True) def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), False) + self.assertEquals(self.core.playback.repeat.get(), True) + self.assertEquals(self.core.playback.single.get(), False) def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') @@ -98,46 +101,46 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_get_shuffle_returns_true_if_random_is_active(self): - self.backend.playback.random = True + self.core.playback.random = True result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') self.assertTrue(result) def test_get_shuffle_returns_false_if_random_is_inactive(self): - self.backend.playback.random = False + self.core.playback.random = False result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') self.assertFalse(result) def test_set_shuffle_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.random = False + self.core.playback.random = False result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) def test_set_shuffle_to_true_activates_random_mode(self): - self.backend.playback.random = False - self.assertFalse(self.backend.playback.random.get()) + self.core.playback.random = False + self.assertFalse(self.core.playback.random.get()) result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) def test_set_shuffle_to_false_deactivates_random_mode(self): - self.backend.playback.random = True - self.assertTrue(self.backend.playback.random.get()) + self.core.playback.random = True + self.assertTrue(self.core.playback.random.get()) result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -145,105 +148,105 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() - (cpid, track) = self.backend.playback.current_cp_track.get() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.play() + (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) self.assertEquals(result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) self.assertEquals(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) self.assertEquals(result['xesam:url'], 'a') def test_get_metadata_has_track_title(self): - self.backend.current_playlist.append([Track(name='a')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(name='a')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) self.assertEquals(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): - self.backend.current_playlist.append([Track(artists=[ + self.core.current_playlist.append([Track(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)])]) - self.backend.playback.play() + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:artist', result.keys()) self.assertEquals(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): - self.backend.current_playlist.append([Track(album=Album(name='a'))]) - self.backend.playback.play() + self.core.current_playlist.append([Track(album=Album(name='a'))]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) self.assertEquals(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): - self.backend.current_playlist.append([Track(album=Album(artists=[ + self.core.current_playlist.append([Track(album=Album(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) - self.backend.playback.play() + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:albumArtist', result.keys()) self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): - self.backend.current_playlist.append([Track(track_no=7)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(track_no=7)]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) self.assertEquals(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): - self.backend.playback.volume = None + self.core.playback.volume = None result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) - self.backend.playback.volume = 0 + self.core.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) - self.backend.playback.volume = 50 + self.core.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0.5) - self.backend.playback.volume = 100 + self.core.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.volume = 0 + self.core.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.backend.playback.volume.get(), 0) + self.assertEquals(self.core.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.backend.playback.volume.get(), 100) + self.assertEquals(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.backend.playback.volume.get(), 100) + self.assertEquals(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.backend.playback.volume = 10 + self.core.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.backend.playback.volume.get(), 10) + self.assertEquals(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(10000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(10000) result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 self.assertGreaterEqual(result_in_milliseconds, 10000) @@ -263,61 +266,61 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertTrue(result) def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.repeat = True - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.repeat = True + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertTrue(result) def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.repeat = True - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.repeat = True + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() - self.assertTrue(self.backend.playback.current_track.get()) + self.core.current_playlist.append([Track(uri='a')]) + self.core.playback.play() + self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') self.assertTrue(result) def test_can_play_is_false_if_no_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.assertFalse(self.backend.playback.current_track.get()) + self.assertFalse(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') self.assertFalse(result) @@ -352,223 +355,223 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.stop() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.stop() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.current_track.get().uri, 'b') def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.core.playback.pause() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - at_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PAUSED) + at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - after_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PLAYING) + after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.state.get(), PAUSED) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_pause = self.backend.playback.time_position.get() + before_pause = self.core.playback.time_position.get() self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - at_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PAUSED) + at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - after_pause = self.backend.playback.time_position.get() + self.assertEquals(self.core.playback.state.get(), PLAYING) + after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): - self.backend.current_playlist.clear() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.clear() + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEquals(self.core.playback.state.get(), STOPPED) def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 @@ -576,15 +579,15 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertLessEqual(before_seek, after_seek) self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 @@ -592,17 +595,17 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -10000 @@ -610,18 +613,18 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -30000 @@ -629,42 +632,42 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) self.assertLess(after_seek, before_seek) self.assertGreaterEqual(after_seek, 0) def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000), + self.core.current_playlist.append([Track(uri='a', length=40000), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() + before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') milliseconds_to_seek = 50000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'b') - after_seek = self.backend.playback.time_position.get() + after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, 0) self.assertLess(after_seek, before_seek) def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, 5000) track_id = 'a' @@ -674,17 +677,17 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, after_set_position) self.assertLess(after_set_position, position_to_set_in_milliseconds) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, 5000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' @@ -693,21 +696,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.state.get(), PLAYING) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, position_to_set_in_milliseconds) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') track_id = '/com/mopidy/track/0' @@ -716,21 +719,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') track_id = 'a' @@ -739,21 +742,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() + before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') track_id = 'b' @@ -762,74 +765,74 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microseconds) - after_set_position = self.backend.playback.time_position.get() + after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') def test_open_uri_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): - self.assertListEqual(self.backend.uri_schemes.get(), ['dummy']) + self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') - self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_adds_uri_to_current_playlist(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.current_playlist.tracks.get()[0].uri, + self.assertEquals(self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEquals(self.core.playback.state.get(), STOPPED) self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.playback.play() + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, + self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEquals(self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 1e54fc15..b84b70c3 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -2,8 +2,10 @@ import sys import mock -from mopidy import OptionalDependencyError, settings -from mopidy.backends.dummy import DummyBackend +from pykka.registry import ActorRegistry + +from mopidy import core, settings, OptionalDependencyError +from mopidy.backends import dummy try: from mopidy.frontends.mpris import objects @@ -18,11 +20,12 @@ class RootInterfaceTest(unittest.TestCase): def setUp(self): objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backend=self.backend).proxy() self.mpris = objects.MprisObject() def tearDown(self): - self.backend.stop() + ActorRegistry.stop_all() def test_constructor_connects_to_dbus(self): self.assert_(self.mpris._connect_to_dbus.called) From 2fb878df2e346996b6b5a8cf7646245d4dd6f0c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:18:22 +0200 Subject: [PATCH 026/323] MPD: Rename context.backend to context.core --- mopidy/frontends/mpd/dispatcher.py | 3 +- .../mpd/protocol/current_playlist.py | 84 +++++++++---------- mopidy/frontends/mpd/protocol/music_db.py | 10 +-- mopidy/frontends/mpd/protocol/playback.py | 72 ++++++++-------- mopidy/frontends/mpd/protocol/reflection.py | 2 +- mopidy/frontends/mpd/protocol/status.py | 26 +++--- .../mpd/protocol/stored_playlists.py | 10 +-- 7 files changed, 103 insertions(+), 104 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index c9dee576..1f2af153 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -235,11 +235,10 @@ class MpdContext(object): self._core = None @property - def backend(self): + def core(self): """ The Mopidy core. An instance of :class:`mopidy.core.Core`. """ - # TODO: Rename property to 'core' if self._core is None: core_refs = ActorRegistry.get_by_class(core.Core) assert len(core_refs) == 1, \ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index c60cbc4a..622f79c9 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -20,11 +20,11 @@ def add(context, uri): """ if not uri: return - for uri_scheme in context.backend.uri_schemes.get(): + for uri_scheme in context.core.uri_schemes.get(): if uri.startswith(uri_scheme): - track = context.backend.library.lookup(uri).get() + track = context.core.library.lookup(uri).get() if track is not None: - context.backend.current_playlist.add(track) + context.core.current_playlist.add(track) return raise MpdNoExistError( u'directory or file not found', command=u'add') @@ -52,12 +52,12 @@ def addid(context, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos is not None: songpos = int(songpos) - track = context.backend.library.lookup(uri).get() + track = context.core.library.lookup(uri).get() if track is None: raise MpdNoExistError(u'No such song', command=u'addid') - if songpos and songpos > context.backend.current_playlist.length.get(): + if songpos and songpos > context.core.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = context.backend.current_playlist.add(track, + cp_track = context.core.current_playlist.add(track, at_position=songpos).get() return ('Id', cp_track.cpid) @@ -74,21 +74,21 @@ def delete_range(context, start, end=None): if end is not None: end = int(end) else: - end = context.backend.current_playlist.length.get() - cp_tracks = context.backend.current_playlist.slice(start, end).get() + end = context.core.current_playlist.length.get() + cp_tracks = context.core.current_playlist.slice(start, end).get() if not cp_tracks: raise MpdArgError(u'Bad song index', command=u'delete') for (cpid, _) in cp_tracks: - context.backend.current_playlist.remove(cpid=cpid) + context.core.current_playlist.remove(cpid=cpid) @handle_request(r'^delete "(?P\d+)"$') def delete_songpos(context, songpos): """See :meth:`delete_range`""" try: songpos = int(songpos) - (cpid, _) = context.backend.current_playlist.slice( + (cpid, _) = context.core.current_playlist.slice( songpos, songpos + 1).get()[0] - context.backend.current_playlist.remove(cpid=cpid) + context.core.current_playlist.remove(cpid=cpid) except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') @@ -103,9 +103,9 @@ def deleteid(context, cpid): """ try: cpid = int(cpid) - if context.backend.playback.current_cpid.get() == cpid: - context.backend.playback.next() - return context.backend.current_playlist.remove(cpid=cpid).get() + if context.core.playback.current_cpid.get() == cpid: + context.core.playback.next() + return context.core.current_playlist.remove(cpid=cpid).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') @@ -118,7 +118,7 @@ def clear(context): Clears the current playlist. """ - context.backend.current_playlist.clear() + context.core.current_playlist.clear() @handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def move_range(context, start, to, end=None): @@ -131,18 +131,18 @@ def move_range(context, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = context.backend.current_playlist.length.get() + end = context.core.current_playlist.length.get() start = int(start) end = int(end) to = int(to) - context.backend.current_playlist.move(start, end, to) + context.core.current_playlist.move(start, end, to) @handle_request(r'^move "(?P\d+)" "(?P\d+)"$') def move_songpos(context, songpos, to): """See :meth:`move_range`.""" songpos = int(songpos) to = int(to) - context.backend.current_playlist.move(songpos, songpos + 1, to) + context.core.current_playlist.move(songpos, songpos + 1, to) @handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') def moveid(context, cpid, to): @@ -157,9 +157,9 @@ def moveid(context, cpid, to): """ cpid = int(cpid) to = int(to) - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - position = context.backend.current_playlist.index(cp_track).get() - context.backend.current_playlist.move(position, position + 1, to) + cp_track = context.core.current_playlist.get(cpid=cpid).get() + position = context.core.current_playlist.index(cp_track).get() + context.core.current_playlist.move(position, position + 1, to) @handle_request(r'^playlist$') def playlist(context): @@ -192,8 +192,8 @@ def playlistfind(context, tag, needle): """ if tag == 'filename': try: - cp_track = context.backend.current_playlist.get(uri=needle).get() - position = context.backend.current_playlist.index(cp_track).get() + cp_track = context.core.current_playlist.get(uri=needle).get() + position = context.core.current_playlist.index(cp_track).get() return track_to_mpd_format(cp_track, position=position) except LookupError: return None @@ -212,14 +212,14 @@ def playlistid(context, cpid=None): if cpid is not None: try: cpid = int(cpid) - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - position = context.backend.current_playlist.index(cp_track).get() + cp_track = context.core.current_playlist.get(cpid=cpid).get() + position = context.core.current_playlist.index(cp_track).get() return track_to_mpd_format(cp_track, position=position) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: return tracks_to_mpd_format( - context.backend.current_playlist.cp_tracks.get()) + context.core.current_playlist.cp_tracks.get()) @handle_request(r'^playlistinfo$') @handle_request(r'^playlistinfo "-1"$') @@ -243,19 +243,19 @@ def playlistinfo(context, songpos=None, """ if songpos is not None: songpos = int(songpos) - cp_track = context.backend.current_playlist.cp_tracks.get()[songpos] + cp_track = context.core.current_playlist.cp_tracks.get()[songpos] return track_to_mpd_format(cp_track, position=songpos) else: if start is None: start = 0 start = int(start) - if not (0 <= start <= context.backend.current_playlist.length.get()): + if not (0 <= start <= context.core.current_playlist.length.get()): raise MpdArgError(u'Bad song index', command=u'playlistinfo') if end is not None: end = int(end) - if end > context.backend.current_playlist.length.get(): + if end > context.core.current_playlist.length.get(): end = None - cp_tracks = context.backend.current_playlist.cp_tracks.get() + cp_tracks = context.core.current_playlist.cp_tracks.get() return tracks_to_mpd_format(cp_tracks, start, end) @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @@ -294,9 +294,9 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.backend.current_playlist.version: + if int(version) < context.core.current_playlist.version: return tracks_to_mpd_format( - context.backend.current_playlist.cp_tracks.get()) + context.core.current_playlist.cp_tracks.get()) @handle_request(r'^plchangesposid "(?P\d+)"$') def plchangesposid(context, version): @@ -313,10 +313,10 @@ def plchangesposid(context, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != context.backend.current_playlist.version.get(): + if int(version) != context.core.current_playlist.version.get(): result = [] for (position, (cpid, _)) in enumerate( - context.backend.current_playlist.cp_tracks.get()): + context.core.current_playlist.cp_tracks.get()): result.append((u'cpos', position)) result.append((u'Id', cpid)) return result @@ -336,7 +336,7 @@ def shuffle(context, start=None, end=None): start = int(start) if end is not None: end = int(end) - context.backend.current_playlist.shuffle(start, end) + context.core.current_playlist.shuffle(start, end) @handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') def swap(context, songpos1, songpos2): @@ -349,15 +349,15 @@ def swap(context, songpos1, songpos2): """ songpos1 = int(songpos1) songpos2 = int(songpos2) - tracks = context.backend.current_playlist.tracks.get() + tracks = context.core.current_playlist.tracks.get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) - context.backend.current_playlist.clear() - context.backend.current_playlist.append(tracks) + context.core.current_playlist.clear() + context.core.current_playlist.append(tracks) @handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(context, cpid1, cpid2): @@ -370,8 +370,8 @@ def swapid(context, cpid1, cpid2): """ cpid1 = int(cpid1) cpid2 = int(cpid2) - cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get() - cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get() - position1 = context.backend.current_playlist.index(cp_track1).get() - position2 = context.backend.current_playlist.index(cp_track2).get() + cp_track1 = context.core.current_playlist.get(cpid=cpid1).get() + cp_track2 = context.core.current_playlist.get(cpid=cpid2).get() + position1 = context.core.current_playlist.index(cp_track1).get() + position2 = context.core.current_playlist.index(cp_track2).get() swap(context, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index d0128a1e..2678714a 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -70,7 +70,7 @@ def find(context, mpd_query): """ query = _build_query(mpd_query) return playlist_to_mpd_format( - context.backend.library.find_exact(**query).get()) + context.core.library.find_exact(**query).get()) @handle_request(r'^findadd ' r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' @@ -223,7 +223,7 @@ def _list_build_query(field, mpd_query): def _list_artist(context, query): artists = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: for artist in track.artists: artists.add((u'Artist', artist.name)) @@ -231,7 +231,7 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.album is not None: albums.add((u'Album', track.album.name)) @@ -239,7 +239,7 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: dates.add((u'Date', track.date)) @@ -333,7 +333,7 @@ def search(context, mpd_query): """ query = _build_query(mpd_query) return playlist_to_mpd_format( - context.backend.library.search(**query).get()) + context.core.library.search(**query).get()) @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 4152f11e..76cefdc3 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -16,9 +16,9 @@ def consume(context, state): playlist. """ if int(state): - context.backend.playback.consume = True + context.core.playback.consume = True else: - context.backend.playback.consume = False + context.core.playback.consume = False @handle_request(r'^crossfade "(?P\d+)"$') def crossfade(context, seconds): @@ -87,7 +87,7 @@ def next_(context): order as the first time. """ - return context.backend.playback.next().get() + return context.core.playback.next().get() @handle_request(r'^pause$') @handle_request(r'^pause "(?P[01])"$') @@ -104,14 +104,14 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (context.backend.playback.state.get() == PlaybackState.PLAYING): - context.backend.playback.pause() - elif (context.backend.playback.state.get() == PlaybackState.PAUSED): - context.backend.playback.resume() + if (context.core.playback.state.get() == PlaybackState.PLAYING): + context.core.playback.pause() + elif (context.core.playback.state.get() == PlaybackState.PAUSED): + context.core.playback.resume() elif int(state): - context.backend.playback.pause() + context.core.playback.pause() else: - context.backend.playback.resume() + context.core.playback.resume() @handle_request(r'^play$') def play(context): @@ -119,7 +119,7 @@ def play(context): The original MPD server resumes from the paused state on ``play`` without arguments. """ - return context.backend.playback.play().get() + return context.core.playback.play().get() @handle_request(r'^playid (?P-?\d+)$') @handle_request(r'^playid "(?P-?\d+)"$') @@ -144,8 +144,8 @@ def playid(context, cpid): if cpid == -1: return _play_minus_one(context) try: - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - return context.backend.playback.play(cp_track).get() + cp_track = context.core.current_playlist.get(cpid=cpid).get() + return context.core.playback.play(cp_track).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') @@ -176,23 +176,23 @@ def playpos(context, songpos): if songpos == -1: return _play_minus_one(context) try: - cp_track = context.backend.current_playlist.slice( + cp_track = context.core.current_playlist.slice( songpos, songpos + 1).get()[0] - return context.backend.playback.play(cp_track).get() + return context.core.playback.play(cp_track).get() except IndexError: raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackState.PLAYING): + if (context.core.playback.state.get() == PlaybackState.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackState.PAUSED): - return context.backend.playback.resume().get() - elif context.backend.playback.current_cp_track.get() is not None: - cp_track = context.backend.playback.current_cp_track.get() - return context.backend.playback.play(cp_track).get() - elif context.backend.current_playlist.slice(0, 1).get(): - cp_track = context.backend.current_playlist.slice(0, 1).get()[0] - return context.backend.playback.play(cp_track).get() + elif (context.core.playback.state.get() == PlaybackState.PAUSED): + return context.core.playback.resume().get() + elif context.core.playback.current_cp_track.get() is not None: + cp_track = context.core.playback.current_cp_track.get() + return context.core.playback.play(cp_track).get() + elif context.core.current_playlist.slice(0, 1).get(): + cp_track = context.core.current_playlist.slice(0, 1).get()[0] + return context.core.playback.play(cp_track).get() else: return # Fail silently @@ -240,7 +240,7 @@ def previous(context): ``previous`` should do a seek to time position 0. """ - return context.backend.playback.previous().get() + return context.core.playback.previous().get() @handle_request(r'^random (?P[01])$') @handle_request(r'^random "(?P[01])"$') @@ -253,9 +253,9 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.backend.playback.random = True + context.core.playback.random = True else: - context.backend.playback.random = False + context.core.playback.random = False @handle_request(r'^repeat (?P[01])$') @handle_request(r'^repeat "(?P[01])"$') @@ -268,9 +268,9 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.backend.playback.repeat = True + context.core.playback.repeat = True else: - context.backend.playback.repeat = False + context.core.playback.repeat = False @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') def replay_gain_mode(context, mode): @@ -315,9 +315,9 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.backend.playback.current_playlist_position != songpos: + if context.core.playback.current_playlist_position != songpos: playpos(context, songpos) - context.backend.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000) @handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(context, cpid, seconds): @@ -328,9 +328,9 @@ def seekid(context, cpid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - if context.backend.playback.current_cpid != cpid: + if context.core.playback.current_cpid != cpid: playid(context, cpid) - context.backend.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000) @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') @@ -351,7 +351,7 @@ def setvol(context, volume): volume = 0 if volume > 100: volume = 100 - context.backend.playback.volume = volume + context.core.playback.volume = volume @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') @@ -366,9 +366,9 @@ def single(context, state): song is repeated if the ``repeat`` mode is enabled. """ if int(state): - context.backend.playback.single = True + context.core.playback.single = True else: - context.backend.playback.single = False + context.core.playback.single = False @handle_request(r'^stop$') def stop(context): @@ -379,4 +379,4 @@ def stop(context): Stops playing. """ - context.backend.playback.stop() + context.core.playback.stop() diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index df13b4b4..8cd1337b 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -84,4 +84,4 @@ def urlhandlers(context): Gets a list of available URL handlers. """ return [(u'handler', uri_scheme) - for uri_scheme in context.backend.uri_schemes.get()] + for uri_scheme in context.core.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index fc24e1e1..4f48265c 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -31,9 +31,9 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - current_cp_track = context.backend.playback.current_cp_track.get() + current_cp_track = context.core.playback.current_cp_track.get() if current_cp_track is not None: - position = context.backend.playback.current_playlist_position.get() + position = context.core.playback.current_playlist_position.get() return track_to_mpd_format(current_cp_track, position=position) @handle_request(r'^idle$') @@ -166,18 +166,18 @@ def status(context): decimal places for millisecond precision. """ futures = { - 'current_playlist.length': context.backend.current_playlist.length, - 'current_playlist.version': context.backend.current_playlist.version, - 'playback.volume': context.backend.playback.volume, - 'playback.consume': context.backend.playback.consume, - 'playback.random': context.backend.playback.random, - 'playback.repeat': context.backend.playback.repeat, - 'playback.single': context.backend.playback.single, - 'playback.state': context.backend.playback.state, - 'playback.current_cp_track': context.backend.playback.current_cp_track, + 'current_playlist.length': context.core.current_playlist.length, + 'current_playlist.version': context.core.current_playlist.version, + 'playback.volume': context.core.playback.volume, + 'playback.consume': context.core.playback.consume, + 'playback.random': context.core.playback.random, + 'playback.repeat': context.core.playback.repeat, + 'playback.single': context.core.playback.single, + 'playback.state': context.core.playback.state, + 'playback.current_cp_track': context.core.playback.current_cp_track, 'playback.current_playlist_position': - context.backend.playback.current_playlist_position, - 'playback.time_position': context.backend.playback.time_position, + context.core.playback.current_playlist_position, + 'playback.time_position': context.core.playback.time_position, } pykka.future.get_all(futures.values()) result = [ diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index bb39d328..c21f4714 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -20,7 +20,7 @@ def listplaylist(context, name): file: relative/path/to/file3.mp3 """ try: - playlist = context.backend.stored_playlists.get(name=name).get() + playlist = context.core.stored_playlists.get(name=name).get() return ['file: %s' % t.uri for t in playlist.tracks] except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') @@ -40,7 +40,7 @@ def listplaylistinfo(context, name): Album, Artist, Track """ try: - playlist = context.backend.stored_playlists.get(name=name).get() + playlist = context.core.stored_playlists.get(name=name).get() return playlist_to_mpd_format(playlist) except LookupError: raise MpdNoExistError( @@ -68,7 +68,7 @@ def listplaylists(context): Last-Modified: 2010-02-06T02:11:08Z """ result = [] - for playlist in context.backend.stored_playlists.playlists.get(): + for playlist in context.core.stored_playlists.playlists.get(): result.append((u'playlist', playlist.name)) last_modified = (playlist.last_modified or dt.datetime.now()).isoformat() @@ -94,8 +94,8 @@ def load(context, name): - ``load`` appends the given playlist to the current playlist. """ try: - playlist = context.backend.stored_playlists.get(name=name).get() - context.backend.current_playlist.append(playlist.tracks) + playlist = context.core.stored_playlists.get(name=name).get() + context.core.current_playlist.append(playlist.tracks) except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') From 5a628a4150cf43fe4bfab5a55f2c67b2ee25a7ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:18:35 +0200 Subject: [PATCH 027/323] MPRIS: Rename self.backend to self.core --- mopidy/frontends/mpris/objects.py | 91 +++++++++++++++---------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index c2c9f527..cb1e73eb 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -84,8 +84,7 @@ class MprisObject(dbus.service.Object): return bus_name @property - def backend(self): - # TODO: Rename property to 'core' + def core(self): if self._core is None: core_refs = ActorRegistry.get_by_class(core.Core) assert len(core_refs) == 1, \ @@ -162,7 +161,7 @@ class MprisObject(dbus.service.Object): return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0] def get_SupportedUriSchemes(self): - return dbus.Array(self.backend.uri_schemes.get(), signature='s') + return dbus.Array(self.core.uri_schemes.get(), signature='s') ### Player interface methods @@ -173,7 +172,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanGoNext(): logger.debug(u'%s.Next not allowed', PLAYER_IFACE) return - self.backend.playback.next().get() + self.core.playback.next().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Previous(self): @@ -181,7 +180,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanGoPrevious(): logger.debug(u'%s.Previous not allowed', PLAYER_IFACE) return - self.backend.playback.previous().get() + self.core.playback.previous().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Pause(self): @@ -189,7 +188,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanPause(): logger.debug(u'%s.Pause not allowed', PLAYER_IFACE) return - self.backend.playback.pause().get() + self.core.playback.pause().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def PlayPause(self): @@ -197,13 +196,13 @@ class MprisObject(dbus.service.Object): if not self.get_CanPause(): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PLAYING: - self.backend.playback.pause().get() + self.core.playback.pause().get() elif state == PlaybackState.PAUSED: - self.backend.playback.resume().get() + self.core.playback.resume().get() elif state == PlaybackState.STOPPED: - self.backend.playback.play().get() + self.core.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Stop(self): @@ -211,7 +210,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanControl(): logger.debug(u'%s.Stop not allowed', PLAYER_IFACE) return - self.backend.playback.stop().get() + self.core.playback.stop().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Play(self): @@ -219,11 +218,11 @@ class MprisObject(dbus.service.Object): if not self.get_CanPlay(): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PAUSED: - self.backend.playback.resume().get() + self.core.playback.resume().get() else: - self.backend.playback.play().get() + self.core.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Seek(self, offset): @@ -232,9 +231,9 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Seek not allowed', PLAYER_IFACE) return offset_in_milliseconds = offset // 1000 - current_position = self.backend.playback.time_position.get() + current_position = self.core.playback.time_position.get() new_position = current_position + offset_in_milliseconds - self.backend.playback.seek(new_position) + self.core.playback.seek(new_position) @dbus.service.method(dbus_interface=PLAYER_IFACE) def SetPosition(self, track_id, position): @@ -243,7 +242,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE) return position = position // 1000 - current_cp_track = self.backend.playback.current_cp_track.get() + current_cp_track = self.core.playback.current_cp_track.get() if current_cp_track is None: return if track_id != self._get_track_id(current_cp_track): @@ -252,7 +251,7 @@ class MprisObject(dbus.service.Object): return if current_cp_track.track.length < position: return - self.backend.playback.seek(position) + self.core.playback.seek(position) @dbus.service.method(dbus_interface=PLAYER_IFACE) def OpenUri(self, uri): @@ -264,13 +263,13 @@ class MprisObject(dbus.service.Object): return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. - uri_schemes = self.backend.uri_schemes.get() + uri_schemes = self.core.uri_schemes.get() if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]): return - track = self.backend.library.lookup(uri).get() + track = self.core.library.lookup(uri).get() if track is not None: - cp_track = self.backend.current_playlist.add(track).get() - self.backend.playback.play(cp_track) + cp_track = self.core.current_playlist.add(track).get() + self.core.playback.play(cp_track) else: logger.debug(u'Track with URI "%s" not found in library.', uri) @@ -286,7 +285,7 @@ class MprisObject(dbus.service.Object): ### Player interface properties def get_PlaybackStatus(self): - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PLAYING: return 'Playing' elif state == PlaybackState.PAUSED: @@ -295,8 +294,8 @@ class MprisObject(dbus.service.Object): return 'Stopped' def get_LoopStatus(self): - repeat = self.backend.playback.repeat.get() - single = self.backend.playback.single.get() + repeat = self.core.playback.repeat.get() + single = self.core.playback.single.get() if not repeat: return 'None' else: @@ -310,14 +309,14 @@ class MprisObject(dbus.service.Object): logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE) return if value == 'None': - self.backend.playback.repeat = False - self.backend.playback.single = False + self.core.playback.repeat = False + self.core.playback.single = False elif value == 'Track': - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True elif value == 'Playlist': - self.backend.playback.repeat = True - self.backend.playback.single = False + self.core.playback.repeat = True + self.core.playback.single = False def set_Rate(self, value): if not self.get_CanControl(): @@ -329,19 +328,19 @@ class MprisObject(dbus.service.Object): self.Pause() def get_Shuffle(self): - return self.backend.playback.random.get() + return self.core.playback.random.get() def set_Shuffle(self, value): if not self.get_CanControl(): logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE) return if value: - self.backend.playback.random = True + self.core.playback.random = True else: - self.backend.playback.random = False + self.core.playback.random = False def get_Metadata(self): - current_cp_track = self.backend.playback.current_cp_track.get() + current_cp_track = self.core.playback.current_cp_track.get() if current_cp_track is None: return {'mpris:trackid': ''} else: @@ -370,7 +369,7 @@ class MprisObject(dbus.service.Object): return dbus.Dictionary(metadata, signature='sv') def get_Volume(self): - volume = self.backend.playback.volume.get() + volume = self.core.playback.volume.get() if volume is None: return 0 return volume / 100.0 @@ -382,32 +381,32 @@ class MprisObject(dbus.service.Object): if value is None: return elif value < 0: - self.backend.playback.volume = 0 + self.core.playback.volume = 0 elif value > 1: - self.backend.playback.volume = 100 + self.core.playback.volume = 100 elif 0 <= value <= 1: - self.backend.playback.volume = int(value * 100) + self.core.playback.volume = int(value * 100) def get_Position(self): - return self.backend.playback.time_position.get() * 1000 + return self.core.playback.time_position.get() * 1000 def get_CanGoNext(self): if not self.get_CanControl(): return False - return (self.backend.playback.cp_track_at_next.get() != - self.backend.playback.current_cp_track.get()) + return (self.core.playback.cp_track_at_next.get() != + self.core.playback.current_cp_track.get()) def get_CanGoPrevious(self): if not self.get_CanControl(): return False - return (self.backend.playback.cp_track_at_previous.get() != - self.backend.playback.current_cp_track.get()) + return (self.core.playback.cp_track_at_previous.get() != + self.core.playback.current_cp_track.get()) def get_CanPlay(self): if not self.get_CanControl(): return False - return (self.backend.playback.current_track.get() is not None - or self.backend.playback.track_at_next.get() is not None) + return (self.core.playback.current_track.get() is not None + or self.core.playback.track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): From e7f08a7a20e3493e1c35561d293423659f0c3977 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 20:31:41 +0200 Subject: [PATCH 028/323] Rename mopidy.{listeners.BackendListener => core.CoreListener} --- docs/api/core.rst | 7 +++++++ docs/api/index.rst | 1 - docs/api/listeners.rst | 7 ------- mopidy/core/__init__.py | 1 + mopidy/core/current_playlist.py | 5 +++-- mopidy/{listeners.py => core/listener.py} | 11 ++++++----- mopidy/core/playback.py | 16 ++++++++-------- mopidy/frontends/lastfm.py | 7 ++++--- mopidy/frontends/mpd/__init__.py | 6 ++++-- mopidy/frontends/mpris/__init__.py | 5 ++--- tests/backends/events_test.py | 3 +-- .../{listeners_test.py => core/listener_test.py} | 7 +++---- 12 files changed, 39 insertions(+), 37 deletions(-) delete mode 100644 docs/api/listeners.rst rename mopidy/{listeners.py => core/listener.py} (92%) rename tests/{listeners_test.py => core/listener_test.py} (87%) diff --git a/docs/api/core.rst b/docs/api/core.rst index e74d9f45..1563b61b 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -48,3 +48,10 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. .. autoclass:: mopidy.core.LibraryController :members: + + +Core listener +============= + +.. autoclass:: mopidy.core.CoreListener + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 618096ee..5a210812 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,4 +11,3 @@ API reference core audio frontends - listeners diff --git a/docs/api/listeners.rst b/docs/api/listeners.rst deleted file mode 100644 index 609dc3c7..00000000 --- a/docs/api/listeners.rst +++ /dev/null @@ -1,7 +0,0 @@ -************ -Listener API -************ - -.. automodule:: mopidy.listeners - :synopsis: Listener API - :members: diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 6070dcc8..28274fe3 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,5 +1,6 @@ from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController +from .listener import CoreListener from .playback import PlaybackController, PlaybackState from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index a39b4c39..973fe71f 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -2,9 +2,10 @@ from copy import copy import logging import random -from mopidy.listeners import BackendListener from mopidy.models import CpTrack +from .listener import CoreListener + logger = logging.getLogger('mopidy.core') @@ -240,4 +241,4 @@ class CurrentPlaylistController(object): def _trigger_playlist_changed(self): logger.debug(u'Triggering playlist changed event') - BackendListener.send('playlist_changed') + CoreListener.send('playlist_changed') diff --git a/mopidy/listeners.py b/mopidy/core/listener.py similarity index 92% rename from mopidy/listeners.py rename to mopidy/core/listener.py index a8794232..a77b29a8 100644 --- a/mopidy/listeners.py +++ b/mopidy/core/listener.py @@ -1,11 +1,12 @@ from pykka import registry -class BackendListener(object): + +class CoreListener(object): """ - Marker interface for recipients of events sent by the backend. + Marker interface for recipients of events sent by the core actor. Any Pykka actor that mixes in this class will receive calls to the methods - defined here when the corresponding events happen in the backend. This + defined here when the corresponding events happen in the core actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. @@ -13,7 +14,7 @@ class BackendListener(object): @staticmethod def send(event, **kwargs): - """Helper to allow calling of backend listener events""" + """Helper to allow calling of core listener events""" # FIXME this should be updated once Pykka supports non-blocking calls # on proxies or some similar solution. registry.ActorRegistry.broadcast({ @@ -21,7 +22,7 @@ class BackendListener(object): 'attr_path': (event,), 'args': [], 'kwargs': kwargs, - }, target_class=BackendListener) + }, target_class=CoreListener) def track_playback_paused(self, track, time_position): """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index efba03dd..603b40a4 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,7 @@ import logging import random -from mopidy.listeners import BackendListener +from .listener import CoreListener logger = logging.getLogger('mopidy.backends.base') @@ -479,7 +479,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - BackendListener.send('track_playback_paused', + CoreListener.send('track_playback_paused', track=self.current_track, time_position=self.time_position) @@ -487,7 +487,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - BackendListener.send('track_playback_resumed', + CoreListener.send('track_playback_resumed', track=self.current_track, time_position=self.time_position) @@ -495,26 +495,26 @@ class PlaybackController(object): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - BackendListener.send('track_playback_started', + CoreListener.send('track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - BackendListener.send('track_playback_ended', + CoreListener.send('track_playback_ended', track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed', + CoreListener.send('playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') + CoreListener.send('options_changed') def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - BackendListener.send('seeked', time_position=time_position) + CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 0e79024b..f2bc44d2 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -9,15 +9,16 @@ except ImportError as import_error: from pykka.actor import ThreadingActor -from mopidy import settings, SettingsError -from mopidy.listeners import BackendListener +from mopidy import core, settings, SettingsError + logger = logging.getLogger('mopidy.frontends.lastfm') API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, BackendListener): + +class LastfmFrontend(ThreadingActor, core.CoreListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 5d287d03..9dcf4c34 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -3,13 +3,15 @@ import sys from pykka import registry, actor -from mopidy import listeners, settings +from mopidy import core, settings from mopidy.frontends.mpd import dispatcher, protocol from mopidy.utils import locale_decode, log, network, process + logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): + +class MpdFrontend(actor.ThreadingActor, core.CoreListener): """ The MPD frontend. diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 4d4d5edb..2815c551 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -10,12 +10,11 @@ except ImportError as import_error: from pykka.actor import ThreadingActor -from mopidy import settings +from mopidy import core, settings from mopidy.frontends.mpris import objects -from mopidy.listeners import BackendListener -class MprisFrontend(ThreadingActor, BackendListener): +class MprisFrontend(ThreadingActor, core.CoreListener): """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 5408d71f..200e0ca2 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -4,13 +4,12 @@ from pykka.registry import ActorRegistry from mopidy import audio, core from mopidy.backends import dummy -from mopidy.listeners import BackendListener from mopidy.models import Track from tests import unittest -@mock.patch.object(BackendListener, 'send') +@mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) diff --git a/tests/listeners_test.py b/tests/core/listener_test.py similarity index 87% rename from tests/listeners_test.py rename to tests/core/listener_test.py index 896fedf0..2abd9479 100644 --- a/tests/listeners_test.py +++ b/tests/core/listener_test.py @@ -1,13 +1,12 @@ -from mopidy.core import PlaybackState -from mopidy.listeners import BackendListener +from mopidy.core import CoreListener, PlaybackState from mopidy.models import Track from tests import unittest -class BackendListenerTest(unittest.TestCase): +class CoreListenerTest(unittest.TestCase): def setUp(self): - self.listener = BackendListener() + self.listener = CoreListener() def test_listener_has_default_impl_for_track_playback_paused(self): self.listener.track_playback_paused(Track(), 0) From 8c78d469e29b54d6ab5c085e6246348050f46009 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 22:18:39 +0200 Subject: [PATCH 029/323] Use Pykka proxies to send events With Pykka >= 0.16, sending events can be done using proxies instead of manually crafting Pykka's internal function call messages. --- docs/changes.rst | 4 +++- docs/installation/index.rst | 2 +- mopidy/core/listener.py | 14 ++++---------- requirements/core.txt | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index caec53ba..17d50072 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.9.0 (in development) ======================= -- Nothing so far. +**Dependencies** + +- Pykka >= 0.16 is now required. v0.8.0 (2012-09-20) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 66b920f8..d5728c00 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -26,7 +26,7 @@ dependencies installed. - Python >= 2.6, < 3 - - Pykka >= 0.12.3:: + - Pykka >= 0.16:: sudo pip install -U pykka diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index a77b29a8..9476ac4f 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,4 +1,4 @@ -from pykka import registry +from pykka.registry import ActorRegistry class CoreListener(object): @@ -15,14 +15,9 @@ class CoreListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" - # FIXME this should be updated once Pykka supports non-blocking calls - # on proxies or some similar solution. - registry.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': (event,), - 'args': [], - 'kwargs': kwargs, - }, target_class=CoreListener) + listeners = ActorRegistry.get_by_class(CoreListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) def track_playback_paused(self, track, time_position): """ @@ -50,7 +45,6 @@ class CoreListener(object): """ pass - def track_playback_started(self, track): """ Called whenever a new track starts playing. diff --git a/requirements/core.txt b/requirements/core.txt index 8f9da622..1c2371f3 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.12.3 +Pykka >= 0.16 From 4b13f46e2e9c1f8fd85a03cb23f27ddb4edc74ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Sep 2012 23:17:57 +0200 Subject: [PATCH 030/323] Add AudioListener for events from the audio actor This is analogous to how the core actor sends events to the frontends. This removes the audio actor's direct dependency on the core actor, which conceptually is on a higher layer. --- docs/api/audio.rst | 13 ++++++++++--- mopidy/audio/__init__.py | 19 +++++++------------ mopidy/audio/listener.py | 28 ++++++++++++++++++++++++++++ mopidy/core/actor.py | 7 ++++++- 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 mopidy/audio/listener.py diff --git a/docs/api/audio.rst b/docs/api/audio.rst index d5fb5dd9..e00772fd 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -10,10 +10,17 @@ the URI of the resource they want to play, for these cases the default playback provider should be used. For more advanced cases such as when the raw audio data is delivered outside of -GStreamer or the backend needs to add metadata to the currently playing resource, -developers should sub-class the base playback provider and implement the extra -behaviour that is needed through the following API: +GStreamer or the backend needs to add metadata to the currently playing +resource, developers should sub-class the base playback provider and implement +the extra behaviour that is needed through the following API: .. autoclass:: mopidy.audio.Audio :members: + + +Audio listener +============== + +.. autoclass:: mopidy.audio.AudioListener + :members: diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 3ce459dd..4abd5774 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -6,13 +6,13 @@ import gobject import logging from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry -from mopidy import core, settings, utils +from mopidy import settings, utils from mopidy.utils import process # Trigger install of gst mixer plugins -from mopidy.audio import mixers +from . import mixers +from .listener import AudioListener logger = logging.getLogger('mopidy.audio') @@ -149,7 +149,7 @@ class Audio(ThreadingActor): def _on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: - self._notify_core_of_eos() + self._trigger_reached_end_of_stream_event() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() logger.error(u'%s %s', error, debug) @@ -158,14 +158,9 @@ class Audio(ThreadingActor): error, debug = message.parse_warning() logger.warning(u'%s %s', error, debug) - def _notify_core_of_eos(self): - core_refs = ActorRegistry.get_by_class(core.Core) - assert len(core_refs) <= 1, 'Expected at most one running core instance' - if core_refs: - logger.debug(u'Notifying core of end-of-stream') - core_refs[0].proxy().playback.on_end_of_track() - else: - logger.debug(u'No core instance to notify of end-of-stream found') + def _trigger_reached_end_of_stream_event(self): + logger.debug(u'Triggering reached end of stream event') + AudioListener.send('reached_end_of_stream') def set_uri(self, uri): """ diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py new file mode 100644 index 00000000..757cd5f4 --- /dev/null +++ b/mopidy/audio/listener.py @@ -0,0 +1,28 @@ +from pykka.registry import ActorRegistry + + +class AudioListener(object): + """ + Marker interface for recipients of events sent by the audio actor. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the core actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of audio listener events""" + listeners = ActorRegistry.get_by_class(AudioListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) + + def reached_end_of_stream(self): + """ + Called whenever the end of the audio stream is reached. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4ff378c4..4ec86e8b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,12 +1,14 @@ from pykka.actor import ThreadingActor +from mopidy import audio + from .current_playlist import CurrentPlaylistController from .library import LibraryController from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController -class Core(ThreadingActor): +class Core(ThreadingActor, audio.AudioListener): #: The current playlist controller. An instance of #: :class:`mopidy.core.CurrentPlaylistController`. current_playlist = None @@ -40,3 +42,6 @@ class Core(ThreadingActor): def uri_schemes(self): """List of URI schemes we can handle""" return self._backend.uri_schemes.get() + + def reached_end_of_stream(self): + self.playback.on_end_of_track() From 9798c34e79eccf45c5ffbadc25fd631c30a7d7bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:14:40 +0200 Subject: [PATCH 031/323] Remove unused variable --- mopidy/audio/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 4abd5774..10a74959 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -32,15 +32,6 @@ class Audio(ThreadingActor): def __init__(self): super(Audio, self).__init__() - self._default_caps = gst.Caps(""" - audio/x-raw-int, - endianness=(int)1234, - channels=(int)2, - width=(int)16, - depth=(int)16, - signed=(boolean)true, - rate=(int)44100""") - self._playbin = None self._mixer = None self._mixer_track = None From 63cd153b1b2b3a58a4b5741c935c86735928f56f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:45:09 +0200 Subject: [PATCH 032/323] Let NetworkServer pass protocol_kwargs on --- mopidy/utils/network.py | 19 +++++++++++++++---- tests/utils/network/connection_test.py | 13 ++++++++----- tests/utils/network/server_test.py | 3 ++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 9cb8d74c..d2e0690b 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -11,11 +11,14 @@ from pykka.registry import ActorRegistry from mopidy.utils import locale_decode + logger = logging.getLogger('mopidy.utils.server') + class ShouldRetrySocketCall(Exception): """Indicate that attempted socket call should be retried""" + def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: @@ -28,9 +31,11 @@ def try_ipv6_socket(): 'creation failed, disabling: %s', locale_decode(error)) return False + #: Boolean value that indicates if creating an IPv6 socket will succeed. has_ipv6 = try_ipv6_socket() + def create_socket(): """Create a TCP socket with or without IPv6 depending on system support""" if has_ipv6: @@ -42,17 +47,21 @@ def create_socket(): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock + def format_hostname(hostname): """Format hostname for display.""" if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname + class Server(object): """Setup listener and register it with gobject's event loop.""" - def __init__(self, host, port, protocol, max_connections=5, timeout=30): + def __init__(self, host, port, protocol, protocol_kwargs=None, + max_connections=5, timeout=30): self.protocol = protocol + self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections self.timeout = timeout self.server_socket = self.create_server_socket(host, port) @@ -105,7 +114,8 @@ class Server(object): pass def init_connection(self, sock, addr): - Connection(self.protocol, sock, addr, self.timeout) + Connection(self.protocol, self.protocol_kwargs, + sock, addr, self.timeout) class Connection(object): @@ -117,13 +127,14 @@ class Connection(object): # false return value would only tell us that what we thought was registered # is already gone, there is really nothing more we can do. - def __init__(self, protocol, sock, addr, timeout): + def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) self.host, self.port = addr[:2] # IPv6 has larger addr self.sock = sock self.protocol = protocol + self.protocol_kwargs = protocol_kwargs self.timeout = timeout self.send_lock = threading.Lock() @@ -135,7 +146,7 @@ class Connection(object): self.send_id = None self.timeout_id = None - self.actor_ref = self.protocol.start(self) + self.actor_ref = self.protocol.start(self, **self.protocol_kwargs) self.enable_recv() self.enable_timeout() diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index 96ddb833..25ae1940 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -17,19 +17,19 @@ class ConnectionTest(unittest.TestCase): def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) - network.Connection.__init__(self.mock, Mock(), sock, + network.Connection.__init__(self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), sentinel.timeout) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) - network.Connection.__init__(self.mock, protocol, Mock(), + network.Connection.__init__(self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): - network.Connection.__init__(self.mock, Mock(), Mock(), + network.Connection.__init__(self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), sentinel.timeout) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() @@ -37,12 +37,14 @@ class ConnectionTest(unittest.TestCase): def test_init_stores_values_in_attributes(self): addr = (sentinel.host, sentinel.port) protocol = Mock(spec=network.LineProtocol) + protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( - self.mock, protocol, sock, addr, sentinel.timeout) + self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sock, self.mock.sock) self.assertEqual(protocol, self.mock.protocol) + self.assertEqual(protocol_kwargs, self.mock.protocol_kwargs) self.assertEqual(sentinel.timeout, self.mock.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) @@ -51,10 +53,11 @@ class ConnectionTest(unittest.TestCase): addr = (sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid) protocol = Mock(spec=network.LineProtocol) + protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( - self.mock, protocol, sock, addr, sentinel.timeout) + self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py index e0399525..268b5dbd 100644 --- a/tests/utils/network/server_test.py +++ b/tests/utils/network/server_test.py @@ -164,10 +164,11 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'Connection', new=Mock()) def test_init_connection(self): self.mock.protocol = sentinel.protocol + self.mock.protocol_kwargs = {} self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) - network.Connection.assert_called_once_with(sentinel.protocol, + network.Connection.assert_called_once_with(sentinel.protocol, {}, sentinel.sock, sentinel.addr, sentinel.timeout) def test_reject_connection(self): From 706b6c6d3f9f89a92e435ac3b7751b38fb58cc63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:14:21 +0200 Subject: [PATCH 033/323] Pass core actor to frontends --- docs/api/frontends.rst | 18 ++++++++++++++++-- mopidy/__main__.py | 10 +++++----- mopidy/frontends/lastfm.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- tests/frontends/mpris/events_test.py | 2 +- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index af0cc991..36626fa0 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -6,22 +6,36 @@ The following requirements applies to any frontend implementation: - A frontend MAY do mostly whatever it wants to, including creating threads, opening TCP ports and exposing Mopidy for a group of clients. + - A frontend MUST implement at least one `Pykka `_ actor, called the "main actor" from here on. + +- The main actor MUST accept a constructor argument ``core``, which will be an + :class:`ActorProxy ` for the core actor. This object + gives access to the full :ref:`core-api`. + - It MAY use additional actors to implement whatever it does, and using actors in frontend implementations is encouraged. + - The frontend is activated by including its main actor in the :attr:`mopidy.settings.FRONTENDS` setting. + - The main actor MUST be able to start and stop the frontend when the main actor is started and stopped. + - The frontend MAY require additional settings to be set for it to work. + - Such settings MUST be documented. + - The main actor MUST stop itself if the defined settings are not adequate for the frontend to work properly. -- Any actor which is part of the frontend MAY implement any listener interface - from :mod:`mopidy.listeners` to receive notification of the specified events. + +- Any actor which is part of the frontend MAY implement the + :class:`mopidy.core.CoreListener` interface to receive notification of the + specified events. + Frontend implementations ======================== diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ee2e21b6..dbdb193b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -54,8 +54,8 @@ def main(): setup_settings(options.interactive) audio = setup_audio() backend = setup_backend(audio) - setup_core(audio, backend) - setup_frontends() + core = setup_core(audio, backend) + setup_frontends(core) loop.run() except SettingsError as e: logger.error(e.message) @@ -137,17 +137,17 @@ def stop_backend(): def setup_core(audio, backend): - return Core.start(audio, backend).proxy() + return Core.start(audio=audio, backend=backend).proxy() def stop_core(): stop_actors_by_class(Core) -def setup_frontends(): +def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - get_class(frontend_class_name).start() + get_class(frontend_class_name).start(core=core) except OptionalDependencyError as e: logger.info(u'Disabled: %s (%s)', frontend_class_name, e) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index f2bc44d2..37fbafe2 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -37,7 +37,7 @@ class LastfmFrontend(ThreadingActor, core.CoreListener): - :attr:`mopidy.settings.LASTFM_PASSWORD` """ - def __init__(self): + def __init__(self, core): super(LastfmFrontend, self).__init__() self.lastfm = None self.last_start_time = None diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 9dcf4c34..f8c7a9ef 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -26,7 +26,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` """ - def __init__(self): + def __init__(self, core): super(MpdFrontend, self).__init__() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 2815c551..769f2e84 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -55,7 +55,7 @@ class MprisFrontend(ThreadingActor, core.CoreListener): player.Quit(dbus_interface='org.mpris.MediaPlayer2') """ - def __init__(self): + def __init__(self, core): super(MprisFrontend, self).__init__() self.indicate_server = None self.mpris_object = None diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 3db03ccf..f466e207 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -16,7 +16,7 @@ from tests import unittest @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.mpris_frontend = MprisFrontend() # As a plain class, not an actor + self.mpris_frontend = MprisFrontend(core=None) # As a plain class, not an actor self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object From 9fd3e93cb6fa8438cc54c0115bd8c393e8c353b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:15:47 +0200 Subject: [PATCH 034/323] MPRIS: Use core actor passed to frontend --- mopidy/frontends/mpris/__init__.py | 3 ++- mopidy/frontends/mpris/objects.py | 22 +++++-------------- .../frontends/mpris/player_interface_test.py | 3 +-- tests/frontends/mpris/root_interface_test.py | 2 +- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 769f2e84..1a8797f2 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -57,12 +57,13 @@ class MprisFrontend(ThreadingActor, core.CoreListener): def __init__(self, core): super(MprisFrontend, self).__init__() + self.core = core self.indicate_server = None self.mpris_object = None def on_start(self): try: - self.mpris_object = objects.MprisObject() + self.mpris_object = objects.MprisObject(self.core) self._send_startup_notification() except Exception as e: logger.error(u'MPRIS frontend setup failed (%s)', e) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index cb1e73eb..7c8b6f5a 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -1,8 +1,6 @@ import logging import os -logger = logging.getLogger('mopidy.frontends.mpris') - try: import dbus import dbus.mainloop.glib @@ -12,12 +10,13 @@ except ImportError as import_error: from mopidy import OptionalDependencyError raise OptionalDependencyError(import_error) -from pykka.registry import ActorRegistry - -from mopidy import core, settings +from mopidy import settings from mopidy.core import PlaybackState from mopidy.utils.process import exit_process + +logger = logging.getLogger('mopidy.frontends.mpris') + # Must be done before dbus.SessionBus() is called gobject.threads_init() dbus.mainloop.glib.threads_init() @@ -33,8 +32,8 @@ class MprisObject(dbus.service.Object): properties = None - def __init__(self): - self._core = None + def __init__(self, core): + self.core = core self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), @@ -83,15 +82,6 @@ class MprisObject(dbus.service.Object): logger.info(u'Connected to D-Bus') return bus_name - @property - def core(self): - if self._core is None: - core_refs = ActorRegistry.get_by_class(core.Core) - assert len(core_refs) == 1, \ - 'Expected exactly one running core instance.' - self._core = core_refs[0].proxy() - return self._core - def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 236ec645..403d05c7 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -27,8 +27,7 @@ class PlayerInterfaceTest(unittest.TestCase): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backend=self.backend).proxy() - self.mpris = objects.MprisObject() - self.mpris._core = self.core + self.mpris = objects.MprisObject(core=self.core) def tearDown(self): ActorRegistry.stop_all() diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index b84b70c3..847ed2de 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -22,7 +22,7 @@ class RootInterfaceTest(unittest.TestCase): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backend=self.backend).proxy() - self.mpris = objects.MprisObject() + self.mpris = objects.MprisObject(core=self.core) def tearDown(self): ActorRegistry.stop_all() From c115cf123f8be2725885ec885417c076f990aaeb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 00:46:44 +0200 Subject: [PATCH 035/323] MPD: Use core actor passed to frontend --- mopidy/frontends/mpd/__init__.py | 7 ++++--- mopidy/frontends/mpd/dispatcher.py | 23 +++++++---------------- tests/frontends/mpd/protocol/__init__.py | 2 +- tests/frontends/mpd/status_test.py | 2 +- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f8c7a9ef..d7eeaaa3 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -32,7 +32,8 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): port = settings.MPD_SERVER_PORT try: - network.Server(hostname, port, protocol=MpdSession, + network.Server(hostname, port, + protocol=MpdSession, protocol_kwargs={'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error(u'MPD server startup failed: %s', locale_decode(error)) @@ -76,9 +77,9 @@ class MpdSession(network.LineProtocol): encoding = protocol.ENCODING delimiter = r'\r?\n' - def __init__(self, connection): + def __init__(self, connection, core=None): super(MpdSession, self).__init__(connection) - self.dispatcher = dispatcher.MpdDispatcher(self) + self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) def on_start(self): logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 1f2af153..c29cdf4d 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -27,12 +27,12 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None): + def __init__(self, session=None, core=None): self.authenticated = False self.command_list = False self.command_list_ok = False self.command_list_index = None - self.context = MpdContext(self, session=session) + self.context = MpdContext(self, session=session, core=core) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -221,27 +221,18 @@ class MpdContext(object): #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + #: The active subsystems that have pending events. events = None #: The subsytems that we want to be notified about in idle mode. subscriptions = None - def __init__(self, dispatcher, session=None): + def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher self.session = session + self.core = core self.events = set() self.subscriptions = set() - self._core = None - - @property - def core(self): - """ - The Mopidy core. An instance of :class:`mopidy.core.Core`. - """ - if self._core is None: - core_refs = ActorRegistry.get_by_class(core.Core) - assert len(core_refs) == 1, \ - 'Expected exactly one running core instance.' - self._core = core_refs[0].proxy() - return self._core diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index a2dafb9b..041b6532 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -27,7 +27,7 @@ class BaseTestCase(unittest.TestCase): self.core = core.Core.start(backend=self.backend).proxy() self.connection = MockConnection() - self.session = mpd.MpdSession(self.connection) + self.session = mpd.MpdSession(self.connection, core=self.core) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 3a5bdcbe..6322ec36 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -22,7 +22,7 @@ class StatusHandlerTest(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backend=self.backend).proxy() - self.dispatcher = dispatcher.MpdDispatcher() + self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context def tearDown(self): From 609bd6a5b5d12215d7ffb3385d177381966d5993 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 01:38:39 +0200 Subject: [PATCH 036/323] Limit audio access to the playback provider --- mopidy/backends/base/__init__.py | 3 --- mopidy/backends/base/playback.py | 23 +++++++++--------- mopidy/backends/dummy/__init__.py | 6 ++--- mopidy/backends/local/__init__.py | 6 ++--- mopidy/backends/spotify/__init__.py | 28 ++++++++-------------- mopidy/backends/spotify/playback.py | 12 +++++----- mopidy/backends/spotify/session_manager.py | 5 ++-- 7 files changed, 35 insertions(+), 48 deletions(-) diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 4e0f0b08..c27acae2 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -24,6 +24,3 @@ class Backend(object): #: List of URI schemes this backend can handle. uri_schemes = [] - - def __init__(self, audio): - self.audio = audio diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 197ba90e..635146ff 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -6,7 +6,8 @@ class BasePlaybackProvider(object): pykka_traversable = True - def __init__(self, backend): + def __init__(self, audio, backend): + self.audio = audio self.backend = backend def pause(self): @@ -17,7 +18,7 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.pause_playback().get() + return self.audio.pause_playback().get() def play(self, track): """ @@ -29,9 +30,9 @@ class BasePlaybackProvider(object): :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - self.backend.audio.prepare_change() - self.backend.audio.set_uri(track.uri).get() - return self.backend.audio.start_playback().get() + self.audio.prepare_change() + self.audio.set_uri(track.uri).get() + return self.audio.start_playback().get() def resume(self): """ @@ -41,7 +42,7 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.start_playback().get() + return self.audio.start_playback().get() def seek(self, time_position): """ @@ -53,7 +54,7 @@ class BasePlaybackProvider(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.set_position(time_position).get() + return self.audio.set_position(time_position).get() def stop(self): """ @@ -63,7 +64,7 @@ class BasePlaybackProvider(object): :rtype: :class:`True` if successful, else :class:`False` """ - return self.backend.audio.stop_playback().get() + return self.audio.stop_playback().get() def get_time_position(self): """ @@ -73,7 +74,7 @@ class BasePlaybackProvider(object): :rtype: int """ - return self.backend.audio.get_position().get() + return self.audio.get_position().get() def get_volume(self): """ @@ -83,7 +84,7 @@ class BasePlaybackProvider(object): :rtype: int [0..100] or :class:`None` """ - return self.backend.audio.get_volume().get() + return self.audio.get_volume().get() def set_volume(self, volume): """ @@ -94,4 +95,4 @@ class BasePlaybackProvider(object): :param: volume :type volume: int [0..100] """ - self.backend.audio.set_volume(volume) + self.audio.set_volume(volume) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 1d69ed7c..5e028ea3 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -12,11 +12,9 @@ class DummyBackend(ThreadingActor, base.Backend): Handles URIs starting with ``dummy:``. """ - def __init__(self, *args, **kwargs): - base.Backend.__init__(self, *args, **kwargs) - + def __init__(self, audio): self.library = DummyLibraryProvider(backend=self) - self.playback = DummyPlaybackProvider(backend=self) + self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'dummy'] diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index f3e86679..ee8448b3 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -30,11 +30,9 @@ class LocalBackend(ThreadingActor, base.Backend): - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` """ - def __init__(self, *args, **kwargs): - base.Backend.__init__(self, *args, **kwargs) - + def __init__(self, audio): self.library = LocalLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'file'] diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index a79168f5..0e2a2bfa 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -41,37 +41,29 @@ class SpotifyBackend(ThreadingActor, base.Backend): # Imports inside methods are to prevent loading of __init__.py to fail on # missing spotify dependencies. - def __init__(self, *args, **kwargs): + def __init__(self, audio): from .library import SpotifyLibraryProvider from .playback import SpotifyPlaybackProvider + from .session_manager import SpotifySessionManager from .stored_playlists import SpotifyStoredPlaylistsProvider - base.Backend.__init__(self, *args, **kwargs) - self.library = SpotifyLibraryProvider(backend=self) - self.playback = SpotifyPlaybackProvider(backend=self) + self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'spotify'] - self.spotify = None - # Fail early if settings are not present - self.username = settings.SPOTIFY_USERNAME - self.password = settings.SPOTIFY_PASSWORD + username = settings.SPOTIFY_USERNAME + password = settings.SPOTIFY_PASSWORD + + self.spotify = SpotifySessionManager(username, password, + audio=audio, backend_ref=self.actor_ref) def on_start(self): logger.info(u'Mopidy uses SPOTIFY(R) CORE') - self.spotify = self._connect() + logger.debug(u'Connecting to Spotify') + self.spotify.start() def on_stop(self): self.spotify.logout() - - def _connect(self): - from .session_manager import SpotifySessionManager - - logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager(self.username, self.password, - audio=self.audio, backend=self.actor_ref.proxy()) - spotify.start() - return spotify diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 94d57f56..d3d0cfa9 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -30,10 +30,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.audio.prepare_change() - self.backend.audio.set_uri('appsrc://') - self.backend.audio.start_playback() - self.backend.audio.set_metadata(track) + self.audio.prepare_change() + self.audio.set_uri('appsrc://') + self.audio.start_playback() + self.audio.set_metadata(track) self._timer.play() @@ -50,9 +50,9 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(time_position) def seek(self, time_position): - self.backend.audio.prepare_change() + self.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.audio.start_playback() + self.audio.start_playback() self._timer.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 52769d84..dab759c9 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -24,13 +24,13 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() - def __init__(self, username, password, audio, backend): + def __init__(self, username, password, audio, backend_ref): PyspotifySessionManager.__init__(self, username, password) BaseThread.__init__(self) self.name = 'SpotifyThread' self.audio = audio - self.backend = backend + self.backend_ref = backend_ref self.connected = threading.Event() self.session = None @@ -41,6 +41,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self._initial_data_receive_completed = False def run_inside_try(self): + self.backend = self.backend_ref.proxy() self.connect() def logged_in(self, session, error): From c6b38820ce20026ed9a36ff28e303f39a0a41c2d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 01:58:53 +0200 Subject: [PATCH 037/323] Remove volume handling from backends --- mopidy/backends/base/playback.py | 21 --------------------- mopidy/backends/dummy/__init__.py | 7 ------- mopidy/core/playback.py | 15 ++++++++++++--- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 635146ff..b21c30dc 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -75,24 +75,3 @@ class BasePlaybackProvider(object): :rtype: int """ return self.audio.get_position().get() - - def get_volume(self): - """ - Get current volume - - *MAY be reimplemented by subclass.* - - :rtype: int [0..100] or :class:`None` - """ - return self.audio.get_volume().get() - - def set_volume(self, volume): - """ - Get current volume - - *MAY be reimplemented by subclass.* - - :param: volume - :type volume: int [0..100] - """ - self.audio.set_volume(volume) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 5e028ea3..6c3e1437 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -44,7 +44,6 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._time_position = 0 - self._volume = None def pause(self): return True @@ -67,12 +66,6 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): def get_time_position(self): return self._time_position - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume - class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 603b40a4..a86c5650 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -86,6 +86,7 @@ class PlaybackController(object): self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True + self._volume = None def _get_cpid(self, cp_track): if cp_track is None: @@ -296,12 +297,20 @@ class PlaybackController(object): @property def volume(self): - """Volume as int in range [0..100].""" - return self.backend.playback.get_volume().get() + """Volume as int in range [0..100] or :class:`None`""" + if self.audio: + return self.audio.get_volume().get() + else: + # For testing + return self._volume @volume.setter def volume(self, volume): - self.backend.playback.set_volume(volume).get() + if self.audio: + self.audio.set_volume(volume) + else: + # For testing + self._volume = volume def change_track(self, cp_track, on_error_step=1): """ From fe80189accc89f65ebe94fda93366609e7ae26a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 02:20:35 +0200 Subject: [PATCH 038/323] Simplify import --- mopidy/__main__.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index dbdb193b..a67c58b8 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -32,12 +32,10 @@ from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) from mopidy.audio import Audio from mopidy.core import Core -from mopidy.utils import get_class +from mopidy.utils import get_class, process from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import (exit_handler, stop_remaining_actors, - stop_actors_by_class) from mopidy.utils.settings import list_settings_optparse_callback @@ -45,7 +43,7 @@ logger = logging.getLogger('mopidy.main') def main(): - signal.signal(signal.SIGTERM, exit_handler) + signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() try: @@ -69,7 +67,7 @@ def main(): stop_core() stop_backend() stop_audio() - stop_remaining_actors() + process.stop_remaining_actors() def parse_options(): @@ -125,7 +123,7 @@ def setup_audio(): def stop_audio(): - stop_actors_by_class(Audio) + process.stop_actors_by_class(Audio) def setup_backend(audio): @@ -133,7 +131,7 @@ def setup_backend(audio): def stop_backend(): - stop_actors_by_class(get_class(settings.BACKENDS[0])) + process.stop_actors_by_class(get_class(settings.BACKENDS[0])) def setup_core(audio, backend): @@ -141,7 +139,7 @@ def setup_core(audio, backend): def stop_core(): - stop_actors_by_class(Core) + process.stop_actors_by_class(Core) def setup_frontends(core): @@ -155,7 +153,7 @@ def setup_frontends(core): def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - stop_actors_by_class(get_class(frontend_class_name)) + process.stop_actors_by_class(get_class(frontend_class_name)) except OptionalDependencyError: pass From 3c66b3a011ca8833b0e54ad01c69139f33b05e41 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 11:40:31 +0200 Subject: [PATCH 039/323] Use module imports --- mopidy/__main__.py | 56 +++++++++++++++------------------ mopidy/core/current_playlist.py | 4 +-- mopidy/core/playback.py | 16 +++++----- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a67c58b8..3e586044 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -28,14 +28,10 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import (get_version, settings, OptionalDependencyError, - SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.audio import Audio -from mopidy.core import Core -from mopidy.utils import get_class, process +import mopidy +from mopidy import audio, core, settings, utils +from mopidy.utils import log, path, process from mopidy.utils.deps import list_deps_optparse_callback -from mopidy.utils.log import setup_logging -from mopidy.utils.path import get_or_create_folder, get_or_create_file from mopidy.utils.settings import list_settings_optparse_callback @@ -47,7 +43,7 @@ def main(): loop = gobject.MainLoop() options = parse_options() try: - setup_logging(options.verbosity_level, options.save_debug_log) + log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) audio = setup_audio() @@ -55,12 +51,12 @@ def main(): core = setup_core(audio, backend) setup_frontends(core) loop.run() - except SettingsError as e: - logger.error(e.message) + except mopidy.SettingsError as ex: + logger.error(ex.message) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) finally: loop.quit() stop_frontends() @@ -71,7 +67,7 @@ def main(): def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) + parser = optparse.OptionParser(version=u'Mopidy %s' % mopidy.get_version()) parser.add_option('--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') @@ -104,57 +100,57 @@ def check_old_folders(): logger.warning(u'Old settings folder found at %s, settings.py should be ' 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, SETTINGS_PATH) + 'for further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) def setup_settings(interactive): - get_or_create_folder(SETTINGS_PATH) - get_or_create_folder(DATA_PATH) - get_or_create_file(SETTINGS_FILE) + path.get_or_create_folder(mopidy.SETTINGS_PATH) + path.get_or_create_folder(mopidy.DATA_PATH) + path.get_or_create_file(mopidy.SETTINGS_FILE) try: settings.validate(interactive) - except SettingsError, e: - logger.error(e.message) + except mopidy.SettingsError as ex: + logger.error(ex.message) sys.exit(1) def setup_audio(): - return Audio.start().proxy() + return audio.Audio.start().proxy() def stop_audio(): - process.stop_actors_by_class(Audio) + process.stop_actors_by_class(audio.Audio) def setup_backend(audio): - return get_class(settings.BACKENDS[0]).start(audio=audio).proxy() + return utils.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() def stop_backend(): - process.stop_actors_by_class(get_class(settings.BACKENDS[0])) + process.stop_actors_by_class(utils.get_class(settings.BACKENDS[0])) def setup_core(audio, backend): - return Core.start(audio=audio, backend=backend).proxy() + return core.Core.start(audio=audio, backend=backend).proxy() def stop_core(): - process.stop_actors_by_class(Core) + process.stop_actors_by_class(core.Core) def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - get_class(frontend_class_name).start(core=core) - except OptionalDependencyError as e: - logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + utils.get_class(frontend_class_name).start(core=core) + except mopidy.OptionalDependencyError as ex: + logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - process.stop_actors_by_class(get_class(frontend_class_name)) - except OptionalDependencyError: + process.stop_actors_by_class(utils.get_class(frontend_class_name)) + except mopidy.OptionalDependencyError: pass diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 973fe71f..17cd70ad 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -4,7 +4,7 @@ import random from mopidy.models import CpTrack -from .listener import CoreListener +from . import listener logger = logging.getLogger('mopidy.core') @@ -241,4 +241,4 @@ class CurrentPlaylistController(object): def _trigger_playlist_changed(self): logger.debug(u'Triggering playlist changed event') - CoreListener.send('playlist_changed') + listener.CoreListener.send('playlist_changed') diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index a86c5650..f3592831 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,7 @@ import logging import random -from .listener import CoreListener +from . import listener logger = logging.getLogger('mopidy.backends.base') @@ -488,7 +488,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - CoreListener.send('track_playback_paused', + listener.CoreListener.send('track_playback_paused', track=self.current_track, time_position=self.time_position) @@ -496,7 +496,7 @@ class PlaybackController(object): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - CoreListener.send('track_playback_resumed', + listener.CoreListener.send('track_playback_resumed', track=self.current_track, time_position=self.time_position) @@ -504,26 +504,26 @@ class PlaybackController(object): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - CoreListener.send('track_playback_started', + listener.CoreListener.send('track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - CoreListener.send('track_playback_ended', + listener.CoreListener.send('track_playback_ended', track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - CoreListener.send('playback_state_changed', + listener.CoreListener.send('playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') - CoreListener.send('options_changed') + listener.CoreListener.send('options_changed') def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - CoreListener.send('seeked', time_position=time_position) + listener.CoreListener.send('seeked', time_position=time_position) From cef3f73d9a7737afc51cd18060a9161586340916 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 30 Sep 2012 23:39:14 +0200 Subject: [PATCH 040/323] Check Pykka version on startup --- mopidy/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 26e5b904..3b0f76a5 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,12 +2,17 @@ import sys if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') +from distutils.version import StrictVersion import os import platform from subprocess import PIPE, Popen import glib +import pykka +if StrictVersion(pykka.__version__) < StrictVersion('0.16'): + sys.exit(u'Mopidy requires Pykka >= 0.16') + __version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') From 666800ec57ac3ffb75a680b31d37bed35ef2176a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:00:34 +0200 Subject: [PATCH 041/323] Fix most flake8 warnings (#211) --- mopidy/__init__.py | 9 ++- mopidy/__main__.py | 40 +++++++------ mopidy/audio/__init__.py | 39 +++++++------ mopidy/audio/mixers/__init__.py | 3 +- mopidy/audio/mixers/auto.py | 9 +-- mopidy/audio/mixers/fake.py | 14 ++--- mopidy/audio/mixers/nad.py | 25 ++++---- mopidy/backends/base/stored_playlists.py | 2 +- mopidy/backends/local/__init__.py | 19 ++++--- mopidy/backends/local/translator.py | 9 ++- mopidy/backends/spotify/__init__.py | 5 +- mopidy/backends/spotify/container_manager.py | 7 ++- mopidy/backends/spotify/library.py | 9 +-- mopidy/backends/spotify/playlist_manager.py | 33 +++++++---- mopidy/backends/spotify/session_manager.py | 12 ++-- mopidy/backends/spotify/stored_playlists.py | 13 +++-- mopidy/backends/spotify/translator.py | 3 +- mopidy/core/current_playlist.py | 7 +-- mopidy/core/playback.py | 29 +++++----- mopidy/core/stored_playlists.py | 6 +- mopidy/frontends/mpd/__init__.py | 23 +++++--- mopidy/frontends/mpd/dispatcher.py | 37 ++++++------ mopidy/frontends/mpd/exceptions.py | 8 +++ mopidy/frontends/mpd/protocol/__init__.py | 1 + mopidy/frontends/mpd/protocol/audio_output.py | 7 ++- mopidy/frontends/mpd/protocol/command_list.py | 3 + mopidy/frontends/mpd/protocol/connection.py | 8 ++- .../mpd/protocol/current_playlist.py | 49 ++++++++++------ mopidy/frontends/mpd/protocol/empty.py | 1 + mopidy/frontends/mpd/protocol/music_db.py | 55 +++++++++++------- mopidy/frontends/mpd/protocol/playback.py | 32 ++++++++--- mopidy/frontends/mpd/protocol/reflection.py | 32 +++++++---- mopidy/frontends/mpd/protocol/status.py | 57 +++++++++++++------ mopidy/frontends/mpd/protocol/stickers.py | 27 ++++++--- .../mpd/protocol/stored_playlists.py | 32 +++++++---- mopidy/frontends/mpd/translator.py | 20 +++++-- mopidy/frontends/mpris/__init__.py | 6 +- mopidy/frontends/mpris/objects.py | 47 +++++++-------- mopidy/models.py | 9 +-- mopidy/scanner.py | 10 ++-- mopidy/utils/__init__.py | 1 - mopidy/utils/deps.py | 10 ++-- mopidy/utils/log.py | 5 ++ mopidy/utils/network.py | 32 +++++++---- mopidy/utils/path.py | 7 ++- mopidy/utils/process.py | 10 +++- mopidy/utils/settings.py | 29 ++++++---- 47 files changed, 531 insertions(+), 320 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 3b0f76a5..2a88666c 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -20,12 +20,14 @@ CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') + def get_version(): try: return get_git_version() except EnvironmentError: return __version__ + def get_git_version(): process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) if process.wait() != 0: @@ -35,14 +37,17 @@ def get_git_version(): version = version[1:] return version + def get_platform(): return platform.platform() + def get_python(): implementation = platform.python_implementation() version = platform.python_version() return u' '.join([implementation, version]) + class MopidyException(Exception): def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) @@ -53,13 +58,15 @@ class MopidyException(Exception): """Reimplement message field that was deprecated in Python 2.6""" return self._message - @message.setter + @message.setter # noqa def message(self, message): self._message = message + class SettingsError(MopidyException): pass + class OptionalDependencyError(MopidyException): pass diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 3e586044..bfc600f5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,8 +24,8 @@ sys.argv[1:] = gstreamer_args # Add ../ to the path so we can run Mopidy from a Git checkout without # installing it on the system. -sys.path.insert(0, - os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) import mopidy @@ -46,10 +46,10 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - audio = setup_audio() - backend = setup_backend(audio) - core = setup_core(audio, backend) - setup_frontends(core) + audio_ref = setup_audio() + backend_ref = setup_backend(audio_ref) + core_ref = setup_core(audio_ref, backend_ref) + setup_frontends(core_ref) loop.run() except mopidy.SettingsError as ex: logger.error(ex.message) @@ -68,25 +68,32 @@ def main(): def parse_options(): parser = optparse.OptionParser(version=u'Mopidy %s' % mopidy.get_version()) - parser.add_option('--help-gst', + parser.add_option( + '--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') - parser.add_option('-i', '--interactive', + parser.add_option( + '-i', '--interactive', action='store_true', dest='interactive', help='ask interactively for required settings which are missing') - parser.add_option('-q', '--quiet', + parser.add_option( + '-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') - parser.add_option('-v', '--verbose', + parser.add_option( + '-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') - parser.add_option('--save-debug-log', + parser.add_option( + '--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') - parser.add_option('--list-settings', + parser.add_option( + '--list-settings', action='callback', callback=list_settings_optparse_callback, help='list current settings') - parser.add_option('--list-deps', + parser.add_option( + '--list-deps', action='callback', callback=list_deps_optparse_callback, help='list dependencies and their versions') return parser.parse_args(args=mopidy_args)[0] @@ -98,9 +105,10 @@ def check_old_folders(): if not os.path.isdir(old_settings_folder): return - logger.warning(u'Old settings folder found at %s, settings.py should be ' - 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) + logger.warning( + u'Old settings folder found at %s, settings.py should be moved ' + u'to %s, any cache data should be deleted. See release notes for ' + u'further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) def setup_settings(interactive): diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 10a74959..a342799b 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -70,8 +70,8 @@ class Audio(ThreadingActor): self._playbin.set_property('audio-sink', output) logger.info('Output set to %s', settings.OUTPUT) except gobject.GError as ex: - logger.error('Failed to create output "%s": %s', - settings.OUTPUT, ex) + logger.error( + 'Failed to create output "%s": %s', settings.OUTPUT, ex) process.exit_process() def _setup_mixer(self): @@ -85,11 +85,11 @@ class Audio(ThreadingActor): return try: - mixerbin = gst.parse_bin_from_description(settings.MIXER, - ghost_unconnected_pads=False) + mixerbin = gst.parse_bin_from_description( + settings.MIXER, ghost_unconnected_pads=False) except gobject.GError as ex: - logger.warning('Failed to create mixer "%s": %s', - settings.MIXER, ex) + logger.warning( + 'Failed to create mixer "%s": %s', settings.MIXER, ex) return # We assume that the bin will contain a single mixer. @@ -215,10 +215,11 @@ class Audio(ThreadingActor): :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple(gst.Format(gst.FORMAT_TIME), - gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._playbin.get_state() # block until seek is done + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple( + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, + position * gst.MSECOND) + self._playbin.get_state() # block until seek is done return handeled def start_playback(self): @@ -279,16 +280,16 @@ class Audio(ThreadingActor): """ result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: - logger.warning('Setting GStreamer state to %s: failed', - state.value_name) + logger.warning( + 'Setting GStreamer state to %s: failed', state.value_name) return False elif result == gst.STATE_CHANGE_ASYNC: - logger.debug('Setting GStreamer state to %s: async', - state.value_name) + logger.debug( + 'Setting GStreamer state to %s: async', state.value_name) return True else: - logger.debug('Setting GStreamer state to %s: OK', - state.value_name) + logger.debug( + 'Setting GStreamer state to %s: OK', state.value_name) return True def get_volume(self): @@ -316,7 +317,8 @@ class Audio(ThreadingActor): avg_volume = float(sum(volumes)) / len(volumes) new_scale = (0, 100) - old_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) + old_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) return utils.rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): @@ -335,7 +337,8 @@ class Audio(ThreadingActor): return False old_scale = (0, 100) - new_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) + new_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) volume = utils.rescale(volume, old=old_scale, new=new_scale) diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index a0247519..08ecda0d 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -5,7 +5,8 @@ import gobject def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): + num_channels, flags): + class Track(gst.interfaces.MixerTrack): def __init__(self): super(Track, self).__init__() diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 1233afa3..3dce11f7 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -10,10 +10,11 @@ logger = logging.getLogger('mopidy.audio.mixers.auto') # TODO: we might want to add some ranking to the mixers we know about? class AutoAudioMixer(gst.Bin): - __gstdetails__ = ('AutoAudioMixer', - 'Mixer', - 'Element automatically selects a mixer.', - 'Thomas Adamcik') + __gstdetails__ = ( + 'AutoAudioMixer', + 'Mixer', + 'Element automatically selects a mixer.', + 'Thomas Adamcik') def __init__(self): gst.Bin.__init__(self) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index c5faa03f..d44fbd71 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -7,19 +7,19 @@ from mopidy.audio.mixers import create_track class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('FakeMixer', - 'Mixer', - 'Fake mixer for use in tests.', - 'Thomas Adamcik') + __gstdetails__ = ( + 'FakeMixer', + 'Mixer', + 'Fake mixer for use in tests.', + 'Thomas Adamcik') track_label = gobject.property(type=str, default='Master') track_initial_volume = gobject.property(type=int, default=0) track_min_volume = gobject.property(type=int, default=0) track_max_volume = gobject.property(type=int, default=100) track_num_channels = gobject.property(type=int, default=2) - track_flags = gobject.property(type=int, - default=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) + track_flags = gobject.property(type=int, default=( + gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) def __init__(self): gst.Element.__init__(self) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 667dee53..d50c1242 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -8,7 +8,7 @@ import gst try: import serial except ImportError: - serial = None + serial = None # noqa from pykka.actor import ThreadingActor @@ -19,10 +19,11 @@ logger = logging.getLogger('mopidy.audio.mixers.nad') class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('NadMixer', - 'Mixer', - 'Mixer to control NAD amplifiers using a serial link', - 'Stein Magnus Jodal') + __gstdetails__ = ( + 'NadMixer', + 'Mixer', + 'Mixer to control NAD amplifiers using a serial link', + 'Stein Magnus Jodal') port = gobject.property(type=str, default='/dev/ttyUSB0') source = gobject.property(type=str) @@ -41,8 +42,9 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): min_volume=0, max_volume=100, num_channels=1, - flags=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) + flags=( + gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) return [track] def get_volume(self, track): @@ -121,8 +123,7 @@ class NadTalker(ThreadingActor): self._set_device_to_known_state() def _open_connection(self): - logger.info(u'NAD amplifier: Connecting through "%s"', - self.port) + logger.info(u'NAD amplifier: Connecting through "%s"', self.port) self._device = serial.Serial( port=self.port, baudrate=self.BAUDRATE, @@ -200,11 +201,13 @@ class NadTalker(ThreadingActor): for attempt in range(1, 4): if self._ask_device(key) == value: return - logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', + logger.info( + u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', key, value, attempt) self._command_device(key, value) if self._ask_device(key) != value: - logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"', + logger.info( + u'NAD amplifier: Gave up on setting "%s" to "%s"', key, value) def _ask_device(self, key): diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index d1d52c9a..d808798d 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -22,7 +22,7 @@ class BaseStoredPlaylistsProvider(object): """ return copy(self._playlists) - @playlists.setter + @playlists.setter # noqa def playlists(self, playlists): self._playlists = playlists diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index ee8448b3..b34c3da5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,5 +1,4 @@ import glob -import glib import logging import os import shutil @@ -8,7 +7,7 @@ from pykka.actor import ThreadingActor from mopidy import settings from mopidy.backends import base -from mopidy.models import Playlist, Track, Album +from mopidy.models import Playlist, Album from .translator import parse_m3u, parse_mpd_tag_cache @@ -45,7 +44,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.refresh() def lookup(self, uri): - pass # TODO + pass # TODO def refresh(self): playlists = [] @@ -118,11 +117,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE, - settings.LOCAL_MUSIC_PATH) + tracks = parse_mpd_tag_cache( + settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) - logger.info('Loading tracks in %s from %s', settings.LOCAL_MUSIC_PATH, - settings.LOCAL_TAG_CACHE_FILE) + logger.info( + 'Loading tracks in %s from %s', + settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) for track in tracks: self._uri_mapping[track.uri] = track @@ -150,7 +150,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider): 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 + any_filter = lambda t: ( + track_filter(t) or album_filter(t) or artist_filter(t) or uri_filter(t)) if field == 'track': @@ -178,7 +179,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): for value in values: q = value.strip().lower() - track_filter = lambda t: q in t.name.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( diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 1fea555c..fbdace15 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,5 +1,4 @@ import logging -import os logger = logging.getLogger('mopidy.backends.local.translator') @@ -7,6 +6,7 @@ from mopidy.models import Track, Artist, Album from mopidy.utils import locale_decode from mopidy.utils.path import path_to_uri + def parse_m3u(file_path, music_folder): """ Convert M3U file list of uris @@ -51,6 +51,7 @@ def parse_m3u(file_path, music_folder): return uris + def parse_mpd_tag_cache(tag_cache, music_dir=''): """ Converts a MPD tag_cache into a lists of tracks, artists and albums. @@ -89,6 +90,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''): return tracks + def _convert_mpd_data(data, tracks, music_dir): if not data: return @@ -128,7 +130,8 @@ def _convert_mpd_data(data, tracks, music_dir): artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = data['musicbrainz_albumartistid'] + albumartist_kwargs['musicbrainz_id'] = ( + data['musicbrainz_albumartistid']) if data['file'][0] == '/': path = data['file'][1:] @@ -142,7 +145,7 @@ def _convert_mpd_data(data, tracks, music_dir): if albumartist_kwargs: albumartist = Artist(**albumartist_kwargs) album_kwargs['artists'] = [albumartist] - + if album_kwargs: album = Album(**album_kwargs) track_kwargs['album'] = album diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 0e2a2bfa..749a43c0 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -9,6 +9,7 @@ logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} + class SpotifyBackend(ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ @@ -57,8 +58,8 @@ class SpotifyBackend(ThreadingActor, base.Backend): username = settings.SPOTIFY_USERNAME password = settings.SPOTIFY_PASSWORD - self.spotify = SpotifySessionManager(username, password, - audio=audio, backend_ref=self.actor_ref) + self.spotify = SpotifySessionManager( + username, password, audio=audio, backend_ref=self.actor_ref) def on_start(self): logger.info(u'Mopidy uses SPOTIFY(R) CORE') diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 27a4d78a..a45b1adc 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -5,6 +5,7 @@ from spotify.manager import SpotifyContainerManager as \ logger = logging.getLogger('mopidy.backends.spotify.container_manager') + class SpotifyContainerManager(PyspotifyContainerManager): def __init__(self, session_manager): PyspotifyContainerManager.__init__(self) @@ -25,13 +26,13 @@ class SpotifyContainerManager(PyspotifyContainerManager): def playlist_added(self, container, playlist, position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: playlist added at position %d', - position) + logger.debug( + u'Callback called: playlist added at position %d', position) # container_loaded() is called after this callback, so we do not need # to handle this callback. def playlist_moved(self, container, playlist, old_position, new_position, - userdata): + userdata): """Callback used by pyspotify""" logger.debug( u'Callback called: playlist "%s" moved from position %d to %d', diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 18276ecd..8519a650 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -22,7 +22,8 @@ class SpotifyTrack(Track): if self._track: return self._track elif self._spotify_track.is_loaded(): - self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track) + self._track = SpotifyTranslator.to_mopidy_track( + self._spotify_track) return self._track else: return self._unloaded_track @@ -59,7 +60,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): return None def refresh(self, uri=None): - pass # TODO + pass # TODO def search(self, **query): if not query: @@ -81,7 +82,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): if field == u'any': spotify_query.append(value) elif field == u'year': - value = int(value.split('-')[0]) # Extract year + value = int(value.split('-')[0]) # Extract year spotify_query.append(u'%s:%d' % (field, value)) else: spotify_query.append(u'%s:"%s"' % (field, value)) @@ -90,6 +91,6 @@ class SpotifyLibraryProvider(BaseLibraryProvider): queue = Queue.Queue() self.backend.spotify.search(spotify_query, queue) try: - return queue.get(timeout=3) # XXX What is an reasonable timeout? + return queue.get(timeout=3) # XXX What is an reasonable timeout? except Queue.Empty: return Playlist(tracks=[]) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index 05f9514d..e1308a49 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -5,6 +5,7 @@ from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') + class SpotifyPlaylistManager(PyspotifyPlaylistManager): def __init__(self, session_manager): PyspotifyPlaylistManager.__init__(self) @@ -12,48 +13,55 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def tracks_added(self, playlist, tracks, position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) added to position %d in playlist "%s"', len(tracks), position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_moved(self, playlist, tracks, new_position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) moved to position %d in playlist "%s"', len(tracks), new_position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_removed(self, playlist, tracks, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_renamed(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Playlist renamed to "%s"', - playlist.name()) + logger.debug( + u'Callback called: Playlist renamed to "%s"', playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_state_changed(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: The state of playlist "%s" changed', + logger.debug( + u'Callback called: The state of playlist "%s" changed', playlist.name()) def playlist_update_in_progress(self, playlist, done, userdata): """Callback used by pyspotify""" if done: - logger.debug(u'Callback called: ' - u'Update of playlist "%s" done', playlist.name()) + logger.debug( + u'Callback called: Update of playlist "%s" done', + playlist.name()) else: - logger.debug(u'Callback called: ' - u'Update of playlist "%s" in progress', playlist.name()) + logger.debug( + u'Callback called: Update of playlist "%s" in progress', + playlist.name()) def playlist_metadata_updated(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Metadata updated for playlist "%s"', + logger.debug( + u'Callback called: Metadata updated for playlist "%s"', playlist.name()) def track_created_changed(self, playlist, position, user, when, userdata): @@ -90,5 +98,6 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def image_changed(self, playlist, image, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Image changed for playlist "%s"', + logger.debug( + u'Callback called: Image changed for playlist "%s"', playlist.name()) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index dab759c9..99859abd 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -53,7 +53,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): logger.info(u'Connected to Spotify') self.session = session - logger.debug(u'Preferred Spotify bitrate is %s kbps', + logger.debug( + u'Preferred Spotify bitrate is %s kbps', settings.SPOTIFY_BITRATE) self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) @@ -85,7 +86,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): logger.debug(u'User message: %s', message.strip()) def music_delivery(self, session, frames, frame_size, num_frames, - sample_type, sample_rate, channels): + sample_type, sample_rate, channels): """Callback used by pyspotify""" # pylint: disable = R0913 # Too many arguments (8/5) @@ -136,7 +137,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): if not self._initial_data_receive_completed: logger.debug(u'Still getting data; skipped refresh of playlists') return - playlists = map(SpotifyTranslator.to_mopidy_playlist, + playlists = map( + SpotifyTranslator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists @@ -153,8 +155,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): for t in results.tracks()]) queue.put(playlist) self.connected.wait() - self.session.search(query, callback, track_count=100, - album_count=0, artist_count=0) + self.session.search( + query, callback, track_count=100, album_count=0, artist_count=0) def logout(self): """Log out from spotify""" diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index 054e2bd1..85695c40 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -1,20 +1,21 @@ from mopidy.backends.base import BaseStoredPlaylistsProvider + class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): def create(self, name): - pass # TODO + pass # TODO def delete(self, playlist): - pass # TODO + pass # TODO def lookup(self, uri): - pass # TODO + pass # TODO def refresh(self): - pass # TODO + pass # TODO def rename(self, playlist, new_name): - pass # TODO + pass # TODO def save(self, playlist): - pass # TODO + pass # TODO diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 1a8f048d..82c11ef7 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -7,6 +7,7 @@ from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') + class SpotifyTranslator(object): @classmethod def to_mopidy_artist(cls, spotify_artist): @@ -57,7 +58,7 @@ class SpotifyTranslator(object): name=spotify_playlist.name(), # FIXME if check on link is a hackish workaround for is_local tracks=[cls.to_mopidy_track(t) for t in spotify_playlist - if str(Link.from_track(t, 0))], + if str(Link.from_track(t, 0))], ) except SpotifyError, e: logger.warning(u'Failed translating Spotify playlist: %s', e) diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 17cd70ad..5aa7ed5d 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -6,7 +6,6 @@ from mopidy.models import CpTrack from . import listener - logger = logging.getLogger('mopidy.core') @@ -57,7 +56,7 @@ class CurrentPlaylistController(object): """ return self._version - @version.setter + @version.setter # noqa def version(self, version): self._version = version self.core.playback.on_current_playlist_change() @@ -128,8 +127,8 @@ class CurrentPlaylistController(object): if key == 'cpid': matches = filter(lambda ct: ct.cpid == value, matches) else: - matches = filter(lambda ct: getattr(ct.track, key) == value, - matches) + matches = filter( + lambda ct: getattr(ct.track, key) == value, matches) if len(matches) == 1: return matches[0] criteria_string = ', '.join( diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index f3592831..90e7e639 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -283,7 +283,7 @@ class PlaybackController(object): """ return self._state - @state.setter + @state.setter # noqa def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) @@ -304,7 +304,7 @@ class PlaybackController(object): # For testing return self._volume - @volume.setter + @volume.setter # noqa def volume(self, volume): if self.audio: self.audio.set_volume(volume) @@ -488,36 +488,37 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - listener.CoreListener.send('track_playback_paused', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_paused', + track=self.current_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - listener.CoreListener.send('track_playback_resumed', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_resumed', + track=self.current_track, time_position=self.time_position) def _trigger_track_playback_started(self): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - listener.CoreListener.send('track_playback_started', - track=self.current_track) + listener.CoreListener.send( + 'track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - listener.CoreListener.send('track_playback_ended', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_ended', + track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - listener.CoreListener.send('playback_state_changed', + listener.CoreListener.send( + 'playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 6ea9b1d3..2c5ef752 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -21,7 +21,7 @@ class StoredPlaylistsController(object): """ return self.backend.stored_playlists.playlists.get() - @playlists.setter + @playlists.setter # noqa def playlists(self, playlists): self.backend.stored_playlists.playlists = playlists @@ -71,8 +71,8 @@ class StoredPlaylistsController(object): if len(matches) == 0: raise LookupError('"%s" match no playlists' % criteria_string) else: - raise LookupError('"%s" match multiple playlists' - % criteria_string) + raise LookupError( + '"%s" match multiple playlists' % criteria_string) def lookup(self, uri): """ diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index d7eeaaa3..e5bafcf1 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -7,7 +7,6 @@ from mopidy import core, settings from mopidy.frontends.mpd import dispatcher, protocol from mopidy.utils import locale_decode, log, network, process - logger = logging.getLogger('mopidy.frontends.mpd') @@ -32,11 +31,13 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): port = settings.MPD_SERVER_PORT try: - network.Server(hostname, port, + network.Server( + hostname, port, protocol=MpdSession, protocol_kwargs={'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: - logger.error(u'MPD server startup failed: %s', locale_decode(error)) + logger.error( + u'MPD server startup failed: %s', locale_decode(error)) sys.exit(1) logger.info(u'MPD server running at [%s]:%s', hostname, port) @@ -86,15 +87,18 @@ class MpdSession(network.LineProtocol): self.send_lines([u'OK MPD %s' % protocol.VERSION]) def on_line_received(self, line): - logger.debug(u'Request from [%s]:%s to %s: %s', self.host, self.port, - self.actor_urn, line) + logger.debug( + u'Request from [%s]:%s to %s: %s', + self.host, self.port, self.actor_urn, line) response = self.dispatcher.handle_request(line) if not response: return - logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port, - self.actor_urn, log.indent(self.terminator.join(response))) + logger.debug( + u'Response to [%s]:%s from %s: %s', + self.host, self.port, self.actor_urn, + log.indent(self.terminator.join(response))) self.send_lines(response) @@ -105,8 +109,9 @@ class MpdSession(network.LineProtocol): try: return super(MpdSession, self).decode(line.decode('string_escape')) except ValueError: - logger.warning(u'Stopping actor due to unescaping error, data ' - 'supplied by client was not valid.') + logger.warning( + u'Stopping actor due to unescaping error, data ' + u'supplied by client was not valid.') self.stop() def close(self): diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index c29cdf4d..24db6a7a 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -2,22 +2,22 @@ import logging import re from pykka import ActorDeadError -from pykka.registry import ActorRegistry -from mopidy import core, settings +from mopidy import settings from mopidy.frontends.mpd import exceptions from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers # Do not remove the following import. The protocol modules must be imported to # get them registered as request handlers. # pylint: disable = W0611 -from mopidy.frontends.mpd.protocol import (audio_output, command_list, - connection, current_playlist, empty, music_db, playback, reflection, - status, stickers, stored_playlists) +from mopidy.frontends.mpd.protocol import ( + audio_output, command_list, connection, current_playlist, empty, music_db, + playback, reflection, status, stickers, stored_playlists) # pylint: enable = W0611 from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') + class MpdDispatcher(object): """ The MPD session feeds the MPD dispatcher with requests. The dispatcher @@ -71,7 +71,6 @@ class MpdDispatcher(object): else: return response - ### Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): @@ -82,7 +81,6 @@ class MpdDispatcher(object): mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] - ### Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): @@ -101,7 +99,6 @@ class MpdDispatcher(object): else: raise exceptions.MpdPermissionError(command=command_name) - ### Filter: command list def _command_list_filter(self, request, response, filter_chain): @@ -117,25 +114,27 @@ class MpdDispatcher(object): return response def _is_receiving_command_list(self, request): - return (self.command_list is not False - and request != u'command_list_end') + return ( + self.command_list is not False and + request != u'command_list_end') def _is_processing_command_list(self, request): - return (self.command_list_index is not None - and request != u'command_list_end') - + return ( + self.command_list_index is not None and + request != u'command_list_end') ### Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): - logger.debug(u'Client sent us %s, only %s is allowed while in ' - 'the idle state', repr(request), repr(u'noidle')) + logger.debug( + u'Client sent us %s, only %s is allowed while in ' + u'the idle state', repr(request), repr(u'noidle')) self.context.session.close() return [] if not self._is_currently_idle() and self._noidle.match(request): - return [] # noidle was called before idle + return [] # noidle was called before idle response = self._call_next_filter(request, response, filter_chain) @@ -147,7 +146,6 @@ class MpdDispatcher(object): def _is_currently_idle(self): return bool(self.context.subscriptions) - ### Filter: add OK def _add_ok_filter(self, request, response, filter_chain): @@ -159,7 +157,6 @@ class MpdDispatcher(object): def _has_error(self, response): return response and response[-1].startswith(u'ACK') - ### Filter: call handler def _call_handler_filter(self, request, response, filter_chain): @@ -181,8 +178,8 @@ class MpdDispatcher(object): return (request_handlers[pattern], matches.groupdict()) command_name = request.split(' ')[0] if command_name in [command.name for command in mpd_commands]: - raise exceptions.MpdArgError(u'incorrect arguments', - command=command_name) + raise exceptions.MpdArgError( + u'incorrect arguments', command=command_name) raise exceptions.MpdUnknownCommand(command=command_name) def _format_response(self, response): diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index 661d6905..e5844b60 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -1,5 +1,6 @@ from mopidy import MopidyException + class MpdAckError(MopidyException): """See fields on this class for available MPD error codes""" @@ -33,12 +34,15 @@ class MpdAckError(MopidyException): return u'ACK [%i@%i] {%s} %s' % ( self.__class__.error_code, self.index, self.command, self.message) + class MpdArgError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG + class MpdPasswordError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PASSWORD + class MpdPermissionError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PERMISSION @@ -46,6 +50,7 @@ class MpdPermissionError(MpdAckError): super(MpdPermissionError, self).__init__(*args, **kwargs) self.message = u'you don\'t have permission for "%s"' % self.command + class MpdUnknownCommand(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN @@ -54,12 +59,15 @@ class MpdUnknownCommand(MpdAckError): self.message = u'unknown command "%s"' % self.command self.command = u'' + class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST + class MpdSystemError(MpdAckError): error_code = MpdAckError.ACK_ERROR_SYSTEM + class MpdNotImplemented(MpdAckError): error_code = 0 diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index f0b56a57..590a8ef4 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -29,6 +29,7 @@ mpd_commands = set() request_handlers = {} + def handle_request(pattern, auth_required=True): """ Decorator for connecting command handlers to command requests. diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 7147963a..7e50c8c0 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented + @handle_request(r'^disableoutput "(?P\d+)"$') def disableoutput(context, outputid): """ @@ -10,7 +11,8 @@ def disableoutput(context, outputid): Turns an output off. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^enableoutput "(?P\d+)"$') def enableoutput(context, outputid): @@ -21,7 +23,8 @@ def enableoutput(context, outputid): Turns an output on. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^outputs$') def outputs(context): diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index 37e5c93d..a58c11e2 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdUnknownCommand + @handle_request(r'^command_list_begin$') def command_list_begin(context): """ @@ -21,6 +22,7 @@ def command_list_begin(context): context.dispatcher.command_list = [] context.dispatcher.command_list_ok = False + @handle_request(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" @@ -43,6 +45,7 @@ def command_list_end(context): command_list_response.append(u'list_OK') return command_list_response + @handle_request(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index ff230173..3228807f 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,7 +1,8 @@ from mopidy import settings from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import (MpdPasswordError, - MpdPermissionError) +from mopidy.frontends.mpd.exceptions import ( + MpdPasswordError, MpdPermissionError) + @handle_request(r'^close$', auth_required=False) def close(context): @@ -14,6 +15,7 @@ def close(context): """ context.session.close() + @handle_request(r'^kill$') def kill(context): """ @@ -25,6 +27,7 @@ def kill(context): """ raise MpdPermissionError(command=u'kill') + @handle_request(r'^password "(?P[^"]+)"$', auth_required=False) def password_(context, password): """ @@ -40,6 +43,7 @@ def password_(context, password): else: raise MpdPasswordError(u'incorrect password', command=u'password') + @handle_request(r'^ping$', auth_required=False) def ping(context): """ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 622f79c9..429af2cc 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -1,8 +1,8 @@ -from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, - MpdNotImplemented) +from mopidy.frontends.mpd import translator +from mopidy.frontends.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import (track_to_mpd_format, - tracks_to_mpd_format) + @handle_request(r'^add "(?P[^"]*)"$') def add(context, uri): @@ -29,6 +29,7 @@ def add(context, uri): raise MpdNoExistError( u'directory or file not found', command=u'add') + @handle_request(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') def addid(context, uri, songpos=None): """ @@ -57,10 +58,11 @@ def addid(context, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos and songpos > context.core.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = context.core.current_playlist.add(track, - at_position=songpos).get() + cp_track = context.core.current_playlist.add( + track, at_position=songpos).get() return ('Id', cp_track.cpid) + @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') def delete_range(context, start, end=None): """ @@ -81,6 +83,7 @@ def delete_range(context, start, end=None): for (cpid, _) in cp_tracks: context.core.current_playlist.remove(cpid=cpid) + @handle_request(r'^delete "(?P\d+)"$') def delete_songpos(context, songpos): """See :meth:`delete_range`""" @@ -92,6 +95,7 @@ def delete_songpos(context, songpos): except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') + @handle_request(r'^deleteid "(?P\d+)"$') def deleteid(context, cpid): """ @@ -109,6 +113,7 @@ def deleteid(context, cpid): except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') + @handle_request(r'^clear$') def clear(context): """ @@ -120,6 +125,7 @@ def clear(context): """ context.core.current_playlist.clear() + @handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def move_range(context, start, to, end=None): """ @@ -137,6 +143,7 @@ def move_range(context, start, to, end=None): to = int(to) context.core.current_playlist.move(start, end, to) + @handle_request(r'^move "(?P\d+)" "(?P\d+)"$') def move_songpos(context, songpos, to): """See :meth:`move_range`.""" @@ -144,6 +151,7 @@ def move_songpos(context, songpos, to): to = int(to) context.core.current_playlist.move(songpos, songpos + 1, to) + @handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') def moveid(context, cpid, to): """ @@ -161,6 +169,7 @@ def moveid(context, cpid, to): position = context.core.current_playlist.index(cp_track).get() context.core.current_playlist.move(position, position + 1, to) + @handle_request(r'^playlist$') def playlist(context): """ @@ -176,6 +185,7 @@ def playlist(context): """ return playlistinfo(context) + @handle_request(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') @handle_request(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def playlistfind(context, tag, needle): @@ -194,10 +204,11 @@ def playlistfind(context, tag, needle): try: cp_track = context.core.current_playlist.get(uri=needle).get() position = context.core.current_playlist.index(cp_track).get() - return track_to_mpd_format(cp_track, position=position) + return translator.track_to_mpd_format(cp_track, position=position) except LookupError: return None - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistid( "(?P\d+)")*$') def playlistid(context, cpid=None): @@ -214,19 +225,19 @@ def playlistid(context, cpid=None): cpid = int(cpid) cp_track = context.core.current_playlist.get(cpid=cpid).get() position = context.core.current_playlist.index(cp_track).get() - return track_to_mpd_format(cp_track, position=position) + return translator.track_to_mpd_format(cp_track, position=position) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: - return tracks_to_mpd_format( + return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) + @handle_request(r'^playlistinfo$') @handle_request(r'^playlistinfo "-1"$') @handle_request(r'^playlistinfo "(?P-?\d+)"$') @handle_request(r'^playlistinfo "(?P\d+):(?P\d+)*"$') -def playlistinfo(context, songpos=None, - start=None, end=None): +def playlistinfo(context, songpos=None, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -244,7 +255,7 @@ def playlistinfo(context, songpos=None, if songpos is not None: songpos = int(songpos) cp_track = context.core.current_playlist.cp_tracks.get()[songpos] - return track_to_mpd_format(cp_track, position=songpos) + return translator.track_to_mpd_format(cp_track, position=songpos) else: if start is None: start = 0 @@ -256,7 +267,8 @@ def playlistinfo(context, songpos=None, if end > context.core.current_playlist.length.get(): end = None cp_tracks = context.core.current_playlist.cp_tracks.get() - return tracks_to_mpd_format(cp_tracks, start, end) + return translator.tracks_to_mpd_format(cp_tracks, start, end) + @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @handle_request(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') @@ -274,7 +286,8 @@ def playlistsearch(context, tag, needle): - does not add quotes around the tag - uses ``filename`` and ``any`` as tags """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^plchanges (?P-?\d+)$') @handle_request(r'^plchanges "(?P-?\d+)"$') @@ -295,9 +308,10 @@ def plchanges(context, version): """ # XXX Naive implementation that returns all tracks as changed if int(version) < context.core.current_playlist.version: - return tracks_to_mpd_format( + return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) + @handle_request(r'^plchangesposid "(?P\d+)"$') def plchangesposid(context, version): """ @@ -321,6 +335,7 @@ def plchangesposid(context, version): result.append((u'Id', cpid)) return result + @handle_request(r'^shuffle$') @handle_request(r'^shuffle "(?P\d+):(?P\d+)*"$') def shuffle(context, start=None, end=None): @@ -338,6 +353,7 @@ def shuffle(context, start=None, end=None): end = int(end) context.core.current_playlist.shuffle(start, end) + @handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') def swap(context, songpos1, songpos2): """ @@ -359,6 +375,7 @@ def swap(context, songpos1, songpos2): context.core.current_playlist.clear() context.core.current_playlist.append(tracks) + @handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(context, cpid1, cpid2): """ diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index 4cdafd87..f2ee4757 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,5 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_request + @handle_request(r'^[ ]*$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 2678714a..a5d5b214 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,6 +5,7 @@ from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.translator import playlist_to_mpd_format + def _build_query(mpd_query): """ Parses a MPD query string and converts it to the Mopidy query format. @@ -21,7 +22,7 @@ def _build_query(mpd_query): field = m.groupdict()['field'].lower() if field == u'title': field = u'track' - field = str(field) # Needed for kwargs keys on OS X and Windows + field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'].lower() if field in query: query[field].append(what) @@ -29,6 +30,7 @@ def _build_query(mpd_query): query[field] = [what] return query + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -39,11 +41,12 @@ def count(context, tag, needle): Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. """ - return [('songs', 0), ('playtime', 0)] # TODO + return [('songs', 0), ('playtime', 0)] # TODO -@handle_request(r'^find ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + +@handle_request( + r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def find(context, mpd_query): """ *musicpd.org, music database section:* @@ -72,9 +75,11 @@ def find(context, mpd_query): return playlist_to_mpd_format( context.core.library.find_exact(**query).get()) -@handle_request(r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' - '"[^"]+"\s?)+)$') + +@handle_request( + r'^findadd ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' + r'"[^"]+"\s?)+)$') def findadd(context, query): """ *musicpd.org, music database section:* @@ -88,8 +93,10 @@ def findadd(context, query): # TODO Add result to current playlist #result = context.find(query) -@handle_request(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' - '( (?P.*))?$') + +@handle_request( + r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' + r'( (?P.*))?$') def list_(context, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -183,7 +190,8 @@ def list_(context, field, mpd_query=None): elif field == u'date': return _list_date(context, query) elif field == u'genre': - pass # TODO We don't have genre in our internal data structures yet + 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.""" @@ -208,7 +216,7 @@ def _list_build_query(field, mpd_query): query = {} while tokens: key = tokens[0].lower() - key = str(key) # Needed for kwargs keys on OS X and Windows + key = str(key) # Needed for kwargs keys on OS X and Windows value = tokens[1] tokens = tokens[2:] if key not in (u'artist', u'album', u'date', u'genre'): @@ -221,6 +229,7 @@ def _list_build_query(field, mpd_query): else: raise MpdArgError(u'not able to parse args', command=u'list') + def _list_artist(context, query): artists = set() playlist = context.core.library.find_exact(**query).get() @@ -229,6 +238,7 @@ def _list_artist(context, query): artists.add((u'Artist', artist.name)) return artists + def _list_album(context, query): albums = set() playlist = context.core.library.find_exact(**query).get() @@ -237,6 +247,7 @@ def _list_album(context, query): albums.add((u'Album', track.album.name)) return albums + def _list_date(context, query): dates = set() playlist = context.core.library.find_exact(**query).get() @@ -245,6 +256,7 @@ def _list_date(context, query): dates.add((u'Date', track.date)) return dates + @handle_request(r'^listall "(?P[^"]+)"') def listall(context, uri): """ @@ -254,7 +266,8 @@ def listall(context, uri): Lists all songs and directories in ``URI``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^listallinfo "(?P[^"]+)"') def listallinfo(context, uri): @@ -266,7 +279,8 @@ def listallinfo(context, uri): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^lsinfo$') @handle_request(r'^lsinfo "(?P[^"]*)"$') @@ -288,7 +302,8 @@ def lsinfo(context, uri=None): """ if uri is None or uri == u'/' or uri == u'': return stored_playlists.listplaylists(context) - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rescan( "(?P[^"]+)")*$') def rescan(context, uri=None): @@ -301,9 +316,10 @@ 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]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + +@handle_request( + r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def search(context, mpd_query): """ *musicpd.org, music database section:* @@ -335,6 +351,7 @@ def search(context, mpd_query): return playlist_to_mpd_format( context.core.library.search(**query).get()) + @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): """ @@ -352,4 +369,4 @@ def update(context, uri=None, rescan_unmodified_files=False): identifying the update job. You can read the current job id in the ``status`` response. """ - return {'updating_db': 0} # TODO + return {'updating_db': 0} # TODO diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 76cefdc3..7851ebe0 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,7 +1,8 @@ from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, - MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) + @handle_request(r'^consume (?P[01])$') @handle_request(r'^consume "(?P[01])"$') @@ -20,6 +21,7 @@ def consume(context, state): else: context.core.playback.consume = False + @handle_request(r'^crossfade "(?P\d+)"$') def crossfade(context, seconds): """ @@ -30,7 +32,8 @@ def crossfade(context, seconds): Sets crossfading between songs. """ seconds = int(seconds) - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^next$') def next_(context): @@ -89,6 +92,7 @@ def next_(context): """ return context.core.playback.next().get() + @handle_request(r'^pause$') @handle_request(r'^pause "(?P[01])"$') def pause(context, state=None): @@ -113,6 +117,7 @@ def pause(context, state=None): else: context.core.playback.resume() + @handle_request(r'^play$') def play(context): """ @@ -121,6 +126,7 @@ def play(context): """ return context.core.playback.play().get() + @handle_request(r'^playid (?P-?\d+)$') @handle_request(r'^playid "(?P-?\d+)"$') def playid(context, cpid): @@ -149,6 +155,7 @@ def playid(context, cpid): except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') + @handle_request(r'^play (?P-?\d+)$') @handle_request(r'^play "(?P-?\d+)"$') def playpos(context, songpos): @@ -182,9 +189,10 @@ def playpos(context, songpos): except IndexError: raise MpdArgError(u'Bad song index', command=u'play') + def _play_minus_one(context): if (context.core.playback.state.get() == PlaybackState.PLAYING): - return # Nothing to do + return # Nothing to do elif (context.core.playback.state.get() == PlaybackState.PAUSED): return context.core.playback.resume().get() elif context.core.playback.current_cp_track.get() is not None: @@ -194,7 +202,8 @@ def _play_minus_one(context): cp_track = context.core.current_playlist.slice(0, 1).get()[0] return context.core.playback.play(cp_track).get() else: - return # Fail silently + return # Fail silently + @handle_request(r'^previous$') def previous(context): @@ -242,6 +251,7 @@ def previous(context): """ return context.core.playback.previous().get() + @handle_request(r'^random (?P[01])$') @handle_request(r'^random "(?P[01])"$') def random(context, state): @@ -257,6 +267,7 @@ def random(context, state): else: context.core.playback.random = False + @handle_request(r'^repeat (?P[01])$') @handle_request(r'^repeat "(?P[01])"$') def repeat(context, state): @@ -272,6 +283,7 @@ def repeat(context, state): else: context.core.playback.repeat = False + @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') def replay_gain_mode(context, mode): """ @@ -286,7 +298,8 @@ def replay_gain_mode(context, mode): This command triggers the options idle event. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^replay_gain_status$') def replay_gain_status(context): @@ -298,7 +311,8 @@ def replay_gain_status(context): Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ - return u'off' # TODO + return u'off' # TODO + @handle_request(r'^seek (?P\d+) (?P\d+)$') @handle_request(r'^seek "(?P\d+)" "(?P\d+)"$') @@ -319,6 +333,7 @@ def seek(context, songpos, seconds): playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000) + @handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(context, cpid, seconds): """ @@ -332,6 +347,7 @@ def seekid(context, cpid, seconds): playid(context, cpid) context.core.playback.seek(int(seconds) * 1000) + @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') def setvol(context, volume): @@ -353,6 +369,7 @@ def setvol(context, volume): volume = 100 context.core.playback.volume = volume + @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') def single(context, state): @@ -370,6 +387,7 @@ def single(context, state): else: context.core.playback.single = False + @handle_request(r'^stop$') def stop(context): """ diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 8cd1337b..bc18eb3a 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented + @handle_request(r'^commands$', auth_required=False) def commands(context): """ @@ -13,16 +14,20 @@ def commands(context): if context.dispatcher.authenticated: command_names = set([command.name for command in mpd_commands]) else: - command_names = set([command.name for command in mpd_commands + command_names = set([ + 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. - command_names = command_names - set(['kill', 'command_list_begin', - 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', - 'idle', 'noidle', 'sticker']) + command_names = command_names - set([ + 'kill', 'command_list_begin', 'command_list_ok_begin', + 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', + 'sticker']) + + return [ + ('command', command_name) for command_name in sorted(command_names)] - return [('command', command_name) for command_name in sorted(command_names)] @handle_request(r'^decoders$') def decoders(context): @@ -41,7 +46,8 @@ def decoders(context): plugin: mpcdec suffix: mpc """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^notcommands$', auth_required=False) def notcommands(context): @@ -55,13 +61,15 @@ def notcommands(context): if context.dispatcher.authenticated: command_names = [] else: - command_names = [command.name for command in mpd_commands - if command.auth_required] + command_names = [ + command.name for command in mpd_commands if command.auth_required] # No permission to use command_names.append('kill') - return [('command', command_name) for command_name in sorted(command_names)] + return [ + ('command', command_name) for command_name in sorted(command_names)] + @handle_request(r'^tagtypes$') def tagtypes(context): @@ -72,7 +80,8 @@ def tagtypes(context): Shows a list of available song metadata. """ - pass # TODO + pass # TODO + @handle_request(r'^urlhandlers$') def urlhandlers(context): @@ -83,5 +92,6 @@ def urlhandlers(context): Gets a list of available URL handlers. """ - return [(u'handler', uri_scheme) + return [ + (u'handler', uri_scheme) for uri_scheme in context.core.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 4f48265c..deda4986 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -6,8 +6,10 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format #: Subsystems that can be registered with idle command. -SUBSYSTEMS = ['database', 'mixer', 'options', 'output', - 'player', 'playlist', 'stored_playlist', 'update', ] +SUBSYSTEMS = [ + 'database', 'mixer', 'options', 'output', 'player', 'playlist', + 'stored_playlist', 'update'] + @handle_request(r'^clearerror$') def clearerror(context): @@ -19,7 +21,8 @@ def clearerror(context): Clears the current error message in status (this is also accomplished by any command that starts playback). """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^currentsong$') def currentsong(context): @@ -36,6 +39,7 @@ def currentsong(context): position = context.core.playback.current_playlist_position.get() return track_to_mpd_format(current_cp_track, position=position) + @handle_request(r'^idle$') @handle_request(r'^idle (?P.+)$') def idle(context, subsystems=None): @@ -93,6 +97,7 @@ def idle(context, subsystems=None): response.append(u'changed: %s' % subsystem) return response + @handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" @@ -102,6 +107,7 @@ def noidle(context): context.events = set() context.session.prevent_timeout = False + @handle_request(r'^stats$') def stats(context): """ @@ -119,15 +125,16 @@ def stats(context): - ``playtime``: time length of music played """ return { - 'artists': 0, # TODO - 'albums': 0, # TODO - 'songs': 0, # TODO - 'uptime': 0, # TODO - 'db_playtime': 0, # TODO - 'db_update': 0, # TODO - 'playtime': 0, # TODO + 'artists': 0, # TODO + 'albums': 0, # TODO + 'songs': 0, # TODO + 'uptime': 0, # TODO + 'db_playtime': 0, # TODO + 'db_update': 0, # TODO + 'playtime': 0, # TODO } + @handle_request(r'^status$') def status(context): """ @@ -153,7 +160,7 @@ def status(context): - ``nextsongid``: playlist songid of the next song to be played - ``time``: total time elapsed (of current playing/paused song) - ``elapsed``: Total time elapsed within the current song, but with - higher resolution. + higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels @@ -175,8 +182,8 @@ def status(context): 'playback.single': context.core.playback.single, 'playback.state': context.core.playback.state, 'playback.current_cp_track': context.core.playback.current_cp_track, - 'playback.current_playlist_position': - context.core.playback.current_playlist_position, + 'playback.current_playlist_position': ( + context.core.playback.current_playlist_position), 'playback.time_position': context.core.playback.time_position, } pykka.future.get_all(futures.values()) @@ -194,39 +201,47 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (PlaybackState.PLAYING, - PlaybackState.PAUSED): + if futures['playback.state'].get() in ( + PlaybackState.PLAYING, PlaybackState.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) return result + def _status_bitrate(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: return current_cp_track.track.bitrate + def _status_consume(futures): if futures['playback.consume'].get(): return 1 else: return 0 + def _status_playlist_length(futures): return futures['current_playlist.length'].get() + def _status_playlist_version(futures): return futures['current_playlist.version'].get() + def _status_random(futures): return int(futures['playback.random'].get()) + def _status_repeat(futures): return int(futures['playback.repeat'].get()) + def _status_single(futures): return int(futures['playback.single'].get()) + def _status_songid(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: @@ -234,9 +249,11 @@ def _status_songid(futures): else: return _status_songpos(futures) + def _status_songpos(futures): return futures['playback.current_playlist_position'].get() + def _status_state(futures): state = futures['playback.state'].get() if state == PlaybackState.PLAYING: @@ -246,13 +263,17 @@ def _status_state(futures): elif state == PlaybackState.PAUSED: return u'pause' + def _status_time(futures): - return u'%d:%d' % (futures['playback.time_position'].get() // 1000, + return u'%d:%d' % ( + futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) + def _status_time_elapsed(futures): return u'%.3f' % (futures['playback.time_position'].get() / 1000.0) + def _status_time_total(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is None: @@ -262,6 +283,7 @@ def _status_time_total(futures): else: return current_cp_track.track.length + def _status_volume(futures): volume = futures['playback.volume'].get() if volume is not None: @@ -269,5 +291,6 @@ def _status_volume(futures): else: return -1 + def _status_xfade(futures): - return 0 # Not supported + return 0 # Not supported diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index c3663ff1..074a306d 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -1,7 +1,9 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_request(r'^sticker delete "(?P[^"]+)" ' + +@handle_request( + r'^sticker delete "(?P[^"]+)" ' r'"(?P[^"]+)"( "(?P[^"]+)")*$') def sticker_delete(context, field, uri, name=None): """ @@ -12,9 +14,11 @@ def sticker_delete(context, field, uri, name=None): Deletes a sticker value from the specified object. If you do not specify a sticker name, all sticker values are deleted. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_find(context, field, uri, name): """ @@ -26,9 +30,11 @@ def sticker_find(context, field, uri, name): below the specified directory (``URI``). For each matching song, it prints the ``URI`` and that one sticker's value. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_get(context, field, uri, name): """ @@ -38,7 +44,8 @@ def sticker_get(context, field, uri, name): Reads a sticker value for the specified object. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') def sticker_list(context, field, uri): @@ -49,9 +56,11 @@ def sticker_list(context, field, uri): Lists the stickers for the specified object. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)" "(?P[^"]+)"$') def sticker_set(context, field, uri, name, value): """ @@ -62,4 +71,4 @@ def sticker_set(context, field, uri, name, value): Adds a sticker value to the specified object. If a sticker item with that name already exists, it is replaced. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index c21f4714..ed1c38ab 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -4,6 +4,7 @@ from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import playlist_to_mpd_format + @handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ @@ -25,6 +26,7 @@ def listplaylist(context, name): except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') + @handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ @@ -46,6 +48,7 @@ def listplaylistinfo(context, name): raise MpdNoExistError( u'No such playlist', command=u'listplaylistinfo') + @handle_request(r'^listplaylists$') def listplaylists(context): """ @@ -70,8 +73,8 @@ def listplaylists(context): result = [] for playlist in context.core.stored_playlists.playlists.get(): result.append((u'playlist', playlist.name)) - last_modified = (playlist.last_modified or - dt.datetime.now()).isoformat() + last_modified = ( + playlist.last_modified or dt.datetime.now()).isoformat() # Remove microseconds last_modified = last_modified.split('.')[0] # Add time zone information @@ -80,6 +83,7 @@ def listplaylists(context): result.append((u'Last-Modified', last_modified)) return result + @handle_request(r'^load "(?P[^"]+)"$') def load(context, name): """ @@ -99,6 +103,7 @@ def load(context, name): except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') + @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def playlistadd(context, name, uri): """ @@ -110,7 +115,8 @@ def playlistadd(context, name, uri): ``NAME.m3u`` will be created if it does not exist. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistclear "(?P[^"]+)"$') def playlistclear(context, name): @@ -121,7 +127,8 @@ def playlistclear(context, name): Clears the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') def playlistdelete(context, name, songpos): @@ -132,9 +139,11 @@ def playlistdelete(context, name, songpos): Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^playlistmove "(?P[^"]+)" ' + +@handle_request( + r'^playlistmove "(?P[^"]+)" ' r'"(?P\d+)" "(?P\d+)"$') def playlistmove(context, name, from_pos, to_pos): """ @@ -151,7 +160,8 @@ def playlistmove(context, name, from_pos, to_pos): documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def rename(context, old_name, new_name): @@ -162,7 +172,8 @@ def rename(context, old_name, new_name): Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rm "(?P[^"]+)"$') def rm(context, name): @@ -173,7 +184,8 @@ def rm(context, name): Removes the playlist ``NAME.m3u`` from the playlist directory. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^save "(?P[^"]+)"$') def save(context, name): @@ -185,4 +197,4 @@ def save(context, name): Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 6ae32c9e..0ab28271 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -6,6 +6,7 @@ from mopidy.frontends.mpd import protocol from mopidy.models import CpTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path + def track_to_mpd_format(track, position=None): """ Format track for output to MPD client. @@ -48,8 +49,8 @@ def track_to_mpd_format(track, position=None): # FIXME don't use first and best artist? # FIXME don't duplicate following code? if track.album is not None and track.album.artists: - artists = filter(lambda a: a.musicbrainz_id is not None, - track.album.artists) + artists = filter( + lambda a: a.musicbrainz_id is not None, track.album.artists) if artists: result.append( ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) @@ -61,16 +62,19 @@ def track_to_mpd_format(track, position=None): result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result + MPD_KEY_ORDER = ''' key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime '''.split() + def order_mpd_track_info(result): """ - Order results from :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` - so that it matches MPD's ordering. Simply a cosmetic fix for easier - diffing of tag_caches. + Order results from + :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it + matches MPD's ordering. Simply a cosmetic fix for easier diffing of + tag_caches. :param result: the track info :type result: list of tuples @@ -78,6 +82,7 @@ def order_mpd_track_info(result): """ return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) + def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. @@ -90,6 +95,7 @@ def artists_to_mpd_format(artists): artists.sort(key=lambda a: a.name) return u', '.join([a.name for a in artists if a.name]) + def tracks_to_mpd_format(tracks, start=0, end=None): """ Format list of tracks for output to MPD client. @@ -115,6 +121,7 @@ def tracks_to_mpd_format(tracks, start=0, end=None): result.append(track_to_mpd_format(track, position)) return result + def playlist_to_mpd_format(playlist, *args, **kwargs): """ Format playlist for output to MPD client. @@ -123,6 +130,7 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): """ return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) + def tracks_to_tag_cache_format(tracks): """ Format list of tracks for output to MPD tag cache @@ -141,6 +149,7 @@ def tracks_to_tag_cache_format(tracks): _add_to_tag_cache(result, *tracks_to_directory_tree(tracks)) return result + def _add_to_tag_cache(result, folders, files): music_folder = settings.LOCAL_MUSIC_PATH regexp = '^' + re.escape(music_folder).rstrip('/') + '/?' @@ -165,6 +174,7 @@ def _add_to_tag_cache(result, folders, files): result.extend(track_result) result.append(('songList end',)) + def tracks_to_directory_tree(tracks): directories = ({}, []) for track in tracks: diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 1a8797f2..80995adf 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -5,7 +5,7 @@ logger = logging.getLogger('mopidy.frontends.mpris') try: import indicate except ImportError as import_error: - indicate = None + indicate = None # noqa logger.debug(u'Startup notification will not be sent (%s)', import_error) from pykka.actor import ThreadingActor @@ -100,8 +100,8 @@ class MprisFrontend(ThreadingActor, core.CoreListener): props_with_new_values = [ (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) for p in changed_properties] - self.mpris_object.PropertiesChanged(objects.PLAYER_IFACE, - dict(props_with_new_values), []) + self.mpris_object.PropertiesChanged( + objects.PLAYER_IFACE, dict(props_with_new_values), []) def track_playback_paused(self, track, time_position): logger.debug(u'Received track playback paused event') diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 7c8b6f5a..ee54f91c 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -77,8 +77,8 @@ class MprisObject(dbus.service.Object): def _connect_to_dbus(self): logger.debug(u'Connecting to D-Bus...') mainloop = dbus.mainloop.glib.DBusGMainLoop() - bus_name = dbus.service.BusName(BUS_NAME, - dbus.SessionBus(mainloop=mainloop)) + bus_name = dbus.service.BusName( + BUS_NAME, dbus.SessionBus(mainloop=mainloop)) logger.info(u'Connected to D-Bus') return bus_name @@ -92,9 +92,10 @@ class MprisObject(dbus.service.Object): ### Properties interface @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ss', out_signature='v') + in_signature='ss', out_signature='v') def Get(self, interface, prop): - logger.debug(u'%s.Get(%s, %s) called', + logger.debug( + u'%s.Get(%s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) (getter, setter) = self.properties[interface][prop] if callable(getter): @@ -103,35 +104,36 @@ class MprisObject(dbus.service.Object): return getter @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='s', out_signature='a{sv}') + in_signature='s', out_signature='a{sv}') def GetAll(self, interface): - logger.debug(u'%s.GetAll(%s) called', - dbus.PROPERTIES_IFACE, repr(interface)) + logger.debug( + u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) getters = {} for key, (getter, setter) in self.properties[interface].iteritems(): getters[key] = getter() if callable(getter) else getter return getters @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ssv', out_signature='') + in_signature='ssv', out_signature='') def Set(self, interface, prop, value): - logger.debug(u'%s.Set(%s, %s, %s) called', + logger.debug( + u'%s.Set(%s, %s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) getter, setter = self.properties[interface][prop] if setter is not None: setter(value) - self.PropertiesChanged(interface, - {prop: self.Get(interface, prop)}, []) + self.PropertiesChanged( + interface, {prop: self.Get(interface, prop)}, []) @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, - signature='sa{sv}as') + signature='sa{sv}as') def PropertiesChanged(self, interface, changed_properties, - invalidated_properties): - logger.debug(u'%s.PropertiesChanged(%s, %s, %s) signaled', + invalidated_properties): + logger.debug( + u'%s.PropertiesChanged(%s, %s, %s) signaled', dbus.PROPERTIES_IFACE, interface, changed_properties, invalidated_properties) - ### Root interface methods @dbus.service.method(dbus_interface=ROOT_IFACE) @@ -144,7 +146,6 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Quit called', ROOT_IFACE) exit_process() - ### Root interface properties def get_DesktopEntry(self): @@ -153,7 +154,6 @@ class MprisObject(dbus.service.Object): def get_SupportedUriSchemes(self): return dbus.Array(self.core.uri_schemes.get(), signature='s') - ### Player interface methods @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -263,7 +263,6 @@ class MprisObject(dbus.service.Object): else: logger.debug(u'Track with URI "%s" not found in library.', uri) - ### Player interface signals @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') @@ -271,7 +270,6 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Seeked signaled', PLAYER_IFACE) # Do nothing, as just calling the method is enough to emit the signal. - ### Player interface properties def get_PlaybackStatus(self): @@ -383,20 +381,23 @@ class MprisObject(dbus.service.Object): def get_CanGoNext(self): if not self.get_CanControl(): return False - return (self.core.playback.cp_track_at_next.get() != + return ( + self.core.playback.cp_track_at_next.get() != self.core.playback.current_cp_track.get()) def get_CanGoPrevious(self): if not self.get_CanControl(): return False - return (self.core.playback.cp_track_at_previous.get() != + return ( + self.core.playback.cp_track_at_previous.get() != self.core.playback.current_cp_track.get()) def get_CanPlay(self): if not self.get_CanControl(): return False - return (self.core.playback.current_track.get() is not None - or self.core.playback.track_at_next.get() is not None) + return ( + self.core.playback.current_track.get() is not None or + self.core.playback.track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/mopidy/models.py b/mopidy/models.py index 507ca088..8eaa4ee5 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -13,8 +13,9 @@ class ImmutableObject(object): def __init__(self, *args, **kwargs): for key, value in kwargs.items(): if not hasattr(self, key): - raise TypeError('__init__() got an unexpected keyword ' + \ - 'argument \'%s\'' % key) + raise TypeError( + u"__init__() got an unexpected keyword argument '%s'" % + key) self.__dict__[key] = value def __setattr__(self, name, value): @@ -71,8 +72,8 @@ class ImmutableObject(object): if hasattr(self, key): data[key] = values.pop(key) if values: - raise TypeError("copy() got an unexpected keyword argument '%s'" - % key) + raise TypeError( + u"copy() got an unexpected keyword argument '%s'" % key) return self.__class__(**data) def serialize(self): diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 29511c80..2c12d26a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -10,6 +10,7 @@ import datetime from mopidy.utils.path import path_to_uri, find_files from mopidy.models import Track, Artist, Album + def translator(data): albumartist_kwargs = {} album_kwargs = {} @@ -37,7 +38,8 @@ def translator(data): _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) - _retrieve('musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) + _retrieve( + 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] @@ -61,8 +63,8 @@ class Scanner(object): self.uribin = gst.element_factory_make('uridecodebin') self.uribin.set_property('caps', gst.Caps('audio/x-raw-int')) - self.uribin.connect('pad-added', self.process_new_pad, - fakesink.get_pad('sink')) + self.uribin.connect( + 'pad-added', self.process_new_pad, fakesink.get_pad('sink')) self.pipe = gst.element_factory_make('pipeline') self.pipe.add(self.uribin) @@ -106,7 +108,7 @@ class Scanner(object): self.next_uri() def get_duration(self): - self.pipe.get_state() # Block until state change is done. + self.pipe.get_state() # Block until state change is done. try: return self.pipe.query_duration( gst.FORMAT_TIME, None)[0] // gst.MSECOND diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index aacc2e85..839e4f79 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -2,7 +2,6 @@ from __future__ import division import locale import logging -import os import sys logger = logging.getLogger('mopidy.utils') diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 2c68e429..d72f1392 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -61,8 +61,8 @@ def platform_info(): def python_info(): return { 'name': 'Python', - 'version': '%s %s' % (platform.python_implementation(), - platform.python_version()), + 'version': '%s %s' % ( + platform.python_implementation(), platform.python_version()), 'path': platform.__file__, } @@ -125,9 +125,11 @@ def _gstreamer_check_elements(): # Shoutcast output 'shout2send', ] - known_elements = [factory.get_name() for factory in + known_elements = [ + factory.get_name() for factory in gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] - return [(element, element in known_elements) for element in elements_to_check] + return [ + (element, element in known_elements) for element in elements_to_check] def pykka_info(): diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 191efa2f..9b9495d5 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,6 +3,7 @@ import logging.handlers from mopidy import get_version, get_platform, get_python, settings + def setup_logging(verbosity_level, save_debug_log): setup_root_logger() setup_console_logging(verbosity_level) @@ -13,10 +14,12 @@ def setup_logging(verbosity_level, save_debug_log): logger.info(u'Platform: %s', get_platform()) logger.info(u'Python: %s', get_python()) + def setup_root_logger(): root = logging.getLogger('') root.setLevel(logging.DEBUG) + def setup_console_logging(verbosity_level): if verbosity_level == 0: log_level = logging.WARNING @@ -37,6 +40,7 @@ def setup_console_logging(verbosity_level): if verbosity_level < 3: logging.getLogger('pykka').setLevel(logging.INFO) + def setup_debug_logging_to_file(): formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( @@ -46,6 +50,7 @@ def setup_debug_logging_to_file(): root = logging.getLogger('') root.addHandler(handler) + def indent(string, places=4, linebreak='\n'): lines = string.split(linebreak) if len(lines) == 1: diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index d2e0690b..2a637c9b 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -27,8 +27,10 @@ def try_ipv6_socket(): socket.socket(socket.AF_INET6).close() return True except IOError as error: - logger.debug(u'Platform supports IPv6, but socket ' - 'creation failed, disabling: %s', locale_decode(error)) + logger.debug( + u'Platform supports IPv6, but socket creation failed, ' + u'disabling: %s', + locale_decode(error)) return False @@ -59,7 +61,7 @@ class Server(object): """Setup listener and register it with gobject's event loop.""" def __init__(self, host, port, protocol, protocol_kwargs=None, - max_connections=5, timeout=30): + max_connections=5, timeout=30): self.protocol = protocol self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections @@ -114,8 +116,8 @@ class Server(object): pass def init_connection(self, sock, addr): - Connection(self.protocol, self.protocol_kwargs, - sock, addr, self.timeout) + Connection( + self.protocol, self.protocol_kwargs, sock, addr, self.timeout) class Connection(object): @@ -130,7 +132,7 @@ class Connection(object): def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) - self.host, self.port = addr[:2] # IPv6 has larger addr + self.host, self.port = addr[:2] # IPv6 has larger addr self.sock = sock self.protocol = protocol @@ -214,7 +216,8 @@ class Connection(object): return try: - self.recv_id = gobject.io_add_watch(self.sock.fileno(), + self.recv_id = gobject.io_add_watch( + self.sock.fileno(), gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.recv_callback) except socket.error as e: @@ -231,7 +234,8 @@ class Connection(object): return try: - self.send_id = gobject.io_add_watch(self.sock.fileno(), + self.send_id = gobject.io_add_watch( + self.sock.fileno(), gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.send_callback) except socket.error as e: @@ -372,8 +376,10 @@ class LineProtocol(ThreadingActor): try: return line.encode(self.encoding) except UnicodeError: - logger.warning(u'Stopping actor due to encode problem, data ' - 'supplied by client was not valid %s', self.encoding) + logger.warning( + u'Stopping actor due to encode problem, data ' + u'supplied by client was not valid %s', + self.encoding) self.stop() def decode(self, line): @@ -385,8 +391,10 @@ class LineProtocol(ThreadingActor): try: return line.decode(self.encoding) except UnicodeError: - logger.warning(u'Stopping actor due to decode problem, data ' - 'supplied by client was not valid %s', self.encoding) + logger.warning( + u'Stopping actor due to decode problem, data ' + u'supplied by client was not valid %s', + self.encoding) self.stop() def join_lines(self, lines): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 7f1b9233..0cf02a4a 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -18,8 +18,9 @@ XDG_DIRS = { def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): - raise OSError('A file with the same name as the desired ' \ - 'dir, "%s", already exists.' % folder) + raise OSError( + u'A file with the same name as the desired dir, ' + u'"%s", already exists.' % folder) elif not os.path.isdir(folder): logger.info(u'Creating dir %s', folder) os.makedirs(folder, 0755) @@ -47,7 +48,7 @@ def uri_to_path(uri): path = urllib.url2pathname(re.sub('^file:', '', uri)) else: path = urllib.url2pathname(re.sub('^file://', '', uri)) - return path.encode('latin1').decode('utf-8') # Undo double encoding + return path.encode('latin1').decode('utf-8') # Undo double encoding def split_path(path): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 80d850fe..c45659bb 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -10,30 +10,35 @@ from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') + def exit_process(): logger.debug(u'Interrupting main...') thread.interrupt_main() logger.debug(u'Interrupted main') + def exit_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal.""" signals = dict((k, v) for v, k in signal.__dict__.iteritems() - if v.startswith('SIG') and not v.startswith('SIG_')) + if v.startswith('SIG') and not v.startswith('SIG_')) logger.info(u'Got %s signal', signals[signum]) exit_process() + def stop_actors_by_class(klass): actors = ActorRegistry.get_by_class(klass) logger.debug(u'Stopping %d instance(s) of %s', len(actors), klass.__name__) for actor in actors: actor.stop() + def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: logger.error( u'There are actor threads still running, this is probably a bug') - logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', + logger.debug( + u'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) logger.debug(u'Stopping %d actor(s)...', num_actors) @@ -41,6 +46,7 @@ def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) logger.debug(u'All actors stopped.') + class BaseThread(threading.Thread): def __init__(self): super(BaseThread, self).__init__() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 5468b9bf..0ecdd827 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -32,7 +32,8 @@ class SettingsProxy(object): return self._get_settings_dict_from_module(local_settings_module) def _get_settings_dict_from_module(self, module): - settings = filter(lambda (key, value): self._is_setting(key), + settings = filter( + lambda (key, value): self._is_setting(key), module.__dict__.iteritems()) return dict(settings) @@ -50,7 +51,7 @@ class SettingsProxy(object): if not self._is_setting(attr): return - current = self.current # bind locally to avoid copying+updates + current = self.current # bind locally to avoid copying+updates if attr not in current: raise SettingsError(u'Setting "%s" is not set.' % attr) @@ -73,7 +74,8 @@ class SettingsProxy(object): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): - logger.error(u'Settings validation errors: %s', + logger.error( + u'Settings validation errors: %s', log.indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') @@ -84,11 +86,13 @@ class SettingsProxy(object): def _read_from_stdin(self, prompt): if u'_PASSWORD' in prompt: - return (getpass.getpass(prompt) + return ( + getpass.getpass(prompt) .decode(sys.stdin.encoding, 'ignore')) else: sys.stdout.write(prompt) - return (sys.stdin.readline().strip() + return ( + sys.stdin.readline().strip() .decode(sys.stdin.encoding, 'ignore')) def get_errors(self): @@ -201,7 +205,8 @@ def format_settings_list(settings): lines.append(u'%s: %s' % ( key, log.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: - lines.append(u' Default: %s' % + lines.append( + u' Default: %s' % log.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) @@ -235,13 +240,13 @@ def levenshtein(a, b, max=3): if n > m: return levenshtein(b, a) - current = xrange(n+1) - for i in xrange(1, m+1): + current = xrange(n + 1) + for i in xrange(1, m + 1): previous, current = current, [i] + [0] * n - for j in xrange(1, n+1): - add, delete = previous[j] + 1, current[j-1] + 1 - change = previous[j-1] - if a[j-1] != b[i-1]: + for j in xrange(1, n + 1): + add, delete = previous[j] + 1, current[j - 1] + 1 + change = previous[j - 1] + if a[j - 1] != b[i - 1]: change += 1 current[j] = min(add, delete, change) return current[n] From 4341b7c2efaa98b9997cf5841c050b3e52ca6978 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:01:17 +0200 Subject: [PATCH 042/323] Change author of mixers to 'Mopidy' --- mopidy/audio/mixers/auto.py | 2 +- mopidy/audio/mixers/fake.py | 2 +- mopidy/audio/mixers/nad.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 3dce11f7..a4bd8bdb 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -14,7 +14,7 @@ class AutoAudioMixer(gst.Bin): 'AutoAudioMixer', 'Mixer', 'Element automatically selects a mixer.', - 'Thomas Adamcik') + 'Mopidy') def __init__(self): gst.Bin.__init__(self) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index d44fbd71..0e397e55 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -11,7 +11,7 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): 'FakeMixer', 'Mixer', 'Fake mixer for use in tests.', - 'Thomas Adamcik') + 'Mopidy') track_label = gobject.property(type=str, default='Master') track_initial_volume = gobject.property(type=int, default=0) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index d50c1242..39a7b25e 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -23,7 +23,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): 'NadMixer', 'Mixer', 'Mixer to control NAD amplifiers using a serial link', - 'Stein Magnus Jodal') + 'Mopidy') port = gobject.property(type=str, default='/dev/ttyUSB0') source = gobject.property(type=str) From ac60bcdf8e1162f9a72d1ed9aa3b003868be5332 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:43:31 +0200 Subject: [PATCH 043/323] Fix all flake8 warnings in tests (#211) --- tests/__init__.py | 2 +- tests/audio_test.py | 14 +-- tests/backends/base/current_playlist.py | 11 ++- tests/backends/base/library.py | 12 ++- tests/backends/base/playback.py | 45 +++++---- tests/backends/base/stored_playlists.py | 2 +- tests/backends/local/current_playlist_test.py | 10 +- tests/backends/local/library_test.py | 4 - tests/backends/local/playback_test.py | 8 +- tests/backends/local/stored_playlists_test.py | 6 +- tests/backends/local/translator_test.py | 57 ++++++----- tests/frontends/mpd/dispatcher_test.py | 8 +- tests/frontends/mpd/exception_test.py | 14 ++- tests/frontends/mpd/protocol/__init__.py | 18 ++-- .../mpd/protocol/command_list_test.py | 4 +- .../frontends/mpd/protocol/connection_test.py | 2 +- .../mpd/protocol/current_playlist_test.py | 16 +-- tests/frontends/mpd/protocol/playback_test.py | 44 ++++----- .../frontends/mpd/protocol/regression_test.py | 5 +- .../mpd/protocol/stored_playlists_test.py | 9 +- tests/frontends/mpd/serializer_test.py | 11 ++- tests/frontends/mpd/status_test.py | 4 +- tests/frontends/mpris/events_test.py | 7 +- .../frontends/mpris/player_interface_test.py | 92 +++++++++--------- tests/models_test.py | 97 ++++++++++--------- tests/scanner_test.py | 9 +- tests/utils/deps_test.py | 6 +- tests/utils/network/connection_test.py | 59 ++++++----- tests/utils/network/lineprotocol_test.py | 4 +- tests/utils/network/server_test.py | 65 +++++++------ tests/utils/network/utils_test.py | 8 +- tests/utils/path_test.py | 21 ++-- tests/utils/settings_test.py | 70 +++++++------ 33 files changed, 396 insertions(+), 348 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 833ff239..5d9ea2b5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,7 +4,7 @@ import sys if sys.version_info < (2, 7): import unittest2 as unittest else: - import unittest + import unittest # noqa from mopidy import settings diff --git a/tests/audio_test.py b/tests/audio_test.py index fcafa75f..852ce36b 100644 --- a/tests/audio_test.py +++ b/tests/audio_test.py @@ -1,13 +1,9 @@ -import sys - from mopidy import audio, settings from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class AudioTest(unittest.TestCase): def setUp(self): settings.MIXER = 'fakemixer track_max_volume=65536' @@ -43,11 +39,11 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_deliver_data(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_end_of_data_stream(self): - pass # TODO + pass # TODO def test_set_volume(self): for value in range(0, 101): @@ -56,12 +52,12 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_set_state_encapsulation(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_set_position(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_invalid_output_raises_error(self): - pass # TODO + pass # TODO diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index db4473bb..00ffaea8 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -48,7 +48,8 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_add_at_position_outside_of_playlist(self): - test = lambda: self.controller.add(self.tracks[0], len(self.tracks)+2) + test = lambda: self.controller.add( + self.tracks[0], len(self.tracks) + 2) self.assertRaises(AssertionError, test) @populate_playlist @@ -180,19 +181,19 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_moving_track_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks+5) + test = lambda: self.controller.move(0, 0, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist def test_move_group_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks+5) + test = lambda: self.controller.move(0, 2, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist def test_move_group_out_of_range(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks+2, tracks+3, 0) + test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) self.assertRaises(AssertionError, test) @populate_playlist @@ -253,7 +254,7 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_shuffle_superset(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks+5) + test = lambda: self.controller.shuffle(1, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 99dce78e..85ba54bb 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,5 +1,3 @@ -import mock - from pykka.registry import ActorRegistry from mopidy import core @@ -10,12 +8,16 @@ from tests import unittest, path_to_data_dir class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] - albums = [Album(name='album1', artists=artists[:1]), + albums = [ + Album(name='album1', artists=artists[:1]), Album(name='album2', artists=artists[1:2]), Album()] - tracks = [Track(name='track1', length=4000, artists=artists[:1], + tracks = [ + Track( + name='track1', length=4000, artists=artists[:1], album=albums[0], uri='file://' + path_to_data_dir('uri1')), - Track(name='track2', length=4000, artists=artists[1:2], + Track( + name='track2', length=4000, artists=artists[1:2], album=albums[1], uri='file://' + path_to_data_dir('uri2')), Track()] diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 46863f03..5a3b9157 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -125,10 +125,10 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist @@ -175,8 +175,8 @@ class PlaybackControllerTest(object): self.playback.next() - self.assertEqual(self.playback.current_playlist_position, - old_position+1) + self.assertEqual( + self.playback.current_playlist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -311,8 +311,8 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() - self.assertEqual(self.playback.current_playlist_position, - old_position+1) + self.assertEqual( + self.playback.current_playlist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -406,7 +406,6 @@ class PlaybackControllerTest(object): self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - @populate_playlist def test_end_of_track_with_consume(self): self.playback.consume = True @@ -448,10 +447,10 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 self.assertEqual(self.playback.track_at_previous, self.tracks[0]) def test_previous_track_empty_playlist(self): @@ -462,16 +461,16 @@ class PlaybackControllerTest(object): self.playback.consume = True for _ in self.tracks: self.playback.next() - self.assertEqual(self.playback.track_at_previous, - self.playback.current_track) + self.assertEqual( + self.playback.track_at_previous, self.playback.current_track) @populate_playlist def test_previous_track_with_random(self): self.playback.random = True for _ in self.tracks: self.playback.next() - self.assertEqual(self.playback.track_at_previous, - self.playback.current_track) + self.assertEqual( + self.playback.track_at_previous, self.playback.current_track) @populate_playlist def test_initial_current_track(self): @@ -522,7 +521,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) - @unittest.SkipTest # Blocks for 10ms + @unittest.SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -601,7 +600,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @unittest.SkipTest # Uses sleep and might not work with LocalBackend + @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -675,13 +674,13 @@ class PlaybackControllerTest(object): def test_seek_beyond_end_of_song(self): # FIXME need to decide return value self.playback.play() - result = self.playback.seek(self.tracks[0].length*100) + result = self.playback.seek(self.tracks[0].length * 100) self.assert_(not result, 'Seek return value was %s' % result) @populate_playlist def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play() - self.playback.seek(self.tracks[0].length*100) + self.playback.seek(self.tracks[0].length * 100) self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist @@ -743,7 +742,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.time_position, 0) - @unittest.SkipTest # Uses sleep and does might not work with LocalBackend + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() @@ -752,7 +751,7 @@ class PlaybackControllerTest(object): second = self.playback.time_position self.assertGreater(second, first) - @unittest.SkipTest # Uses sleep + @unittest.SkipTest # Uses sleep @populate_playlist def test_time_position_when_paused(self): self.playback.play() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 4e65c034..c16be173 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -19,7 +19,7 @@ class StoredPlaylistsControllerTest(object): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(backend=self.backend) - self.stored = self.core.stored_playlists + self.stored = self.core.stored_playlists def tearDown(self): if os.path.exists(settings.LOCAL_PLAYLIST_PATH): diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index a475a6fd..52fa9eb5 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track @@ -9,14 +7,12 @@ from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, - unittest.TestCase): + unittest.TestCase): backend_class = LocalBackend - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] + tracks = [ + Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 046e747a..75cebdbc 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend @@ -7,8 +5,6 @@ from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index fe5fee32..fea93ae3 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.core import PlaybackState @@ -11,12 +9,10 @@ from tests.backends.base.playback import PlaybackControllerTest from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = LocalBackend - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] + tracks = [ + Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 3f3d9c58..437152fe 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,6 +1,4 @@ import os -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Playlist, Track @@ -12,10 +10,8 @@ from tests.backends.base.stored_playlists import ( from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, - unittest.TestCase): + unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 08f29c1b..6f754399 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -55,7 +55,7 @@ class M3UToUriTest(unittest.TestCase): def test_file_with_multiple_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path+'\n') + tmp.write(song1_path + '\n') tmp.write('# comment \n') tmp.write(song2_path) try: @@ -87,17 +87,21 @@ class M3UToUriTest(unittest.TestCase): class URItoM3UTest(unittest.TestCase): pass + expected_artists = [Artist(name='name')] -expected_albums = [Album(name='albumname', artists=expected_artists, - num_tracks=2)] +expected_albums = [ + Album(name='albumname', artists=expected_artists, num_tracks=2)] 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, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=expected_albums[0], length=4000, uri=uri) expected_tracks.append(track) + generate_track('song1.mp3', 6) generate_track('song2.mp3', 7) generate_track('song3.mp3', 8) @@ -108,34 +112,36 @@ generate_track('subdir2/song7.mp3', 5) generate_track('subdir1/subsubdir/song8.mp3', 0) generate_track('subdir1/subsubdir/song9.mp3', 1) + class MPDTagCacheToTracksTest(unittest.TestCase): def test_emtpy_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('empty_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) self.assertEqual(set(), tracks) def test_simple_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('simple_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=expected_albums[0], length=4000, uri=uri) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('advanced_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) self.assertEqual(set(expected_tracks), tracks) def test_unicode_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('utf8_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) artists = [Artist(name=u'æøå')] album = Album(name=u'æøå', artists=artists) - track = Track(uri=uri, name=u'æøå', artists=artists, - album=album, length=4000) + track = Track( + uri=uri, name=u'æøå', artists=artists, album=album, length=4000) self.assertEqual(track, list(tracks)[0]) @@ -145,32 +151,35 @@ class MPDTagCacheToTracksTest(unittest.TestCase): pass def test_cache_with_blank_track_info(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) self.assertEqual(set([Track(uri=uri, length=4000)]), tracks) def test_musicbrainz_tagcache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('musicbrainz_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) artist = list(expected_tracks[0].artists)[0].copy( musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') albumartist = list(expected_tracks[0].artists)[0].copy( name='albumartistname', musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - album = expected_tracks[0].album.copy(artists=[albumartist], + album = expected_tracks[0].album.copy( + artists=[albumartist], musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') - track = expected_tracks[0].copy(artists=[artist], album=album, + track = expected_tracks[0].copy( + artists=[artist], album=album, musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') self.assertEqual(track, list(tracks)[0]) def test_albumartist_tag_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('albumartist_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=album, length=4000, uri=uri) self.assertEqual(track, list(tracks)[0]) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 0bff04e7..0b5098c1 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -32,14 +32,16 @@ class MpdDispatcherTest(unittest.TestCase): self.dispatcher._find_handler('an_unknown_command with args') self.fail('Should raise exception') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [5@0] {} unknown command "an_unknown_command"') - def test_finding_handler_for_known_command_returns_handler_and_kwargs(self): + def test_find_handler_for_known_command_returns_handler_and_kwargs(self): expected_handler = lambda x: None request_handlers['known_command (?P.+)'] = \ expected_handler - (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') + (handler, kwargs) = self.dispatcher._find_handler( + 'known_command an_arg') self.assertEqual(handler, expected_handler) self.assertIn('arg1', kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index 2ea3fe62..8fb0c933 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,5 +1,6 @@ -from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, - MpdUnknownCommand, MpdSystemError, MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import ( + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, + MpdNotImplemented) from tests import unittest @@ -34,19 +35,22 @@ class MpdExceptionsTest(unittest.TestCase): try: raise MpdUnknownCommand(command=u'play') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [5@0] {} unknown command "play"') def test_mpd_system_error(self): try: raise MpdSystemError('foo') except MpdSystemError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [52@0] {} foo') def test_mpd_permission_error(self): try: raise MpdPermissionError(command='foo') except MpdPermissionError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [4@0] {foo} you don\'t have permission for "foo"') diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 041b6532..63c253d9 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -45,17 +45,23 @@ class BaseTestCase(unittest.TestCase): self.assertEqual([], self.connection.response) def assertInResponse(self, value): - self.assertIn(value, self.connection.response, u'Did not find %s ' - 'in %s' % (repr(value), repr(self.connection.response))) + self.assertIn( + value, self.connection.response, + u'Did not find %s in %s' % ( + repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): matched = len([r for r in self.connection.response if r == value]) - self.assertEqual(1, matched, 'Expected to find %s once in %s' % - (repr(value), repr(self.connection.response))) + self.assertEqual( + 1, matched, + u'Expected to find %s once in %s' % ( + repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): - self.assertNotIn(value, self.connection.response, u'Found %s in %s' % - (repr(value), repr(self.connection.response))) + self.assertNotIn( + value, self.connection.response, + u'Found %s in %s' % ( + repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): self.assertEqual(1, len(self.connection.response)) diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index 65b051d3..64ef8688 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -28,8 +28,8 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_with_error_returns_ack_with_correct_index(self): self.sendRequest(u'command_list_begin') - self.sendRequest(u'play') # Known command - self.sendRequest(u'paly') # Unknown command + self.sendRequest(u'play') # Known command + self.sendRequest(u'paly') # Unknown command self.sendRequest(u'command_list_end') self.assertEqualResponse(u'ACK [5@1] {} unknown command "paly"') diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py index cd08313f..9b8972d3 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -8,7 +8,7 @@ from tests.frontends.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: - response = self.sendRequest(u'close') + self.sendRequest(u'close') close_mock.assertEqualResponsecalled_once_with() self.assertEqualResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 63c4a42b..a64b08ea 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -38,8 +38,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest(u'addid "dummy://foo"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) - self.assertInResponse(u'Id: %d' % - self.core.current_playlist.cp_tracks.get()[5][0]) + self.assertInResponse( + u'Id: %d' % self.core.current_playlist.cp_tracks.get()[5][0]) self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): @@ -57,8 +57,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest(u'addid "dummy://foo" "3"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) - self.assertInResponse(u'Id: %d' % - self.core.current_playlist.cp_tracks.get()[3][0]) + self.assertInResponse( + u'Id: %d' % self.core.current_playlist.cp_tracks.get()[3][0]) self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): @@ -91,8 +91,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "%d"' % - self.core.current_playlist.cp_tracks.get()[2][0]) + self.sendRequest( + u'delete "%d"' % self.core.current_playlist.cp_tracks.get()[2][0]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) self.assertInResponse(u'OK') @@ -233,7 +233,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.core.current_playlist.append([ Track(uri='file:///exists')]) - self.sendRequest( u'playlistfind filename "file:///exists"') + self.sendRequest(u'playlistfind filename "file:///exists"') self.assertInResponse(u'file: file:///exists') self.assertInResponse(u'Id: 0') self.assertInResponse(u'Pos: 0') @@ -357,7 +357,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistsearch(self): - self.sendRequest( u'playlistsearch "any" "needle"') + self.sendRequest(u'playlistsearch "any" "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistsearch_without_quotes(self): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 2380c7bc..431c4663 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -259,29 +259,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_is_ignored_if_playing(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid(self): @@ -298,7 +298,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') - def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): + def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -307,7 +307,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual('a', self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') - def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): + def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() @@ -331,29 +331,29 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_is_ignored_if_playing(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): self.core.current_playlist.append([Track(length=40000)]) self.core.playback.seek(30000) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): @@ -388,15 +388,15 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid(self): self.core.current_playlist.append([Track(length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assertGreaterEqual(self.core.playback.time_position.get(), - 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 90bcaf60..a7b7611d 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -19,7 +19,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): self.core.current_playlist.append([ Track(uri='a'), Track(uri='b'), None, Track(uri='d'), Track(uri='e'), Track(uri='f')]) - random.seed(1) # Playlist order: abcfde + random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') self.assertEquals('a', self.core.playback.current_track.get().uri) @@ -158,7 +158,8 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): """ def test(self): - self.sendRequest(u'list Date Artist "Anita Ward" ' + self.sendRequest( + u'list Date Artist "Anita Ward" ' u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') self.assertInResponse('ACK [2@0] {list} Invalid unquoted character') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 0bf9756f..8cfcb338 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -35,8 +35,8 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.core.stored_playlists.playlists = [Playlist(name='a', - last_modified=last_modified)] + self.core.stored_playlists.playlists = [ + Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') self.assertInResponse(u'playlist: a') @@ -47,8 +47,9 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_known_playlist_appends_to_current_playlist(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.core.stored_playlists.playlists = [Playlist(name='A-list', - tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] + self.core.stored_playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest(u'load "A-list"') tracks = self.core.current_playlist.tracks.get() diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index e6cd80e2..2d2a9f87 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -49,7 +49,8 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertNotIn(('Id', 1), result) def test_track_to_mpd_format_with_position_and_cpid(self): - result = translator.track_to_mpd_format(CpTrack(2, Track()), position=1) + result = translator.track_to_mpd_format( + CpTrack(2, Track()), position=1) self.assertIn(('Pos', 1), result) self.assertIn(('Id', 2), result) @@ -79,7 +80,7 @@ class TrackMpdFormatTest(unittest.TestCase): result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) - def test_track_to_mpd_format_musicbrainz_albumid(self): + def test_track_to_mpd_format_musicbrainz_albumartistid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') album = self.track.album.copy(artists=[artist]) track = self.track.copy(album=album) @@ -131,7 +132,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): folder = settings.LOCAL_MUSIC_PATH result = dict(translator.track_to_mpd_format(track)) result['file'] = uri_to_path(result['file']) - result['file'] = result['file'][len(folder)+1:] + result['file'] = result['file'][len(folder) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) @@ -147,7 +148,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): self.assertEqual(('songList begin',), result[0]) for i, row in enumerate(result): if row == ('songList end',): - return result[1:i], result[i+1:] + return result[1:i], result[i + 1:] self.fail("Couldn't find songList end in result") def consume_directory(self, result): @@ -157,7 +158,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): directory = result[2][1] for i, row in enumerate(result): if row == ('end', directory): - return result[3:i], result[i+1:] + return result[3:i], result[i + 1:] self.fail("Couldn't find end %s in result" % directory) def test_empty_tag_cache_has_header(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 6322ec36..61fd0854 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,6 +1,6 @@ from pykka.registry import ActorRegistry -from mopidy import audio, core +from mopidy import core from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher @@ -97,7 +97,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) self.assertIn('playlist', result) - self.assertIn(int(result['playlist']), xrange(0, 2**31 - 1)) + self.assertIn(int(result['playlist']), xrange(0, 2 ** 31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index f466e207..241b9365 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -16,7 +16,8 @@ from tests import unittest @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.mpris_frontend = MprisFrontend(core=None) # As a plain class, not an actor + # As a plain class, not an actor: + self.mpris_frontend = MprisFrontend(core=None) self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object @@ -38,7 +39,7 @@ class BackendEventsTest(unittest.TestCase): self.mpris_object.PropertiesChanged.assert_called_with( objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) - def test_track_playback_started_event_changes_playback_status_and_metadata(self): + 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.assertListEqual(self.mpris_object.Get.call_args_list, [ @@ -49,7 +50,7 @@ class BackendEventsTest(unittest.TestCase): objects.PLAYER_IFACE, {'Metadata': '...', 'PlaybackStatus': '...'}, []) - def test_track_playback_ended_event_changes_playback_status_and_metadata(self): + 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.assertListEqual(self.mpris_object.Get.call_args_list, [ diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 403d05c7..6088a94b 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -59,7 +59,7 @@ class PlayerInterfaceTest(unittest.TestCase): result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Track', result) - def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): + def test_get_loop_status_is_playlist_when_looping_current_playlist(self): self.core.playback.repeat = True self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') @@ -126,19 +126,19 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_shuffle_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False self.core.playback.random = False - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) self.assertFalse(self.core.playback.random.get()) def test_set_shuffle_to_true_activates_random_mode(self): self.core.playback.random = False self.assertFalse(self.core.playback.random.get()) - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) self.assertTrue(self.core.playback.random.get()) def test_set_shuffle_to_false_deactivates_random_mode(self): self.core.playback.random = True self.assertTrue(self.core.playback.random.get()) - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) self.assertFalse(self.core.playback.random.get()) def test_get_metadata_has_trackid_even_when_no_current_track(self): @@ -152,8 +152,8 @@ class PlayerInterfaceTest(unittest.TestCase): (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals(result['mpris:trackid'], - '/com/mopidy/track/%d' % cpid) + self.assertEquals( + result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) @@ -233,7 +233,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) self.assertEquals(self.core.playback.volume.get(), 100) - def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): + def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) self.assertEquals(self.core.playback.volume.get(), 100) @@ -246,12 +246,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.current_playlist.append([Track(uri='a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) - result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_microseconds = self.mpris.Get( + objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 self.assertGreaterEqual(result_in_milliseconds, 10000) def test_get_position_when_no_current_track_should_be_zero(self): - result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_microseconds = self.mpris.Get( + objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 self.assertEquals(result_in_milliseconds, 0) @@ -285,7 +287,7 @@ class PlayerInterfaceTest(unittest.TestCase): result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) - def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): + def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() @@ -360,7 +362,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Next() self.assertEquals(self.core.playback.current_track.get().uri, 'a') - def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): + def test_next_when_playing_skips_to_next_track_and_keep_playing(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.assertEquals(self.core.playback.current_track.get().uri, 'a') @@ -388,7 +390,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.core.playback.current_track.get().uri, 'b') self.assertEquals(self.core.playback.state.get(), PAUSED) - def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): + def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.stop() @@ -407,7 +409,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Previous() self.assertEquals(self.core.playback.current_track.get().uri, 'b') - def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): + def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.next() @@ -425,7 +427,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Previous() self.assertEquals(self.core.playback.state.get(), STOPPED) - def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): + def test_previous_when_paused_skips_to_previous_track_and_pause(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.next() @@ -436,7 +438,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(self.core.playback.current_track.get().uri, 'a') self.assertEquals(self.core.playback.state.get(), PAUSED) - def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): + def test_previous_when_stopped_skips_to_previous_track_and_stops(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.core.playback.play() self.core.playback.next() @@ -638,8 +640,9 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek) self.assertGreaterEqual(after_seek, 0) - def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): - self.core.current_playlist.append([Track(uri='a', length=40000), + def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): + self.core.current_playlist.append([ + Track(uri='a', length=40000), Track(uri='b')]) self.core.playback.play() self.core.playback.seek(20000) @@ -671,14 +674,14 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = 'a' - position_to_set_in_milliseconds = 20000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 20000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, after_set_position) - self.assertLess(after_set_position, position_to_set_in_milliseconds) + self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) @@ -690,15 +693,16 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = '/com/mopidy/track/0' - position_to_set_in_milliseconds = 20000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 20000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) self.assertEquals(self.core.playback.state.get(), PLAYING) after_set_position = self.core.playback.time_position.get() - self.assertGreaterEqual(after_set_position, position_to_set_in_milliseconds) + self.assertGreaterEqual( + after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) @@ -713,17 +717,17 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = '/com/mopidy/track/0' - position_to_set_in_milliseconds = -1000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = -1000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.core.playback.state.get(), PLAYING) self.assertEquals(self.core.playback.current_track.get().uri, 'a') - def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): + def test_set_position_does_nothing_if_position_is_gt_track_length(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -736,17 +740,17 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = 'a' - position_to_set_in_milliseconds = 50000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 50000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) self.assertEquals(self.core.playback.state.get(), PLAYING) self.assertEquals(self.core.playback.current_track.get().uri, 'a') - def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): + def test_set_position_is_noop_if_track_id_isnt_current_track(self): self.core.current_playlist.append([Track(uri='a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -759,10 +763,10 @@ class PlayerInterfaceTest(unittest.TestCase): track_id = 'b' - position_to_set_in_milliseconds = 0 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 0 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) @@ -789,8 +793,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.current_playlist.tracks.get()[0].uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True @@ -802,8 +806,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.OpenUri('dummy:/test/uri') self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True @@ -818,8 +822,8 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.OpenUri('dummy:/test/uri') self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True @@ -833,5 +837,5 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.OpenUri('dummy:/test/uri') self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEquals( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/models_test.py b/tests/models_test.py index 779d1a4b..a3c9cc96 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -67,8 +67,8 @@ class ArtistTest(unittest.TestCase): mb_id = u'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, artist, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, artist, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Artist(foo='baz') @@ -168,8 +168,8 @@ class AlbumTest(unittest.TestCase): mb_id = u'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, album, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, album, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Album(foo='baz') @@ -237,9 +237,11 @@ class AlbumTest(unittest.TestCase): def test_eq(self): artists = [Artist()] - album1 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, + album1 = Album( + name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') - album2 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, + album2 = Album( + name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) @@ -281,12 +283,12 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) def test_ne(self): - album1 = Album(name=u'name1', uri=u'uri1', - artists=[Artist(name=u'name1')], num_tracks=1, - musicbrainz_id='id1') - album2 = Album(name=u'name2', uri=u'uri2', - artists=[Artist(name=u'name2')], num_tracks=2, - musicbrainz_id='id2') + album1 = Album( + name=u'name1', uri=u'uri1', artists=[Artist(name=u'name1')], + num_tracks=1, musicbrainz_id='id1') + album2 = Album( + name=u'name2', uri=u'uri2', artists=[Artist(name=u'name2')], + num_tracks=2, musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) @@ -359,8 +361,8 @@ class TrackTest(unittest.TestCase): mb_id = u'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, track, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, track, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Track(foo='baz') @@ -462,12 +464,12 @@ class TrackTest(unittest.TestCase): date = '1977-01-01' artists = [Artist()] album = Album() - track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100, - musicbrainz_id='id') - track2 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100, - musicbrainz_id='id') + track1 = Track( + uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + date=date, length=100, bitrate=100, musicbrainz_id='id') + track2 = Track( + uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + date=date, length=100, bitrate=100, musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -532,14 +534,14 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne(self): - track1 = Track(uri=u'uri1', name=u'name1', - artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date='1977-01-01', length=100, bitrate=100, - musicbrainz_id='id1') - track2 = Track(uri=u'uri2', name=u'name2', - artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date='1977-01-02', length=200, bitrate=200, - musicbrainz_id='id2') + track1 = Track( + uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], + album=Album(name=u'name1'), track_no=1, date='1977-01-01', + length=100, bitrate=100, musicbrainz_id='id1') + track2 = Track( + uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], + album=Album(name=u'name2'), track_no=2, date='1977-01-02', + length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -572,13 +574,14 @@ class PlaylistTest(unittest.TestCase): last_modified = datetime.datetime.now() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) - self.assertRaises(AttributeError, setattr, playlist, 'last_modified', - None) + self.assertRaises( + AttributeError, setattr, playlist, 'last_modified', None) def test_with_new_uri(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(uri=u'another uri') self.assertEqual(new_playlist.uri, u'another uri') @@ -589,7 +592,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(name=u'another name') self.assertEqual(new_playlist.uri, u'an uri') @@ -600,7 +604,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] new_playlist = playlist.copy(tracks=new_tracks) @@ -613,7 +618,8 @@ class PlaylistTest(unittest.TestCase): tracks = [Track()] last_modified = datetime.datetime.now() new_last_modified = last_modified + datetime.timedelta(1) - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, u'an uri') @@ -666,7 +672,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) - def test_eq_uri(self): + def test_eq_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=1) self.assertEqual(playlist1, playlist2) @@ -674,10 +680,10 @@ class PlaylistTest(unittest.TestCase): def test_eq(self): tracks = [Track()] - playlist1 = Playlist(uri=u'uri', name=u'name', tracks=tracks, - last_modified=1) - playlist2 = Playlist(uri=u'uri', name=u'name', tracks=tracks, - last_modified=1) + playlist1 = Playlist( + uri=u'uri', name=u'name', tracks=tracks, last_modified=1) + playlist2 = Playlist( + uri=u'uri', name=u'name', tracks=tracks, last_modified=1) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) @@ -705,17 +711,18 @@ class PlaylistTest(unittest.TestCase): self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) - def test_ne_uri(self): + def test_ne_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne(self): - playlist1 = Playlist(uri=u'uri1', name=u'name2', - tracks=[Track(uri=u'uri1')], last_modified=1) - playlist2 = Playlist(uri=u'uri2', name=u'name2', - tracks=[Track(uri=u'uri2')], last_modified=2) + playlist1 = Playlist( + uri=u'uri1', name=u'name2', tracks=[Track(uri=u'uri1')], + last_modified=1) + playlist2 = Playlist( + uri=u'uri2', name=u'name2', tracks=[Track(uri=u'uri2')], + last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) - diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 91e67e11..6af48bb5 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -134,8 +134,8 @@ class ScannerTest(unittest.TestCase): self.data = {} def scan(self, path): - scanner = Scanner(path_to_data_dir(path), - self.data_callback, self.error_callback) + scanner = Scanner( + path_to_data_dir(path), self.data_callback, self.error_callback) scanner.start() def check(self, name, key, value): @@ -160,8 +160,9 @@ class ScannerTest(unittest.TestCase): def test_uri_is_set(self): self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'uri', 'file://' - + path_to_data_dir('scanner/simple/song1.mp3')) + self.check( + 'scanner/simple/song1.mp3', 'uri', + 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) def test_duration_is_set(self): self.scan('scanner/simple') diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index f5aa0b1e..42c8b299 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -65,10 +65,12 @@ class DepsTest(unittest.TestCase): result = deps.gstreamer_info() self.assertEquals('GStreamer', result['name']) - self.assertEquals('.'.join(map(str, gst.get_gst_version())), result['version']) + self.assertEquals( + '.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertIn('Python wrapper: gst-python', result['other']) - self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) + self.assertIn( + '.'.join(map(str, gst.get_pygst_version())), result['other']) self.assertIn('Relevant elements:', result['other']) def test_pykka_info(self): diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index 25ae1940..c51957f1 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -17,20 +17,23 @@ class ConnectionTest(unittest.TestCase): def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) - network.Connection.__init__(self.mock, Mock(), {}, sock, - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), + sentinel.timeout) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) - network.Connection.__init__(self.mock, protocol, {}, Mock(), - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), + sentinel.timeout) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): - network.Connection.__init__(self.mock, Mock(), {}, Mock(), - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), + sentinel.timeout) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() @@ -50,8 +53,8 @@ class ConnectionTest(unittest.TestCase): self.assertEqual(sentinel.port, self.mock.port) def test_init_handles_ipv6_addr(self): - addr = (sentinel.host, sentinel.port, - sentinel.flowinfo, sentinel.scopeid) + addr = ( + sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid) protocol = Mock(spec=network.LineProtocol) protocol_kwargs = {} sock = Mock(spec=socket.SocketType) @@ -138,8 +141,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) - network.Connection.stop(self.mock, sentinel.reason, - level=sentinel.level) + network.Connection.stop( + self.mock, sentinel.reason, level=sentinel.level) network.logger.log.assert_called_once_with( sentinel.level, sentinel.reason) @@ -160,7 +163,8 @@ class ConnectionTest(unittest.TestCase): gobject.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.mock.recv_callback) self.assertEqual(sentinel.tag, self.mock.recv_id) @@ -213,7 +217,8 @@ class ConnectionTest(unittest.TestCase): gobject.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.mock.send_callback) self.assertEqual(sentinel.tag, self.mock.send_id) @@ -270,8 +275,8 @@ class ConnectionTest(unittest.TestCase): gobject.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) - gobject.timeout_add_seconds.assert_called_once_with(10, - self.mock.timeout_callback) + gobject.timeout_add_seconds.assert_called_once_with( + 10, self.mock.timeout_callback) self.assertEqual(sentinel.tag, self.mock.timeout_id) @patch.object(gobject, 'timeout_add_seconds', new=Mock()) @@ -359,24 +364,25 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, + gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): @@ -432,8 +438,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): @@ -443,8 +449,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): @@ -454,8 +460,9 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, + gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index 4ba62b8f..9a19e12e 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -103,8 +103,8 @@ class LineProtocolTest(unittest.TestCase): self.mock.parse_lines.return_value = ['line1', 'line2'] self.mock.decode.return_value = sentinel.decoded - network.LineProtocol.on_receive(self.mock, - {'received': 'line1\nline2\n'}) + network.LineProtocol.on_receive( + self.mock, {'received': 'line1\nline2\n'}) self.assertEqual(2, self.mock.on_line_received.call_count) def test_parse_lines_emtpy_buffer(self): diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py index 268b5dbd..6090077d 100644 --- a/tests/utils/network/server_test.py +++ b/tests/utils/network/server_test.py @@ -13,8 +13,8 @@ class ServerTest(unittest.TestCase): self.mock = Mock(spec=network.Server) def test_init_calls_create_server_socket(self): - network.Server.__init__(self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.create_server_socket.assert_called_once_with( sentinel.host, sentinel.port) @@ -23,8 +23,8 @@ class ServerTest(unittest.TestCase): sock.fileno.return_value = sentinel.fileno self.mock.create_server_socket.return_value = sock - network.Server.__init__(self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.register_server_socket.assert_called_once_with( sentinel.fileno) @@ -33,17 +33,18 @@ class ServerTest(unittest.TestCase): sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock - self.assertRaises(socket.error, network.Server.__init__, - self.mock, sentinel.host, sentinel.port, sentinel.protocol) + self.assertRaises( + socket.error, network.Server.__init__, self.mock, sentinel.host, + sentinel.port, sentinel.protocol) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it sock = Mock(spec=socket.SocketType) self.mock.create_server_socket.return_value = sock - network.Server.__init__(self.mock, sentinel.host, sentinel.port, - sentinel.protocol, max_connections=sentinel.max_connections, - timeout=sentinel.timeout) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol, + max_connections=sentinel.max_connections, timeout=sentinel.timeout) self.assertEqual(sentinel.protocol, self.mock.protocol) self.assertEqual(sentinel.max_connections, self.mock.max_connections) self.assertEqual(sentinel.timeout, self.mock.timeout) @@ -53,8 +54,8 @@ class ServerTest(unittest.TestCase): def test_create_server_socket_sets_up_listener(self, create_socket): sock = create_socket.return_value - network.Server.create_server_socket(self.mock, - sentinel.host, sentinel.port) + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with((sentinel.host, sentinel.port)) sock.listen.assert_called_once_with(any_int) @@ -62,30 +63,33 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'create_socket', new=Mock()) def test_create_server_socket_fails(self): network.create_socket.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_bind_fails(self): sock = network.create_socket.return_value sock.bind.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_listen_fails(self): sock = network.create_socket.return_value sock.listen.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(gobject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, - gobject.IO_IN, self.mock.handle_connection) + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_IN, self.mock.handle_connection) def test_handle_connection(self): self.mock.accept_connection.return_value = ( @@ -128,7 +132,8 @@ class ServerTest(unittest.TestCase): for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, '') - self.assertRaises(network.ShouldRetrySocketCall, + self.assertRaises( + network.ShouldRetrySocketCall, network.Server.accept_connection, self.mock) # FIXME decide if this should be allowed to propegate @@ -136,8 +141,8 @@ class ServerTest(unittest.TestCase): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock sock.accept.side_effect = socket.error - self.assertRaises(socket.error, - network.Server.accept_connection, self.mock) + self.assertRaises( + socket.error, network.Server.accept_connection, self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 @@ -149,7 +154,8 @@ class ServerTest(unittest.TestCase): self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) self.mock.number_of_connections.return_value = 9 - self.assertFalse(network.Server.maximum_connections_exceeded(self.mock)) + self.assertFalse( + network.Server.maximum_connections_exceeded(self.mock)) @patch('pykka.registry.ActorRegistry.get_by_class') def test_number_of_connections(self, get_by_class): @@ -168,20 +174,21 @@ class ServerTest(unittest.TestCase): self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) - network.Connection.assert_called_once_with(sentinel.protocol, {}, - sentinel.sock, sentinel.addr, sentinel.timeout) + network.Connection.assert_called_once_with( + sentinel.protocol, {}, sentinel.sock, sentinel.addr, + sentinel.timeout) def test_reject_connection(self): sock = Mock(spec=socket.SocketType) - network.Server.reject_connection(self.mock, sock, - (sentinel.host, sentinel.port)) + network.Server.reject_connection( + self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() def test_reject_connection_error(self): sock = Mock(spec=socket.SocketType) sock.close.side_effect = socket.error - network.Server.reject_connection(self.mock, sock, - (sentinel.host, sentinel.port)) + network.Server.reject_connection( + self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() diff --git a/tests/utils/network/utils_test.py b/tests/utils/network/utils_test.py index 1e11673e..f28aeb4b 100644 --- a/tests/utils/network/utils_test.py +++ b/tests/utils/network/utils_test.py @@ -42,15 +42,15 @@ class CreateSocketTest(unittest.TestCase): @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() - self.assertEqual(socket_mock.call_args[0], - (socket.AF_INET, socket.SOCK_STREAM)) + self.assertEqual( + socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) @patch('mopidy.utils.network.has_ipv6', True) @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() - self.assertEqual(socket_mock.call_args[0], - (socket.AF_INET6, socket.SOCK_STREAM)) + self.assertEqual( + socket_mock.call_args[0], (socket.AF_INET6, socket.SOCK_STREAM)) @unittest.SkipTest def test_ipv6_only_is_set(self): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index d782aa15..91951ac7 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -108,7 +108,7 @@ class UriToPathTest(unittest.TestCase): def test_unicode_in_uri(self): if sys.platform == 'win32': - result = path.uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'C:/æøå') else: result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') @@ -125,11 +125,9 @@ class SplitPathTest(unittest.TestCase): def test_folders(self): self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) - def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) - def test_initial_slash_is_ignored(self): - self.assertEqual(['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) + self.assertEqual( + ['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): self.assertEqual([], path.split_path('/')) @@ -145,17 +143,20 @@ class ExpandPathTest(unittest.TestCase): self.assertEqual('/tmp/foo', path.expand_path('/tmp/foo')) def test_home_dir_expansion(self): - self.assertEqual(os.path.expanduser('~/foo'), path.expand_path('~/foo')) + self.assertEqual( + os.path.expanduser('~/foo'), path.expand_path('~/foo')) def test_abspath(self): self.assertEqual(os.path.abspath('./foo'), path.expand_path('./foo')) def test_xdg_subsititution(self): - self.assertEqual(glib.get_user_data_dir() + '/foo', + self.assertEqual( + glib.get_user_data_dir() + '/foo', path.expand_path('$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): - self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', + self.assertEqual( + '/tmp/$XDG_INVALID_DIR/foo', path.expand_path('/tmp/$XDG_INVALID_DIR/foo')) @@ -177,8 +178,8 @@ class FindFilesTest(unittest.TestCase): def test_names_are_unicode(self): is_unicode = lambda f: isinstance(f, unicode) for name in self.find(''): - self.assert_(is_unicode(name), - '%s is not unicode object' % repr(name)) + self.assert_( + is_unicode(name), '%s is not unicode object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index cf476c24..bbeda20c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -19,41 +19,47 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual(result, {}) def test_unknown_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) - self.assertEqual(result['MPD_SERVER_HOSTNMAE'], + result = setting_utils.validate_settings( + self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) + self.assertEqual( + result['MPD_SERVER_HOSTNMAE'], u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SERVER_HOSTNAME': '127.0.0.1'}) - self.assertEqual(result['SERVER_HOSTNAME'], + result = setting_utils.validate_settings( + self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) + self.assertEqual( + result['SERVER_HOSTNAME'], u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') def test_unneeded_settings_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) - self.assertEqual(result['SPOTIFY_LIB_APPKEY'], + result = setting_utils.validate_settings( + self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) + self.assertEqual( + result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') def test_deprecated_setting_value_returns_error(self): - result = setting_utils.validate_settings(self.defaults, + result = setting_utils.validate_settings( + self.defaults, {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) - self.assertEqual(result['BACKENDS'], - u'Deprecated setting value. ' + - '"mopidy.backends.despotify.DespotifyBackend" is no longer ' + - 'available.') + self.assertEqual( + result['BACKENDS'], + u'Deprecated setting value. ' + u'"mopidy.backends.despotify.DespotifyBackend" is no longer ' + u'available.') def test_unavailable_bitrate_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SPOTIFY_BITRATE': 50}) - self.assertEqual(result['SPOTIFY_BITRATE'], - u'Unavailable Spotify bitrate. ' + + result = setting_utils.validate_settings( + self.defaults, {'SPOTIFY_BITRATE': 50}) + self.assertEqual( + result['SPOTIFY_BITRATE'], + u'Unavailable Spotify bitrate. ' u'Available bitrates are 96, 160, and 320.') def test_two_errors_are_both_reported(self): - result = setting_utils.validate_settings(self.defaults, - {'FOO': '', 'BAR': ''}) + result = setting_utils.validate_settings( + self.defaults, {'FOO': '', 'BAR': ''}) self.assertEqual(len(result), 2) def test_masks_value_if_secret(self): @@ -61,11 +67,13 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual(u'********', secret) def test_does_not_mask_value_if_not_secret(self): - not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', 'foo') + not_secret = setting_utils.mask_value_if_secret( + 'SPOTIFY_USERNAME', 'foo') self.assertEqual('foo', not_secret) def test_does_not_mask_value_if_none(self): - not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', None) + not_secret = setting_utils.mask_value_if_secret( + 'SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) @@ -80,7 +88,7 @@ class SettingsProxyTest(unittest.TestCase): def test_getattr_raises_error_on_missing_setting(self): try: - _ = self.settings.TEST + self.settings.TEST self.fail(u'Should raise exception') except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) @@ -88,7 +96,7 @@ class SettingsProxyTest(unittest.TestCase): def test_getattr_raises_error_on_empty_setting(self): self.settings.TEST = u'' try: - _ = self.settings.TEST + self.settings.TEST self.fail(u'Should raise exception') except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) @@ -207,15 +215,19 @@ class FormatSettingListTest(unittest.TestCase): def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) result = setting_utils.format_settings_list(self.settings) - self.assertIn("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) + self.assertIn( + "FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) def test_long_values_are_pretty_printed(self): - self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', + self.settings.FRONTEND = ( + u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend') result = setting_utils.format_settings_list(self.settings) - self.assert_("""FRONTEND: - (u'mopidy.frontends.mpd.MpdFrontend', - u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) + self.assertIn( + "FRONTEND: \n" + " (u'mopidy.frontends.mpd.MpdFrontend',\n" + " u'mopidy.frontends.lastfm.LastfmFrontend')", + result) class DidYouMeanTest(unittest.TestCase): From e65a612ac8972bedd811b6756f5535c6ce8cca0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:48:58 +0200 Subject: [PATCH 044/323] Fix all flake8 warnings in tools (#211) --- tools/debug-proxy.py | 11 ++- tools/idle.py | 176 +++++++++++++++++++++---------------------- 2 files changed, 95 insertions(+), 92 deletions(-) diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py index 2f54ea36..4fb39b5b 100755 --- a/tools/debug-proxy.py +++ b/tools/debug-proxy.py @@ -6,7 +6,7 @@ import sys from gevent import select, server, socket -COLORS = ['\033[1;%dm' % (30+i) for i in range(8)] +COLORS = ['\033[1;%dm' % (30 + i) for i in range(8)] BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS RESET = "\033[0m" BOLD = "\033[1m" @@ -53,7 +53,8 @@ def loop(client, address, reference, actual): # Consume banners from backends responses = dict() - disconnected = read([reference, actual], responses, find_response_end_token) + disconnected = read( + [reference, actual], responses, find_response_end_token) diff(address, '', responses[reference], responses[actual]) # We lost a backend, might as well give up. @@ -78,13 +79,15 @@ def loop(client, address, reference, actual): actual.sendall(responses[client]) # Get the entire resonse from both backends. - disconnected = read([reference, actual], responses, find_response_end_token) + disconnected = read( + [reference, actual], responses, find_response_end_token) # Send the client the complete reference response client.sendall(responses[reference]) # Compare our responses - diff(address, responses[client], responses[reference], responses[actual]) + diff(address, + responses[client], responses[reference], responses[actual]) # Give up if we lost a backend. if disconnected: diff --git a/tools/idle.py b/tools/idle.py index aa56dce2..fc9cb021 100644 --- a/tools/idle.py +++ b/tools/idle.py @@ -17,98 +17,98 @@ data = {'id': None, 'id2': None, 'url': url, 'artist': artist} # Commands to run before test requests to coerce MPD into right state setup_requests = [ - 'clear', - 'add "%(url)s"', - 'add "%(url)s"', - 'add "%(url)s"', - 'play', -# 'pause', # Uncomment to test paused idle behaviour -# 'stop', # Uncomment to test stopped idle behaviour + 'clear', + 'add "%(url)s"', + 'add "%(url)s"', + 'add "%(url)s"', + 'play', + #'pause', # Uncomment to test paused idle behaviour + #'stop', # Uncomment to test stopped idle behaviour ] # List of commands to test for idle behaviour. Ordering of list is important in # order to keep MPD state as intended. Commands that are obviously # informational only or "harmfull" have been excluded. test_requests = [ - 'add "%(url)s"', - 'addid "%(url)s" "1"', - 'clear', -# 'clearerror', -# 'close', -# 'commands', - 'consume "1"', - 'consume "0"', -# 'count', - 'crossfade "1"', - 'crossfade "0"', -# 'currentsong', -# 'delete "1:2"', - 'delete "0"', - 'deleteid "%(id)s"', - 'disableoutput "0"', - 'enableoutput "0"', -# 'find', -# 'findadd "artist" "%(artist)s"', -# 'idle', -# 'kill', -# 'list', -# 'listall', -# 'listallinfo', -# 'listplaylist', -# 'listplaylistinfo', -# 'listplaylists', -# 'lsinfo', - 'move "0:1" "2"', - 'move "0" "1"', - 'moveid "%(id)s" "1"', - 'next', -# 'notcommands', -# 'outputs', -# 'password', - 'pause', -# 'ping', - 'play', - 'playid "%(id)s"', -# 'playlist', - 'playlistadd "foo" "%(url)s"', - 'playlistclear "foo"', - 'playlistadd "foo" "%(url)s"', - 'playlistdelete "foo" "0"', -# 'playlistfind', -# 'playlistid', -# 'playlistinfo', - 'playlistadd "foo" "%(url)s"', - 'playlistadd "foo" "%(url)s"', - 'playlistmove "foo" "0" "1"', -# 'playlistsearch', -# 'plchanges', -# 'plchangesposid', - 'previous', - 'random "1"', - 'random "0"', - 'rm "bar"', - 'rename "foo" "bar"', - 'repeat "0"', - 'rm "bar"', - 'save "bar"', - 'load "bar"', -# 'search', - 'seek "1" "10"', - 'seekid "%(id)s" "10"', -# 'setvol "10"', - 'shuffle', - 'shuffle "0:1"', - 'single "1"', - 'single "0"', -# 'stats', -# 'status', - 'stop', - 'swap "1" "2"', - 'swapid "%(id)s" "%(id2)s"', -# 'tagtypes', -# 'update', -# 'urlhandlers', -# 'volume', + 'add "%(url)s"', + 'addid "%(url)s" "1"', + 'clear', + #'clearerror', + #'close', + #'commands', + 'consume "1"', + 'consume "0"', + # 'count', + 'crossfade "1"', + 'crossfade "0"', + #'currentsong', + #'delete "1:2"', + 'delete "0"', + 'deleteid "%(id)s"', + 'disableoutput "0"', + 'enableoutput "0"', + #'find', + #'findadd "artist" "%(artist)s"', + #'idle', + #'kill', + #'list', + #'listall', + #'listallinfo', + #'listplaylist', + #'listplaylistinfo', + #'listplaylists', + #'lsinfo', + 'move "0:1" "2"', + 'move "0" "1"', + 'moveid "%(id)s" "1"', + 'next', + #'notcommands', + #'outputs', + #'password', + 'pause', + #'ping', + 'play', + 'playid "%(id)s"', + #'playlist', + 'playlistadd "foo" "%(url)s"', + 'playlistclear "foo"', + 'playlistadd "foo" "%(url)s"', + 'playlistdelete "foo" "0"', + #'playlistfind', + #'playlistid', + #'playlistinfo', + 'playlistadd "foo" "%(url)s"', + 'playlistadd "foo" "%(url)s"', + 'playlistmove "foo" "0" "1"', + #'playlistsearch', + #'plchanges', + #'plchangesposid', + 'previous', + 'random "1"', + 'random "0"', + 'rm "bar"', + 'rename "foo" "bar"', + 'repeat "0"', + 'rm "bar"', + 'save "bar"', + 'load "bar"', + #'search', + 'seek "1" "10"', + 'seekid "%(id)s" "10"', + #'setvol "10"', + 'shuffle', + 'shuffle "0:1"', + 'single "1"', + 'single "0"', + #'stats', + #'status', + 'stop', + 'swap "1" "2"', + 'swapid "%(id)s" "%(id2)s"', + #'tagtypes', + #'update', + #'urlhandlers', + #'volume', ] @@ -116,8 +116,8 @@ def create_socketfile(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.settimeout(0.5) - fd = sock.makefile('rw', 1) # 1 = line buffered - fd.readline() # Read banner + fd = sock.makefile('rw', 1) # 1 = line buffered + fd.readline() # Read banner return fd From 357a08b30e12ae5c60c6d320bc544845ec0f2832 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:49:53 +0200 Subject: [PATCH 045/323] Fix all flake8 warnings in setup.py (#211) --- setup.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ae6cc699..99fb7f49 100644 --- a/setup.py +++ b/setup.py @@ -9,11 +9,13 @@ import os import re import sys + def get_version(): init_py = open('mopidy/__init__.py').read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) return metadata['version'] + class osx_install_data(install_data): # On MacOS, the platform-specific lib dir is # /System/Library/Framework/Python/.../ which is wrong. Python 2.5 supplied @@ -28,11 +30,13 @@ class osx_install_data(install_data): self.set_undefined_options('install', ('install_lib', 'install_dir')) install_data.finalize_options(self) + if sys.platform == "darwin": cmdclasses = {'install_data': osx_install_data} else: cmdclasses = {'install_data': install_data} + def fullsplit(path, result=None): """ Split a pathname into components (the opposite of os.path.join) in a @@ -47,6 +51,7 @@ def fullsplit(path, result=None): return result return fullsplit(head, [tail] + result) + # Tell distutils to put the data_files in platform-specific installation # locations. See here for an explanation: # http://groups.google.com/group/comp.lang.python/browse_thread/ @@ -54,6 +59,7 @@ def fullsplit(path, result=None): for scheme in INSTALL_SCHEMES.values(): scheme['data'] = scheme['purelib'] + # Compile the list of packages available, because distutils doesn't have # an easy way to do this. packages, data_files = [], [] @@ -62,6 +68,7 @@ if root_dir != '': os.chdir(root_dir) project_dir = 'mopidy' + for dirpath, dirnames, filenames in os.walk(project_dir): # Ignore dirnames that start with '.' for i, dirname in enumerate(dirnames): @@ -70,8 +77,9 @@ for dirpath, dirnames, filenames in os.walk(project_dir): if '__init__.py' in filenames: packages.append('.'.join(fullsplit(dirpath))) elif filenames: - data_files.append([dirpath, - [os.path.join(dirpath, f) for f in filenames]]) + data_files.append([ + dirpath, [os.path.join(dirpath, f) for f in filenames]]) + setup( name='Mopidy', From f3d7f8f65f315dc5ffbe6b0c44a7cd0dfea89212 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 14:52:01 +0200 Subject: [PATCH 046/323] Fix all flake8 warnings in docs (#211) --- docs/conf.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8129adec..e37f5713 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,7 +3,8 @@ # Mopidy documentation build configuration file, created by # sphinx-quickstart on Fri Feb 5 22:19:08 2010. # -# This file is execfile()d with the current directory set to its containing dir. +# This file is execfile()d with the current directory set to its containing +# dir. # # Note that not all possible configuration values are present in this # autogenerated file. @@ -12,9 +13,9 @@ # serve to show the default. import os -import re import sys + class Mock(object): def __init__(self, *args, **kwargs): pass @@ -34,6 +35,7 @@ class Mock(object): else: return Mock() + MOCK_MODULES = [ 'dbus', 'dbus.mainloop', @@ -63,12 +65,16 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) # the string True. on_rtd = os.environ.get('READTHEDOCS', None) == 'True' -# -- General configuration ----------------------------------------------------- +# -- General configuration ---------------------------------------------------- -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.graphviz', - 'sphinx.ext.extlinks', 'sphinx.ext.viewcode'] +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.extlinks', + 'sphinx.ext.graphviz', + 'sphinx.ext.viewcode', +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -114,7 +120,8 @@ version = '.'.join(release.split('.')[:2]) # for source files. exclude_trees = ['_build'] -# The reST default role (used for this markup: `text`) to use for all documents. +# The reST default role (used for this markup: `text`) to use for all +# documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. @@ -135,7 +142,7 @@ pygments_style = 'sphinx' modindex_common_prefix = ['mopidy.'] -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. @@ -210,7 +217,7 @@ html_static_path = ['_static'] htmlhelp_basename = 'Mopidydoc' -# -- Options for LaTeX output -------------------------------------------------- +# -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). #latex_paper_size = 'letter' @@ -218,11 +225,16 @@ htmlhelp_basename = 'Mopidydoc' # The font size ('10pt', '11pt' or '12pt'). #latex_font_size = '10pt' -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). +# Grouping the document tree into LaTeX files. List of tuples (source start +# file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'Mopidy.tex', u'Mopidy Documentation', - u'Stein Magnus Jodal', 'manual'), + ( + 'index', + 'Mopidy.tex', + u'Mopidy Documentation', + u'Stein Magnus Jodal', + 'manual' + ), ] # The name of an image file (relative to this directory) to place at the top of From f69148c57224e46c3e7a23e366a652a8f195318b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:24:47 +0200 Subject: [PATCH 047/323] Move loading of MPD protocol modules into a function (#211) --- mopidy/frontends/mpd/dispatcher.py | 21 ++++++++------------- mopidy/frontends/mpd/protocol/__init__.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 24db6a7a..d7ba8cdf 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -4,19 +4,13 @@ import re from pykka import ActorDeadError from mopidy import settings -from mopidy.frontends.mpd import exceptions -from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers -# Do not remove the following import. The protocol modules must be imported to -# get them registered as request handlers. -# pylint: disable = W0611 -from mopidy.frontends.mpd.protocol import ( - audio_output, command_list, connection, current_playlist, empty, music_db, - playback, reflection, status, stickers, stored_playlists) -# pylint: enable = W0611 +from mopidy.frontends.mpd import exceptions, protocol from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') +protocol.load_protocol_modules() + class MpdDispatcher(object): """ @@ -92,7 +86,7 @@ class MpdDispatcher(object): else: command_name = request.split(' ')[0] command_names_not_requiring_auth = [ - command.name for command in mpd_commands + command.name for command in protocol.mpd_commands if not command.auth_required] if command_name in command_names_not_requiring_auth: return self._call_next_filter(request, response, filter_chain) @@ -172,12 +166,13 @@ class MpdDispatcher(object): return handler(self.context, **kwargs) def _find_handler(self, request): - for pattern in request_handlers: + for pattern in protocol.request_handlers: matches = re.match(pattern, request) if matches is not None: - return (request_handlers[pattern], matches.groupdict()) + return ( + protocol.request_handlers[pattern], matches.groupdict()) command_name = request.split(' ')[0] - if command_name in [command.name for command in mpd_commands]: + if command_name in [command.name for command in protocol.mpd_commands]: raise exceptions.MpdArgError( u'incorrect arguments', command=command_name) raise exceptions.MpdUnknownCommand(command=command_name) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 590a8ef4..66c8a84a 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -24,9 +24,10 @@ VERSION = u'0.16.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) -#: List of all available commands, represented as :class:`MpdCommand` objects. +#: Set of all available commands, represented as :class:`MpdCommand` objects. mpd_commands = set() +#: Map between request matchers and request handler functions. request_handlers = {} @@ -61,3 +62,15 @@ def handle_request(pattern, auth_required=True): pattern, func.__doc__ or '') return func return decorator + + +def load_protocol_modules(): + """ + The protocol modules must be imported to get them registered in + :attr:`request_handlers` and :attr:`mpd_commands`. + """ + # pylint: disable = W0611 + from . import ( # noqa + audio_output, command_list, connection, current_playlist, empty, + music_db, playback, reflection, status, stickers, stored_playlists) + # pylint: enable = W0611 From def361578733d9f26c64d0bf500dd3e15f73f263 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:33:26 +0200 Subject: [PATCH 048/323] Move registration of audio mixers into a function (#211) --- mopidy/audio/__init__.py | 3 ++- mopidy/audio/mixers/__init__.py | 10 ++++++++++ mopidy/audio/mixers/auto.py | 5 ----- mopidy/audio/mixers/fake.py | 4 ---- mopidy/audio/mixers/nad.py | 4 ---- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index a342799b..4a0b0000 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -10,12 +10,13 @@ from pykka.actor import ThreadingActor from mopidy import settings, utils from mopidy.utils import process -# Trigger install of gst mixer plugins from . import mixers from .listener import AudioListener logger = logging.getLogger('mopidy.audio') +mixers.register_mixers() + class Audio(ThreadingActor): """ diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index 08ecda0d..26faff02 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -42,3 +42,13 @@ def create_track(label, initial_volume, min_volume, max_volume, from .auto import AutoAudioMixer from .fake import FakeMixer from .nad import NadMixer + + +def register_mixer(mixer_class): + gobject.type_register(mixer_class) + gst.element_register( + mixer_class, mixer_class.__name__.lower(), gst.RANK_MARGINAL) + + +def register_mixers(): + map(register_mixer, [AutoAudioMixer, FakeMixer, NadMixer]) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index a4bd8bdb..45806040 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -1,6 +1,5 @@ import pygst pygst.require('0.10') -import gobject import gst import logging @@ -67,7 +66,3 @@ class AutoAudioMixer(gst.Bin): if track.flags & flags: return True return False - - -gobject.type_register(AutoAudioMixer) -gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 0e397e55..e0f1ae1f 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -42,7 +42,3 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): def set_record(self, track, record): pass - - -gobject.type_register(FakeMixer) -gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 39a7b25e..df8c3ec9 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -76,10 +76,6 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): ).proxy() -gobject.type_register(NadMixer) -gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL) - - class NadTalker(ThreadingActor): """ Independent thread which does the communication with the NAD amplifier From 0c3c9a9cce76fd578fe56a2516eb2841662bc7e3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:37:41 +0200 Subject: [PATCH 049/323] Fold base backend into a single file This removes three unused imports, which was only present to move the providers to the correct location in the module tree. (related to #211) --- mopidy/backends/base.py | 221 +++++++++++++++++++++++ mopidy/backends/base/__init__.py | 26 --- mopidy/backends/base/library.py | 42 ----- mopidy/backends/base/playback.py | 77 -------- mopidy/backends/base/stored_playlists.py | 75 -------- 5 files changed, 221 insertions(+), 220 deletions(-) create mode 100644 mopidy/backends/base.py delete mode 100644 mopidy/backends/base/__init__.py delete mode 100644 mopidy/backends/base/library.py delete mode 100644 mopidy/backends/base/playback.py delete mode 100644 mopidy/backends/base/stored_playlists.py diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py new file mode 100644 index 00000000..e8a7decd --- /dev/null +++ b/mopidy/backends/base.py @@ -0,0 +1,221 @@ +import copy + + +class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + + #: The library provider. An instance of + # :class:`mopidy.backends.base.BaseLibraryProvider`. + library = None + + #: The playback provider. An instance of + #: :class:`mopidy.backends.base.BasePlaybackProvider`. + playback = None + + #: The stored playlists provider. An instance of + #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. + stored_playlists = None + + #: List of URI schemes this backend can handle. + uri_schemes = [] + + +class BaseLibraryProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + + def find_exact(self, **query): + """ + See :meth:`mopidy.backends.base.LibraryController.find_exact`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.backends.base.LibraryController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self, uri=None): + """ + See :meth:`mopidy.backends.base.LibraryController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def search(self, **query): + """ + See :meth:`mopidy.backends.base.LibraryController.search`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + +class BasePlaybackProvider(object): + """ + :param backend: the backend + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, audio, backend): + self.audio = audio + self.backend = backend + + def pause(self): + """ + Pause playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.pause_playback().get() + + def play(self, track): + """ + Play given track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.prepare_change() + self.audio.set_uri(track.uri).get() + return self.audio.start_playback().get() + + def resume(self): + """ + Resume playback at the same time position playback was paused. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.start_playback().get() + + def seek(self, time_position): + """ + Seek to a given time position. + + *MAY be reimplemented by subclass.* + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.set_position(time_position).get() + + def stop(self): + """ + Stop playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.stop_playback().get() + + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.audio.get_position().get() + + +class BaseStoredPlaylistsProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return copy.copy(self._playlists) + + @playlists.setter # noqa + def playlists(self, playlists): + self._playlists = playlists + + def create(self, name): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def delete(self, playlist): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def rename(self, playlist, new_name): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def save(self, playlist): + """ + See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py deleted file mode 100644 index c27acae2..00000000 --- a/mopidy/backends/base/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from .library import BaseLibraryProvider -from .playback import BasePlaybackProvider -from .stored_playlists import BaseStoredPlaylistsProvider - - -class Backend(object): - #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. - #: - #: Should be passed to the backend constructor as the kwarg ``audio``, - #: which will then set this field. - audio = None - - #: The library provider. An instance of - # :class:`mopidy.backends.base.BaseLibraryProvider`. - library = None - - #: The playback provider. An instance of - #: :class:`mopidy.backends.base.BasePlaybackProvider`. - playback = None - - #: The stored playlists provider. An instance of - #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. - stored_playlists = None - - #: List of URI schemes this backend can handle. - uri_schemes = [] diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py deleted file mode 100644 index 837eef49..00000000 --- a/mopidy/backends/base/library.py +++ /dev/null @@ -1,42 +0,0 @@ -class BaseLibraryProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - - def find_exact(self, **query): - """ - See :meth:`mopidy.backends.base.LibraryController.find_exact`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.backends.base.LibraryController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self, uri=None): - """ - See :meth:`mopidy.backends.base.LibraryController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def search(self, **query): - """ - See :meth:`mopidy.backends.base.LibraryController.search`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py deleted file mode 100644 index b21c30dc..00000000 --- a/mopidy/backends/base/playback.py +++ /dev/null @@ -1,77 +0,0 @@ -class BasePlaybackProvider(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, audio, backend): - self.audio = audio - self.backend = backend - - def pause(self): - """ - Pause playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.pause_playback().get() - - def play(self, track): - """ - Play given track. - - *MAY be reimplemented by subclass.* - - :param track: the track to play - :type track: :class:`mopidy.models.Track` - :rtype: :class:`True` if successful, else :class:`False` - """ - self.audio.prepare_change() - self.audio.set_uri(track.uri).get() - return self.audio.start_playback().get() - - def resume(self): - """ - Resume playback at the same time position playback was paused. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.start_playback().get() - - def seek(self, time_position): - """ - Seek to a given time position. - - *MAY be reimplemented by subclass.* - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.set_position(time_position).get() - - def stop(self): - """ - Stop playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.audio.stop_playback().get() - - def get_time_position(self): - """ - Get the current time position in milliseconds. - - *MAY be reimplemented by subclass.* - - :rtype: int - """ - return self.audio.get_position().get() diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py deleted file mode 100644 index d808798d..00000000 --- a/mopidy/backends/base/stored_playlists.py +++ /dev/null @@ -1,75 +0,0 @@ -from copy import copy - - -class BaseStoredPlaylistsProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - self._playlists = [] - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return copy(self._playlists) - - @playlists.setter # noqa - def playlists(self, playlists): - self._playlists = playlists - - def create(self, name): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def delete(self, playlist): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def rename(self, playlist, new_name): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def save(self, playlist): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError From 3351d0e0d502cbf9d5530022f5430fc4aa1d2e66 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:40:27 +0200 Subject: [PATCH 050/323] Move dummy backend out of its own dir --- mopidy/backends/{dummy/__init__.py => dummy.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/backends/{dummy/__init__.py => dummy.py} (100%) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy.py similarity index 100% rename from mopidy/backends/dummy/__init__.py rename to mopidy/backends/dummy.py From 7c0d724df8b6bf8b5d7cf177705948262310dfef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:42:41 +0200 Subject: [PATCH 051/323] Make flake8 ignore imports that flattens the module tree (#211) --- mopidy/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 28274fe3..7fecfd79 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController From e22175ca98fd495b7915d67657d64d472f64f98c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:49:10 +0200 Subject: [PATCH 052/323] Move Audio actor from __init__.py to actor.py --- mopidy/audio/__init__.py | 382 +-------------------------------------- mopidy/audio/actor.py | 381 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 380 deletions(-) create mode 100644 mopidy/audio/actor.py diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 4a0b0000..ba76bd84 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -1,381 +1,3 @@ -import pygst -pygst.require('0.10') -import gst -import gobject - -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings, utils -from mopidy.utils import process - -from . import mixers +# flake8: noqa +from .actor import Audio from .listener import AudioListener - -logger = logging.getLogger('mopidy.audio') - -mixers.register_mixers() - - -class Audio(ThreadingActor): - """ - Audio output through `GStreamer `_. - - **Settings:** - - - :attr:`mopidy.settings.OUTPUT` - - :attr:`mopidy.settings.MIXER` - - :attr:`mopidy.settings.MIXER_TRACK` - - """ - - def __init__(self): - super(Audio, self).__init__() - - self._playbin = None - self._mixer = None - self._mixer_track = None - self._software_mixing = False - - self._message_processor_set_up = False - - def on_start(self): - try: - self._setup_playbin() - self._setup_output() - self._setup_mixer() - self._setup_message_processor() - except gobject.GError as ex: - logger.exception(ex) - process.exit_process() - - def on_stop(self): - self._teardown_message_processor() - self._teardown_mixer() - self._teardown_playbin() - - def _setup_playbin(self): - self._playbin = gst.element_factory_make('playbin2') - - fakesink = gst.element_factory_make('fakesink') - self._playbin.set_property('video-sink', fakesink) - - def _teardown_playbin(self): - self._playbin.set_state(gst.STATE_NULL) - - def _setup_output(self): - try: - output = gst.parse_bin_from_description( - settings.OUTPUT, ghost_unconnected_pads=True) - self._playbin.set_property('audio-sink', output) - logger.info('Output set to %s', settings.OUTPUT) - except gobject.GError as ex: - logger.error( - 'Failed to create output "%s": %s', settings.OUTPUT, ex) - process.exit_process() - - def _setup_mixer(self): - if not settings.MIXER: - logger.info('Not setting up mixer.') - return - - if settings.MIXER == 'software': - self._software_mixing = True - logger.info('Mixer set to software mixing.') - return - - try: - mixerbin = gst.parse_bin_from_description( - settings.MIXER, ghost_unconnected_pads=False) - except gobject.GError as ex: - logger.warning( - 'Failed to create mixer "%s": %s', settings.MIXER, ex) - return - - # We assume that the bin will contain a single mixer. - mixer = mixerbin.get_by_interface('GstMixer') - if not mixer: - logger.warning('Did not find any mixers in %r', settings.MIXER) - return - - if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Setting mixer %r to READY failed.', settings.MIXER) - return - - track = self._select_mixer_track(mixer, settings.MIXER_TRACK) - if not track: - logger.warning('Could not find usable mixer track.') - return - - self._mixer = mixer - self._mixer_track = track - logger.info('Mixer set to %s using track called %s', - mixer.get_factory().get_name(), track.label) - - def _select_mixer_track(self, mixer, track_label): - # Look for track with label == MIXER_TRACK, otherwise fallback to - # master track which is also an output. - for track in mixer.list_tracks(): - if track_label: - if track.label == track_label: - return track - elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT): - return track - - def _teardown_mixer(self): - if self._mixer is not None: - self._mixer.set_state(gst.STATE_NULL) - - def _setup_message_processor(self): - bus = self._playbin.get_bus() - bus.add_signal_watch() - bus.connect('message', self._on_message) - self._message_processor_set_up = True - - def _teardown_message_processor(self): - if self._message_processor_set_up: - bus = self._playbin.get_bus() - bus.remove_signal_watch() - - def _on_message(self, bus, message): - if message.type == gst.MESSAGE_EOS: - self._trigger_reached_end_of_stream_event() - elif message.type == gst.MESSAGE_ERROR: - error, debug = message.parse_error() - logger.error(u'%s %s', error, debug) - self.stop_playback() - elif message.type == gst.MESSAGE_WARNING: - error, debug = message.parse_warning() - logger.warning(u'%s %s', error, debug) - - def _trigger_reached_end_of_stream_event(self): - logger.debug(u'Triggering reached end of stream event') - AudioListener.send('reached_end_of_stream') - - def set_uri(self, uri): - """ - Set URI of audio to be played. - - You *MUST* call :meth:`prepare_change` before calling this method. - - :param uri: the URI to play - :type uri: string - """ - self._playbin.set_property('uri', uri) - - def emit_data(self, capabilities, data): - """ - Call this to deliver raw audio data to be played. - - Note that the uri must be set to ``appsrc://`` for this to work. - - :param capabilities: a GStreamer capabilities string - :type capabilities: string - :param data: raw audio data to be played - """ - caps = gst.caps_from_string(capabilities) - buffer_ = gst.Buffer(buffer(data)) - buffer_.set_caps(caps) - - source = self._playbin.get_property('source') - source.set_property('caps', caps) - source.emit('push-buffer', buffer_) - - def emit_end_of_stream(self): - """ - Put an end-of-stream token on the playbin. This is typically used in - combination with :meth:`emit_data`. - - We will get a GStreamer message when the stream playback reaches the - token, and can then do any end-of-stream related tasks. - """ - self._playbin.get_property('source').emit('end-of-stream') - - def get_position(self): - """ - Get position in milliseconds. - - :rtype: int - """ - if self._playbin.get_state()[1] == gst.STATE_NULL: - return 0 - try: - position = self._playbin.query_position(gst.FORMAT_TIME)[0] - return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) - return 0 - - def set_position(self, position): - """ - Set position in milliseconds. - - :param position: the position in milliseconds - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._playbin.get_state() # block until state changes are done - handeled = self._playbin.seek_simple( - gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, - position * gst.MSECOND) - self._playbin.get_state() # block until seek is done - return handeled - - def start_playback(self): - """ - Notify GStreamer that it should start playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PLAYING) - - def pause_playback(self): - """ - Notify GStreamer that it should pause playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PAUSED) - - def prepare_change(self): - """ - Notify GStreamer that we are about to change state of playback. - - This function *MUST* be called before changing URIs or doing - changes like updating data that is being pushed. The reason for this - is that GStreamer will reset all its state when it changes to - :attr:`gst.STATE_READY`. - """ - return self._set_state(gst.STATE_READY) - - def stop_playback(self): - """ - Notify GStreamer that is should stop playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_NULL) - - def _set_state(self, state): - """ - Internal method for setting the raw GStreamer state. - - .. digraph:: gst_state_transitions - - graph [rankdir="LR"]; - node [fontsize=10]; - - "NULL" -> "READY" - "PAUSED" -> "PLAYING" - "PAUSED" -> "READY" - "PLAYING" -> "PAUSED" - "READY" -> "NULL" - "READY" -> "PAUSED" - - :param state: State to set playbin to. One of: `gst.STATE_NULL`, - `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. - :type state: :class:`gst.State` - :rtype: :class:`True` if successfull, else :class:`False` - """ - result = self._playbin.set_state(state) - if result == gst.STATE_CHANGE_FAILURE: - logger.warning( - 'Setting GStreamer state to %s: failed', state.value_name) - return False - elif result == gst.STATE_CHANGE_ASYNC: - logger.debug( - 'Setting GStreamer state to %s: async', state.value_name) - return True - else: - logger.debug( - 'Setting GStreamer state to %s: OK', state.value_name) - return True - - def get_volume(self): - """ - Get volume level of the installed mixer. - - Example values: - - 0: - Muted. - 100: - Max volume for given system. - :class:`None`: - No mixer present, so the volume is unknown. - - :rtype: int in range [0..100] or :class:`None` - """ - if self._software_mixing: - return round(self._playbin.get_property('volume') * 100) - - if self._mixer is None: - return None - - volumes = self._mixer.get_volume(self._mixer_track) - avg_volume = float(sum(volumes)) / len(volumes) - - new_scale = (0, 100) - old_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - return utils.rescale(avg_volume, old=old_scale, new=new_scale) - - def set_volume(self, volume): - """ - Set volume level of the installed mixer. - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if self._software_mixing: - self._playbin.set_property('volume', volume / 100.0) - return True - - if self._mixer is None: - return False - - old_scale = (0, 100) - new_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - - volume = utils.rescale(volume, old=old_scale, new=new_scale) - - volumes = (volume,) * self._mixer_track.num_channels - self._mixer.set_volume(self._mixer_track, volumes) - - return self._mixer.get_volume(self._mixer_track) == volumes - - def set_metadata(self, track): - """ - Set track metadata for currently playing song. - - Only needs to be called by sources such as `appsrc` which do not - already inject tags in playbin, e.g. when using :meth:`emit_data` to - deliver raw audio data to GStreamer. - - :param track: the current track - :type track: :class:`mopidy.models.Track` - """ - taglist = gst.TagList() - artists = [a for a in (track.artists or []) if a.name] - - # Default to blank data to trick shoutcast into clearing any previous - # values it might have. - taglist[gst.TAG_ARTIST] = u' ' - taglist[gst.TAG_TITLE] = u' ' - taglist[gst.TAG_ALBUM] = u' ' - - if artists: - taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) - - if track.name: - taglist[gst.TAG_TITLE] = track.name - - if track.album and track.album.name: - taglist[gst.TAG_ALBUM] = track.album.name - - event = gst.event_new_tag(taglist) - self._playbin.send_event(event) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py new file mode 100644 index 00000000..4a0b0000 --- /dev/null +++ b/mopidy/audio/actor.py @@ -0,0 +1,381 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + +import logging + +from pykka.actor import ThreadingActor + +from mopidy import settings, utils +from mopidy.utils import process + +from . import mixers +from .listener import AudioListener + +logger = logging.getLogger('mopidy.audio') + +mixers.register_mixers() + + +class Audio(ThreadingActor): + """ + Audio output through `GStreamer `_. + + **Settings:** + + - :attr:`mopidy.settings.OUTPUT` + - :attr:`mopidy.settings.MIXER` + - :attr:`mopidy.settings.MIXER_TRACK` + + """ + + def __init__(self): + super(Audio, self).__init__() + + self._playbin = None + self._mixer = None + self._mixer_track = None + self._software_mixing = False + + self._message_processor_set_up = False + + def on_start(self): + try: + self._setup_playbin() + self._setup_output() + self._setup_mixer() + self._setup_message_processor() + except gobject.GError as ex: + logger.exception(ex) + process.exit_process() + + def on_stop(self): + self._teardown_message_processor() + self._teardown_mixer() + self._teardown_playbin() + + def _setup_playbin(self): + self._playbin = gst.element_factory_make('playbin2') + + fakesink = gst.element_factory_make('fakesink') + self._playbin.set_property('video-sink', fakesink) + + def _teardown_playbin(self): + self._playbin.set_state(gst.STATE_NULL) + + def _setup_output(self): + try: + output = gst.parse_bin_from_description( + settings.OUTPUT, ghost_unconnected_pads=True) + self._playbin.set_property('audio-sink', output) + logger.info('Output set to %s', settings.OUTPUT) + except gobject.GError as ex: + logger.error( + 'Failed to create output "%s": %s', settings.OUTPUT, ex) + process.exit_process() + + def _setup_mixer(self): + if not settings.MIXER: + logger.info('Not setting up mixer.') + return + + if settings.MIXER == 'software': + self._software_mixing = True + logger.info('Mixer set to software mixing.') + return + + try: + mixerbin = gst.parse_bin_from_description( + settings.MIXER, ghost_unconnected_pads=False) + except gobject.GError as ex: + logger.warning( + 'Failed to create mixer "%s": %s', settings.MIXER, ex) + return + + # We assume that the bin will contain a single mixer. + mixer = mixerbin.get_by_interface('GstMixer') + if not mixer: + logger.warning('Did not find any mixers in %r', settings.MIXER) + return + + if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + logger.warning('Setting mixer %r to READY failed.', settings.MIXER) + return + + track = self._select_mixer_track(mixer, settings.MIXER_TRACK) + if not track: + logger.warning('Could not find usable mixer track.') + return + + self._mixer = mixer + self._mixer_track = track + logger.info('Mixer set to %s using track called %s', + mixer.get_factory().get_name(), track.label) + + def _select_mixer_track(self, mixer, track_label): + # Look for track with label == MIXER_TRACK, otherwise fallback to + # master track which is also an output. + for track in mixer.list_tracks(): + if track_label: + if track.label == track_label: + return track + elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT): + return track + + def _teardown_mixer(self): + if self._mixer is not None: + self._mixer.set_state(gst.STATE_NULL) + + def _setup_message_processor(self): + bus = self._playbin.get_bus() + bus.add_signal_watch() + bus.connect('message', self._on_message) + self._message_processor_set_up = True + + def _teardown_message_processor(self): + if self._message_processor_set_up: + bus = self._playbin.get_bus() + bus.remove_signal_watch() + + def _on_message(self, bus, message): + if message.type == gst.MESSAGE_EOS: + self._trigger_reached_end_of_stream_event() + elif message.type == gst.MESSAGE_ERROR: + error, debug = message.parse_error() + logger.error(u'%s %s', error, debug) + self.stop_playback() + elif message.type == gst.MESSAGE_WARNING: + error, debug = message.parse_warning() + logger.warning(u'%s %s', error, debug) + + def _trigger_reached_end_of_stream_event(self): + logger.debug(u'Triggering reached end of stream event') + AudioListener.send('reached_end_of_stream') + + def set_uri(self, uri): + """ + Set URI of audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param uri: the URI to play + :type uri: string + """ + self._playbin.set_property('uri', uri) + + def emit_data(self, capabilities, data): + """ + Call this to deliver raw audio data to be played. + + Note that the uri must be set to ``appsrc://`` for this to work. + + :param capabilities: a GStreamer capabilities string + :type capabilities: string + :param data: raw audio data to be played + """ + caps = gst.caps_from_string(capabilities) + buffer_ = gst.Buffer(buffer(data)) + buffer_.set_caps(caps) + + source = self._playbin.get_property('source') + source.set_property('caps', caps) + source.emit('push-buffer', buffer_) + + def emit_end_of_stream(self): + """ + Put an end-of-stream token on the playbin. This is typically used in + combination with :meth:`emit_data`. + + We will get a GStreamer message when the stream playback reaches the + token, and can then do any end-of-stream related tasks. + """ + self._playbin.get_property('source').emit('end-of-stream') + + def get_position(self): + """ + Get position in milliseconds. + + :rtype: int + """ + if self._playbin.get_state()[1] == gst.STATE_NULL: + return 0 + try: + position = self._playbin.query_position(gst.FORMAT_TIME)[0] + return position // gst.MSECOND + except gst.QueryError, e: + logger.error('time_position failed: %s', e) + return 0 + + def set_position(self, position): + """ + Set position in milliseconds. + + :param position: the position in milliseconds + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple( + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, + position * gst.MSECOND) + self._playbin.get_state() # block until seek is done + return handeled + + def start_playback(self): + """ + Notify GStreamer that it should start playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PLAYING) + + def pause_playback(self): + """ + Notify GStreamer that it should pause playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PAUSED) + + def prepare_change(self): + """ + Notify GStreamer that we are about to change state of playback. + + This function *MUST* be called before changing URIs or doing + changes like updating data that is being pushed. The reason for this + is that GStreamer will reset all its state when it changes to + :attr:`gst.STATE_READY`. + """ + return self._set_state(gst.STATE_READY) + + def stop_playback(self): + """ + Notify GStreamer that is should stop playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_NULL) + + def _set_state(self, state): + """ + Internal method for setting the raw GStreamer state. + + .. digraph:: gst_state_transitions + + graph [rankdir="LR"]; + node [fontsize=10]; + + "NULL" -> "READY" + "PAUSED" -> "PLAYING" + "PAUSED" -> "READY" + "PLAYING" -> "PAUSED" + "READY" -> "NULL" + "READY" -> "PAUSED" + + :param state: State to set playbin to. One of: `gst.STATE_NULL`, + `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. + :type state: :class:`gst.State` + :rtype: :class:`True` if successfull, else :class:`False` + """ + result = self._playbin.set_state(state) + if result == gst.STATE_CHANGE_FAILURE: + logger.warning( + 'Setting GStreamer state to %s: failed', state.value_name) + return False + elif result == gst.STATE_CHANGE_ASYNC: + logger.debug( + 'Setting GStreamer state to %s: async', state.value_name) + return True + else: + logger.debug( + 'Setting GStreamer state to %s: OK', state.value_name) + return True + + def get_volume(self): + """ + Get volume level of the installed mixer. + + Example values: + + 0: + Muted. + 100: + Max volume for given system. + :class:`None`: + No mixer present, so the volume is unknown. + + :rtype: int in range [0..100] or :class:`None` + """ + if self._software_mixing: + return round(self._playbin.get_property('volume') * 100) + + if self._mixer is None: + return None + + volumes = self._mixer.get_volume(self._mixer_track) + avg_volume = float(sum(volumes)) / len(volumes) + + new_scale = (0, 100) + old_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) + return utils.rescale(avg_volume, old=old_scale, new=new_scale) + + def set_volume(self, volume): + """ + Set volume level of the installed mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if self._software_mixing: + self._playbin.set_property('volume', volume / 100.0) + return True + + if self._mixer is None: + return False + + old_scale = (0, 100) + new_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) + + volume = utils.rescale(volume, old=old_scale, new=new_scale) + + volumes = (volume,) * self._mixer_track.num_channels + self._mixer.set_volume(self._mixer_track, volumes) + + return self._mixer.get_volume(self._mixer_track) == volumes + + def set_metadata(self, track): + """ + Set track metadata for currently playing song. + + Only needs to be called by sources such as `appsrc` which do not + already inject tags in playbin, e.g. when using :meth:`emit_data` to + deliver raw audio data to GStreamer. + + :param track: the current track + :type track: :class:`mopidy.models.Track` + """ + taglist = gst.TagList() + artists = [a for a in (track.artists or []) if a.name] + + # Default to blank data to trick shoutcast into clearing any previous + # values it might have. + taglist[gst.TAG_ARTIST] = u' ' + taglist[gst.TAG_TITLE] = u' ' + taglist[gst.TAG_ALBUM] = u' ' + + if artists: + taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + + if track.name: + taglist[gst.TAG_TITLE] = track.name + + if track.album and track.album.name: + taglist[gst.TAG_ALBUM] = track.album.name + + event = gst.event_new_tag(taglist) + self._playbin.send_event(event) From 7c997d9221063a8c4819f80ef4db8faf549dc473 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 15:53:44 +0200 Subject: [PATCH 053/323] Move create_track() helper to mopidy.audio.mixers.utils --- mopidy/audio/mixers/__init__.py | 36 --------------------------------- mopidy/audio/mixers/fake.py | 4 ++-- mopidy/audio/mixers/nad.py | 4 ++-- mopidy/audio/mixers/utils.py | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 40 deletions(-) create mode 100644 mopidy/audio/mixers/utils.py diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index 26faff02..fb04bd00 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -3,42 +3,6 @@ pygst.require('0.10') import gst import gobject - -def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): - - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (initial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() - - -# Import all mixers so that they are registered with GStreamer. -# -# Keep these imports at the bottom of the file to avoid cyclic import problems -# when mixers use the above code. from .auto import AutoAudioMixer from .fake import FakeMixer from .nad import NadMixer diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index e0f1ae1f..3c85cc34 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -3,7 +3,7 @@ pygst.require('0.10') import gobject import gst -from mopidy.audio.mixers import create_track +from . import utils class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): @@ -25,7 +25,7 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): gst.Element.__init__(self) def list_tracks(self): - track = create_track( + track = utils.create_track( self.track_label, self.track_initial_volume, self.track_min_volume, diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index df8c3ec9..fc456a2b 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -12,7 +12,7 @@ except ImportError: from pykka.actor import ThreadingActor -from mopidy.audio.mixers import create_track +from . import utils logger = logging.getLogger('mopidy.audio.mixers.nad') @@ -36,7 +36,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): self._nad_talker = None def list_tracks(self): - track = create_track( + track = utils.create_track( label='Master', initial_volume=0, min_volume=0, diff --git a/mopidy/audio/mixers/utils.py b/mopidy/audio/mixers/utils.py new file mode 100644 index 00000000..c257ffd7 --- /dev/null +++ b/mopidy/audio/mixers/utils.py @@ -0,0 +1,35 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + + +def create_track(label, initial_volume, min_volume, max_volume, + num_channels, flags): + + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (initial_volume,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() From d9d6a3d5b69a1fd61b275eddfeed26330d37a3a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:08:46 +0200 Subject: [PATCH 054/323] Move exceptions to mopidy.exceptions --- mopidy/__init__.py | 22 ------------------- mopidy/__main__.py | 10 ++++----- mopidy/exceptions.py | 21 ++++++++++++++++++ mopidy/frontends/lastfm.py | 14 +++++------- mopidy/frontends/mpd/exceptions.py | 2 +- mopidy/frontends/mpris/__init__.py | 10 ++++----- mopidy/frontends/mpris/objects.py | 2 +- mopidy/utils/process.py | 4 ++-- mopidy/utils/settings.py | 8 +++---- tests/frontends/mpris/events_test.py | 2 +- .../frontends/mpris/player_interface_test.py | 4 ++-- tests/frontends/mpris/root_interface_test.py | 4 ++-- tests/utils/settings_test.py | 10 ++++----- 13 files changed, 55 insertions(+), 58 deletions(-) create mode 100644 mopidy/exceptions.py diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 2a88666c..ec2f4147 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -48,28 +48,6 @@ def get_python(): return u' '.join([implementation, version]) -class MopidyException(Exception): - def __init__(self, message, *args, **kwargs): - super(MopidyException, self).__init__(message, *args, **kwargs) - self._message = message - - @property - def message(self): - """Reimplement message field that was deprecated in Python 2.6""" - return self._message - - @message.setter # noqa - def message(self, message): - self._message = message - - -class SettingsError(MopidyException): - pass - - -class OptionalDependencyError(MopidyException): - pass - from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index bfc600f5..a4982362 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -29,7 +29,7 @@ sys.path.insert( import mopidy -from mopidy import audio, core, settings, utils +from mopidy import audio, core, exceptions, settings, utils from mopidy.utils import log, path, process from mopidy.utils.deps import list_deps_optparse_callback from mopidy.utils.settings import list_settings_optparse_callback @@ -51,7 +51,7 @@ def main(): core_ref = setup_core(audio_ref, backend_ref) setup_frontends(core_ref) loop.run() - except mopidy.SettingsError as ex: + except exceptions.SettingsError as ex: logger.error(ex.message) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') @@ -117,7 +117,7 @@ def setup_settings(interactive): path.get_or_create_file(mopidy.SETTINGS_FILE) try: settings.validate(interactive) - except mopidy.SettingsError as ex: + except exceptions.SettingsError as ex: logger.error(ex.message) sys.exit(1) @@ -150,7 +150,7 @@ def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: utils.get_class(frontend_class_name).start(core=core) - except mopidy.OptionalDependencyError as ex: + except exceptions.OptionalDependencyError as ex: logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) @@ -158,7 +158,7 @@ def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: process.stop_actors_by_class(utils.get_class(frontend_class_name)) - except mopidy.OptionalDependencyError: + except exceptions.OptionalDependencyError: pass diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py new file mode 100644 index 00000000..6e0c575e --- /dev/null +++ b/mopidy/exceptions.py @@ -0,0 +1,21 @@ +class MopidyException(Exception): + def __init__(self, message, *args, **kwargs): + super(MopidyException, self).__init__(message, *args, **kwargs) + self._message = message + + @property + def message(self): + """Reimplement message field that was deprecated in Python 2.6""" + return self._message + + @message.setter # noqa + def message(self, message): + self._message = message + + +class SettingsError(MopidyException): + pass + + +class OptionalDependencyError(MopidyException): + pass diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 37fbafe2..70c6c8e4 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,16 +1,14 @@ import logging import time +from pykka.actor import ThreadingActor + +from mopidy import core, exceptions, settings + try: import pylast except ImportError as import_error: - from mopidy import OptionalDependencyError - raise OptionalDependencyError(import_error) - -from pykka.actor import ThreadingActor - -from mopidy import core, settings, SettingsError - + raise exceptions.OptionalDependencyError(import_error) logger = logging.getLogger('mopidy.frontends.lastfm') @@ -50,7 +48,7 @@ class LastfmFrontend(ThreadingActor, core.CoreListener): api_key=API_KEY, api_secret=API_SECRET, username=username, password_hash=password_hash) logger.info(u'Connected to Last.fm') - except SettingsError as e: + except exceptions.SettingsError as e: logger.info(u'Last.fm scrobbler not started') logger.debug(u'Last.fm settings error: %s', e) self.stop() diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index e5844b60..5925d6bc 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -1,4 +1,4 @@ -from mopidy import MopidyException +from mopidy.exceptions import MopidyException class MpdAckError(MopidyException): diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 80995adf..cbfb2cc9 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,5 +1,10 @@ import logging +from pykka.actor import ThreadingActor + +from mopidy import core, settings +from mopidy.frontends.mpris import objects + logger = logging.getLogger('mopidy.frontends.mpris') try: @@ -8,11 +13,6 @@ except ImportError as import_error: indicate = None # noqa logger.debug(u'Startup notification will not be sent (%s)', import_error) -from pykka.actor import ThreadingActor - -from mopidy import core, settings -from mopidy.frontends.mpris import objects - class MprisFrontend(ThreadingActor, core.CoreListener): """ diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index ee54f91c..74c85617 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -7,7 +7,7 @@ try: import dbus.service import gobject except ImportError as import_error: - from mopidy import OptionalDependencyError + from mopidy.exceptions import OptionalDependencyError raise OptionalDependencyError(import_error) from mopidy import settings diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c45659bb..b3f90150 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -6,7 +6,7 @@ import threading from pykka import ActorDeadError from pykka.registry import ActorRegistry -from mopidy import SettingsError +from mopidy import exceptions logger = logging.getLogger('mopidy.utils.process') @@ -59,7 +59,7 @@ class BaseThread(threading.Thread): self.run_inside_try() except KeyboardInterrupt: logger.info(u'Interrupted by user') - except SettingsError as e: + except exceptions.SettingsError as e: logger.error(e.message) except ImportError as e: logger.error(e) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 0ecdd827..39d613b3 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -8,7 +8,7 @@ import os import pprint import sys -from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE +from mopidy import exceptions, SETTINGS_PATH, SETTINGS_FILE from mopidy.utils import log from mopidy.utils import path @@ -53,11 +53,11 @@ class SettingsProxy(object): current = self.current # bind locally to avoid copying+updates if attr not in current: - raise SettingsError(u'Setting "%s" is not set.' % attr) + raise exceptions.SettingsError(u'Setting "%s" is not set.' % attr) value = current[attr] if isinstance(value, basestring) and len(value) == 0: - raise SettingsError(u'Setting "%s" is empty.' % attr) + raise exceptions.SettingsError(u'Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): @@ -77,7 +77,7 @@ class SettingsProxy(object): logger.error( u'Settings validation errors: %s', log.indent(self.get_errors_as_string())) - raise SettingsError(u'Settings validation failed.') + raise exceptions.SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): for setting, value in sorted(current.iteritems()): diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 241b9365..a4efe344 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -2,7 +2,7 @@ import sys import mock -from mopidy import OptionalDependencyError +from mopidy.exceptions import OptionalDependencyError from mopidy.models import Track try: diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 6088a94b..5c3d2cae 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -4,14 +4,14 @@ import mock from pykka.registry import ActorRegistry -from mopidy import core, OptionalDependencyError +from mopidy import core, exceptions from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track try: from mopidy.frontends.mpris import objects -except OptionalDependencyError: +except exceptions.OptionalDependencyError: pass from tests import unittest diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 847ed2de..8f37cc47 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -4,12 +4,12 @@ import mock from pykka.registry import ActorRegistry -from mopidy import core, settings, OptionalDependencyError +from mopidy import core, exceptions, settings from mopidy.backends import dummy try: from mopidy.frontends.mpris import objects -except OptionalDependencyError: +except exceptions.OptionalDependencyError: pass from tests import unittest diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index bbeda20c..5ce643cb 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,6 +1,6 @@ import os -import mopidy +from mopidy import exceptions, settings from mopidy.utils import settings as setting_utils from tests import unittest @@ -79,7 +79,7 @@ class ValidateSettingsTest(unittest.TestCase): class SettingsProxyTest(unittest.TestCase): def setUp(self): - self.settings = setting_utils.SettingsProxy(mopidy.settings) + self.settings = setting_utils.SettingsProxy(settings) self.settings.local.clear() def test_set_and_get_attr(self): @@ -90,7 +90,7 @@ class SettingsProxyTest(unittest.TestCase): try: self.settings.TEST self.fail(u'Should raise exception') - except mopidy.SettingsError as e: + except exceptions.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) def test_getattr_raises_error_on_empty_setting(self): @@ -98,7 +98,7 @@ class SettingsProxyTest(unittest.TestCase): try: self.settings.TEST self.fail(u'Should raise exception') - except mopidy.SettingsError as e: + except exceptions.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) def test_getattr_does_not_raise_error_if_setting_is_false(self): @@ -184,7 +184,7 @@ class SettingsProxyTest(unittest.TestCase): class FormatSettingListTest(unittest.TestCase): def setUp(self): - self.settings = setting_utils.SettingsProxy(mopidy.settings) + self.settings = setting_utils.SettingsProxy(settings) def test_contains_the_setting_name(self): self.settings.TEST = u'test' From d4f5d02c72b68ad00149ca7b24bf30902c13a2a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:13:20 +0200 Subject: [PATCH 055/323] Move MpdSession to a session module --- mopidy/frontends/mpd/__init__.py | 60 ++---------------------- mopidy/frontends/mpd/session.py | 56 ++++++++++++++++++++++ tests/frontends/mpd/protocol/__init__.py | 4 +- 3 files changed, 63 insertions(+), 57 deletions(-) create mode 100644 mopidy/frontends/mpd/session.py diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e5bafcf1..b90f7c86 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -4,8 +4,8 @@ import sys from pykka import registry, actor from mopidy import core, settings -from mopidy.frontends.mpd import dispatcher, protocol -from mopidy.utils import locale_decode, log, network, process +from mopidy.frontends.mpd import session +from mopidy.utils import locale_decode, network, process logger = logging.getLogger('mopidy.frontends.mpd') @@ -33,7 +33,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): try: network.Server( hostname, port, - protocol=MpdSession, protocol_kwargs={'core': core}, + protocol=session.MpdSession, protocol_kwargs={'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error( @@ -43,7 +43,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): logger.info(u'MPD server running at [%s]:%s', hostname, port) def on_stop(self): - process.stop_actors_by_class(MpdSession) + process.stop_actors_by_class(session.MpdSession) def send_idle(self, subsystem): # FIXME this should be updated once pykka supports non-blocking calls @@ -53,7 +53,7 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): 'attr_path': ('on_idle',), 'args': [subsystem], 'kwargs': {}, - }, target_class=MpdSession) + }, target_class=session.MpdSession) def playback_state_changed(self, old_state, new_state): self.send_idle('player') @@ -66,53 +66,3 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): def volume_changed(self): self.send_idle('mixer') - - -class MpdSession(network.LineProtocol): - """ - The MPD client session. Keeps track of a single client session. Any - requests from the client is passed on to the MPD request dispatcher. - """ - - terminator = protocol.LINE_TERMINATOR - encoding = protocol.ENCODING - delimiter = r'\r?\n' - - def __init__(self, connection, core=None): - super(MpdSession, self).__init__(connection) - self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) - - def on_start(self): - logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) - self.send_lines([u'OK MPD %s' % protocol.VERSION]) - - def on_line_received(self, line): - logger.debug( - u'Request from [%s]:%s to %s: %s', - self.host, self.port, self.actor_urn, line) - - response = self.dispatcher.handle_request(line) - if not response: - return - - logger.debug( - u'Response to [%s]:%s from %s: %s', - self.host, self.port, self.actor_urn, - log.indent(self.terminator.join(response))) - - self.send_lines(response) - - def on_idle(self, subsystem): - self.dispatcher.handle_idle(subsystem) - - def decode(self, line): - try: - return super(MpdSession, self).decode(line.decode('string_escape')) - except ValueError: - logger.warning( - u'Stopping actor due to unescaping error, data ' - u'supplied by client was not valid.') - self.stop() - - def close(self): - self.stop() diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py new file mode 100644 index 00000000..b4531c83 --- /dev/null +++ b/mopidy/frontends/mpd/session.py @@ -0,0 +1,56 @@ +import logging + +from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.utils import log, network + +logger = logging.getLogger('mopidy.frontends.mpd') + + +class MpdSession(network.LineProtocol): + """ + The MPD client session. Keeps track of a single client session. Any + requests from the client is passed on to the MPD request dispatcher. + """ + + terminator = protocol.LINE_TERMINATOR + encoding = protocol.ENCODING + delimiter = r'\r?\n' + + def __init__(self, connection, core=None): + super(MpdSession, self).__init__(connection) + self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) + + def on_start(self): + logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) + self.send_lines([u'OK MPD %s' % protocol.VERSION]) + + def on_line_received(self, line): + logger.debug( + u'Request from [%s]:%s to %s: %s', + self.host, self.port, self.actor_urn, line) + + response = self.dispatcher.handle_request(line) + if not response: + return + + logger.debug( + u'Response to [%s]:%s from %s: %s', + self.host, self.port, self.actor_urn, + log.indent(self.terminator.join(response))) + + self.send_lines(response) + + def on_idle(self, subsystem): + self.dispatcher.handle_idle(subsystem) + + def decode(self, line): + try: + return super(MpdSession, self).decode(line.decode('string_escape')) + except ValueError: + logger.warning( + u'Stopping actor due to unescaping error, data ' + u'supplied by client was not valid.') + self.stop() + + def close(self): + self.stop() diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 63c253d9..34557513 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -4,7 +4,7 @@ from pykka.registry import ActorRegistry from mopidy import core, settings from mopidy.backends import dummy -from mopidy.frontends import mpd +from mopidy.frontends.mpd import session from tests import unittest @@ -27,7 +27,7 @@ class BaseTestCase(unittest.TestCase): self.core = core.Core.start(backend=self.backend).proxy() self.connection = MockConnection() - self.session = mpd.MpdSession(self.connection, core=self.core) + self.session = session.MpdSession(self.connection, core=self.core) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context From 95946caa08e8e9139fc309fefa9843b41708133d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:15:16 +0200 Subject: [PATCH 056/323] Move MpdFrontend to an actor module --- mopidy/frontends/mpd/__init__.py | 70 +------------------------------- mopidy/frontends/mpd/actor.py | 68 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 68 deletions(-) create mode 100644 mopidy/frontends/mpd/actor.py diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index b90f7c86..e2d2b9c7 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,68 +1,2 @@ -import logging -import sys - -from pykka import registry, actor - -from mopidy import core, settings -from mopidy.frontends.mpd import session -from mopidy.utils import locale_decode, network, process - -logger = logging.getLogger('mopidy.frontends.mpd') - - -class MpdFrontend(actor.ThreadingActor, core.CoreListener): - """ - The MPD frontend. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PORT` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - """ - - def __init__(self, core): - super(MpdFrontend, self).__init__() - hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) - port = settings.MPD_SERVER_PORT - - try: - network.Server( - hostname, port, - protocol=session.MpdSession, protocol_kwargs={'core': core}, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) - except IOError as error: - logger.error( - u'MPD server startup failed: %s', locale_decode(error)) - sys.exit(1) - - logger.info(u'MPD server running at [%s]:%s', hostname, port) - - def on_stop(self): - process.stop_actors_by_class(session.MpdSession) - - def send_idle(self, subsystem): - # FIXME this should be updated once pykka supports non-blocking calls - # on proxies or some similar solution - registry.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('on_idle',), - 'args': [subsystem], - 'kwargs': {}, - }, target_class=session.MpdSession) - - def playback_state_changed(self, old_state, new_state): - self.send_idle('player') - - def playlist_changed(self): - self.send_idle('playlist') - - def options_changed(self): - self.send_idle('options') - - def volume_changed(self): - self.send_idle('mixer') +# flake8: noqa +from .actor import MpdFrontend diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py new file mode 100644 index 00000000..b90f7c86 --- /dev/null +++ b/mopidy/frontends/mpd/actor.py @@ -0,0 +1,68 @@ +import logging +import sys + +from pykka import registry, actor + +from mopidy import core, settings +from mopidy.frontends.mpd import session +from mopidy.utils import locale_decode, network, process + +logger = logging.getLogger('mopidy.frontends.mpd') + + +class MpdFrontend(actor.ThreadingActor, core.CoreListener): + """ + The MPD frontend. + + **Dependencies:** + + - None + + **Settings:** + + - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` + - :attr:`mopidy.settings.MPD_SERVER_PORT` + - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` + """ + + def __init__(self, core): + super(MpdFrontend, self).__init__() + hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) + port = settings.MPD_SERVER_PORT + + try: + network.Server( + hostname, port, + protocol=session.MpdSession, protocol_kwargs={'core': core}, + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + except IOError as error: + logger.error( + u'MPD server startup failed: %s', locale_decode(error)) + sys.exit(1) + + logger.info(u'MPD server running at [%s]:%s', hostname, port) + + def on_stop(self): + process.stop_actors_by_class(session.MpdSession) + + def send_idle(self, subsystem): + # FIXME this should be updated once pykka supports non-blocking calls + # on proxies or some similar solution + registry.ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('on_idle',), + 'args': [subsystem], + 'kwargs': {}, + }, target_class=session.MpdSession) + + def playback_state_changed(self, old_state, new_state): + self.send_idle('player') + + def playlist_changed(self): + self.send_idle('playlist') + + def options_changed(self): + self.send_idle('options') + + def volume_changed(self): + self.send_idle('mixer') From 7c0495e6daed9f0015b18c6d3817d505835e06c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 16:17:14 +0200 Subject: [PATCH 057/323] Move MprisFrontend to an actor module --- mopidy/frontends/mpris/__init__.py | 130 +---------------------------- mopidy/frontends/mpris/actor.py | 128 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 128 deletions(-) create mode 100644 mopidy/frontends/mpris/actor.py diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index cbfb2cc9..93ad0795 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,128 +1,2 @@ -import logging - -from pykka.actor import ThreadingActor - -from mopidy import core, settings -from mopidy.frontends.mpris import objects - -logger = logging.getLogger('mopidy.frontends.mpris') - -try: - import indicate -except ImportError as import_error: - indicate = None # noqa - logger.debug(u'Startup notification will not be sent (%s)', import_error) - - -class MprisFrontend(ThreadingActor, core.CoreListener): - """ - Frontend which lets you control Mopidy through the Media Player Remote - Interfacing Specification (`MPRIS `_) D-Bus - interface. - - An example of an MPRIS client is the `Ubuntu Sound Menu - `_. - - **Dependencies:** - - - D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for - details. - - **Testing the frontend** - - To test, start Mopidy, and then run the following in a Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - - Now you can control Mopidy through the player object. Examples: - - - To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - - - To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - """ - - def __init__(self, core): - super(MprisFrontend, self).__init__() - self.core = core - self.indicate_server = None - self.mpris_object = None - - def on_start(self): - try: - self.mpris_object = objects.MprisObject(self.core) - self._send_startup_notification() - except Exception as e: - logger.error(u'MPRIS frontend setup failed (%s)', e) - self.stop() - - def on_stop(self): - logger.debug(u'Removing MPRIS object from D-Bus connection...') - if self.mpris_object: - self.mpris_object.remove_from_connection() - self.mpris_object = None - logger.debug(u'Removed MPRIS object from D-Bus connection') - - def _send_startup_notification(self): - """ - Send startup notification using libindicate to make Mopidy appear in - e.g. `Ubuntu's sound menu `_. - - A reference to the libindicate server is kept for as long as Mopidy is - running. When Mopidy exits, the server will be unreferenced and Mopidy - will automatically be unregistered from e.g. the sound menu. - """ - if not indicate: - return - logger.debug(u'Sending startup notification...') - self.indicate_server = indicate.Server() - self.indicate_server.set_type('music.mopidy') - self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) - self.indicate_server.show() - logger.debug(u'Startup notification sent') - - def _emit_properties_changed(self, *changed_properties): - if self.mpris_object is None: - return - props_with_new_values = [ - (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) - for p in changed_properties] - self.mpris_object.PropertiesChanged( - objects.PLAYER_IFACE, dict(props_with_new_values), []) - - def track_playback_paused(self, track, time_position): - logger.debug(u'Received track playback paused event') - self._emit_properties_changed('PlaybackStatus') - - def track_playback_resumed(self, track, time_position): - logger.debug(u'Received track playback resumed event') - self._emit_properties_changed('PlaybackStatus') - - def track_playback_started(self, track): - logger.debug(u'Received track playback started event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') - - def track_playback_ended(self, track, time_position): - logger.debug(u'Received track playback ended event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') - - def volume_changed(self): - logger.debug(u'Received volume changed event') - self._emit_properties_changed('Volume') - - def seeked(self, time_position_in_ms): - logger.debug(u'Received seeked event') - self.mpris_object.Seeked(time_position_in_ms * 1000) +# flake8: noqa +from .actor import MprisFrontend diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py new file mode 100644 index 00000000..cbfb2cc9 --- /dev/null +++ b/mopidy/frontends/mpris/actor.py @@ -0,0 +1,128 @@ +import logging + +from pykka.actor import ThreadingActor + +from mopidy import core, settings +from mopidy.frontends.mpris import objects + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import indicate +except ImportError as import_error: + indicate = None # noqa + logger.debug(u'Startup notification will not be sent (%s)', import_error) + + +class MprisFrontend(ThreadingActor, core.CoreListener): + """ + Frontend which lets you control Mopidy through the Media Player Remote + Interfacing Specification (`MPRIS `_) D-Bus + interface. + + An example of an MPRIS client is the `Ubuntu Sound Menu + `_. + + **Dependencies:** + + - D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + - An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + details. + + **Testing the frontend** + + To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + + Now you can control Mopidy through the player object. Examples: + + - To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + + - To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') + """ + + def __init__(self, core): + super(MprisFrontend, self).__init__() + self.core = core + self.indicate_server = None + self.mpris_object = None + + def on_start(self): + try: + self.mpris_object = objects.MprisObject(self.core) + self._send_startup_notification() + except Exception as e: + logger.error(u'MPRIS frontend setup failed (%s)', e) + self.stop() + + def on_stop(self): + logger.debug(u'Removing MPRIS object from D-Bus connection...') + if self.mpris_object: + self.mpris_object.remove_from_connection() + self.mpris_object = None + logger.debug(u'Removed MPRIS object from D-Bus connection') + + def _send_startup_notification(self): + """ + Send startup notification using libindicate to make Mopidy appear in + e.g. `Ubuntu's sound menu `_. + + A reference to the libindicate server is kept for as long as Mopidy is + running. When Mopidy exits, the server will be unreferenced and Mopidy + will automatically be unregistered from e.g. the sound menu. + """ + if not indicate: + return + logger.debug(u'Sending startup notification...') + self.indicate_server = indicate.Server() + self.indicate_server.set_type('music.mopidy') + self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) + self.indicate_server.show() + logger.debug(u'Startup notification sent') + + def _emit_properties_changed(self, *changed_properties): + if self.mpris_object is None: + return + props_with_new_values = [ + (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + for p in changed_properties] + self.mpris_object.PropertiesChanged( + objects.PLAYER_IFACE, dict(props_with_new_values), []) + + def track_playback_paused(self, track, time_position): + logger.debug(u'Received track playback paused event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_resumed(self, track, time_position): + logger.debug(u'Received track playback resumed event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_started(self, track): + logger.debug(u'Received track playback started event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def track_playback_ended(self, track, time_position): + logger.debug(u'Received track playback ended event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def volume_changed(self): + logger.debug(u'Received volume changed event') + self._emit_properties_changed('Volume') + + def seeked(self, time_position_in_ms): + logger.debug(u'Received seeked event') + self.mpris_object.Seeked(time_position_in_ms * 1000) From bc531d987effc5bf1245e0ed9bb3f1a81191b898 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 19:36:54 +0200 Subject: [PATCH 058/323] Unroll registration of mixers --- mopidy/audio/mixers/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index fb04bd00..034b0fa9 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -15,4 +15,6 @@ def register_mixer(mixer_class): def register_mixers(): - map(register_mixer, [AutoAudioMixer, FakeMixer, NadMixer]) + register_mixer(AutoAudioMixer) + register_mixer(FakeMixer) + register_mixer(NadMixer) From 5a0529b142ce022110e0ce6d92b8ce890b47c65f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 21:36:22 +0200 Subject: [PATCH 059/323] Empty utils/__init__.py --- mopidy/__main__.py | 21 ++++---- mopidy/audio/actor.py | 13 +++-- mopidy/backends/local/translator.py | 2 +- mopidy/frontends/mpd/actor.py | 5 +- mopidy/frontends/mpd/dispatcher.py | 14 +++-- mopidy/utils/__init__.py | 52 ------------------- mopidy/utils/encoding.py | 8 +++ mopidy/utils/importing.py | 23 ++++++++ mopidy/utils/network.py | 4 +- .../{decode_test.py => encoding_test.py} | 4 +- .../utils/{init_test.py => importing_test.py} | 12 ++--- 11 files changed, 77 insertions(+), 81 deletions(-) create mode 100644 mopidy/utils/encoding.py create mode 100644 mopidy/utils/importing.py rename tests/utils/{decode_test.py => encoding_test.py} (90%) rename tests/utils/{init_test.py => importing_test.py} (68%) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a4982362..aa108f2c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -29,10 +29,9 @@ sys.path.insert( import mopidy -from mopidy import audio, core, exceptions, settings, utils -from mopidy.utils import log, path, process -from mopidy.utils.deps import list_deps_optparse_callback -from mopidy.utils.settings import list_settings_optparse_callback +from mopidy import audio, core, exceptions, settings +from mopidy.utils import ( + deps, importing, log, path, process, settings as settings_utils) logger = logging.getLogger('mopidy.main') @@ -90,11 +89,12 @@ def parse_options(): help='save debug log to "./mopidy.log"') parser.add_option( '--list-settings', - action='callback', callback=list_settings_optparse_callback, + action='callback', + callback=settings_utils.list_settings_optparse_callback, help='list current settings') parser.add_option( '--list-deps', - action='callback', callback=list_deps_optparse_callback, + action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') return parser.parse_args(args=mopidy_args)[0] @@ -131,11 +131,11 @@ def stop_audio(): def setup_backend(audio): - return utils.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() + return importing.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() def stop_backend(): - process.stop_actors_by_class(utils.get_class(settings.BACKENDS[0])) + process.stop_actors_by_class(importing.get_class(settings.BACKENDS[0])) def setup_core(audio, backend): @@ -149,7 +149,7 @@ def stop_core(): def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - utils.get_class(frontend_class_name).start(core=core) + importing.get_class(frontend_class_name).start(core=core) except exceptions.OptionalDependencyError as ex: logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) @@ -157,7 +157,8 @@ def setup_frontends(core): def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - process.stop_actors_by_class(utils.get_class(frontend_class_name)) + frontend_class = importing.get_class(frontend_class_name) + process.stop_actors_by_class(frontend_class) except exceptions.OptionalDependencyError: pass diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 4a0b0000..77b451d7 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -7,7 +7,7 @@ import logging from pykka.actor import ThreadingActor -from mopidy import settings, utils +from mopidy import settings from mopidy.utils import process from . import mixers @@ -320,7 +320,7 @@ class Audio(ThreadingActor): new_scale = (0, 100) old_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - return utils.rescale(avg_volume, old=old_scale, new=new_scale) + return self._rescale(avg_volume, old=old_scale, new=new_scale) def set_volume(self, volume): """ @@ -341,13 +341,20 @@ class Audio(ThreadingActor): new_scale = ( self._mixer_track.min_volume, self._mixer_track.max_volume) - volume = utils.rescale(volume, old=old_scale, new=new_scale) + volume = self._rescale(volume, old=old_scale, new=new_scale) volumes = (volume,) * self._mixer_track.num_channels self._mixer.set_volume(self._mixer_track, volumes) return self._mixer.get_volume(self._mixer_track) == volumes + def _rescale(self, value, old=None, new=None): + """Convert value between scales.""" + new_min, new_max = new + old_min, old_max = old + scaling = float(new_max - new_min) / (old_max - old_min) + return round(scaling * (value - old_min) + new_min) + def set_metadata(self, track): """ Set track metadata for currently playing song. diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index fbdace15..73b97989 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -3,7 +3,7 @@ import logging logger = logging.getLogger('mopidy.backends.local.translator') from mopidy.models import Track, Artist, Album -from mopidy.utils import locale_decode +from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index b90f7c86..167fb1d6 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -5,7 +5,7 @@ from pykka import registry, actor from mopidy import core, settings from mopidy.frontends.mpd import session -from mopidy.utils import locale_decode, network, process +from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') @@ -37,7 +37,8 @@ class MpdFrontend(actor.ThreadingActor, core.CoreListener): max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error( - u'MPD server startup failed: %s', locale_decode(error)) + u'MPD server startup failed: %s', + encoding.locale_decode(error)) sys.exit(1) logger.info(u'MPD server running at [%s]:%s', hostname, port) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index d7ba8cdf..ae51d270 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -5,7 +5,6 @@ from pykka import ActorDeadError from mopidy import settings from mopidy.frontends.mpd import exceptions, protocol -from mopidy.utils import flatten logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') @@ -187,10 +186,19 @@ class MpdDispatcher(object): if result is None: return [] if isinstance(result, set): - return flatten(list(result)) + return self._flatten(list(result)) if not isinstance(result, list): return [result] - return flatten(result) + return self._flatten(result) + + def _flatten(self, the_list): + result = [] + for element in the_list: + if isinstance(element, list): + result.extend(self._flatten(element)) + else: + result.append(element) + return result def _format_lines(self, line): if isinstance(line, dict): diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 839e4f79..e69de29b 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1,52 +0,0 @@ -from __future__ import division - -import locale -import logging -import sys - -logger = logging.getLogger('mopidy.utils') - - -# TODO: use itertools.chain.from_iterable(the_list)? -def flatten(the_list): - result = [] - for element in the_list: - if isinstance(element, list): - result.extend(flatten(element)) - else: - result.append(element) - return result - - -def rescale(v, old=None, new=None): - """Convert value between scales.""" - new_min, new_max = new - old_min, old_max = old - scaling = float(new_max - new_min) / (old_max - old_min) - return round(scaling * (v - old_min) + new_min) - - -def import_module(name): - __import__(name) - return sys.modules[name] - - -def get_class(name): - logger.debug('Loading: %s', name) - if '.' not in name: - raise ImportError("Couldn't load: %s" % name) - module_name = name[:name.rindex('.')] - cls_name = name[name.rindex('.') + 1:] - try: - module = import_module(module_name) - cls = getattr(module, cls_name) - except (ImportError, AttributeError): - raise ImportError("Couldn't load: %s" % name) - return cls - - -def locale_decode(bytestr): - try: - return unicode(bytestr) - except UnicodeError: - return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py new file mode 100644 index 00000000..888896c5 --- /dev/null +++ b/mopidy/utils/encoding.py @@ -0,0 +1,8 @@ +import locale + + +def locale_decode(bytestr): + try: + return unicode(bytestr) + except UnicodeError: + return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/importing.py b/mopidy/utils/importing.py new file mode 100644 index 00000000..3df6abe4 --- /dev/null +++ b/mopidy/utils/importing.py @@ -0,0 +1,23 @@ +import logging +import sys + +logger = logging.getLogger('mopidy.utils') + + +def import_module(name): + __import__(name) + return sys.modules[name] + + +def get_class(name): + logger.debug('Loading: %s', name) + if '.' not in name: + raise ImportError("Couldn't load: %s" % name) + module_name = name[:name.rindex('.')] + cls_name = name[name.rindex('.') + 1:] + try: + module = import_module(module_name) + cls = getattr(module, cls_name) + except (ImportError, AttributeError): + raise ImportError("Couldn't load: %s" % name) + return cls diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 2a637c9b..dc303399 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -9,7 +9,7 @@ from pykka import ActorDeadError from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy.utils import locale_decode +from mopidy.utils import encoding logger = logging.getLogger('mopidy.utils.server') @@ -30,7 +30,7 @@ def try_ipv6_socket(): logger.debug( u'Platform supports IPv6, but socket creation failed, ' u'disabling: %s', - locale_decode(error)) + encoding.locale_decode(error)) return False diff --git a/tests/utils/decode_test.py b/tests/utils/encoding_test.py similarity index 90% rename from tests/utils/decode_test.py rename to tests/utils/encoding_test.py index edbfe651..da50d9be 100644 --- a/tests/utils/decode_test.py +++ b/tests/utils/encoding_test.py @@ -1,11 +1,11 @@ import mock -from mopidy.utils import locale_decode +from mopidy.utils.encoding import locale_decode from tests import unittest -@mock.patch('mopidy.utils.locale.getpreferredencoding') +@mock.patch('mopidy.utils.encoding.locale.getpreferredencoding') class LocaleDecodeTest(unittest.TestCase): def test_can_decode_utf8_strings_with_french_content(self, mock): mock.return_value = 'UTF-8' diff --git a/tests/utils/init_test.py b/tests/utils/importing_test.py similarity index 68% rename from tests/utils/init_test.py rename to tests/utils/importing_test.py index bdd0adc5..271f9dbe 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/importing_test.py @@ -1,4 +1,4 @@ -from mopidy import utils +from mopidy.utils import importing from tests import unittest @@ -6,22 +6,22 @@ from tests import unittest class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): with self.assertRaises(ImportError): - utils.get_class('foo.bar.Baz') + importing.get_class('foo.bar.Baz') def test_loading_class_that_does_not_exist(self): with self.assertRaises(ImportError): - utils.get_class('unittest.FooBarBaz') + importing.get_class('unittest.FooBarBaz') def test_loading_incorrect_class_path(self): with self.assertRaises(ImportError): - utils.get_class('foobarbaz') + importing.get_class('foobarbaz') def test_import_error_message_contains_complete_class_path(self): try: - utils.get_class('foo.bar.Baz') + importing.get_class('foo.bar.Baz') except ImportError as e: self.assertIn('foo.bar.Baz', str(e)) def test_loading_existing_class(self): - cls = utils.get_class('unittest.TestCase') + cls = importing.get_class('unittest.TestCase') self.assertEqual(cls.__name__, 'TestCase') From 986c0a9ad37b1249bb7cd38447d3ddda831a98d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 21:45:36 +0200 Subject: [PATCH 060/323] Move get_version() helper to mopidy.utils.versioning --- docs/conf.py | 2 +- mopidy/__init__.py | 18 ------------------ mopidy/__main__.py | 6 ++++-- mopidy/backends/spotify/session_manager.py | 10 +++++----- mopidy/utils/log.py | 5 +++-- mopidy/utils/versioning.py | 20 ++++++++++++++++++++ 6 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 mopidy/utils/versioning.py diff --git a/docs/conf.py b/docs/conf.py index e37f5713..d02303df 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -97,7 +97,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors' # built documents. # # The full version, including alpha/beta/rc tags. -from mopidy import get_version +from mopidy.utils.versioning import get_version release = get_version() # The short X.Y version. version = '.'.join(release.split('.')[:2]) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index ec2f4147..dc782db9 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -5,7 +5,6 @@ if not (2, 6) <= sys.version_info < (3,): from distutils.version import StrictVersion import os import platform -from subprocess import PIPE, Popen import glib @@ -21,23 +20,6 @@ SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') -def get_version(): - try: - return get_git_version() - except EnvironmentError: - return __version__ - - -def get_git_version(): - process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) - if process.wait() != 0: - raise EnvironmentError('Execution of "git describe" failed') - version = process.stdout.read().strip() - if version.startswith('v'): - version = version[1:] - return version - - def get_platform(): return platform.platform() diff --git a/mopidy/__main__.py b/mopidy/__main__.py index aa108f2c..e712fc63 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,7 +31,8 @@ sys.path.insert( import mopidy from mopidy import audio, core, exceptions, settings from mopidy.utils import ( - deps, importing, log, path, process, settings as settings_utils) + deps, importing, log, path, process, settings as settings_utils, + versioning) logger = logging.getLogger('mopidy.main') @@ -66,7 +67,8 @@ def main(): def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % mopidy.get_version()) + parser = optparse.OptionParser( + version=u'Mopidy %s' % versioning.get_version()) parser.add_option( '--help-gst', action='store_true', dest='help_gst', diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 99859abd..2ca7d673 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,13 +4,13 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from mopidy import get_version, settings +from mopidy import settings from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist -from mopidy.utils.process import BaseThread +from mopidy.utils import process, versioning logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -18,15 +18,15 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') # SpotifySessionManager: Too many ancestors (9/7) -class SpotifySessionManager(BaseThread, PyspotifySessionManager): +class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): cache_location = settings.SPOTIFY_CACHE_PATH settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') - user_agent = 'Mopidy %s' % get_version() + user_agent = 'Mopidy %s' % versioning.get_version() def __init__(self, username, password, audio, backend_ref): PyspotifySessionManager.__init__(self, username, password) - BaseThread.__init__(self) + process.BaseThread.__init__(self) self.name = 'SpotifyThread' self.audio = audio diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 9b9495d5..d5c9a14d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,7 +1,8 @@ import logging import logging.handlers -from mopidy import get_version, get_platform, get_python, settings +from mopidy import get_platform, get_python, settings +from . import versioning def setup_logging(verbosity_level, save_debug_log): @@ -10,7 +11,7 @@ def setup_logging(verbosity_level, save_debug_log): if save_debug_log: setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s', get_version()) + logger.info(u'Starting Mopidy %s', versioning.get_version()) logger.info(u'Platform: %s', get_platform()) logger.info(u'Python: %s', get_python()) diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py new file mode 100644 index 00000000..b25761e9 --- /dev/null +++ b/mopidy/utils/versioning.py @@ -0,0 +1,20 @@ +from subprocess import PIPE, Popen + +from mopidy import __version__ + + +def get_version(): + try: + return get_git_version() + except EnvironmentError: + return __version__ + + +def get_git_version(): + process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) + if process.wait() != 0: + raise EnvironmentError('Execution of "git describe" failed') + version = process.stdout.read().strip() + if version.startswith('v'): + version = version[1:] + return version From 074fb431bf4f687f05c1877fc4ebf148a13d3b6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:09:40 +0200 Subject: [PATCH 061/323] Move Pykka version check to startup, to unbreak docs building --- mopidy/__init__.py | 11 +++-------- mopidy/__main__.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index dc782db9..83607be6 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,17 +1,12 @@ +import os +import platform import sys + if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') -from distutils.version import StrictVersion -import os -import platform - import glib -import pykka -if StrictVersion(pykka.__version__) < StrictVersion('0.16'): - sys.exit(u'Mopidy requires Pykka >= 0.16') - __version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index e712fc63..ba175ceb 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,3 +1,4 @@ +from distutils.version import StrictVersion import logging import optparse import os @@ -7,6 +8,8 @@ import sys import gobject gobject.threads_init() +import pykka + # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, @@ -39,6 +42,7 @@ logger = logging.getLogger('mopidy.main') def main(): + check_dependencies() signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() @@ -66,6 +70,14 @@ def main(): process.stop_remaining_actors() +def check_dependencies(): + pykka_required = '0.16' + if StrictVersion(pykka.__version__) < StrictVersion(pykka_required): + sys.exit( + u'Mopidy requires Pykka >= %s, but found %s' % + (pykka_required, pykka.__version__)) + + def parse_options(): parser = optparse.OptionParser( version=u'Mopidy %s' % versioning.get_version()) From e9e5330a14a8a6b37c04c31f5cf35005dcdee256 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:14:04 +0200 Subject: [PATCH 062/323] Turn off creation of nosetests.xml report by default This is only needed by the Jenkins CI server, and our builds there have been updated to pass --with-xunit explicitly. --- setup.cfg | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index e09a7b15..bce0a6e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,6 @@ [nosetests] verbosity = 1 -#with-doctest = 1 #with-coverage = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 -with-xunit = 1 From 479ab249bb46a087dc4df844d7ee66a40aca9be2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:24:11 +0200 Subject: [PATCH 063/323] Move mopidy.utils.{log => formatting}.indent to break import cycle --- mopidy/frontends/mpd/session.py | 4 ++-- mopidy/utils/deps.py | 4 ++-- mopidy/utils/formatting.py | 8 ++++++++ mopidy/utils/log.py | 10 ---------- mopidy/utils/settings.py | 9 ++++----- 5 files changed, 16 insertions(+), 19 deletions(-) create mode 100644 mopidy/utils/formatting.py diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index b4531c83..b5368a08 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -1,7 +1,7 @@ import logging from mopidy.frontends.mpd import dispatcher, protocol -from mopidy.utils import log, network +from mopidy.utils import formatting, network logger = logging.getLogger('mopidy.frontends.mpd') @@ -36,7 +36,7 @@ class MpdSession(network.LineProtocol): logger.debug( u'Response to [%s]:%s from %s: %s', self.host, self.port, self.actor_urn, - log.indent(self.terminator.join(response))) + formatting.indent(self.terminator.join(response))) self.send_lines(response) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index d72f1392..32949f55 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -8,7 +8,7 @@ import gst import pykka -from mopidy.utils.log import indent +from . import formatting def list_deps_optparse_callback(*args): @@ -47,7 +47,7 @@ def format_dependency_list(adapters=None): os.path.dirname(dep_info['path']))) if 'other' in dep_info: lines.append(' Other: %s' % ( - indent(dep_info['other'])),) + formatting.indent(dep_info['other'])),) return '\n'.join(lines) diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py new file mode 100644 index 00000000..46459959 --- /dev/null +++ b/mopidy/utils/formatting.py @@ -0,0 +1,8 @@ +def indent(string, places=4, linebreak='\n'): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index d5c9a14d..93f17c92 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -50,13 +50,3 @@ def setup_debug_logging_to_file(): handler.setLevel(logging.DEBUG) root = logging.getLogger('') root.addHandler(handler) - - -def indent(string, places=4, linebreak='\n'): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 39d613b3..6d868d39 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -9,8 +9,7 @@ import pprint import sys from mopidy import exceptions, SETTINGS_PATH, SETTINGS_FILE -from mopidy.utils import log -from mopidy.utils import path +from mopidy.utils import formatting, path logger = logging.getLogger('mopidy.utils.settings') @@ -76,7 +75,7 @@ class SettingsProxy(object): if self.get_errors(): logger.error( u'Settings validation errors: %s', - log.indent(self.get_errors_as_string())) + formatting.indent(self.get_errors_as_string())) raise exceptions.SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): @@ -203,11 +202,11 @@ def format_settings_list(settings): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) lines.append(u'%s: %s' % ( - key, log.indent(pprint.pformat(masked_value), places=2))) + key, formatting.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append( u' Default: %s' % - log.indent(pprint.pformat(default_value), places=4)) + formatting.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) From 5fc77be76ea26f4552b2c58e36487a1e4cb27895 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:25:37 +0200 Subject: [PATCH 064/323] Update path in comment --- mopidy/utils/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 6d868d39..a886a90c 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,4 +1,5 @@ -# Absolute import needed to import ~/.mopidy/settings.py and not ourselves +# Absolute import needed to import ~/.config/mopidy/settings.py and not +# ourselves from __future__ import absolute_import import copy From afdc665ac089a53c803324fadf5802568a1d1dcb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 16 Oct 2012 22:26:28 +0200 Subject: [PATCH 065/323] Use deps.{platform_info,python_info} in log --- mopidy/__init__.py | 12 ------------ mopidy/utils/log.py | 8 ++++---- tests/version_test.py | 12 +----------- 3 files changed, 5 insertions(+), 27 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 83607be6..76aca226 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,5 +1,4 @@ import os -import platform import sys if not (2, 6) <= sys.version_info < (3,): @@ -14,17 +13,6 @@ CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') - -def get_platform(): - return platform.platform() - - -def get_python(): - implementation = platform.python_implementation() - version = platform.python_version() - return u' '.join([implementation, version]) - - from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 93f17c92..3421746d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,8 +1,8 @@ import logging import logging.handlers -from mopidy import get_platform, get_python, settings -from . import versioning +from mopidy import settings +from . import deps, versioning def setup_logging(verbosity_level, save_debug_log): @@ -12,8 +12,8 @@ def setup_logging(verbosity_level, save_debug_log): setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') logger.info(u'Starting Mopidy %s', versioning.get_version()) - logger.info(u'Platform: %s', get_platform()) - logger.info(u'Python: %s', get_python()) + logger.info(u'%(name)s: %(version)s', deps.platform_info()) + logger.info(u'%(name)s: %(version)s', deps.python_info()) def setup_root_logger(): diff --git a/tests/version_test.py b/tests/version_test.py index 678dc221..004abab7 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -1,7 +1,6 @@ from distutils.version import StrictVersion as SV -import platform -from mopidy import __version__, get_platform, get_python +from mopidy import __version__ from tests import unittest @@ -30,12 +29,3 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.2'), SV('0.7.3')) self.assertLess(SV('0.7.3'), SV(__version__)) self.assertLess(SV(__version__), SV('0.8.1')) - - def test_get_platform_contains_platform(self): - self.assertIn(platform.platform(), get_platform()) - - def test_get_python_contains_python_implementation(self): - self.assertIn(platform.python_implementation(), get_python()) - - def test_get_python_contains_python_version(self): - self.assertIn(platform.python_version(), get_python()) From b8d637e1f53c2e40c23be85e5a602bf03e61d77b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 00:21:24 +0200 Subject: [PATCH 066/323] Move DATA_PATH, SETTINGS_PATH, and SETTINGS_FILE to mopidy.utils.path --- mopidy/__init__.py | 9 --------- mopidy/__main__.py | 9 ++++----- mopidy/utils/path.py | 6 +++++- mopidy/utils/settings.py | 6 +++--- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 76aca226..0b0be1a6 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,18 +1,9 @@ -import os import sys - if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') -import glib - __version__ = '0.8.0' -DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') -CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') -SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') -SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') - from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ba175ceb..719e8e24 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,7 +31,6 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -import mopidy from mopidy import audio, core, exceptions, settings from mopidy.utils import ( deps, importing, log, path, process, settings as settings_utils, @@ -122,13 +121,13 @@ def check_old_folders(): logger.warning( u'Old settings folder found at %s, settings.py should be moved ' u'to %s, any cache data should be deleted. See release notes for ' - u'further instructions.', old_settings_folder, mopidy.SETTINGS_PATH) + u'further instructions.', old_settings_folder, path.SETTINGS_PATH) def setup_settings(interactive): - path.get_or_create_folder(mopidy.SETTINGS_PATH) - path.get_or_create_folder(mopidy.DATA_PATH) - path.get_or_create_file(mopidy.SETTINGS_FILE) + path.get_or_create_folder(path.SETTINGS_PATH) + path.get_or_create_folder(path.DATA_PATH) + path.get_or_create_file(path.SETTINGS_FILE) try: settings.validate(interactive) except exceptions.SettingsError as ex: diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0cf02a4a..220d6775 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,4 +1,3 @@ -import glib import logging import os import re @@ -6,8 +5,13 @@ import string import sys import urllib +import glib + logger = logging.getLogger('mopidy.utils.path') +DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') +SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') +SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') XDG_DIRS = { 'XDG_CACHE_DIR': glib.get_user_cache_dir(), 'XDG_DATA_DIR': glib.get_user_data_dir(), diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index a886a90c..be0e4420 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -9,7 +9,7 @@ import os import pprint import sys -from mopidy import exceptions, SETTINGS_PATH, SETTINGS_FILE +from mopidy import exceptions from mopidy.utils import formatting, path logger = logging.getLogger('mopidy.utils.settings') @@ -23,9 +23,9 @@ class SettingsProxy(object): self.runtime = {} def _get_local_settings(self): - if not os.path.isfile(SETTINGS_FILE): + if not os.path.isfile(path.SETTINGS_FILE): return {} - sys.path.insert(0, SETTINGS_PATH) + sys.path.insert(0, path.SETTINGS_PATH) # pylint: disable = F0401 import settings as local_settings_module # pylint: enable = F0401 From ad78aba3fd1902506e21ac0bf036a9a42c44baa1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 00:50:29 +0200 Subject: [PATCH 067/323] Fix shadowing of imports --- mopidy/__main__.py | 20 +++++++++++--------- mopidy/core/actor.py | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 719e8e24..30e71b60 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -31,7 +31,9 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import audio, core, exceptions, settings +from mopidy import exceptions, settings +from mopidy.audio import Audio +from mopidy.core import Core from mopidy.utils import ( deps, importing, log, path, process, settings as settings_utils, versioning) @@ -49,10 +51,10 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - audio_ref = setup_audio() - backend_ref = setup_backend(audio_ref) - core_ref = setup_core(audio_ref, backend_ref) - setup_frontends(core_ref) + audio = setup_audio() + backend = setup_backend(audio) + core = setup_core(audio, backend) + setup_frontends(core) loop.run() except exceptions.SettingsError as ex: logger.error(ex.message) @@ -136,11 +138,11 @@ def setup_settings(interactive): def setup_audio(): - return audio.Audio.start().proxy() + return Audio.start().proxy() def stop_audio(): - process.stop_actors_by_class(audio.Audio) + process.stop_actors_by_class(Audio) def setup_backend(audio): @@ -152,11 +154,11 @@ def stop_backend(): def setup_core(audio, backend): - return core.Core.start(audio=audio, backend=backend).proxy() + return Core.start(audio=audio, backend=backend).proxy() def stop_core(): - process.stop_actors_by_class(core.Core) + process.stop_actors_by_class(Core) def setup_frontends(core): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 4ec86e8b..aded0774 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,6 +1,6 @@ from pykka.actor import ThreadingActor -from mopidy import audio +from mopidy.audio import AudioListener from .current_playlist import CurrentPlaylistController from .library import LibraryController @@ -8,7 +8,7 @@ from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController -class Core(ThreadingActor, audio.AudioListener): +class Core(ThreadingActor, AudioListener): #: The current playlist controller. An instance of #: :class:`mopidy.core.CurrentPlaylistController`. current_playlist = None From 01d7e3bd31bc296188bb0f45c3eb1fcc9e04712c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:03:16 +0200 Subject: [PATCH 068/323] Remove pylint ignores not needed with pylint 0.26 --- pylintrc | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pylintrc b/pylintrc index 98e10416..db821a5b 100644 --- a/pylintrc +++ b/pylintrc @@ -5,12 +5,6 @@ # # C0103 - Invalid name "%s" (should match %s) # C0111 - Missing docstring -# E0102 - %s already defined line %s -# Does not understand @property getters and setters -# E0202 - An attribute inherited from %s hide this method -# Does not understand @property getters and setters -# E1101 - %s %r has no %r member -# Does not understand @property getters and setters # R0201 - Method could be a function # R0801 - Similar lines in %s files # R0903 - Too few public methods (%s/%s) @@ -21,4 +15,4 @@ # W0511 - TODO, FIXME and XXX in the code # W0613 - Unused argument %r # -disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 +disable = C0103,C0111,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 From dbea615a105b1d7e86a5bed41f1f0280b50b44a7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 00:56:49 +0200 Subject: [PATCH 069/323] Fix shadowing of imports (#211) --- mopidy/frontends/lastfm.py | 5 +++-- mopidy/frontends/mpd/actor.py | 5 +++-- mopidy/frontends/mpris/actor.py | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 70c6c8e4..45c2db16 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -3,7 +3,8 @@ import time from pykka.actor import ThreadingActor -from mopidy import core, exceptions, settings +from mopidy import exceptions, settings +from mopidy.core import CoreListener try: import pylast @@ -16,7 +17,7 @@ API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, core.CoreListener): +class LastfmFrontend(ThreadingActor, CoreListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 167fb1d6..d7a20158 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -3,14 +3,15 @@ import sys from pykka import registry, actor -from mopidy import core, settings +from mopidy import settings +from mopidy.core import CoreListener from mopidy.frontends.mpd import session from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(actor.ThreadingActor, core.CoreListener): +class MpdFrontend(actor.ThreadingActor, CoreListener): """ The MPD frontend. diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index cbfb2cc9..e3199ac3 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -2,7 +2,8 @@ import logging from pykka.actor import ThreadingActor -from mopidy import core, settings +from mopidy import settings +from mopidy.core import CoreListener from mopidy.frontends.mpris import objects logger = logging.getLogger('mopidy.frontends.mpris') @@ -14,7 +15,7 @@ except ImportError as import_error: logger.debug(u'Startup notification will not be sent (%s)', import_error) -class MprisFrontend(ThreadingActor, core.CoreListener): +class MprisFrontend(ThreadingActor, CoreListener): """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus From 7d76f1d214a672c8734a2e491755f5125ca59548 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:01:57 +0200 Subject: [PATCH 070/323] Fix unused variables (#211) --- mopidy/frontends/mpris/objects.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 74c85617..4d4efe1e 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -39,7 +39,7 @@ class MprisObject(dbus.service.Object): PLAYER_IFACE: self._get_player_iface_properties(), } bus_name = self._connect_to_dbus() - super(MprisObject, self).__init__(bus_name, OBJECT_PATH) + dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) def _get_root_iface_properties(self): return { @@ -97,7 +97,7 @@ class MprisObject(dbus.service.Object): logger.debug( u'%s.Get(%s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) - (getter, setter) = self.properties[interface][prop] + (getter, _) = self.properties[interface][prop] if callable(getter): return getter() else: @@ -109,7 +109,7 @@ class MprisObject(dbus.service.Object): logger.debug( u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) getters = {} - for key, (getter, setter) in self.properties[interface].iteritems(): + for key, (getter, _) in self.properties[interface].iteritems(): getters[key] = getter() if callable(getter) else getter return getters @@ -119,7 +119,7 @@ class MprisObject(dbus.service.Object): logger.debug( u'%s.Set(%s, %s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) - getter, setter = self.properties[interface][prop] + _, setter = self.properties[interface][prop] if setter is not None: setter(value) self.PropertiesChanged( @@ -332,7 +332,7 @@ class MprisObject(dbus.service.Object): if current_cp_track is None: return {'mpris:trackid': ''} else: - (cpid, track) = current_cp_track + (_, track) = current_cp_track metadata = {'mpris:trackid': self._get_track_id(current_cp_track)} if track.length: metadata['mpris:length'] = track.length * 1000 From 8042f9961ae4e0c56688e3e800589d0d926778e3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:06:18 +0200 Subject: [PATCH 071/323] Mark strings with backslashes as raw strings (#211) --- mopidy/backends/local/translator.py | 2 +- mopidy/utils/network.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 73b97989..5a4a238b 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -8,7 +8,7 @@ from mopidy.utils.path import path_to_uri def parse_m3u(file_path, music_folder): - """ + r""" Convert M3U file list of uris Example M3U data:: diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index dc303399..b8914614 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -52,7 +52,7 @@ def create_socket(): def format_hostname(hostname): """Format hostname for display.""" - if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): + if (has_ipv6 and re.match(r'\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname From 928851764a316a798862ed699c6a46808fd87878 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:13:34 +0200 Subject: [PATCH 072/323] Ignore select pylint refactoring recommendations --- pylintrc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylintrc b/pylintrc index db821a5b..41e1ab5d 100644 --- a/pylintrc +++ b/pylintrc @@ -7,12 +7,15 @@ # C0111 - Missing docstring # R0201 - Method could be a function # R0801 - Similar lines in %s files +# R0902 - Too many instance attributes (%s/%s) # R0903 - Too few public methods (%s/%s) # R0904 - Too many public methods (%s/%s) +# R0912 - Too many branches (%s/%s) +# R0913 - Too many arguments (%s/%s) # R0921 - Abstract class not referenced # W0141 - Used builtin function '%s' # W0142 - Used * or ** magic # W0511 - TODO, FIXME and XXX in the code # W0613 - Unused argument %r # -disable = C0103,C0111,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 +disable = C0103,C0111,R0201,R0801,R0902,R0903,R0904,R0912,R0913,R0921,W0141,W0142,W0511,W0613 From 39d0bfa1247a208a2c374f8408eaa813d23c5f16 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:18:50 +0200 Subject: [PATCH 073/323] Ensure that superclasses' __init__ are called (#211) --- mopidy/audio/mixers/fake.py | 3 --- mopidy/audio/mixers/nad.py | 6 ++---- mopidy/backends/spotify/library.py | 1 + 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 3c85cc34..b22e731e 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -21,9 +21,6 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): track_flags = gobject.property(type=int, default=( gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) - def __init__(self): - gst.Element.__init__(self) - def list_tracks(self): track = utils.create_track( self.track_label, diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index fc456a2b..72bede82 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -30,10 +30,8 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): speakers_a = gobject.property(type=str) speakers_b = gobject.property(type=str) - def __init__(self): - gst.Element.__init__(self) - self._volume_cache = 0 - self._nad_talker = None + _volume_cache = 0 + _nad_talker = None def list_tracks(self): track = utils.create_track( diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 8519a650..b254519e 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -13,6 +13,7 @@ logger = logging.getLogger('mopidy.backends.spotify.library') class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" def __init__(self, uri): + super(SpotifyTrack, self).__init__() self._spotify_track = Link.from_string(uri).as_track() self._unloaded_track = Track(uri=uri, name=u'[loading...]') self._track = None From 8f1f0bc82abe3bdaf91873aab5df0090f308ef5f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:31:20 +0200 Subject: [PATCH 074/323] Create attribute in __init__ (#211) --- mopidy/backends/spotify/session_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 2ca7d673..caa777e1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -30,6 +30,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.name = 'SpotifyThread' self.audio = audio + self.backend = None self.backend_ref = backend_ref self.connected = threading.Event() From 0c9452d9d3a5a1d6d5060da16ba25491c26f00f8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:31:41 +0200 Subject: [PATCH 075/323] Remove unused argument shadowing builtin (#211) --- mopidy/utils/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index be0e4420..d6c5d644 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -234,7 +234,7 @@ def did_you_mean(setting, defaults): return None -def levenshtein(a, b, max=3): +def levenshtein(a, b): """Calculates the Levenshtein distance between a and b.""" n, m = len(a), len(b) if n > m: From 65b550eb4413361952197776af4d718d604b2ce8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:42:58 +0200 Subject: [PATCH 076/323] Ignore invalid pylint warnings (#211) --- mopidy/__main__.py | 2 ++ mopidy/audio/mixers/auto.py | 2 ++ mopidy/frontends/mpd/protocol/__init__.py | 4 ++-- mopidy/utils/path.py | 2 ++ mopidy/utils/versioning.py | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 30e71b60..97c2a010 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,4 +1,6 @@ +# pylint: disable = E0611,F0401 from distutils.version import StrictVersion +# pylint: enable = E0611,F0401 import logging import optparse import os diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 45806040..f3806eef 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -19,7 +19,9 @@ class AutoAudioMixer(gst.Bin): gst.Bin.__init__(self) mixer = self._find_mixer() if mixer: + # pylint: disable=E1101 self.add(mixer) + # pylint: enable=E1101 logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) else: logger.debug('AutoAudioMixer did not find any usable mixers') diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 66c8a84a..968a7dac 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -69,8 +69,8 @@ def load_protocol_modules(): The protocol modules must be imported to get them registered in :attr:`request_handlers` and :attr:`mpd_commands`. """ - # pylint: disable = W0611 + # pylint: disable = W0612 from . import ( # noqa audio_output, command_list, connection, current_playlist, empty, music_db, playback, reflection, status, stickers, stored_playlists) - # pylint: enable = W0611 + # pylint: enable = W0612 diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 220d6775..eef0c2db 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,7 +1,9 @@ import logging import os import re +# pylint: disable = W0402 import string +# pylint: enable = W0402 import sys import urllib diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index b25761e9..8e7d55bd 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -12,9 +12,11 @@ def get_version(): def get_git_version(): process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) + # pylint: disable = E1101 if process.wait() != 0: raise EnvironmentError('Execution of "git describe" failed') version = process.stdout.read().strip() + # pylint: enable = E1101 if version.startswith('v'): version = version[1:] return version From 8683537816f380234f105e8da8f5c6fe6aac5da5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 17 Oct 2012 01:43:22 +0200 Subject: [PATCH 077/323] Don't use command_list as both bool and list (#211) --- mopidy/frontends/mpd/dispatcher.py | 6 +++--- mopidy/frontends/mpd/protocol/command_list.py | 12 +++++++----- .../mpd/protocol/command_list_test.py | 19 ++++++++++++++----- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index ae51d270..6f91c491 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -22,8 +22,9 @@ class MpdDispatcher(object): def __init__(self, session=None, core=None): self.authenticated = False - self.command_list = False + self.command_list_receiving = False self.command_list_ok = False + self.command_list = [] self.command_list_index = None self.context = MpdContext(self, session=session, core=core) @@ -108,8 +109,7 @@ class MpdDispatcher(object): def _is_receiving_command_list(self, request): return ( - self.command_list is not False and - request != u'command_list_end') + self.command_list_receiving and request != u'command_list_end') def _is_processing_command_list(self, request): return ( diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index a58c11e2..d422f97e 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -19,18 +19,19 @@ def command_list_begin(context): returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ - context.dispatcher.command_list = [] + context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = False + context.dispatcher.command_list = [] @handle_request(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" - if context.dispatcher.command_list is False: - # Test for False exactly, and not e.g. empty list + if not context.dispatcher.command_list_receiving: raise MpdUnknownCommand(command='command_list_end') + context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( - context.dispatcher.command_list, False) + context.dispatcher.command_list, []) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False) command_list_response = [] @@ -49,5 +50,6 @@ def command_list_end(context): @handle_request(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" - context.dispatcher.command_list = [] + context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = True + context.dispatcher.command_list = [] diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index 64ef8688..dbd7f9c9 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -18,13 +18,18 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_with_ping(self): self.sendRequest(u'command_list_begin') + self.assertTrue(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest(u'command_list_end') self.assertInResponse(u'OK') - self.assertEqual(False, self.dispatcher.command_list) + self.assertFalse(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) + self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): self.sendRequest(u'command_list_begin') @@ -39,15 +44,19 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_ok_with_ping(self): self.sendRequest(u'command_list_ok_begin') + self.assertTrue(self.dispatcher.command_list_receiving) + self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(True, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest(u'command_list_end') self.assertInResponse(u'list_OK') self.assertInResponse(u'OK') - self.assertEqual(False, self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) + self.assertFalse(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) + self.assertEqual([], self.dispatcher.command_list) # FIXME this should also include the special handling of idle within a # command list. That is that once a idle/noidle command is found inside a From 893efe426f16fa5ff5147e4752c87f4da524032d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 18 Oct 2012 12:11:42 +0200 Subject: [PATCH 078/323] Ignore pylint warning (#211) Caused by helper function given access to class internals --- mopidy/core/playback.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 90e7e639..d2411738 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -13,7 +13,9 @@ def option_wrapper(name, default): def set_option(self, value): if getattr(self, name, default) != value: + # pylint: disable = W0212 self._trigger_options_changed() + # pylint: enable = W0212 return setattr(self, name, value) return property(get_option, set_option) From 9144c28483a434d298f232dccbdda0160444bd86 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 24 Oct 2012 22:35:36 +0200 Subject: [PATCH 079/323] Fix 'not-negotiated' errors on some Spotify tracks (fixes #213) --- docs/changes.rst | 6 ++++++ mopidy/audio/actor.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 17d50072..854c90d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,12 @@ v0.9.0 (in development) - Pykka >= 0.16 is now required. +**Bug fixes** + +- :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors + observed by some users on some Spotify tracks due to a change introduced in + 0.8.0. See the issue for a patch that applies to 0.8.0. + v0.8.0 (2012-09-20) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 77b451d7..fee5f094 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -61,6 +61,21 @@ class Audio(ThreadingActor): fakesink = gst.element_factory_make('fakesink') self._playbin.set_property('video-sink', fakesink) + self._playbin.connect('notify::source', self._on_new_source) + + def _on_new_source(self, element, pad): + uri = element.get_property('uri') + if not uri or not uri.startswith('appsrc://'): + return + + # These caps matches the audio data provided by libspotify + default_caps = gst.Caps( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + source = element.get_property('source') + source.set_property('caps', default_caps) + def _teardown_playbin(self): self._playbin.set_state(gst.STATE_NULL) From 2fd86cb16e86c1c8131acfc649638df47105588c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 09:30:59 +0200 Subject: [PATCH 080/323] Recommend flake8 for style checking --- docs/development.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 49d8add5..eae211b9 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -41,9 +41,10 @@ Code style ========== - Follow :pep:`8` unless otherwise noted. `pep8.py - `_ can be used to check your code against - the guidelines, however remember that matching the style of the surrounding - code is also important. + `_ or `flake8 + `_ can be used to check your code + against the guidelines, however remember that matching the style of the + surrounding code is also important. - Use four spaces for indentation, *never* tabs. From 4588dd2ec230f0d45d8777b019ec5e5b7d8be2f7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 22:42:24 +0200 Subject: [PATCH 081/323] Empty Spotify backend's __init__ and flatten logger hierarchy --- mopidy/backends/spotify/__init__.py | 72 +------------------- mopidy/backends/spotify/actor.py | 68 ++++++++++++++++++ mopidy/backends/spotify/container_manager.py | 2 +- mopidy/backends/spotify/library.py | 9 +-- mopidy/backends/spotify/playback.py | 6 +- mopidy/backends/spotify/playlist_manager.py | 2 +- mopidy/backends/spotify/session_manager.py | 12 ++-- mopidy/backends/spotify/stored_playlists.py | 4 +- mopidy/backends/spotify/translator.py | 2 +- 9 files changed, 90 insertions(+), 87 deletions(-) create mode 100644 mopidy/backends/spotify/actor.py diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 749a43c0..87d76c46 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,70 +1,2 @@ -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.backends import base - -logger = logging.getLogger('mopidy.backends.spotify') - -BITRATES = {96: 2, 160: 0, 320: 1} - - -class SpotifyBackend(ThreadingActor, base.Backend): - """ - A backend for playing music from the `Spotify `_ - music streaming service. The backend uses the official `libspotify - `_ library and the - `pyspotify `_ Python bindings for - libspotify. - - .. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - - **Issues:** - https://github.com/mopidy/mopidy/issues?labels=backend-spotify - - **Dependencies:** - - - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) - - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) - - **Settings:** - - - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` - - :attr:`mopidy.settings.SPOTIFY_USERNAME` - - :attr:`mopidy.settings.SPOTIFY_PASSWORD` - """ - - # Imports inside methods are to prevent loading of __init__.py to fail on - # missing spotify dependencies. - - def __init__(self, audio): - from .library import SpotifyLibraryProvider - from .playback import SpotifyPlaybackProvider - from .session_manager import SpotifySessionManager - from .stored_playlists import SpotifyStoredPlaylistsProvider - - self.library = SpotifyLibraryProvider(backend=self) - self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) - self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) - - self.uri_schemes = [u'spotify'] - - # Fail early if settings are not present - username = settings.SPOTIFY_USERNAME - password = settings.SPOTIFY_PASSWORD - - self.spotify = SpotifySessionManager( - username, password, audio=audio, backend_ref=self.actor_ref) - - def on_start(self): - logger.info(u'Mopidy uses SPOTIFY(R) CORE') - logger.debug(u'Connecting to Spotify') - self.spotify.start() - - def on_stop(self): - self.spotify.logout() +# flake8: noqa +from .actor import SpotifyBackend diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py new file mode 100644 index 00000000..186f5729 --- /dev/null +++ b/mopidy/backends/spotify/actor.py @@ -0,0 +1,68 @@ +import logging + +from pykka.actor import ThreadingActor + +from mopidy import settings +from mopidy.backends import base + +logger = logging.getLogger('mopidy.backends.spotify') + + +class SpotifyBackend(ThreadingActor, base.Backend): + """ + A backend for playing music from the `Spotify `_ + music streaming service. The backend uses the official `libspotify + `_ library and the + `pyspotify `_ Python bindings for + libspotify. + + .. note:: + + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. + + **Issues:** + https://github.com/mopidy/mopidy/issues?labels=backend-spotify + + **Dependencies:** + + - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) + - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) + + **Settings:** + + - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` + - :attr:`mopidy.settings.SPOTIFY_USERNAME` + - :attr:`mopidy.settings.SPOTIFY_PASSWORD` + """ + + # Imports inside methods are to prevent loading of __init__.py to fail on + # missing spotify dependencies. + + def __init__(self, audio): + from .library import SpotifyLibraryProvider + from .playback import SpotifyPlaybackProvider + from .session_manager import SpotifySessionManager + from .stored_playlists import SpotifyStoredPlaylistsProvider + + self.library = SpotifyLibraryProvider(backend=self) + self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) + self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) + + self.uri_schemes = [u'spotify'] + + # Fail early if settings are not present + username = settings.SPOTIFY_USERNAME + password = settings.SPOTIFY_PASSWORD + + self.spotify = SpotifySessionManager( + username, password, audio=audio, backend_ref=self.actor_ref) + + def on_start(self): + logger.info(u'Mopidy uses SPOTIFY(R) CORE') + logger.debug(u'Connecting to Spotify') + self.spotify.start() + + def on_stop(self): + self.spotify.logout() diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index a45b1adc..e3388e0b 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -3,7 +3,7 @@ import logging from spotify.manager import SpotifyContainerManager as \ PyspotifyContainerManager -logger = logging.getLogger('mopidy.backends.spotify.container_manager') +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyContainerManager(PyspotifyContainerManager): diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index b254519e..e237a04a 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -3,11 +3,12 @@ import Queue from spotify import Link, SpotifyError -from mopidy.backends.base import BaseLibraryProvider -from mopidy.backends.spotify.translator import SpotifyTranslator +from mopidy.backends import base from mopidy.models import Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify.library') +from .translator import SpotifyTranslator + +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTrack(Track): @@ -49,7 +50,7 @@ class SpotifyTrack(Track): return self._proxy.copy(**values) -class SpotifyLibraryProvider(BaseLibraryProvider): +class SpotifyLibraryProvider(base.BaseLibraryProvider): def find_exact(self, **query): return self.search(**query) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d3d0cfa9..40868745 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -3,14 +3,14 @@ import time from spotify import Link, SpotifyError -from mopidy.backends.base import BasePlaybackProvider +from mopidy.backends import base from mopidy.core import PlaybackState -logger = logging.getLogger('mopidy.backends.spotify.playback') +logger = logging.getLogger('mopidy.backends.spotify') -class SpotifyPlaybackProvider(BasePlaybackProvider): +class SpotifyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index e1308a49..645a574c 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -3,7 +3,7 @@ import logging from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager -logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyPlaylistManager(PyspotifyPlaylistManager): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index caa777e1..983f3861 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -5,14 +5,16 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager from mopidy import settings -from mopidy.backends.spotify import BITRATES -from mopidy.backends.spotify.container_manager import SpotifyContainerManager -from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager -from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist from mopidy.utils import process, versioning -logger = logging.getLogger('mopidy.backends.spotify.session_manager') +from .container_manager import SpotifyContainerManager +from .playlist_manager import SpotifyPlaylistManager +from .translator import SpotifyTranslator + +logger = logging.getLogger('mopidy.backends.spotify') + +BITRATES = {96: 2, 160: 0, 320: 1} # pylint: disable = R0901 # SpotifySessionManager: Too many ancestors (9/7) diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index 85695c40..9a2328c4 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -1,7 +1,7 @@ -from mopidy.backends.base import BaseStoredPlaylistsProvider +from mopidy.backends import base -class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): pass # TODO diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 82c11ef7..104029f5 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -5,7 +5,7 @@ from spotify import Link, SpotifyError from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify.translator') +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTranslator(object): From 45a79df0a88ec07d6a79c6391c4ffb1dae2f2e97 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 22:47:20 +0200 Subject: [PATCH 082/323] Split local backend into multiple files and flatten logging hierarchy --- mopidy/backends/local/__init__.py | 213 +--------------------- mopidy/backends/local/actor.py | 33 ++++ mopidy/backends/local/library.py | 110 +++++++++++ mopidy/backends/local/stored_playlists.py | 85 +++++++++ mopidy/backends/local/translator.py | 4 +- 5 files changed, 232 insertions(+), 213 deletions(-) create mode 100644 mopidy/backends/local/actor.py create mode 100644 mopidy/backends/local/library.py create mode 100644 mopidy/backends/local/stored_playlists.py diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index b34c3da5..6f0f3770 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,211 +1,2 @@ -import glob -import logging -import os -import shutil - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.backends import base -from mopidy.models import Playlist, Album - -from .translator import parse_m3u, parse_mpd_tag_cache - -logger = logging.getLogger(u'mopidy.backends.local') - - -class LocalBackend(ThreadingActor, base.Backend): - """ - A backend for playing music from a local music archive. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` - - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` - - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` - """ - - def __init__(self, audio): - self.library = LocalLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) - self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) - - self.uri_schemes = [u'file'] - - -class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): - def __init__(self, *args, **kwargs): - super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH - self.refresh() - - def lookup(self, uri): - pass # TODO - - def refresh(self): - playlists = [] - - logger.info('Loading playlists from %s', self._folder) - - for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:-len('.m3u')] - tracks = [] - for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): - try: - tracks.append(self.backend.library.lookup(uri)) - except LookupError, e: - logger.error('Playlist item could not be added: %s', e) - playlist = Playlist(tracks=tracks, name=name) - - # FIXME playlist name needs better handling - # FIXME tracks should come from lib. lookup - - playlists.append(playlist) - - self.playlists = playlists - - def create(self, name): - playlist = Playlist(name=name) - self.save(playlist) - return playlist - - def delete(self, playlist): - if playlist not in self._playlists: - return - - self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) - - def rename(self, playlist, name): - if playlist not in self._playlists: - return - - src = os.path.join(self._folder, playlist.name + '.m3u') - dst = os.path.join(self._folder, name + '.m3u') - - renamed = playlist.copy(name=name) - index = self._playlists.index(playlist) - self._playlists[index] = renamed - - shutil.move(src, dst) - - def save(self, playlist): - file_path = os.path.join(self._folder, playlist.name + '.m3u') - - # FIXME this should be a save_m3u function, not inside save - with open(file_path, 'w') as file_handle: - for track in playlist.tracks: - if track.uri.startswith('file://'): - file_handle.write(track.uri[len('file://'):] + '\n') - else: - file_handle.write(track.uri + '\n') - - self._playlists.append(playlist) - - -class LocalLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self.refresh() - - def refresh(self, uri=None): - tracks = parse_mpd_tag_cache( - settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) - - logger.info( - 'Loading tracks in %s from %s', - settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) - - for track in tracks: - self._uri_mapping[track.uri] = track - - def lookup(self, uri): - try: - return self._uri_mapping[uri] - except KeyError: - logger.debug(u'Failed to lookup "%s"', uri) - return None - - def find_exact(self, **query): - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - q = value.strip() - - 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': - 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: - raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) - - def search(self, **query): - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - q = value.strip().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': - 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: - raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) - - def _validate_query(self, query): - for (_, values) in query.iteritems(): - if not values: - raise LookupError('Missing query') - for value in values: - if not value: - raise LookupError('Missing query') +# flake8: noqa +from .actor import LocalBackend diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py new file mode 100644 index 00000000..fe31a5fc --- /dev/null +++ b/mopidy/backends/local/actor.py @@ -0,0 +1,33 @@ +import logging + +from pykka.actor import ThreadingActor + +from mopidy.backends import base + +from .library import LocalLibraryProvider +from .stored_playlists import LocalStoredPlaylistsProvider + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalBackend(ThreadingActor, base.Backend): + """ + A backend for playing music from a local music archive. + + **Dependencies:** + + - None + + **Settings:** + + - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` + - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` + - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` + """ + + def __init__(self, audio): + self.library = LocalLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) + + self.uri_schemes = [u'file'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py new file mode 100644 index 00000000..78178196 --- /dev/null +++ b/mopidy/backends/local/library.py @@ -0,0 +1,110 @@ +import logging + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import Playlist, Album + +from .translator import parse_mpd_tag_cache + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalLibraryProvider(base.BaseLibraryProvider): + def __init__(self, *args, **kwargs): + super(LocalLibraryProvider, self).__init__(*args, **kwargs) + self._uri_mapping = {} + self.refresh() + + def refresh(self, uri=None): + tracks = parse_mpd_tag_cache( + settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) + + logger.info( + 'Loading tracks in %s from %s', + settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) + + for track in tracks: + self._uri_mapping[track.uri] = track + + def lookup(self, uri): + try: + return self._uri_mapping[uri] + except KeyError: + logger.debug(u'Failed to lookup "%s"', uri) + return None + + def find_exact(self, **query): + self._validate_query(query) + result_tracks = self._uri_mapping.values() + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + q = value.strip() + + 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': + 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: + raise LookupError('Invalid lookup field: %s' % field) + return Playlist(tracks=result_tracks) + + def search(self, **query): + self._validate_query(query) + result_tracks = self._uri_mapping.values() + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + q = value.strip().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': + 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: + raise LookupError('Invalid lookup field: %s' % field) + return Playlist(tracks=result_tracks) + + def _validate_query(self, query): + for (_, values) in query.iteritems(): + if not values: + raise LookupError('Missing query') + for value in values: + if not value: + raise LookupError('Missing query') diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py new file mode 100644 index 00000000..1cb03425 --- /dev/null +++ b/mopidy/backends/local/stored_playlists.py @@ -0,0 +1,85 @@ +import glob +import logging +import os +import shutil + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import Playlist + +from .translator import parse_m3u + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): + def __init__(self, *args, **kwargs): + super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) + self._folder = settings.LOCAL_PLAYLIST_PATH + self.refresh() + + def lookup(self, uri): + pass # TODO + + def refresh(self): + playlists = [] + + logger.info('Loading playlists from %s', self._folder) + + for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): + name = os.path.basename(m3u)[:-len('.m3u')] + tracks = [] + for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): + try: + tracks.append(self.backend.library.lookup(uri)) + except LookupError, e: + logger.error('Playlist item could not be added: %s', e) + playlist = Playlist(tracks=tracks, name=name) + + # FIXME playlist name needs better handling + # FIXME tracks should come from lib. lookup + + playlists.append(playlist) + + self.playlists = playlists + + def create(self, name): + playlist = Playlist(name=name) + self.save(playlist) + return playlist + + def delete(self, playlist): + if playlist not in self._playlists: + return + + self._playlists.remove(playlist) + filename = os.path.join(self._folder, playlist.name + '.m3u') + + if os.path.exists(filename): + os.remove(filename) + + def rename(self, playlist, name): + if playlist not in self._playlists: + return + + src = os.path.join(self._folder, playlist.name + '.m3u') + dst = os.path.join(self._folder, name + '.m3u') + + renamed = playlist.copy(name=name) + index = self._playlists.index(playlist) + self._playlists[index] = renamed + + shutil.move(src, dst) + + def save(self, playlist): + file_path = os.path.join(self._folder, playlist.name + '.m3u') + + # FIXME this should be a save_m3u function, not inside save + with open(file_path, 'w') as file_handle: + for track in playlist.tracks: + if track.uri.startswith('file://'): + file_handle.write(track.uri[len('file://'):] + '\n') + else: + file_handle.write(track.uri + '\n') + + self._playlists.append(playlist) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 5a4a238b..01aad440 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,11 +1,11 @@ import logging -logger = logging.getLogger('mopidy.backends.local.translator') - from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri +logger = logging.getLogger('mopidy.backends.local') + def parse_m3u(file_path, music_folder): r""" From c915c197dd3f91ae86695fbcaf033348fdbdb2df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 25 Oct 2012 22:49:47 +0200 Subject: [PATCH 083/323] Formatting --- tests/backends/local/stored_playlists_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 437152fe..4dc5ecdb 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,4 +1,5 @@ import os + from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Playlist, Track From f309e7ec2313f9ca096521e96920e889a8fba51f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 10:40:47 +0200 Subject: [PATCH 084/323] Revise audio logging messages --- mopidy/audio/actor.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index fee5f094..f151f487 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -84,20 +84,20 @@ class Audio(ThreadingActor): output = gst.parse_bin_from_description( settings.OUTPUT, ghost_unconnected_pads=True) self._playbin.set_property('audio-sink', output) - logger.info('Output set to %s', settings.OUTPUT) + logger.info('Audio output set to "%s"', settings.OUTPUT) except gobject.GError as ex: logger.error( - 'Failed to create output "%s": %s', settings.OUTPUT, ex) + 'Failed to create audio output "%s": %s', settings.OUTPUT, ex) process.exit_process() def _setup_mixer(self): if not settings.MIXER: - logger.info('Not setting up mixer.') + logger.info('Not setting up audio mixer') return if settings.MIXER == 'software': self._software_mixing = True - logger.info('Mixer set to software mixing.') + logger.info('Audio mixer is using software mixing') return try: @@ -105,28 +105,31 @@ class Audio(ThreadingActor): settings.MIXER, ghost_unconnected_pads=False) except gobject.GError as ex: logger.warning( - 'Failed to create mixer "%s": %s', settings.MIXER, ex) + 'Failed to create audio mixer "%s": %s', settings.MIXER, ex) return # We assume that the bin will contain a single mixer. mixer = mixerbin.get_by_interface('GstMixer') if not mixer: - logger.warning('Did not find any mixers in %r', settings.MIXER) + logger.warning( + 'Did not find any audio mixers in "%s"', settings.MIXER) return if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Setting mixer %r to READY failed.', settings.MIXER) + logger.warning( + 'Setting audio mixer "%s" to READY failed', settings.MIXER) return track = self._select_mixer_track(mixer, settings.MIXER_TRACK) if not track: - logger.warning('Could not find usable mixer track.') + logger.warning('Could not find usable audio mixer track') return self._mixer = mixer self._mixer_track = track - logger.info('Mixer set to %s using track called %s', - mixer.get_factory().get_name(), track.label) + logger.info( + 'Audio mixer set to "%s" using track "%s"', + mixer.get_factory().get_name(), track.label) def _select_mixer_track(self, mixer, track_label): # Look for track with label == MIXER_TRACK, otherwise fallback to @@ -297,15 +300,15 @@ class Audio(ThreadingActor): result = self._playbin.set_state(state) if result == gst.STATE_CHANGE_FAILURE: logger.warning( - 'Setting GStreamer state to %s: failed', state.value_name) + 'Setting GStreamer state to %s failed', state.value_name) return False elif result == gst.STATE_CHANGE_ASYNC: logger.debug( - 'Setting GStreamer state to %s: async', state.value_name) + 'Setting GStreamer state to %s is async', state.value_name) return True else: logger.debug( - 'Setting GStreamer state to %s: OK', state.value_name) + 'Setting GStreamer state to %s is OK', state.value_name) return True def get_volume(self): From a78492a65b0ee14a7b18b2b55647a72fc470e333 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 10:45:11 +0200 Subject: [PATCH 085/323] Revise local backend logging messages --- mopidy/backends/local/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 78178196..600bfaaa 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -20,7 +20,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) logger.info( - 'Loading tracks in %s from %s', + 'Loading tracks from %s using %s', settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) for track in tracks: @@ -30,7 +30,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): try: return self._uri_mapping[uri] except KeyError: - logger.debug(u'Failed to lookup "%s"', uri) + logger.debug(u'Failed to lookup %r', uri) return None def find_exact(self, **query): From 587dde287faa44c681b11fa86bf4a4fa8cb5c17d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:32:06 +0200 Subject: [PATCH 086/323] Update to work with Pykka 1.0 --- docs/changes.rst | 2 +- mopidy/__main__.py | 2 +- mopidy/backends/dummy.py | 2 ++ mopidy/backends/local/actor.py | 2 ++ mopidy/backends/spotify/actor.py | 2 ++ mopidy/core/actor.py | 2 ++ mopidy/utils/network.py | 2 +- tests/utils/network/connection_test.py | 4 ++-- 8 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 854c90d3..c68db685 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,7 +10,7 @@ v0.9.0 (in development) **Dependencies** -- Pykka >= 0.16 is now required. +- Pykka >= 1.0 is now required. **Bug fixes** diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 97c2a010..67fefde6 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -74,7 +74,7 @@ def main(): def check_dependencies(): - pykka_required = '0.16' + pykka_required = '1.0' if StrictVersion(pykka.__version__) < StrictVersion(pykka_required): sys.exit( u'Mopidy requires Pykka >= %s, but found %s' % diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 6c3e1437..bb5aaf73 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -13,6 +13,8 @@ class DummyBackend(ThreadingActor, base.Backend): """ def __init__(self, audio): + super(DummyBackend, self).__init__() + self.library = DummyLibraryProvider(backend=self) self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index fe31a5fc..1046aaf4 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -26,6 +26,8 @@ class LocalBackend(ThreadingActor, base.Backend): """ def __init__(self, audio): + super(LocalBackend, self).__init__() + self.library = LocalLibraryProvider(backend=self) self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 186f5729..3c897380 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -41,6 +41,8 @@ class SpotifyBackend(ThreadingActor, base.Backend): # missing spotify dependencies. def __init__(self, audio): + super(SpotifyBackend, self).__init__() + from .library import SpotifyLibraryProvider from .playback import SpotifyPlaybackProvider from .session_manager import SpotifySessionManager diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index aded0774..806caca2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -26,6 +26,8 @@ class Core(ThreadingActor, AudioListener): stored_playlists = None def __init__(self, audio=None, backend=None): + super(Core, self).__init__() + self._backend = backend self.current_playlist = CurrentPlaylistController(core=self) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index b8914614..a6032f37 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -265,7 +265,7 @@ class Connection(object): return True try: - self.actor_ref.send_one_way({'received': data}) + self.actor_ref.tell({'received': data}) except ActorDeadError: self.stop(u'Actor is dead.') diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index c51957f1..c9fe9a05 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -392,14 +392,14 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) - self.mock.actor_ref.send_one_way.assert_called_once_with( + self.mock.actor_ref.tell.assert_called_once_with( {'received': 'data'}) def test_recv_callback_handles_dead_actors(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = 'data' self.mock.actor_ref = Mock() - self.mock.actor_ref.send_one_way.side_effect = pykka.ActorDeadError() + self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) From 70c72365b821a720e0086450eb6faac86dcdfded Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:47:41 +0200 Subject: [PATCH 087/323] Update Pykka version in install docs --- docs/installation/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d5728c00..c58ba9dd 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -26,7 +26,7 @@ dependencies installed. - Python >= 2.6, < 3 - - Pykka >= 0.16:: + - Pykka >= 1.0:: sudo pip install -U pykka From d685fe554c37287fc56604581e8d19d00a3a3aee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 22:57:41 +0200 Subject: [PATCH 088/323] Simplify pykka imports --- mopidy/audio/actor.py | 4 ++-- mopidy/audio/listener.py | 4 ++-- mopidy/audio/mixers/nad.py | 4 ++-- mopidy/backends/dummy.py | 4 ++-- mopidy/backends/local/actor.py | 4 ++-- mopidy/backends/spotify/actor.py | 4 ++-- mopidy/core/actor.py | 4 ++-- mopidy/core/listener.py | 4 ++-- mopidy/frontends/lastfm.py | 4 ++-- mopidy/frontends/mpd/actor.py | 6 +++--- mopidy/frontends/mpd/dispatcher.py | 4 ++-- mopidy/frontends/mpd/protocol/status.py | 4 ++-- mopidy/frontends/mpris/actor.py | 4 ++-- mopidy/utils/network.py | 12 +++++------- tests/backends/base/current_playlist.py | 4 ++-- tests/backends/base/library.py | 4 ++-- tests/backends/events_test.py | 5 ++--- tests/frontends/mpd/dispatcher_test.py | 4 ++-- tests/frontends/mpd/protocol/__init__.py | 5 ++--- tests/frontends/mpd/status_test.py | 4 ++-- tests/frontends/mpris/player_interface_test.py | 5 ++--- tests/frontends/mpris/root_interface_test.py | 5 ++--- 22 files changed, 48 insertions(+), 54 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f151f487..53e8f723 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -5,7 +5,7 @@ import gobject import logging -from pykka.actor import ThreadingActor +import pykka from mopidy import settings from mopidy.utils import process @@ -18,7 +18,7 @@ logger = logging.getLogger('mopidy.audio') mixers.register_mixers() -class Audio(ThreadingActor): +class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 757cd5f4..54fe058d 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka class AudioListener(object): @@ -15,7 +15,7 @@ class AudioListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of audio listener events""" - listeners = ActorRegistry.get_by_class(AudioListener) + listeners = pykka.ActorRegistry.get_by_class(AudioListener) for listener in listeners: getattr(listener.proxy(), event)(**kwargs) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 72bede82..1d65ead9 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -10,7 +10,7 @@ try: except ImportError: serial = None # noqa -from pykka.actor import ThreadingActor +import pykka from . import utils @@ -74,7 +74,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): ).proxy() -class NadTalker(ThreadingActor): +class NadTalker(pykka.ThreadingActor): """ Independent thread which does the communication with the NAD amplifier diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index bb5aaf73..3a1d65b7 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,10 +1,10 @@ -from pykka.actor import ThreadingActor +import pykka from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, base.Backend): +class DummyBackend(pykka.ThreadingActor, base.Backend): """ A backend which implements the backend API in the simplest way possible. Used in tests of the frontends. diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 1046aaf4..10802722 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -1,6 +1,6 @@ import logging -from pykka.actor import ThreadingActor +import pykka from mopidy.backends import base @@ -10,7 +10,7 @@ from .stored_playlists import LocalStoredPlaylistsProvider logger = logging.getLogger(u'mopidy.backends.local') -class LocalBackend(ThreadingActor, base.Backend): +class LocalBackend(pykka.ThreadingActor, base.Backend): """ A backend for playing music from a local music archive. diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 3c897380..948636a2 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -1,6 +1,6 @@ import logging -from pykka.actor import ThreadingActor +import pykka from mopidy import settings from mopidy.backends import base @@ -8,7 +8,7 @@ from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') -class SpotifyBackend(ThreadingActor, base.Backend): +class SpotifyBackend(pykka.ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ music streaming service. The backend uses the official `libspotify diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 806caca2..a3766fff 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,4 +1,4 @@ -from pykka.actor import ThreadingActor +import pykka from mopidy.audio import AudioListener @@ -8,7 +8,7 @@ from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController -class Core(ThreadingActor, AudioListener): +class Core(pykka.ThreadingActor, AudioListener): #: The current playlist controller. An instance of #: :class:`mopidy.core.CurrentPlaylistController`. current_playlist = None diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 9476ac4f..ed7dae2f 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka class CoreListener(object): @@ -15,7 +15,7 @@ class CoreListener(object): @staticmethod def send(event, **kwargs): """Helper to allow calling of core listener events""" - listeners = ActorRegistry.get_by_class(CoreListener) + listeners = pykka.ActorRegistry.get_by_class(CoreListener) for listener in listeners: getattr(listener.proxy(), event)(**kwargs) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 45c2db16..e7c2afdb 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,7 +1,7 @@ import logging import time -from pykka.actor import ThreadingActor +import pykka from mopidy import exceptions, settings from mopidy.core import CoreListener @@ -17,7 +17,7 @@ API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, CoreListener): +class LastfmFrontend(pykka.ThreadingActor, CoreListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index d7a20158..0c73bc2b 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -1,7 +1,7 @@ import logging import sys -from pykka import registry, actor +import pykka from mopidy import settings from mopidy.core import CoreListener @@ -11,7 +11,7 @@ from mopidy.utils import encoding, network, process logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(actor.ThreadingActor, CoreListener): +class MpdFrontend(pykka.ThreadingActor, CoreListener): """ The MPD frontend. @@ -50,7 +50,7 @@ class MpdFrontend(actor.ThreadingActor, CoreListener): def send_idle(self, subsystem): # FIXME this should be updated once pykka supports non-blocking calls # on proxies or some similar solution - registry.ActorRegistry.broadcast({ + pykka.ActorRegistry.broadcast({ 'command': 'pykka_call', 'attr_path': ('on_idle',), 'args': [subsystem], diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 6f91c491..148fe443 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -1,7 +1,7 @@ import logging import re -from pykka import ActorDeadError +import pykka from mopidy import settings from mopidy.frontends.mpd import exceptions, protocol @@ -156,7 +156,7 @@ class MpdDispatcher(object): try: response = self._format_response(self._call_handler(request)) return self._call_next_filter(request, response, filter_chain) - except ActorDeadError as e: + except pykka.ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') raise exceptions.MpdSystemError(e) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index deda4986..b8e207d1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,4 +1,4 @@ -import pykka.future +import pykka from mopidy.core import PlaybackState from mopidy.frontends.mpd.exceptions import MpdNotImplemented @@ -186,7 +186,7 @@ def status(context): context.core.playback.current_playlist_position), 'playback.time_position': context.core.playback.time_position, } - pykka.future.get_all(futures.values()) + pykka.get_all(futures.values()) result = [ ('volume', _status_volume(futures)), ('repeat', _status_repeat(futures)), diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index e3199ac3..acca3ab7 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -1,6 +1,6 @@ import logging -from pykka.actor import ThreadingActor +import pykka from mopidy import settings from mopidy.core import CoreListener @@ -15,7 +15,7 @@ except ImportError as import_error: logger.debug(u'Startup notification will not be sent (%s)', import_error) -class MprisFrontend(ThreadingActor, CoreListener): +class MprisFrontend(pykka.ThreadingActor, CoreListener): """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index a6032f37..e56f6a81 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -5,9 +5,7 @@ import re import socket import threading -from pykka import ActorDeadError -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry +import pykka from mopidy.utils import encoding @@ -105,7 +103,7 @@ class Server(object): self.number_of_connections() >= self.max_connections) def number_of_connections(self): - return len(ActorRegistry.get_by_class(self.protocol)) + return len(pykka.ActorRegistry.get_by_class(self.protocol)) def reject_connection(self, sock, addr): # FIXME provide more context in logging? @@ -164,7 +162,7 @@ class Connection(object): try: self.actor_ref.stop(block=False) - except ActorDeadError: + except pykka.ActorDeadError: pass self.disable_timeout() @@ -266,7 +264,7 @@ class Connection(object): try: self.actor_ref.tell({'received': data}) - except ActorDeadError: + except pykka.ActorDeadError: self.stop(u'Actor is dead.') return True @@ -295,7 +293,7 @@ class Connection(object): return False -class LineProtocol(ThreadingActor): +class LineProtocol(pykka.ThreadingActor): """ Base class for handling line based protocols. diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 00ffaea8..9d86027e 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,7 +1,7 @@ import mock import random -from pykka.registry import ActorRegistry +import pykka from mopidy import audio, core from mopidy.core import PlaybackState @@ -23,7 +23,7 @@ class CurrentPlaylistControllerTest(object): assert len(self.tracks) == 3, 'Need three tracks to run tests.' def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_length(self): self.assertEqual(0, len(self.controller.cp_tracks)) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 85ba54bb..edaa704d 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka from mopidy import core from mopidy.models import Playlist, Track, Album, Artist @@ -27,7 +27,7 @@ class LibraryControllerTest(object): self.library = self.core.library def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_refresh(self): self.library.refresh() diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 200e0ca2..9c552f39 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -1,6 +1,5 @@ import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import audio, core from mopidy.backends import dummy @@ -17,7 +16,7 @@ class BackendEventsTest(unittest.TestCase): self.core = core.Core.start(backend=self.backend).proxy() def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): self.core.current_playlist.add(Track(uri='a')) diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 0b5098c1..1e108e07 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka from mopidy import core from mopidy.backends import dummy @@ -16,7 +16,7 @@ class MpdDispatcherTest(unittest.TestCase): self.dispatcher = MpdDispatcher() def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_register_same_pattern_twice_fails(self): func = lambda: None diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 34557513..4c6d3584 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -1,6 +1,5 @@ import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import core, settings from mopidy.backends import dummy @@ -32,7 +31,7 @@ class BaseTestCase(unittest.TestCase): self.context = self.dispatcher.context def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() settings.runtime.clear() def sendRequest(self, request): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 61fd0854..c1b43deb 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,4 +1,4 @@ -from pykka.registry import ActorRegistry +import pykka from mopidy import core from mopidy.backends import dummy @@ -26,7 +26,7 @@ class StatusHandlerTest(unittest.TestCase): self.context = self.dispatcher.context def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_stats_method(self): result = status.stats(self.context) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 5c3d2cae..34375098 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -1,8 +1,7 @@ import sys import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import core, exceptions from mopidy.backends import dummy @@ -30,7 +29,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris = objects.MprisObject(core=self.core) def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_get_playback_status_is_playing_when_playing(self): self.core.playback.state = PLAYING diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 8f37cc47..d185895f 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -1,8 +1,7 @@ import sys import mock - -from pykka.registry import ActorRegistry +import pykka from mopidy import core, exceptions, settings from mopidy.backends import dummy @@ -25,7 +24,7 @@ class RootInterfaceTest(unittest.TestCase): self.mpris = objects.MprisObject(core=self.core) def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_constructor_connects_to_dbus(self): self.assert_(self.mpris._connect_to_dbus.called) From c076fb75d40b85b593bd569eaf7f6e13ab95cdd8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 23:05:53 +0200 Subject: [PATCH 089/323] Replace Pykka internals misuse with proxies --- mopidy/frontends/mpd/actor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 0c73bc2b..f69334b5 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -48,14 +48,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): process.stop_actors_by_class(session.MpdSession) def send_idle(self, subsystem): - # FIXME this should be updated once pykka supports non-blocking calls - # on proxies or some similar solution - pykka.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('on_idle',), - 'args': [subsystem], - 'kwargs': {}, - }, target_class=session.MpdSession) + listeners = pykka.ActorRegistry.get_by_class(session.MpdSession) + for listener in listeners: + getattr(listener.proxy(), 'on_idle')(subsystem) def playback_state_changed(self, old_state, new_state): self.send_idle('player') From a8e71afeaf9238d69df90fa8cdba233e960912c2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 26 Oct 2012 23:09:44 +0200 Subject: [PATCH 090/323] Update Pykka version yet another place --- requirements/core.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/core.txt b/requirements/core.txt index 1c2371f3..7f83e251 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.16 +Pykka >= 1.0 From 956655f7428cb1491a64330845cbd3602dfed250 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 11:22:54 +0200 Subject: [PATCH 091/323] Update tests to use tracks with valid URIs --- mopidy/backends/dummy.py | 4 +- tests/backends/events_test.py | 10 +- tests/frontends/mpd/protocol/playback_test.py | 75 ++-- .../frontends/mpd/protocol/regression_test.py | 36 +- tests/frontends/mpd/status_test.py | 14 +- .../frontends/mpris/player_interface_test.py | 385 ++++++++++-------- 6 files changed, 291 insertions(+), 233 deletions(-) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 3a1d65b7..94bb9b1d 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -51,9 +51,9 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): return True def play(self, track): - """Pass None as track to force failure""" + """Pass a track with URI 'dummy:error' to force failure""" self._time_position = 0 - return track is not None + return track.uri != 'dummy:error' def resume(self): return True diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 9c552f39..a25a73c2 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -19,14 +19,14 @@ class BackendEventsTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.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): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='dummy:a')) self.core.playback.play() self.core.playback.pause().get() send.reset_mock() @@ -34,20 +34,20 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): - self.core.current_playlist.add(Track(uri='a')) + self.core.current_playlist.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.current_playlist.add(Track(uri='a')) + self.core.current_playlist.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.current_playlist.add(Track(uri='a', length=40000)) + self.core.current_playlist.add(Track(uri='dummy:a', length=40000)) self.core.playback.play().get() send.reset_mock() self.core.playback.seek(1000).get() diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 431c4663..ab254bdf 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -166,7 +166,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_off(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') @@ -175,7 +175,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_on(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') @@ -183,7 +183,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_toggle(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -198,22 +198,21 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_play_without_pos(self): - self.core.current_playlist.append([Track()]) - self.core.playback.state = PAUSED + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -228,15 +227,22 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('a', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:a', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -245,7 +251,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('b', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:b', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): @@ -257,7 +264,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -270,7 +278,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -285,14 +294,14 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_playid_without_quotes(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -300,15 +309,22 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('a', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:a', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -317,7 +333,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest(u'playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('b', self.core.playback.current_track.get().uri) + self.assertEqual('dummy:b', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): @@ -329,7 +346,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -342,7 +359,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -357,7 +374,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid "12345"') self.assertInResponse(u'ACK [50@0] {playid} No such song') @@ -367,7 +384,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') @@ -375,16 +392,16 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek_with_songpos(self): - seek_track = Track(uri='2', length=40000) + seek_track = Track(uri='dummy:2', length=40000) self.core.current_playlist.append( - [Track(uri='1', length=40000), seek_track]) + [Track(uri='dummy:1', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse(u'OK') def test_seek_without_quotes(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') @@ -393,16 +410,16 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seekid(self): - self.core.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seekid "0" "30"') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): - seek_track = Track(uri='2', length=40000) + seek_track = Track(uri='dummy:2', length=40000) self.core.current_playlist.append( - [Track(length=40000), seek_track]) + [Track(uri='dummy:1', length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') self.assertEqual(1, self.core.playback.current_cpid.get()) diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index a7b7611d..a90e37ab 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -17,22 +17,32 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): """ def test(self): self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), None, - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:error'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + Track(uri='dummy:f'), + ]) random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') - self.assertEquals('a', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:a', + self.core.playback.current_track.get().uri) self.sendRequest(u'random "1"') self.sendRequest(u'next') - self.assertEquals('b', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:b', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:f', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('d', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:d', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('e', self.core.playback.current_track.get().uri) + self.assertEquals('dummy:e', + self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): @@ -48,8 +58,8 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): def test(self): self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) self.sendRequest(u'play') @@ -84,8 +94,8 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): def test(self): self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) self.sendRequest(u'play') @@ -113,8 +123,8 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): def test(self): self.core.stored_playlists.create('foo') self.core.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) self.sendRequest(u'play') self.sendRequest(u'stop') diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index c1b43deb..46f500e7 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -129,21 +129,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): - self.core.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.core.current_playlist.append([Track(length=None)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=None)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -153,7 +153,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.core.current_playlist.append([Track(length=10000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -163,7 +163,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.core.current_playlist.append([Track(length=60000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=60000)]) self.core.playback.play() self.core.playback.pause() self.core.playback.seek(59123) @@ -172,7 +172,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.core.current_playlist.append([Track(length=10000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) self.core.playback.play() self.core.playback.pause() result = dict(status.status(self.context)) @@ -180,7 +180,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.core.current_playlist.append([Track(bitrate=320)]) + self.core.current_playlist.append([Track(uri='dummy:a', bitrate=320)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 34375098..bd0c1728 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -69,23 +69,23 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.playback.repeat = True self.core.playback.single = True self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.core.playback.repeat.get(), True) - self.assertEquals(self.core.playback.single.get(), True) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), True) def test_set_loop_status_to_none_unsets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.core.playback.repeat.get(), False) - self.assertEquals(self.core.playback.single.get(), False) + self.assertEqual(self.core.playback.repeat.get(), False) + self.assertEqual(self.core.playback.single.get(), False) def test_set_loop_status_to_track_sets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEquals(self.core.playback.repeat.get(), True) - self.assertEquals(self.core.playback.single.get(), True) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), True) def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEquals(self.core.playback.repeat.get(), True) - self.assertEquals(self.core.playback.single.get(), False) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), False) def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') @@ -99,18 +99,20 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_get_shuffle_returns_true_if_random_is_active(self): self.core.playback.random = True @@ -143,37 +145,37 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals(result['mpris:trackid'], '') + self.assertEqual(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals( + self.assertEqual( result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) - self.assertEquals(result['mpris:length'], 40000000) + self.assertEqual(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) - self.assertEquals(result['xesam:url'], 'a') + self.assertEqual(result['xesam:url'], 'dummy:a') def test_get_metadata_has_track_title(self): self.core.current_playlist.append([Track(name='a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) - self.assertEquals(result['xesam:title'], 'a') + self.assertEqual(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): self.core.current_playlist.append([Track(artists=[ @@ -181,14 +183,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:artist', result.keys()) - self.assertEquals(result['xesam:artist'], ['a', 'b']) + self.assertEqual(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): self.core.current_playlist.append([Track(album=Album(name='a'))]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) - self.assertEquals(result['xesam:album'], 'a') + self.assertEqual(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): self.core.current_playlist.append([Track(album=Album(artists=[ @@ -196,53 +198,53 @@ class PlayerInterfaceTest(unittest.TestCase): self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:albumArtist', result.keys()) - self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) + self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): self.core.current_playlist.append([Track(track_no=7)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) - self.assertEquals(result['xesam:trackNumber'], 7) + self.assertEqual(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): self.core.playback.volume = None result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0) + self.assertEqual(result, 0) self.core.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0) + self.assertEqual(result, 0) self.core.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0.5) + self.assertEqual(result, 0.5) self.core.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 1) + self.assertEqual(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False self.core.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.core.playback.volume.get(), 0) + self.assertEqual(self.core.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.core.playback.volume.get(), 100) + self.assertEqual(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.core.playback.volume.get(), 100) + self.assertEqual(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): self.core.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.core.playback.volume.get(), 10) + self.assertEqual(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) result_in_microseconds = self.mpris.Get( @@ -254,7 +256,7 @@ class PlayerInterfaceTest(unittest.TestCase): result_in_microseconds = self.mpris.Get( objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 - self.assertEquals(result_in_milliseconds, 0) + self.assertEqual(result_in_milliseconds, 0) def test_get_minimum_rate_is_one_or_less(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') @@ -266,14 +268,15 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertTrue(result) def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') @@ -281,14 +284,16 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -296,7 +301,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -304,7 +309,8 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -312,7 +318,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='a')]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.core.playback.play() self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') @@ -355,220 +361,242 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_next_when_playing_skips_to_next_track_and_keep_playing(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.stop() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_when_paused_skips_to_previous_track_and_pause(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.core.playback.pause() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_previous_when_stopped_skips_to_previous_track_and_stops(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.core.playback.stop() - self.assertEquals(self.core.playback.current_track.get().uri, 'b') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Previous() - self.assertEquals(self.core.playback.current_track.get().uri, 'a') - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Stop() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_pause = self.core.playback.time_position.get() self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) at_pause = self.core.playback.time_position.get() self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_pause = self.core.playback.time_position.get() self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): self.core.current_playlist.clear() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -584,7 +612,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -595,13 +623,13 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -613,14 +641,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -632,7 +660,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) @@ -641,23 +669,23 @@ class PlayerInterfaceTest(unittest.TestCase): def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): self.core.current_playlist.append([ - Track(uri='a', length=40000), - Track(uri='b')]) + Track(uri='dummy:a', length=40000), + Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.seek(20000) before_seek = self.core.playback.time_position.get() self.assertGreaterEqual(before_seek, 20000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') milliseconds_to_seek = 50000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') after_seek = self.core.playback.time_position.get() self.assertGreaterEqual(after_seek, 0) @@ -665,7 +693,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() @@ -683,12 +711,12 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() self.assertLessEqual(before_set_position, 5000) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' @@ -697,22 +725,22 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.SetPosition(track_id, position_to_set_in_microsec) - self.assertEquals(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual( after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = '/com/mopidy/track/0' @@ -723,19 +751,19 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_does_nothing_if_position_is_gt_track_length(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = 'a' @@ -746,19 +774,19 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_is_noop_if_track_id_isnt_current_track(self): - self.core.current_playlist.append([Track(uri='a', length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) before_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(before_set_position, 20000) self.assertLessEqual(before_set_position, 25000) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = 'b' @@ -769,15 +797,15 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position = self.core.playback.time_position.get() self.assertGreaterEqual(after_set_position, before_set_position) - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_open_uri_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) @@ -785,56 +813,59 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') - self.assertEquals(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_adds_uri_to_current_playlist(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals( + self.assertEqual( self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.core.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals( + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() - self.assertEquals(self.core.playback.state.get(), PAUSED) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals( + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals(self.core.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.core.playback.state.get(), PLAYING) - self.assertEquals( + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( self.core.playback.current_track.get().uri, 'dummy:/test/uri') From 2d92a7a228a25267e3856685bf3213a683d66e83 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 02:23:54 +0200 Subject: [PATCH 092/323] Start multiple backends --- mopidy/__main__.py | 24 ++++++++++++------- mopidy/core/actor.py | 12 +++++----- tests/backends/base/current_playlist.py | 2 +- tests/backends/base/library.py | 2 +- tests/backends/base/playback.py | 2 +- tests/backends/base/stored_playlists.py | 2 +- tests/backends/events_test.py | 2 +- tests/frontends/mpd/dispatcher_test.py | 2 +- tests/frontends/mpd/protocol/__init__.py | 2 +- tests/frontends/mpd/status_test.py | 2 +- .../frontends/mpris/player_interface_test.py | 2 +- tests/frontends/mpris/root_interface_test.py | 2 +- 12 files changed, 31 insertions(+), 25 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 67fefde6..965cd9ba 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -54,8 +54,8 @@ def main(): check_old_folders() setup_settings(options.interactive) audio = setup_audio() - backend = setup_backend(audio) - core = setup_core(audio, backend) + backends = setup_backends(audio) + core = setup_core(audio, backends) setup_frontends(core) loop.run() except exceptions.SettingsError as ex: @@ -68,7 +68,7 @@ def main(): loop.quit() stop_frontends() stop_core() - stop_backend() + stop_backends() stop_audio() process.stop_remaining_actors() @@ -147,16 +147,22 @@ def stop_audio(): process.stop_actors_by_class(Audio) -def setup_backend(audio): - return importing.get_class(settings.BACKENDS[0]).start(audio=audio).proxy() +def setup_backends(audio): + backends = [] + for backend_class_name in settings.BACKENDS: + backend_class = importing.get_class(backend_class_name) + backend = backend_class.start(audio=audio).proxy() + backends.append(backend) + return backends -def stop_backend(): - process.stop_actors_by_class(importing.get_class(settings.BACKENDS[0])) +def stop_backends(): + for backend_class_name in settings.BACKENDS: + process.stop_actors_by_class(importing.get_class(backend_class_name)) -def setup_core(audio, backend): - return Core.start(audio=audio, backend=backend).proxy() +def setup_core(audio, backends): + return Core.start(audio=audio, backends=backends).proxy() def stop_core(): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index a3766fff..0ad68a07 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -25,25 +25,25 @@ class Core(pykka.ThreadingActor, AudioListener): #: :class:`mopidy.core.StoredPlaylistsController`. stored_playlists = None - def __init__(self, audio=None, backend=None): + def __init__(self, audio=None, backends=None): super(Core, self).__init__() - self._backend = backend + self._backends = backends self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backend=backend, core=self) + self.library = LibraryController(backend=backends[0], core=self) self.playback = PlaybackController( - audio=audio, backend=backend, core=self) + audio=audio, backend=backends[0], core=self) self.stored_playlists = StoredPlaylistsController( - backend=backend, core=self) + backend=backends[0], core=self) @property def uri_schemes(self): """List of URI schemes we can handle""" - return self._backend.uri_schemes.get() + return self._backends[0].uri_schemes.get() def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 9d86027e..2ba77ee3 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -16,7 +16,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(audio=audio, backend=self.backend) + self.core = core.Core(audio=audio, backends=[self.backend]) self.controller = self.core.current_playlist self.playback = self.core.playback diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index edaa704d..cc2a0004 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -23,7 +23,7 @@ class LibraryControllerTest(object): def setUp(self): self.backend = self.backend_class.start(audio=None).proxy() - self.core = core.Core(backend=self.backend) + self.core = core.Core(backends=[self.backend]) self.library = self.core.library def tearDown(self): diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 5a3b9157..cd55668c 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -18,7 +18,7 @@ class PlaybackControllerTest(object): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(backend=self.backend) + self.core = core.Core(backends=[self.backend]) self.playback = self.core.playback self.current_playlist = self.core.current_playlist diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index c16be173..57096fd3 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -18,7 +18,7 @@ class StoredPlaylistsControllerTest(object): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() - self.core = core.Core(backend=self.backend) + self.core = core.Core(backends=[self.backend]) self.stored = self.core.stored_playlists def tearDown(self): diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index a25a73c2..600dbf6c 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -13,7 +13,7 @@ class BackendEventsTest(unittest.TestCase): def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = dummy.DummyBackend.start(audio=audio).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 1e108e07..9b047641 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -12,7 +12,7 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher() def tearDown(self): diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 4c6d3584..f7b055fc 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -23,7 +23,7 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() self.session = session.MpdSession(self.connection, core=self.core) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 46f500e7..9f2395e5 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -21,7 +21,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index bd0c1728..620845e4 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -25,7 +25,7 @@ class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) def tearDown(self): diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index d185895f..79a8b07f 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -20,7 +20,7 @@ class RootInterfaceTest(unittest.TestCase): objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.DummyBackend.start(audio=None).proxy() - self.core = core.Core.start(backend=self.backend).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) def tearDown(self): From a5af7290ad7d49759c0b048a924a434b08c191c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 11:26:13 +0200 Subject: [PATCH 093/323] Give the core controllers a list of backends --- mopidy/core/actor.py | 6 +++--- mopidy/core/current_playlist.py | 6 +----- mopidy/core/library.py | 19 ++++++------------- mopidy/core/playback.py | 28 ++++++++++++---------------- mopidy/core/stored_playlists.py | 31 ++++++++++++------------------- 5 files changed, 34 insertions(+), 56 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 0ad68a07..ea360055 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -32,13 +32,13 @@ class Core(pykka.ThreadingActor, AudioListener): self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backend=backends[0], core=self) + self.library = LibraryController(backends=backends, core=self) self.playback = PlaybackController( - audio=audio, backend=backends[0], core=self) + audio=audio, backends=backends, core=self) self.stored_playlists = StoredPlaylistsController( - backend=backends[0], core=self) + backends=backends, core=self) @property def uri_schemes(self): diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 5aa7ed5d..6c484daf 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -6,15 +6,11 @@ from mopidy.models import CpTrack from . import listener + logger = logging.getLogger('mopidy.core') class CurrentPlaylistController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - pykka_traversable = True def __init__(self, core): diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 52f85b55..469b6160 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,15 +1,8 @@ class LibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseLibraryProvider` - """ - pykka_traversable = True - def __init__(self, backend, core): - self.backend = backend + def __init__(self, backends, core): + self.backends = backends self.core = core def find_exact(self, **query): @@ -29,7 +22,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.library.find_exact(**query).get() + return self.backends[0].library.find_exact(**query).get() def lookup(self, uri): """ @@ -39,7 +32,7 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.backend.library.lookup(uri).get() + return self.backends[0].library.lookup(uri).get() def refresh(self, uri=None): """ @@ -48,7 +41,7 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.backend.library.refresh(uri).get() + return self.backends[0].library.refresh(uri).get() def search(self, **query): """ @@ -67,4 +60,4 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.library.search(**query).get() + return self.backends[0].library.search(**query).get() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index d2411738..85faaa13 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -4,7 +4,7 @@ import random from . import listener -logger = logging.getLogger('mopidy.backends.base') +logger = logging.getLogger('mopidy.core') def option_wrapper(name, default): @@ -37,13 +37,6 @@ class PlaybackState(object): class PlaybackController(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BasePlaybackProvider` - """ - # pylint: disable = R0902 # Too many instance attributes @@ -81,10 +74,13 @@ class PlaybackController(object): #: Playback continues after current song. single = option_wrapper('_single', False) - def __init__(self, audio, backend, core): + def __init__(self, audio, backends, core): self.audio = audio - self.backend = backend + + self.backends = backends + self.core = core + self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True @@ -295,7 +291,7 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.backend.playback.get_time_position().get() + return self.backends[0].playback.get_time_position().get() @property def volume(self): @@ -381,7 +377,7 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.backend.playback.pause().get(): + if self.backends[0].playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -413,7 +409,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.backend.playback.play(cp_track.track).get(): + if not self.backends[0].playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -440,7 +436,7 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" if (self.state == PlaybackState.PAUSED and - self.backend.playback.resume().get()): + self.backends[0].playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -466,7 +462,7 @@ class PlaybackController(object): self.next() return True - success = self.backend.playback.seek(time_position).get() + success = self.backends[0].playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -480,7 +476,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.backend.playback.stop().get(): + if self.backends[0].playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 2c5ef752..d7bcbd0c 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,15 +1,8 @@ class StoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseStoredPlaylistsProvider` - """ - pykka_traversable = True - def __init__(self, backend, core): - self.backend = backend + def __init__(self, backends, core): + self.backends = backends self.core = core @property @@ -19,11 +12,11 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.backend.stored_playlists.playlists.get() + return self.backends[0].stored_playlists.playlists.get() @playlists.setter # noqa def playlists(self, playlists): - self.backend.stored_playlists.playlists = playlists + self.backends[0].stored_playlists.playlists = playlists def create(self, name): """ @@ -33,7 +26,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.create(name).get() + return self.backends[0].stored_playlists.create(name).get() def delete(self, playlist): """ @@ -42,7 +35,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.delete(playlist).get() + return self.backends[0].stored_playlists.delete(playlist).get() def get(self, **criteria): """ @@ -83,14 +76,13 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.lookup(uri).get() + return self.backends[0].stored_playlists.lookup(uri).get() def refresh(self): """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. + Refresh the stored playlists in :attr:`playlists`. """ - return self.backend.stored_playlists.refresh().get() + return self.backends[0].stored_playlists.refresh().get() def rename(self, playlist, new_name): """ @@ -101,7 +93,8 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ - return self.backend.stored_playlists.rename(playlist, new_name).get() + return self.backends[0].stored_playlists.rename( + playlist, new_name).get() def save(self, playlist): """ @@ -110,4 +103,4 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ - return self.backend.stored_playlists.save(playlist).get() + return self.backends[0].stored_playlists.save(playlist).get() From 11ddc42120874920a858c641f924e19a92bb6cf1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 11:10:20 +0100 Subject: [PATCH 094/323] Update descriptions of settings --- docs/api/backends.rst | 2 ++ docs/api/frontends.rst | 2 ++ mopidy/settings.py | 15 ++++++++++----- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 781723d6..a1aa48a0 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -30,6 +30,8 @@ Library provider :members: +.. _backend-implementations: + Backend implementations ======================= diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 36626fa0..fc54a8a2 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -37,6 +37,8 @@ The following requirements applies to any frontend implementation: specified events. +.. _frontend-implementations: + Frontend implementations ======================== diff --git a/mopidy/settings.py b/mopidy/settings.py index 98f7e05e..31de4a6e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -7,7 +7,7 @@ All available settings and their default values. file called ``~/.config/mopidy/settings.py`` and redefine settings there. """ -#: List of playback backends to use. See :mod:`mopidy.backends` for all +#: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: #: Default:: @@ -54,7 +54,8 @@ DEBUG_LOG_FILENAME = u'mopidy.log' #: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' -#: List of server frontends to use. +#: List of server frontends to use. See :ref:`frontend-implementations` for +#: available frontends. #: #: Default:: #: @@ -106,7 +107,7 @@ LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' -#: Sound mixer to use. +#: Audio mixer to use. #: #: Expects a GStreamer mixer to use, typical values are: #: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. @@ -119,7 +120,7 @@ LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: MIXER = u'autoaudiomixer' MIXER = u'autoaudiomixer' -#: Sound mixer track to use. +#: Audio mixer track to use. #: #: Name of the mixer track to use. If this is not set we will try to find the #: master output track. As an example, using ``alsamixer`` you would @@ -167,7 +168,11 @@ MPD_SERVER_PASSWORD = None #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 -#: Output to use. See :mod:`mopidy.outputs` for all available backends +#: Audio output to use. +#: +#: Expects a GStreamer sink. Typical values are ``autoaudiosink``, +#: ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``, +#: and additional arguments specific to each sink. #: #: Default:: #: From 7b9c682e951fcd41c10fb633a929ab9ca8c311e5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 28 Sep 2012 02:24:58 +0200 Subject: [PATCH 095/323] Make core.uri_schemes include URI schemes from all backends --- mopidy/core/actor.py | 5 ++++- tests/core/actor_test.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/core/actor_test.py diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ea360055..f5de038d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -43,7 +43,10 @@ class Core(pykka.ThreadingActor, AudioListener): @property def uri_schemes(self): """List of URI schemes we can handle""" - return self._backends[0].uri_schemes.get() + futures = [backend.uri_schemes for backend in self._backends] + results = pykka.get_all(futures) + schemes = [uri_scheme for result in results for uri_scheme in result] + return sorted(schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py new file mode 100644 index 00000000..95639cf8 --- /dev/null +++ b/tests/core/actor_test.py @@ -0,0 +1,26 @@ +import mock +import pykka + +from mopidy.core import Core + +from tests import unittest + + +class CoreActorTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_uri_schemes_has_uris_from_all_backends(self): + result = self.core.uri_schemes + + self.assertIn('dummy1', result) + self.assertIn('dummy2', result) From c47cec9e654a055459a8059d96359b32bcfde5af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 11:27:54 +0200 Subject: [PATCH 096/323] Make core.playback select backend based on track URI --- mopidy/core/playback.py | 28 +++++++-- tests/core/playback_test.py | 118 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tests/core/playback_test.py diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 85faaa13..4cef8db6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -78,6 +78,11 @@ class PlaybackController(object): self.audio = audio self.backends = backends + uri_schemes_by_backend = {backend: backend.uri_schemes.get() + for backend in backends} + self.backends_by_uri_scheme = {uri_scheme: backend + for backend, uri_schemes in uri_schemes_by_backend.items() + for uri_scheme in uri_schemes} self.core = core @@ -86,6 +91,13 @@ class PlaybackController(object): self._first_shuffle = True self._volume = None + def _get_backend(self): + if self.current_cp_track is None: + return None + track = self.current_cp_track.track + uri_scheme = track.uri.split(':', 1)[0] + return self.backends_by_uri_scheme[uri_scheme] + def _get_cpid(self, cp_track): if cp_track is None: return None @@ -291,7 +303,10 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.backends[0].playback.get_time_position().get() + backend = self._get_backend() + if backend is None: + return 0 + return backend.playback.get_time_position().get() @property def volume(self): @@ -377,7 +392,8 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.backends[0].playback.pause().get(): + backend = self._get_backend() + if backend is None or backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -409,7 +425,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.backends[0].playback.play(cp_track.track).get(): + if not self._get_backend().playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -436,7 +452,7 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" if (self.state == PlaybackState.PAUSED and - self.backends[0].playback.resume().get()): + self._get_backend().playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -462,7 +478,7 @@ class PlaybackController(object): self.next() return True - success = self.backends[0].playback.seek(time_position).get() + success = self._get_backend().playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -476,7 +492,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.backends[0].playback.stop().get(): + if self._get_backend().playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py new file mode 100644 index 00000000..b3a75773 --- /dev/null +++ b/tests/core/playback_test.py @@ -0,0 +1,118 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class CorePlaybackTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.playback1 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend1.playback = self.playback1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend2.playback = self.playback2 + + self.tracks = [ + Track(uri='dummy1://foo', length=40000), + Track(uri='dummy1://bar', length=40000), + Track(uri='dummy2://foo', length=40000), + Track(uri='dummy2://bar', length=40000), + ] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core.current_playlist.append(self.tracks) + + self.cp_tracks = self.core.current_playlist.cp_tracks + + def test_play_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + + self.playback1.play.assert_called_once_with(self.tracks[0]) + self.assertFalse(self.playback2.play.called) + + def test_play_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + + self.assertFalse(self.playback1.play.called) + self.playback2.play.assert_called_once_with(self.tracks[2]) + + def test_pause_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + + self.playback1.pause.assert_called_once_with() + self.assertFalse(self.playback2.pause.called) + + def test_pause_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + + self.assertFalse(self.playback1.pause.called) + self.playback2.pause.assert_called_once_with() + + def test_resume_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + self.core.playback.resume() + + self.playback1.resume.assert_called_once_with() + self.assertFalse(self.playback2.resume.called) + + def test_resume_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + self.core.playback.resume() + + self.assertFalse(self.playback1.resume.called) + self.playback2.resume.assert_called_once_with() + + def test_stop_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.stop() + + self.playback1.stop.assert_called_once_with() + self.assertFalse(self.playback2.stop.called) + + def test_stop_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.stop() + + self.assertFalse(self.playback1.stop.called) + self.playback2.stop.assert_called_once_with() + + def test_seek_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + + self.playback1.seek.assert_called_once_with(10000) + self.assertFalse(self.playback2.seek.called) + + def test_seek_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + + self.assertFalse(self.playback1.seek.called) + self.playback2.seek.assert_called_once_with(10000) + + def test_time_position_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.playback1.get_time_position.assert_called_once_with() + self.assertFalse(self.playback2.get_time_position.called) + + def test_time_position_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.assertFalse(self.playback1.get_time_position.called) + self.playback2.get_time_position.assert_called_once_with() From a35deec0507497c7b4d68869d0ae4e34f36f1ff8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 14:47:56 +0200 Subject: [PATCH 097/323] Make core.library support multiple backends --- mopidy/core/library.py | 43 ++++++++++++++++++-- tests/core/library_test.py | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/core/library_test.py diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 469b6160..80d9cbe5 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,10 +1,25 @@ +import pykka + +from mopidy.models import Playlist + + class LibraryController(object): pykka_traversable = True def __init__(self, backends, core): self.backends = backends + uri_schemes_by_backend = {backend: backend.uri_schemes.get() + for backend in backends} + self.backends_by_uri_scheme = {uri_scheme: backend + for backend, uri_schemes in uri_schemes_by_backend.items() + for uri_scheme in uri_schemes} + self.core = core + def _get_backend(self, uri): + uri_scheme = uri.split(':', 1)[0] + return self.backends_by_uri_scheme.get(uri_scheme) + def find_exact(self, **query): """ Search the library for tracks where ``field`` is ``values``. @@ -22,7 +37,12 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backends[0].library.find_exact(**query).get() + futures = [] + for backend in self.backends: + futures.append(backend.library.find_exact(**query)) + results = pykka.get_all(futures) + return Playlist(tracks=[ + track for playlist in results for track in playlist.tracks]) def lookup(self, uri): """ @@ -32,7 +52,9 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.backends[0].library.lookup(uri).get() + backend = self._get_backend(uri) + if backend: + return backend.library.lookup(uri).get() def refresh(self, uri=None): """ @@ -41,7 +63,15 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.backends[0].library.refresh(uri).get() + if uri is not None: + backend = self._get_backend(uri) + if backend: + return backend.library.refresh(uri).get() + else: + futures = [] + for backend in self.backends: + futures.append(backend.library.refresh(uri)) + return pykka.get_all(futures) def search(self, **query): """ @@ -60,4 +90,9 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backends[0].library.search(**query).get() + futures = [] + for backend in self.backends: + futures.append(backend.library.search(**query)) + results = pykka.get_all(futures) + return Playlist(tracks=[ + track for playlist in results for track in playlist.tracks]) diff --git a/tests/core/library_test.py b/tests/core/library_test.py new file mode 100644 index 00000000..04f19909 --- /dev/null +++ b/tests/core/library_test.py @@ -0,0 +1,82 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class CoreLibraryTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend1.library = self.library1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend2.library = self.library2 + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_lookup_selects_dummy1_backend(self): + self.core.library.lookup('dummy1:a') + + self.library1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.lookup.called) + + def test_lookup_selects_dummy2_backend(self): + self.core.library.lookup('dummy2:a') + + self.assertFalse(self.library1.lookup.called) + self.library2.lookup.assert_called_once_with('dummy2:a') + + def test_refresh_with_uri_selects_dummy1_backend(self): + self.core.library.refresh('dummy1:a') + + self.library1.refresh.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.refresh.called) + + def test_refresh_with_uri_selects_dummy2_backend(self): + self.core.library.refresh('dummy2:a') + + self.assertFalse(self.library1.refresh.called) + self.library2.refresh.assert_called_once_with('dummy2:a') + + def test_refresh_without_uri_calls_all_backends(self): + self.core.library.refresh() + + self.library1.refresh.assert_called_once_with(None) + self.library2.refresh.assert_called_once_with(None) + + 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 = Playlist(tracks=[track1]) + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = Playlist(tracks=[track2]) + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + 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 = Playlist(tracks=[track1]) + self.library1.search.reset_mock() + self.library2.search().get.return_value = Playlist(tracks=[track2]) + self.library2.search.reset_mock() + + result = self.core.library.search(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) From 0641d2d2074cc4e7da80e13410ada5387ded7421 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 27 Oct 2012 23:07:19 +0200 Subject: [PATCH 098/323] Make core.stored_playlists.playlists support multiple backends --- mopidy/core/stored_playlists.py | 15 ++++++++++- tests/core/stored_playlists_test.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/core/stored_playlists_test.py diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index d7bcbd0c..4a8f5463 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,3 +1,6 @@ +import pykka + + class StoredPlaylistsController(object): pykka_traversable = True @@ -12,10 +15,14 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.backends[0].stored_playlists.playlists.get() + futures = [backend.stored_playlists.playlists + for backend in self.backends] + results = pykka.get_all(futures) + return [playlist for result in results for playlist in result] @playlists.setter # noqa def playlists(self, playlists): + # TODO Support multiple backends self.backends[0].stored_playlists.playlists = playlists def create(self, name): @@ -26,6 +33,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.create(name).get() def delete(self, playlist): @@ -35,6 +43,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.delete(playlist).get() def get(self, **criteria): @@ -76,12 +85,14 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.lookup(uri).get() def refresh(self): """ Refresh the stored playlists in :attr:`playlists`. """ + # TODO Support multiple backends return self.backends[0].stored_playlists.refresh().get() def rename(self, playlist, new_name): @@ -93,6 +104,7 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ + # TODO Support multiple backends return self.backends[0].stored_playlists.rename( playlist, new_name).get() @@ -103,4 +115,5 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.save(playlist).get() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py new file mode 100644 index 00000000..d92b89c0 --- /dev/null +++ b/tests/core/stored_playlists_test.py @@ -0,0 +1,41 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class StoredPlaylistsTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.sp1 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend1.stored_playlists = self.sp1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.sp2 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend2.stored_playlists = self.sp2 + + self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(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.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_get_playlists_combines_result_from_backends(self): + result = self.core.stored_playlists.playlists + + self.assertIn(self.pl1a, result) + self.assertIn(self.pl1b, result) + self.assertIn(self.pl2a, result) + self.assertIn(self.pl2b, result) + + # TODO The rest of the stored playlists API is pending redesign before + # we'll update it to support multiple backends. From d450e5a238795e194fed2c68fb116596fcef9e8a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 11:11:05 +0100 Subject: [PATCH 099/323] Turn both local and Spotify backend on by default --- mopidy/settings.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 31de4a6e..c1f35887 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -10,17 +10,17 @@ All available settings and their default values. #: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: +#: When results from multiple backends are combined, they are combined in the +#: order the backends are listed here. +#: #: Default:: #: -#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) -#: -#: Other typical values:: -#: -#: BACKENDS = (u'mopidy.backends.local.LocalBackend',) -#: -#: .. note:: -#: Currently only the first backend in the list is used. +#: BACKENDS = ( +#: u'mopidy.backends.local.LocalBackend', +#: u'mopidy.backends.spotify.SpotifyBackend', +#: ) BACKENDS = ( + u'mopidy.backends.local.LocalBackend', u'mopidy.backends.spotify.SpotifyBackend', ) From 9a617b180372972add84f6dc41e2c10bb93c86e5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 20:58:51 +0100 Subject: [PATCH 100/323] Improvements after code review --- mopidy/core/actor.py | 6 ++-- mopidy/core/library.py | 28 +++++++++---------- mopidy/core/playback.py | 11 +++++--- tests/frontends/mpd/protocol/playback_test.py | 8 +++--- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index f5de038d..05c085fd 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,3 +1,5 @@ +import itertools + import pykka from mopidy.audio import AudioListener @@ -45,8 +47,8 @@ class Core(pykka.ThreadingActor, AudioListener): """List of URI schemes we can handle""" futures = [backend.uri_schemes for backend in self._backends] results = pykka.get_all(futures) - schemes = [uri_scheme for result in results for uri_scheme in result] - return sorted(schemes) + uri_schemes = itertools.chain(*results) + return sorted(uri_schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 80d9cbe5..37c8c522 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,3 +1,5 @@ +import urlparse + import pykka from mopidy.models import Playlist @@ -8,16 +10,18 @@ class LibraryController(object): def __init__(self, backends, core): self.backends = backends - uri_schemes_by_backend = {backend: backend.uri_schemes.get() + uri_schemes_by_backend = { + backend: backend.uri_schemes.get() for backend in backends} - self.backends_by_uri_scheme = {uri_scheme: backend + self.backends_by_uri_scheme = { + uri_scheme: backend for backend, uri_schemes in uri_schemes_by_backend.items() for uri_scheme in uri_schemes} self.core = core def _get_backend(self, uri): - uri_scheme = uri.split(':', 1)[0] + uri_scheme = urlparse.urlparse(uri).scheme return self.backends_by_uri_scheme.get(uri_scheme) def find_exact(self, **query): @@ -37,9 +41,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [] - for backend in self.backends: - futures.append(backend.library.find_exact(**query)) + futures = [b.library.find_exact(**query) for b in self.backends] results = pykka.get_all(futures) return Playlist(tracks=[ track for playlist in results for track in playlist.tracks]) @@ -55,6 +57,8 @@ class LibraryController(object): backend = self._get_backend(uri) if backend: return backend.library.lookup(uri).get() + else: + return None def refresh(self, uri=None): """ @@ -66,12 +70,10 @@ class LibraryController(object): if uri is not None: backend = self._get_backend(uri) if backend: - return backend.library.refresh(uri).get() + backend.library.refresh(uri).get() else: - futures = [] - for backend in self.backends: - futures.append(backend.library.refresh(uri)) - return pykka.get_all(futures) + futures = [b.library.refresh(uri) for b in self.backends] + pykka.get_all(futures) def search(self, **query): """ @@ -90,9 +92,7 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [] - for backend in self.backends: - futures.append(backend.library.search(**query)) + futures = [b.library.search(**query) for b in self.backends] results = pykka.get_all(futures) return Playlist(tracks=[ track for playlist in results for track in playlist.tracks]) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4cef8db6..721bc2a8 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,5 +1,6 @@ import logging import random +import urlparse from . import listener @@ -78,9 +79,11 @@ class PlaybackController(object): self.audio = audio self.backends = backends - uri_schemes_by_backend = {backend: backend.uri_schemes.get() + uri_schemes_by_backend = { + backend: backend.uri_schemes.get() for backend in backends} - self.backends_by_uri_scheme = {uri_scheme: backend + self.backends_by_uri_scheme = { + uri_scheme: backend for backend, uri_schemes in uri_schemes_by_backend.items() for uri_scheme in uri_schemes} @@ -94,8 +97,8 @@ class PlaybackController(object): def _get_backend(self): if self.current_cp_track is None: return None - track = self.current_cp_track.track - uri_scheme = track.uri.split(':', 1)[0] + uri = self.current_cp_track.track.uri + uri_scheme = urlparse.urlparse(uri).scheme return self.backends_by_uri_scheme[uri_scheme] def _get_cpid(self, cp_track): diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index ab254bdf..202ac649 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -392,9 +392,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek_with_songpos(self): - seek_track = Track(uri='dummy:2', length=40000) + seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( - [Track(uri='dummy:1', length=40000), seek_track]) + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) @@ -417,9 +417,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seekid_with_cpid(self): - seek_track = Track(uri='dummy:2', length=40000) + seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( - [Track(uri='dummy:1', length=40000), seek_track]) + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') self.assertEqual(1, self.core.playback.current_cpid.get()) From 4f411a48d6bd8875009359cff8350461814fb67f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:22:09 +0100 Subject: [PATCH 101/323] Update docstring references --- mopidy/backends/base.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index e8a7decd..7ae2c3dc 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -37,7 +37,7 @@ class BaseLibraryProvider(object): def find_exact(self, **query): """ - See :meth:`mopidy.backends.base.LibraryController.find_exact`. + See :meth:`mopidy.core.LibraryController.find_exact`. *MUST be implemented by subclass.* """ @@ -45,7 +45,7 @@ class BaseLibraryProvider(object): def lookup(self, uri): """ - See :meth:`mopidy.backends.base.LibraryController.lookup`. + See :meth:`mopidy.core.LibraryController.lookup`. *MUST be implemented by subclass.* """ @@ -53,7 +53,7 @@ class BaseLibraryProvider(object): def refresh(self, uri=None): """ - See :meth:`mopidy.backends.base.LibraryController.refresh`. + See :meth:`mopidy.core.LibraryController.refresh`. *MUST be implemented by subclass.* """ @@ -61,7 +61,7 @@ class BaseLibraryProvider(object): def search(self, **query): """ - See :meth:`mopidy.backends.base.LibraryController.search`. + See :meth:`mopidy.core.LibraryController.search`. *MUST be implemented by subclass.* """ @@ -174,7 +174,7 @@ class BaseStoredPlaylistsProvider(object): def create(self, name): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. + See :meth:`mopidy.core.StoredPlaylistsController.create`. *MUST be implemented by subclass.* """ @@ -182,7 +182,7 @@ class BaseStoredPlaylistsProvider(object): def delete(self, playlist): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. + See :meth:`mopidy.core.StoredPlaylistsController.delete`. *MUST be implemented by subclass.* """ @@ -190,7 +190,7 @@ class BaseStoredPlaylistsProvider(object): def lookup(self, uri): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. + See :meth:`mopidy.core.StoredPlaylistsController.lookup`. *MUST be implemented by subclass.* """ @@ -198,7 +198,7 @@ class BaseStoredPlaylistsProvider(object): def refresh(self): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. + See :meth:`mopidy.core.StoredPlaylistsController.refresh`. *MUST be implemented by subclass.* """ @@ -206,7 +206,7 @@ class BaseStoredPlaylistsProvider(object): def rename(self, playlist, new_name): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. + See :meth:`mopidy.core.StoredPlaylistsController.rename`. *MUST be implemented by subclass.* """ @@ -214,7 +214,7 @@ class BaseStoredPlaylistsProvider(object): def save(self, playlist): """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. + See :meth:`mopidy.core.StoredPlaylistsController.save`. *MUST be implemented by subclass.* """ From 29a19a8b27d747ab8388eaa03b1485537f1139ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:26:10 +0100 Subject: [PATCH 102/323] Document parameter --- mopidy/core/current_playlist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index 6c484daf..fb296a52 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -67,6 +67,8 @@ class CurrentPlaylistController(object): :type track: :class:`mopidy.models.Track` :param at_position: position in current playlist to add track :type at_position: int or :class:`None` + :param increase_version: if the playlist version should be increased + :type increase_version: :class:`True` or :class:`False` :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that was added to the current playlist playlist """ From a6200415842b2bd1cf06856e0f157afed6ee1d07 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:37:37 +0100 Subject: [PATCH 103/323] More improvements after code review --- mopidy/core/stored_playlists.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 4a8f5463..9de1545f 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,3 +1,5 @@ +import itertools + import pykka @@ -15,10 +17,9 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - futures = [backend.stored_playlists.playlists - for backend in self.backends] + futures = [b.stored_playlists.playlists for b in self.backends] results = pykka.get_all(futures) - return [playlist for result in results for playlist in result] + return list(itertools.chain(*results)) @playlists.setter # noqa def playlists(self, playlists): From c2cde5267ab35a91f7ce048907f703e750a1722e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:49:29 +0100 Subject: [PATCH 104/323] Update changelog with multi-backend changes --- docs/changes.rst | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index c68db685..5a17c810 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,54 @@ v0.9.0 (in development) - Pykka >= 1.0 is now required. +**Multiple backends support** + +Support for using the local and Spotify backends simultaneously have for a very +long time been our most requested feature. Finally, it's here! + +- Both the local backend and the Spotify backend are now turned on by default. + The local backend is listed first in the :attr:`mopidy.settings.BACKENDS` + setting, and are thus given the highest priority in e.g. search results, + meaning that we're listing search hits from the local backend first. If you + want to prioritize the backends in another way, simply set ``BACKENDS`` in + your own settings file and reorder the backends. + + There are no other setting changes related to the local and Spotify backends. + As always, see :mod:`mopidy.settings` for the full list of available + settings. + +Internally, Mopidy have seen a lot of changes to pave the way for multiple +backends: + +- A new layer and actor, "core", have been added to our stack, inbetween the + frontends and the backends. The responsibility of this layer and actor is to + take requests from the frontends, pass them on to one or more backends, and + combining the response from the backends into a single response to the + requesting frontend. + + The frontends no longer know anything about the backends. They just use the + :ref:`core-api`. + +- The base playback provider have gotten sane default behavior instead of the + old empty functions. By default, the playback provider now lets GStreamer + keep track of the current track's time position. The local backend simply + uses the base playback provider without any changes. The same applies to any + future backend that just needs GStreamer to play an URI for it. + +- The dependency graph between the core controllers and the backend providers + have been straightened out, so that we don't have any circular dependencies + or similar. The frontend, core, backend, and audio layers are now strictly + separate. The frontend layer calls on the core layer, and the core layer + calls on the backend layer. Both the core layer and the backends are allowed + to call on the audio layer. Any data flow in the opposite direction is done + by broadcasting of events to listeners, through e.g. + :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. + +- All dependencies are now explicitly passed to the constructors of the + frontends, core, and the backends. This makes testing each layer with + dummy/mocked lower layers easier than with the old variant, where + dependencies where looked up in Pykka's actor registry. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors From 86ca1bf3c9e637713f687966d2f6e2c9a1843b29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 21:58:07 +0100 Subject: [PATCH 105/323] Update README with multi-backend, MPRIS and DLNA possibilities --- README.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index e7ecd614..a7df7692 100644 --- a/README.rst +++ b/README.rst @@ -4,11 +4,17 @@ Mopidy .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use any -`MPD client `_. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android and iOS. +Mopidy is a music server which can play music both from your local hard drive +and from `Spotify `_. Searches returns results from +both your local hard drive and from Spotify, and you can mix tracks from both +sources in your play queue. Your Spotify playlists are also available for use, +though we don't support modifying them yet. + +To control your music server, you can use the Ubuntu Sound Menu on the machine +running Mopidy, any device on the same network which supports the DLNA media +controller spec (with the help of Rygel in addition to Mopidy), or any `MPD +client `_. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, check out `the installation docs `_. From 17b0a2ccc3413c0ce939c2df21434194f526aa6c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 22:00:55 +0100 Subject: [PATCH 106/323] Update local backend settings guide --- docs/settings.rst | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index a79dfd78..88004e11 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -43,20 +43,10 @@ Music from local storage ======================== If you want use Mopidy to play music you have locally at your machine instead -of using Spotify, you need to change the backend from the default to -:mod:`mopidy.backends.local` by adding the following line to your settings -file:: - - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - -You may also want to change some of the ``LOCAL_*`` settings. See -:mod:`mopidy.settings`, for a full list of available settings. - -.. note:: - - Currently, Mopidy supports using Spotify *or* local storage as a music - source. We're working on using both sources simultaneously, and will - have support for this in a future release. +of or in addition to using Spotify, you need to review and maybe change some of +the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of +available settings. Then you need to generate a tag cache for your local +music... .. _generating_a_tag_cache: @@ -66,7 +56,7 @@ Generating a tag cache Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache`` files generated by the original MPD server. To remedy this the command -:command:`mopidy-scan` has been created. The program will scan your current +:command:`mopidy-scan` was created. The program will scan your current :attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible ``tag_cache``. From be5759e9a1c43c46d19fc32b5ef60b98445d7189 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 23:24:52 +0100 Subject: [PATCH 107/323] Make sure volume are returned as an int --- docs/changes.rst | 4 ++++ mopidy/audio/actor.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index c68db685..a0b555a6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,10 @@ v0.9.0 (in development) observed by some users on some Spotify tracks due to a change introduced in 0.8.0. See the issue for a patch that applies to 0.8.0. +- Volume returned by the MPD command `status` contained a floating point ``.0`` + suffix. This bug was introduced with the large audio outout and mixer changes + in v0.8.0. It now returns an integer again. + v0.8.0 (2012-09-20) =================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 53e8f723..95d6683c 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -371,7 +371,7 @@ class Audio(pykka.ThreadingActor): new_min, new_max = new old_min, old_max = old scaling = float(new_max - new_min) / (old_max - old_min) - return round(scaling * (value - old_min) + new_min) + return int(round(scaling * (value - old_min) + new_min)) def set_metadata(self, track): """ From 73cc10fffb4288b9808625f6cc30d1747d51d3ba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 28 Oct 2012 23:55:24 +0100 Subject: [PATCH 108/323] Add issue reference to changelog --- docs/changes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a0b555a6..f4131f73 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,9 +18,9 @@ v0.9.0 (in development) observed by some users on some Spotify tracks due to a change introduced in 0.8.0. See the issue for a patch that applies to 0.8.0. -- Volume returned by the MPD command `status` contained a floating point ``.0`` - suffix. This bug was introduced with the large audio outout and mixer changes - in v0.8.0. It now returns an integer again. +- :issue:`216`: Volume returned by the MPD command `status` contained a + floating point ``.0`` suffix. This bug was introduced with the large audio + outout and mixer changes in v0.8.0. It now returns an integer again. v0.8.0 (2012-09-20) From f0602b4e3bec242279b214357137ec25fa2f0fcd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 00:25:25 +0100 Subject: [PATCH 109/323] docs: Fix lots of broken module documentation --- docs/api/audio.rst | 4 +++ docs/api/backends.rst | 3 ++ docs/api/core.rst | 3 ++ docs/modules/audio/mixers/auto.rst | 6 ++++ docs/modules/audio/mixers/fake.rst | 6 ++++ docs/modules/audio/mixers/nad.rst | 6 ++++ docs/modules/backends/local.rst | 1 - docs/modules/backends/spotify.rst | 1 - docs/modules/frontends/lastfm.rst | 1 - docs/modules/frontends/mpd.rst | 12 ++++++- docs/modules/frontends/mpris.rst | 1 - docs/settings.rst | 4 +++ mopidy/audio/actor.py | 2 +- mopidy/audio/mixers/auto.py | 14 ++++++++ mopidy/audio/mixers/fake.py | 11 ++++++ mopidy/audio/mixers/nad.py | 49 ++++++++++++++++++++++++++- mopidy/backends/dummy.py | 23 +++++++++---- mopidy/backends/local/__init__.py | 22 ++++++++++++ mopidy/backends/local/actor.py | 14 -------- mopidy/backends/spotify/__init__.py | 32 ++++++++++++++++++ mopidy/backends/spotify/actor.py | 28 ---------------- mopidy/frontends/lastfm.py | 42 +++++++++++++---------- mopidy/frontends/mpd/__init__.py | 23 +++++++++++++ mopidy/frontends/mpd/actor.py | 14 -------- mopidy/frontends/mpris/__init__.py | 52 +++++++++++++++++++++++++++++ mopidy/frontends/mpris/actor.py | 40 ---------------------- 26 files changed, 286 insertions(+), 128 deletions(-) create mode 100644 docs/modules/audio/mixers/auto.rst create mode 100644 docs/modules/audio/mixers/fake.rst create mode 100644 docs/modules/audio/mixers/nad.rst diff --git a/docs/api/audio.rst b/docs/api/audio.rst index e00772fd..2b9f6cc5 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -4,6 +4,10 @@ Audio API ********* +.. module:: mopidy.audio + :synopsis: Thin wrapper around the parts of GStreamer we use + + The audio API is the interface we have built around GStreamer to support our specific use cases. Most backends should be able to get by with simply setting the URI of the resource they want to play, for these cases the default playback diff --git a/docs/api/backends.rst b/docs/api/backends.rst index a1aa48a0..c296fb78 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -4,6 +4,9 @@ Backend API *********** +.. module:: mopidy.backends.base + :synopsis: The API implemented by backends + The backend API is the interface that must be implemented when you create a backend. If you are working on a frontend and need to access the backend, see the :ref:`core-api`. diff --git a/docs/api/core.rst b/docs/api/core.rst index 1563b61b..eb1b9683 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -4,6 +4,9 @@ Core API ******** +.. module:: mopidy.core + :synopsis: Core API for use by frontends + The core API is the interface that is used by frontends like :mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the diff --git a/docs/modules/audio/mixers/auto.rst b/docs/modules/audio/mixers/auto.rst new file mode 100644 index 00000000..caf6e3ab --- /dev/null +++ b/docs/modules/audio/mixers/auto.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.auto` -- Auto mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.auto + :synopsis: Mixer element which automatically selects the real mixer to use diff --git a/docs/modules/audio/mixers/fake.rst b/docs/modules/audio/mixers/fake.rst new file mode 100644 index 00000000..dcab7767 --- /dev/null +++ b/docs/modules/audio/mixers/fake.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.fake` -- Fake mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.fake + :synopsis: Fake mixer for use in tests diff --git a/docs/modules/audio/mixers/nad.rst b/docs/modules/audio/mixers/nad.rst new file mode 100644 index 00000000..661dc723 --- /dev/null +++ b/docs/modules/audio/mixers/nad.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.nad` -- NAD mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.nad + :synopsis: Mixer element for controlling volume on NAD amplifiers diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst index 892f5a87..b4ab7d49 100644 --- a/docs/modules/backends/local.rst +++ b/docs/modules/backends/local.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.backends.local :synopsis: Backend for playing music files on local storage - :members: diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst index 938d6337..e724da27 100644 --- a/docs/modules/backends/spotify.rst +++ b/docs/modules/backends/spotify.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.backends.spotify :synopsis: Backend for the Spotify music streaming service - :members: diff --git a/docs/modules/frontends/lastfm.rst b/docs/modules/frontends/lastfm.rst index a726f4a2..0dba922f 100644 --- a/docs/modules/frontends/lastfm.rst +++ b/docs/modules/frontends/lastfm.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.frontends.lastfm :synopsis: Last.fm scrobbler frontend - :members: diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 0ce138a2..090ca5cd 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -4,7 +4,6 @@ .. automodule:: mopidy.frontends.mpd :synopsis: MPD server frontend - :members: MPD dispatcher @@ -27,6 +26,7 @@ Audio output ------------ .. automodule:: mopidy.frontends.mpd.protocol.audio_output + :synopsis: MPD protocol: audio output :members: @@ -34,6 +34,7 @@ Command list ------------ .. automodule:: mopidy.frontends.mpd.protocol.command_list + :synopsis: MPD protocol: command list :members: @@ -41,6 +42,7 @@ Connection ---------- .. automodule:: mopidy.frontends.mpd.protocol.connection + :synopsis: MPD protocol: connection :members: @@ -48,12 +50,15 @@ Current playlist ---------------- .. automodule:: mopidy.frontends.mpd.protocol.current_playlist + :synopsis: MPD protocol: current playlist :members: + Music database -------------- .. automodule:: mopidy.frontends.mpd.protocol.music_db + :synopsis: MPD protocol: music database :members: @@ -61,6 +66,7 @@ Playback -------- .. automodule:: mopidy.frontends.mpd.protocol.playback + :synopsis: MPD protocol: playback :members: @@ -68,6 +74,7 @@ Reflection ---------- .. automodule:: mopidy.frontends.mpd.protocol.reflection + :synopsis: MPD protocol: reflection :members: @@ -75,6 +82,7 @@ Status ------ .. automodule:: mopidy.frontends.mpd.protocol.status + :synopsis: MPD protocol: status :members: @@ -82,6 +90,7 @@ Stickers -------- .. automodule:: mopidy.frontends.mpd.protocol.stickers + :synopsis: MPD protocol: stickers :members: @@ -89,4 +98,5 @@ Stored playlists ---------------- .. automodule:: mopidy.frontends.mpd.protocol.stored_playlists + :synopsis: MPD protocol: stored playlists :members: diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst index 05a6e287..2984e4c1 100644 --- a/docs/modules/frontends/mpris.rst +++ b/docs/modules/frontends/mpris.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.frontends.mpris :synopsis: MPRIS frontend - :members: diff --git a/docs/settings.rst b/docs/settings.rst index a79dfd78..b71b18ef 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -29,6 +29,8 @@ A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: SPOTIFY_PASSWORD = u'mysecret' +.. _music-from-spotify: + Music from Spotify ================== @@ -39,6 +41,8 @@ Premium account's username and password into the file, like this:: SPOTIFY_PASSWORD = u'mysecret' +.. _music-from-local-storage: + Music from local storage ======================== diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 95d6683c..852d5d57 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -231,7 +231,7 @@ class Audio(pykka.ThreadingActor): Set position in milliseconds. :param position: the position in milliseconds - :type volume: int + :type position: int :rtype: :class:`True` if successful, else :class:`False` """ self._playbin.get_state() # block until state changes are done diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index f3806eef..05294801 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -1,3 +1,17 @@ +"""Mixer element that automatically selects the real mixer to use. + +This is Mopidy's default mixer. + +**Dependencies:** + +- None + +**Settings:** + +- If this wasn't the default, you would set :attr:`mopidy.settings.MIXER` + to ``autoaudiomixer`` to use this mixer. +""" + import pygst pygst.require('0.10') import gst diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index b22e731e..10710466 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -1,3 +1,14 @@ +"""Fake mixer for use in tests. + +**Dependencies:** + +- None + +**Settings:** + +- Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. +""" + import pygst pygst.require('0.10') import gobject diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 1d65ead9..cb1266a1 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -1,3 +1,50 @@ +"""Mixer that controls volume using a NAD amplifier. + +**Dependencies:** + +- pyserial (python-serial in Debian/Ubuntu) + +- The NAD amplifier must be connected to the machine running Mopidy using a + serial cable. + +**Settings:** + +- Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably + also needs to add some properties to the ``MIXER`` setting. + +Supported properties includes: + +``port``: + The serial device to use, defaults to ``/dev/ttyUSB0``. This must be + set correctly for the mixer to work. + +``source``: + The source that should be selected on the amplifier, like ``aux``, + ``disc``, ``tape``, ``tuner``, etc. Leave unset if you don't want the + mixer to change it for you. + +``speakers-a``: + Set to ``on`` or ``off`` if you want the mixer to make sure that + speaker set A is turned on or off. Leave unset if you don't want the + mixer to change it for you. + +``speakers-b``: + See ``speakers-a``. + +Configuration examples:: + + # Minimum configuration, if the amplifier is available at /dev/ttyUSB0 + MIXER = u'nadmixer' + + # Minimum configuration, if the amplifier is available elsewhere + MIXER = u'nadmixer port=/dev/ttyUSB3' + + # Full configuration + MIXER = ( + u'nadmixer port=/dev/ttyUSB0 ' + u'source=aux speakers-a=on speakers-b=off') +""" + import logging import pygst @@ -76,7 +123,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): class NadTalker(pykka.ThreadingActor): """ - Independent thread which does the communication with the NAD amplifier + Independent thread which does the communication with the NAD amplifier. Since the communication is done in an independent thread, Mopidy won't block other requests while doing rather time consuming work like diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 94bb9b1d..51129200 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,3 +1,19 @@ +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. + +The backend handles URIs starting with ``dummy:``. + +**Dependencies:** + +- None + +**Settings:** + +- None +""" + import pykka from mopidy.backends import base @@ -5,13 +21,6 @@ from mopidy.models import Playlist class DummyBackend(pykka.ThreadingActor, base.Backend): - """ - A backend which implements the backend API in the simplest way possible. - Used in tests of the frontends. - - Handles URIs starting with ``dummy:``. - """ - def __init__(self, audio): super(DummyBackend, self).__init__() diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 6f0f3770..6f049474 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,2 +1,24 @@ +"""A backend for playing music from a local music archive. + +This backend handles URIs starting with ``file:``. + +See :ref:`music-from-local-storage` for further instructions on using this +backend. + +**Issues:** + +https://github.com/mopidy/mopidy/issues?labels=Local+backend + +**Dependencies:** + +- None + +**Settings:** + +- :attr:`mopidy.settings.LOCAL_MUSIC_PATH` +- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` +- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` +""" + # flake8: noqa from .actor import LocalBackend diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 10802722..70351ed1 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -11,20 +11,6 @@ logger = logging.getLogger(u'mopidy.backends.local') class LocalBackend(pykka.ThreadingActor, base.Backend): - """ - A backend for playing music from a local music archive. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` - - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` - - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` - """ - def __init__(self, audio): super(LocalBackend, self).__init__() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 87d76c46..bb0c805b 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,2 +1,34 @@ +"""A backend for playing music from Spotify + +`Spotify `_ is a music streaming service. The backend +uses the official `libspotify +`_ library and the +`pyspotify `_ Python bindings for +libspotify. This backend handles URIs starting with ``spotify:``. + +See :ref:`music-from-spotify` for further instructions on using this backend. + +.. note:: + + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. + +**Issues:** + +https://github.com/mopidy/mopidy/issues?labels=Spotify+backend + +**Dependencies:** + +- libspotify >= 11, < 12 (libspotify11 package from apt.mopidy.com) +- pyspotify >= 1.7, < 1.8 (python-spotify package from apt.mopidy.com) + +**Settings:** + +- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` +- :attr:`mopidy.settings.SPOTIFY_USERNAME` +- :attr:`mopidy.settings.SPOTIFY_PASSWORD` +""" + # flake8: noqa from .actor import SpotifyBackend diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 948636a2..943600fc 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -9,34 +9,6 @@ logger = logging.getLogger('mopidy.backends.spotify') class SpotifyBackend(pykka.ThreadingActor, base.Backend): - """ - A backend for playing music from the `Spotify `_ - music streaming service. The backend uses the official `libspotify - `_ library and the - `pyspotify `_ Python bindings for - libspotify. - - .. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - - **Issues:** - https://github.com/mopidy/mopidy/issues?labels=backend-spotify - - **Dependencies:** - - - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) - - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) - - **Settings:** - - - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` - - :attr:`mopidy.settings.SPOTIFY_USERNAME` - - :attr:`mopidy.settings.SPOTIFY_PASSWORD` - """ - # Imports inside methods are to prevent loading of __init__.py to fail on # missing spotify dependencies. diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index e7c2afdb..aaf55ec1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,3 +1,27 @@ +""" +Frontend which scrobbles the music you play to your `Last.fm +`_ profile. + +.. note:: + + This frontend requires a free user account at Last.fm. + +**Dependencies:** + +- `pylast `_ >= 0.5.7 + +**Settings:** + +- :attr:`mopidy.settings.LASTFM_USERNAME` +- :attr:`mopidy.settings.LASTFM_PASSWORD` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes +the Last.fm frontend. +""" + import logging import time @@ -18,24 +42,6 @@ API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' class LastfmFrontend(pykka.ThreadingActor, CoreListener): - """ - Frontend which scrobbles the music you play to your `Last.fm - `_ profile. - - .. note:: - - This frontend requires a free user account at Last.fm. - - **Dependencies:** - - - `pylast `_ >= 0.5.7 - - **Settings:** - - - :attr:`mopidy.settings.LASTFM_USERNAME` - - :attr:`mopidy.settings.LASTFM_PASSWORD` - """ - def __init__(self, core): super(LastfmFrontend, self).__init__() self.lastfm = None diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e2d2b9c7..a6cfd386 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,2 +1,25 @@ +"""The MPD server frontend. + +MPD stands for Music Player Daemon. MPD is an independent project and server. +Mopidy implements the MPD protocol, and is thus compatible with clients for the +original MPD server. + +**Dependencies:** + +- None + +**Settings:** + +- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` +- :attr:`mopidy.settings.MPD_SERVER_PORT` +- :attr:`mopidy.settings.MPD_SERVER_PASSWORD` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD +frontend. +""" + # flake8: noqa from .actor import MpdFrontend diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index f69334b5..e136ddee 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -12,20 +12,6 @@ logger = logging.getLogger('mopidy.frontends.mpd') class MpdFrontend(pykka.ThreadingActor, CoreListener): - """ - The MPD frontend. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PORT` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - """ - def __init__(self, core): super(MpdFrontend, self).__init__() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 93ad0795..4245f844 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,2 +1,54 @@ +""" +Frontend which lets you control Mopidy through the Media Player Remote +Interfacing Specification (`MPRIS `_) D-Bus +interface. + +An example of an MPRIS client is the `Ubuntu Sound Menu +`_. + +**Dependencies:** + +- D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + +- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + +- An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + details. + +**Settings:** + +- :attr:`mopidy.settings.DESKTOP_FILE` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.mpris.MprisFrontend``. By default, the setting includes the +MPRIS frontend. + +**Testing the frontend** + +To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + +Now you can control Mopidy through the player object. Examples: + +- To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + +- To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') +""" + # flake8: noqa from .actor import MprisFrontend diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index acca3ab7..5d8d5492 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -16,46 +16,6 @@ except ImportError as import_error: class MprisFrontend(pykka.ThreadingActor, CoreListener): - """ - Frontend which lets you control Mopidy through the Media Player Remote - Interfacing Specification (`MPRIS `_) D-Bus - interface. - - An example of an MPRIS client is the `Ubuntu Sound Menu - `_. - - **Dependencies:** - - - D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for - details. - - **Testing the frontend** - - To test, start Mopidy, and then run the following in a Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - - Now you can control Mopidy through the player object. Examples: - - - To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - - - To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - """ - def __init__(self, core): super(MprisFrontend, self).__init__() self.core = core From 9f69c620315c4dfb7a39f689e7a63c387942e104 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 01:08:06 +0100 Subject: [PATCH 110/323] Fix typo in changelog and add another sentence --- docs/changes.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index f4131f73..541f7af9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -20,7 +20,8 @@ v0.9.0 (in development) - :issue:`216`: Volume returned by the MPD command `status` contained a floating point ``.0`` suffix. This bug was introduced with the large audio - outout and mixer changes in v0.8.0. It now returns an integer again. + output and mixer changes in v0.8.0 and broke the MPDroid Android client. It + now returns an integer again. v0.8.0 (2012-09-20) From 519fdb9326c7d6748f622a2a1853193eaa886b98 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 01:09:36 +0100 Subject: [PATCH 111/323] Update concepts description and graphs --- docs/api/concepts.rst | 117 ++++++++++++++++++++++++++++++++--------- docs/api/frontends.rst | 2 + 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index ae959237..5eca2349 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -1,29 +1,98 @@ .. _concepts: -********************************************** -The backend, controller, and provider concepts -********************************************** +************************* +Architecture and concepts +************************* -Backend: - The backend is mostly for convenience. It is a container that holds - references to all the controllers. -Controllers: - Each controller has responsibility for a given part of the backend - functionality. Most, but not all, controllers delegates some work to one or - more providers. The controllers are responsible for choosing the right - provider for any given task based upon i.e. the track's URI. See - :ref:`core-api` for more details. -Providers: - Anything specific to i.e. Spotify integration or local storage is contained - in the providers. To integrate with new music sources, you just add new - providers. See :ref:`backend-api` for more details. +The overall architecture of Mopidy is organized around multiple frontends and +backends. The frontends use the core API. The core actor makes multiple backends +work as one. The backends connect to various music sources. Both the core actor +and the backends use the audio actor to play audio and control audio volume. -.. digraph:: backend_relations +.. digraph:: overall_architecture - Backend -> "Current\nplaylist\ncontroller" - Backend -> "Library\ncontroller" - "Library\ncontroller" -> "Library\nproviders" - Backend -> "Playback\ncontroller" - "Playback\ncontroller" -> "Playback\nproviders" - Backend -> "Stored\nplaylists\ncontroller" - "Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" + "Multiple frontends" -> Core + Core -> "Multiple backends" + Core -> Audio + "Multiple backends" -> Audio + + +Frontends +========= + +Frontends expose Mopidy to the external world. They can implement servers for +protocols like MPD and MPRIS, and they can be used to update other services +when something happens in Mopidy, like the Last.fm scrobbler frontend does. See +:ref:`frontend-api` for more details. + +.. digraph:: frontend_architecture + + "MPD\nfrontend" -> Core + "MPRIS\nfrontend" -> Core + "Last.fm\nfrontend" -> Core + + +Core +==== + +The core is organized as a set of controllers with responsiblity for separate +sets of functionality. + +The core is the single actor that the frontends send their requests to. For +every request from a frontend it calls out to one or more backends which does +the real work, and when the backends respond, the core actor is responsible for +combining the responses into a single response to the requesting frontend. + +The core actor also keeps track of the current playlist, since it doesn't +belong to a specific backend. + +See :ref:`core-api` for more details. + +.. digraph:: core_architecture + + Core -> "Current\nplaylist\ncontroller" + Core -> "Library\ncontroller" + Core -> "Playback\ncontroller" + Core -> "Stored\nplaylists\ncontroller" + + "Library\ncontroller" -> "Local backend" + "Library\ncontroller" -> "Spotify backend" + + "Playback\ncontroller" -> "Local backend" + "Playback\ncontroller" -> "Spotify backend" + "Playback\ncontroller" -> Audio + + "Stored\nplaylists\ncontroller" -> "Local backend" + "Stored\nplaylists\ncontroller" -> "Spotify backend" + +Backends +======== + +The backends are organized as a set of providers with responsiblity forseparate +sets of functionality, similar to the core actor. + +Anything specific to i.e. Spotify integration or local storage is contained in +the backends. To integrate with new music sources, you just add a new backend. +See :ref:`backend-api` for more details. + +.. digraph:: backend_architecture + + "Local backend" -> "Local\nlibrary\nprovider" -> "Local disk" + "Local backend" -> "Local\nplayback\nprovider" -> "Local disk" + "Local backend" -> "Local\nstored\nplaylists\nprovider" -> "Local disk" + "Local\nplayback\nprovider" -> Audio + + "Spotify backend" -> "Spotify\nlibrary\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nplayback\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nstored\nplaylists\nprovider" -> "Spotify service" + "Spotify\nplayback\nprovider" -> Audio + + +Audio +===== + +The audio actor is a thin wrapper around the parts of the GStreamer library we +use. In addition to playback, it's responsible for volume control through both +GStreamer's own volume mixers, and mixers we've created ourselves. If you +implement an advanced backend, you may need to implement your own playback +provider using the :ref:`audio-api`. diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index fc54a8a2..2237b4e7 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -1,3 +1,5 @@ +.. _frontend-api: + ************ Frontend API ************ From 6427f7e6bcdb89d53e62e5eefb8a4cab85ae7a06 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 08:30:28 +0100 Subject: [PATCH 112/323] Split up two-level list comprehension --- mopidy/core/library.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 37c8c522..e0df8928 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,3 +1,4 @@ +import itertools import urlparse import pykka @@ -94,5 +95,6 @@ class LibraryController(object): """ futures = [b.library.search(**query) for b in self.backends] results = pykka.get_all(futures) - return Playlist(tracks=[ - track for playlist in results for track in playlist.tracks]) + track_lists = [playlist.tracks for playlist in results] + tracks = list(itertools.chain(*track_lists)) + return Playlist(tracks=tracks) From ea912620f3c3a1251b3c7346994d3da6a0461a8d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:20:43 +0100 Subject: [PATCH 113/323] Formatting --- docs/api/concepts.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 5eca2349..c3696179 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -65,6 +65,7 @@ See :ref:`core-api` for more details. "Stored\nplaylists\ncontroller" -> "Local backend" "Stored\nplaylists\ncontroller" -> "Spotify backend" + Backends ======== From fbf642ca99df0f1be100ba3d3dfa096543aa20dc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:21:58 +0100 Subject: [PATCH 114/323] docs: Use dashes in all labels --- docs/changes.rst | 4 ++-- docs/settings.rst | 6 +++--- mopidy/frontends/mpris/__init__.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 541f7af9..7d608086 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -592,7 +592,7 @@ to this problem. - Local backend: - Add :command:`mopidy-scan` command to generate ``tag_cache`` files without - any help from the original MPD server. See :ref:`generating_a_tag_cache` + any help from the original MPD server. See :ref:`generating-a-tag-cache` for instructions on how to use it. - Fix support for UTF-8 encoding in tag caches. @@ -601,7 +601,7 @@ to this problem. - Add support for password authentication. See :attr:`mopidy.settings.MPD_SERVER_PASSWORD` and - :ref:`use_mpd_on_a_network` for details on how to use it. (Fixes: + :ref:`use-mpd-on-a-network` for details on how to use it. (Fixes: :issue:`41`) - Support ``setvol 50`` without quotes around the argument. Fixes volume diff --git a/docs/settings.rst b/docs/settings.rst index b71b18ef..37e1d8ed 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -63,7 +63,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See have support for this in a future release. -.. _generating_a_tag_cache: +.. _generating-a-tag-cache: Generating a tag cache ---------------------- @@ -94,7 +94,7 @@ To make a ``tag_cache`` of your local music available for Mopidy: #. Start Mopidy, find the music library in a client, and play some local music! -.. _use_mpd_on_a_network: +.. _use-mpd-on-a-network: Connecting from other machines on the network ============================================= @@ -123,7 +123,7 @@ file:: LASTFM_PASSWORD = u'mysecret' -.. _install_desktop_file: +.. _install-desktop-file: Controlling Mopidy through the Ubuntu Sound Menu ================================================ diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 4245f844..38deac7a 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -16,7 +16,7 @@ An example of an MPRIS client is the `Ubuntu Sound Menu Ubuntu/Debian. - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for details. **Settings:** From 6a39516d05a40e5ec95fbc71c9e5e848ead57380 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:22:41 +0100 Subject: [PATCH 115/323] Fix typo --- docs/api/concepts.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index c3696179..203418de 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -69,8 +69,8 @@ See :ref:`core-api` for more details. Backends ======== -The backends are organized as a set of providers with responsiblity forseparate -sets of functionality, similar to the core actor. +The backends are organized as a set of providers with responsiblity for +separate sets of functionality, similar to the core actor. Anything specific to i.e. Spotify integration or local storage is contained in the backends. To integrate with new music sources, you just add a new backend. From c17f07e14bceb73496ed8f2e44926f22f05f4f89 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:25:48 +0100 Subject: [PATCH 116/323] Code review improvements --- docs/changes.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5a17c810..8df19842 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -31,13 +31,13 @@ long time been our most requested feature. Finally, it's here! Internally, Mopidy have seen a lot of changes to pave the way for multiple backends: -- A new layer and actor, "core", have been added to our stack, inbetween the - frontends and the backends. The responsibility of this layer and actor is to - take requests from the frontends, pass them on to one or more backends, and - combining the response from the backends into a single response to the +- A new layer and actor, "core", has been added to our stack, inbetween the + frontends and the backends. The responsibility of the core layer and actor is + to take requests from the frontends, pass them on to one or more backends, + and combining the response from the backends into a single response to the requesting frontend. - The frontends no longer know anything about the backends. They just use the + Frontends no longer know anything about the backends. They just use the :ref:`core-api`. - The base playback provider have gotten sane default behavior instead of the From b352a6ed4f9aa234dac4cfb4ba123c2c6ac7e66d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:38:32 +0100 Subject: [PATCH 117/323] Code review improvements --- docs/changes.rst | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8df19842..9129584c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -40,19 +40,19 @@ backends: Frontends no longer know anything about the backends. They just use the :ref:`core-api`. -- The base playback provider have gotten sane default behavior instead of the - old empty functions. By default, the playback provider now lets GStreamer - keep track of the current track's time position. The local backend simply - uses the base playback provider without any changes. The same applies to any - future backend that just needs GStreamer to play an URI for it. +- The base playback provider has been updated with sane default behavior + instead of empty functions. By default, the playback provider now lets + GStreamer keep track of the current track's time position. The local backend + simply uses the base playback provider without any changes. The same applies + to any future backend that just needs GStreamer to play an URI for it. - The dependency graph between the core controllers and the backend providers - have been straightened out, so that we don't have any circular dependencies - or similar. The frontend, core, backend, and audio layers are now strictly - separate. The frontend layer calls on the core layer, and the core layer - calls on the backend layer. Both the core layer and the backends are allowed - to call on the audio layer. Any data flow in the opposite direction is done - by broadcasting of events to listeners, through e.g. + have been straightened out, so that we don't have any circular dependencies. + The frontend, core, backend, and audio layers are now strictly separate. The + frontend layer calls on the core layer, and the core layer calls on the + backend layer. Both the core layer and the backends are allowed to call on + the audio layer. Any data flow in the opposite direction is done by + broadcasting of events to listeners, through e.g. :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. - All dependencies are now explicitly passed to the constructors of the From 2e6e53b14dd96e060c5187474de64f91d36dacec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 09:48:53 +0100 Subject: [PATCH 118/323] Remove code duplication --- mopidy/core/actor.py | 29 ++++++++++++++++++++++++----- mopidy/core/library.py | 10 +--------- mopidy/core/playback.py | 11 +---------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 05c085fd..0af8c3b2 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -30,25 +30,44 @@ class Core(pykka.ThreadingActor, AudioListener): def __init__(self, audio=None, backends=None): super(Core, self).__init__() - self._backends = backends + self.backends = Backends(backends) self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backends=backends, core=self) + self.library = LibraryController(backends=self.backends, core=self) self.playback = PlaybackController( - audio=audio, backends=backends, core=self) + audio=audio, backends=self.backends, core=self) self.stored_playlists = StoredPlaylistsController( - backends=backends, core=self) + backends=self.backends, core=self) @property def uri_schemes(self): """List of URI schemes we can handle""" - futures = [backend.uri_schemes for backend in self._backends] + futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() + + +class Backends(object): + def __init__(self, backends): + self._backends = backends + + uri_schemes_by_backend = { + backend: backend.uri_schemes.get() + for backend in backends} + self.by_uri_scheme = { + uri_scheme: backend + for backend, uri_schemes in uri_schemes_by_backend.items() + for uri_scheme in uri_schemes} + + def __len__(self): + return len(self._backends) + + def __getitem__(self, key): + return self._backends[key] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index e0df8928..bf14f5d3 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -11,19 +11,11 @@ class LibraryController(object): def __init__(self, backends, core): self.backends = backends - uri_schemes_by_backend = { - backend: backend.uri_schemes.get() - for backend in backends} - self.backends_by_uri_scheme = { - uri_scheme: backend - for backend, uri_schemes in uri_schemes_by_backend.items() - for uri_scheme in uri_schemes} - self.core = core def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends_by_uri_scheme.get(uri_scheme) + return self.backends.by_uri_scheme.get(uri_scheme) def find_exact(self, **query): """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 721bc2a8..74f4bebd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -77,16 +77,7 @@ class PlaybackController(object): def __init__(self, audio, backends, core): self.audio = audio - self.backends = backends - uri_schemes_by_backend = { - backend: backend.uri_schemes.get() - for backend in backends} - self.backends_by_uri_scheme = { - uri_scheme: backend - for backend, uri_schemes in uri_schemes_by_backend.items() - for uri_scheme in uri_schemes} - self.core = core self._state = PlaybackState.STOPPED @@ -99,7 +90,7 @@ class PlaybackController(object): return None uri = self.current_cp_track.track.uri uri_scheme = urlparse.urlparse(uri).scheme - return self.backends_by_uri_scheme[uri_scheme] + return self.backends.by_uri_scheme[uri_scheme] def _get_cpid(self, cp_track): if cp_track is None: From 44186c1a03d8504aad8a68f1261538801f689c03 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:14:43 +0100 Subject: [PATCH 119/323] Make sure backends is a fully functional list --- mopidy/core/actor.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 0af8c3b2..e2eeb746 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -54,20 +54,12 @@ class Core(pykka.ThreadingActor, AudioListener): self.playback.on_end_of_track() -class Backends(object): +class Backends(list): def __init__(self, backends): - self._backends = backends + super(Backends, self).__init__(backends) - uri_schemes_by_backend = { - backend: backend.uri_schemes.get() - for backend in backends} - self.by_uri_scheme = { - uri_scheme: backend - for backend, uri_schemes in uri_schemes_by_backend.items() - for uri_scheme in uri_schemes} - - def __len__(self): - return len(self._backends) - - def __getitem__(self, key): - return self._backends[key] + self.by_uri_scheme = {} + for backend in backends: + uri_schemes = backend.uri_schemes.get() + for uri_scheme in uri_schemes: + self.by_uri_scheme[uri_scheme] = backend From 7ee43dd20893283d4cdc9fe4effeab86c227a492 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:24:07 +0100 Subject: [PATCH 120/323] Be explicit about returning None for unknown URI schemes --- mopidy/core/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index bf14f5d3..f7514fd8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -15,7 +15,7 @@ class LibraryController(object): def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.by_uri_scheme.get(uri_scheme) + return self.backends.by_uri_scheme.get(uri_scheme, None) def find_exact(self, **query): """ From 4a79b559d547f0602d632c8bd4b2f1b2344d485b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:31:35 +0100 Subject: [PATCH 121/323] Fail if two backends claims to handle the same URI schema --- mopidy/core/actor.py | 3 +++ tests/core/actor_test.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index e2eeb746..7fdaeb71 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -62,4 +62,7 @@ class Backends(list): for backend in backends: uri_schemes = backend.uri_schemes.get() for uri_scheme in uri_schemes: + assert uri_scheme not in self.by_uri_scheme, ( + 'URI scheme %s is already handled by %s' + % (uri_scheme, backend.__class__.__name__)) self.by_uri_scheme[uri_scheme] = backend diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index 95639cf8..9feddbd0 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -24,3 +24,9 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy1', result) self.assertIn('dummy2', result) + + def test_backends_with_colliding_uri_schemes_fails(self): + self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( + AssertionError, 'URI scheme dummy1 is already handled by Mock', + Core, audio=None, backends=[self.backend1, self.backend2]) From 1014c6e373313c642c4d72ad884a1ebbc69e8de1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 10:50:18 +0100 Subject: [PATCH 122/323] Include both involved backends in the error message --- mopidy/core/actor.py | 7 +++++-- tests/core/actor_test.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 7fdaeb71..482868ad 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -63,6 +63,9 @@ class Backends(list): uri_schemes = backend.uri_schemes.get() for uri_scheme in uri_schemes: assert uri_scheme not in self.by_uri_scheme, ( - 'URI scheme %s is already handled by %s' - % (uri_scheme, backend.__class__.__name__)) + 'Cannot add URI scheme %s for %s, ' + 'it is already handled by %s' + ) % ( + uri_scheme, backend.__class__.__name__, + self.by_uri_scheme[uri_scheme].__class__.__name__) self.by_uri_scheme[uri_scheme] = backend diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index 9feddbd0..8212c1da 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -26,7 +26,10 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): + self.backend1.__class__.__name__ = 'B1' + self.backend2.__class__.__name__ = 'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] self.assertRaisesRegexp( - AssertionError, 'URI scheme dummy1 is already handled by Mock', + AssertionError, + 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', Core, audio=None, backends=[self.backend1, self.backend2]) From 262bd98c160267a021a1068a12d4c66746506066 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 12:23:53 +0100 Subject: [PATCH 123/323] Move Pykka version check back to import time --- mopidy/__init__.py | 18 +++++++++++++++++- mopidy/__main__.py | 14 -------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 0b0be1a6..14c67b80 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,9 +1,25 @@ +# pylint: disable = E0611,F0401 +from distutils.version import StrictVersion as SV +# pylint: enable = E0611,F0401 import sys + +import pykka + + if not (2, 6) <= sys.version_info < (3,): - sys.exit(u'Mopidy requires Python >= 2.6, < 3') + sys.exit( + u'Mopidy requires Python >= 2.6, < 3, but found %s' % + '.'.join(map(str, sys.version_info[:3]))) + +if (isinstance(pykka.__version__, basestring) + and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): + sys.exit( + u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) + __version__ = '0.8.0' + from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 965cd9ba..75f847e4 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,6 +1,3 @@ -# pylint: disable = E0611,F0401 -from distutils.version import StrictVersion -# pylint: enable = E0611,F0401 import logging import optparse import os @@ -10,8 +7,6 @@ import sys import gobject gobject.threads_init() -import pykka - # Extract any non-GStreamer arguments, and leave the GStreamer arguments for # processing by GStreamer. This needs to be done before GStreamer is imported, @@ -45,7 +40,6 @@ logger = logging.getLogger('mopidy.main') def main(): - check_dependencies() signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() @@ -73,14 +67,6 @@ def main(): process.stop_remaining_actors() -def check_dependencies(): - pykka_required = '1.0' - if StrictVersion(pykka.__version__) < StrictVersion(pykka_required): - sys.exit( - u'Mopidy requires Pykka >= %s, but found %s' % - (pykka_required, pykka.__version__)) - - def parse_options(): parser = optparse.OptionParser( version=u'Mopidy %s' % versioning.get_version()) From 15799a1ccd723383423cc9106758d1c3bdd7a2d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 12:53:53 +0100 Subject: [PATCH 124/323] Ignore the 'could not open display' warning from GTK --- mopidy/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 14c67b80..48375ae4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -2,6 +2,7 @@ from distutils.version import StrictVersion as SV # pylint: enable = E0611,F0401 import sys +import warnings import pykka @@ -17,6 +18,9 @@ if (isinstance(pykka.__version__, basestring) u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) +warnings.filterwarnings('ignore', 'could not open display') + + __version__ = '0.8.0' From e8af2276e285858abcee98196dbb163b4e78de5a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 12:54:21 +0100 Subject: [PATCH 125/323] Log warnings instead of just printing them --- mopidy/utils/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 3421746d..80047680 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -6,6 +6,7 @@ from . import deps, versioning def setup_logging(verbosity_level, save_debug_log): + logging.captureWarnings(True) setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: From 9bc123693e984f2d432dbf56221cabf43d8b3e17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 14:23:10 +0100 Subject: [PATCH 126/323] Make NAD mixer respond to interrupts during calibration --- docs/changes.rst | 7 +++++++ mopidy/audio/mixers/nad.py | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 025ed71e..c88027bd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -60,6 +60,13 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. +**Changes** + +- Made the :mod:`NAD mixer ` responsive to interrupts + during amplifier calibration. It will now quit immediately, while previously + it completed the calibration first, and then quit, which could take more than + 15 seconds. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index cb1266a1..1a807e39 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -179,7 +179,7 @@ class NadTalker(pykka.ThreadingActor): self._select_speakers() self._select_input_source() self.mute(False) - self._calibrate_volume() + self.calibrate_volume() def _get_device_model(self): model = self._ask_device('Main.Model') @@ -205,14 +205,21 @@ class NadTalker(pykka.ThreadingActor): else: self._check_and_set('Main.Mute', 'Off') - def _calibrate_volume(self): + def calibrate_volume(self, current_nad_volume=None): # The NAD C 355BEE amplifier has 40 different volume levels. We have no # way of asking on which level we are. Thus, we must calibrate the # mixer by decreasing the volume 39 times. - logger.info(u'NAD amplifier: Calibrating by setting volume to 0') - self._nad_volume = self.VOLUME_LEVELS - self.set_volume(0) - logger.info(u'NAD amplifier: Done calibrating') + if current_nad_volume is None: + current_nad_volume = self.VOLUME_LEVELS + if current_nad_volume == self.VOLUME_LEVELS: + logger.info(u'NAD amplifier: Calibrating by setting volume to 0') + self._nad_volume = current_nad_volume + if self._decrease_volume(): + current_nad_volume -= 1 + if current_nad_volume == 0: + logger.info(u'NAD amplifier: Done calibrating') + else: + self.actor_ref.proxy().calibrate_volume(current_nad_volume) def set_volume(self, volume): # Increase or decrease the amplifier volume until it matches the given From a5df718276f1f021138fab4c169c4c88412e2eba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 19:21:42 +0100 Subject: [PATCH 127/323] docs: Sync front page with README --- docs/index.rst | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 0af510d0..bdd8e4c1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,13 +2,19 @@ Mopidy ****** -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use most -`MPD clients `_. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android, and iOS. +Mopidy is a music server which can play music both from your local hard drive +and from `Spotify `_. Searches returns results from +both your local hard drive and from Spotify, and you can mix tracks from both +sources in your play queue. Your Spotify playlists are also available for use, +though we don't support modifying them yet. -To install Mopidy, start out by reading :ref:`installation`. +To control your music server, you can use the Ubuntu Sound Menu on the machine +running Mopidy, any device on the same network which supports the DLNA media +controller spec (with the help of Rygel in addition to Mopidy), or any `MPD +client `_. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android and iOS. + +To install Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_. If you stumble into a bug or got a feature request, @@ -22,6 +28,7 @@ Project resources - `Documentation `_ - `Source code `_ - `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ @@ -39,6 +46,7 @@ User documentation licenses changes + Reference documentation ======================= @@ -48,6 +56,7 @@ Reference documentation api/index modules/index + Development documentation ========================= @@ -56,10 +65,10 @@ Development documentation development + Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` - From 0a8dc743a51d682f456f1f27ff1740787e946478 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 09:31:16 +0100 Subject: [PATCH 128/323] Fix logging on Python 2.6 (fixes #220) --- mopidy/utils/log.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 80047680..bb966a1d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -6,11 +6,13 @@ from . import deps, versioning def setup_logging(verbosity_level, save_debug_log): - logging.captureWarnings(True) setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: setup_debug_logging_to_file() + if hasattr(logging, 'captureWarnings'): + # New in Python 2.7 + logging.captureWarnings(True) logger = logging.getLogger('mopidy.utils.log') logger.info(u'Starting Mopidy %s', versioning.get_version()) logger.info(u'%(name)s: %(version)s', deps.platform_info()) From bbda85462d19466a83f9db30a64747352b82f386 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 20:45:50 +0100 Subject: [PATCH 129/323] Docstring formatting --- mopidy/models.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 8eaa4ee5..a8edfde2 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -203,14 +203,14 @@ class Track(ImmutableObject): class Playlist(ImmutableObject): """ - :param uri: playlist URI - :type uri: string - :param name: playlist name - :type name: string - :param tracks: playlist's tracks - :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time - :type last_modified: :class:`datetime.datetime` + :param uri: playlist URI + :type uri: string + :param name: playlist name + :type name: string + :param tracks: playlist's tracks + :type tracks: list of :class:`Track` elements + :param last_modified: playlist's modification time + :type last_modified: :class:`datetime.datetime` """ #: The playlist URI. Read-only. From d1a42d95f1802c02a8633b70095cfbff541e2aa4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 09:56:26 +0100 Subject: [PATCH 130/323] Add Album.date attribute --- docs/changes.rst | 3 +++ mopidy/models.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index c88027bd..94779cc0 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -67,6 +67,9 @@ backends: it completed the calibration first, and then quit, which could take more than 15 seconds. +- Added :attr:`mopidy.models.Album.date` attribute. It has the same format as + the existing :attr:`mopidy.models.Track.date`. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/mopidy/models.py b/mopidy/models.py index a8edfde2..77561fe3 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -120,6 +120,8 @@ class Album(ImmutableObject): :type artists: list of :class:`Artist` :param num_tracks: number of tracks in album :type num_tracks: integer + :param date: album release date (YYYY or YYYY-MM-DD) + :type date: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string """ @@ -136,6 +138,9 @@ class Album(ImmutableObject): #: The number of tracks in the album. Read-only. num_tracks = 0 + #: The album release date. Read-only. + date = None + #: The MusicBrainz ID of the album. Read-only. musicbrainz_id = None From 53184e62a03eafbc52577621201f8b1f3759a7e8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 20:46:28 +0100 Subject: [PATCH 131/323] Make all Spotify data objects always have URI set --- mopidy/backends/spotify/translator.py | 48 +++++++++++++++------------ 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 104029f5..8bc135b2 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,6 +1,6 @@ import logging -from spotify import Link, SpotifyError +from spotify import Link from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist @@ -11,22 +11,27 @@ logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTranslator(object): @classmethod def to_mopidy_artist(cls, spotify_artist): + if spotify_artist is None: + return + uri = str(Link.from_artist(spotify_artist)) if not spotify_artist.is_loaded(): - return Artist(name=u'[loading...]') - return Artist( - uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name() - ) + return Artist(uri=uri, name=u'[loading...]') + return Artist(uri=uri, name=spotify_artist.name()) @classmethod def to_mopidy_album(cls, spotify_album): - if spotify_album is None or not spotify_album.is_loaded(): - return Album(name=u'[loading...]') + if spotify_album is None: + return + uri = str(Link.from_album(spotify_album)) + if not spotify_album.is_loaded(): + return Album(uri=uri, name=u'[loading...]') # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name()) + return Album(uri=uri, name=spotify_album.name()) @classmethod def to_mopidy_track(cls, spotify_track): + if spotify_track is None: + return uri = str(Link.from_track(spotify_track, 0)) if not spotify_track.is_loaded(): return Track(uri=uri, name=u'[loading...]') @@ -48,17 +53,16 @@ class SpotifyTranslator(object): @classmethod def to_mopidy_playlist(cls, spotify_playlist): - if not spotify_playlist.is_loaded(): - return Playlist(name=u'[loading...]') - if spotify_playlist.type() != 'playlist': + if spotify_playlist is None or spotify_playlist.type() != 'playlist': return - try: - return Playlist( - uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name(), - # FIXME if check on link is a hackish workaround for is_local - tracks=[cls.to_mopidy_track(t) for t in spotify_playlist - if str(Link.from_track(t, 0))], - ) - except SpotifyError, e: - logger.warning(u'Failed translating Spotify playlist: %s', e) + uri = str(Link.from_playlist(spotify_playlist)) + if not spotify_playlist.is_loaded(): + return Playlist(uri=uri, name=u'[loading...]') + return Playlist( + uri=uri, + name=spotify_playlist.name(), + tracks=[ + cls.to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local()], + ) From e792fcd3b934b9c8d4ef794db59df70b2c4667a5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 10:07:11 +0100 Subject: [PATCH 132/323] Include release year and artist on Spotify albums --- docs/changes.rst | 2 ++ mopidy/backends/spotify/translator.py | 13 +++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 94779cc0..dcf08795 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -70,6 +70,8 @@ backends: - Added :attr:`mopidy.models.Album.date` attribute. It has the same format as the existing :attr:`mopidy.models.Track.date`. +- The Spotify backend now includes release year and artist on albums. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 8bc135b2..b424e4b1 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -25,8 +25,11 @@ class SpotifyTranslator(object): uri = str(Link.from_album(spotify_album)) if not spotify_album.is_loaded(): return Album(uri=uri, name=u'[loading...]') - # TODO pyspotify got much more data on albums than this - return Album(uri=uri, name=spotify_album.name()) + return Album( + uri=uri, + name=spotify_album.name(), + artists=[cls.to_mopidy_artist(spotify_album.artist())], + date=spotify_album.year()) @classmethod def to_mopidy_track(cls, spotify_track): @@ -48,8 +51,7 @@ class SpotifyTranslator(object): track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=settings.SPOTIFY_BITRATE, - ) + bitrate=settings.SPOTIFY_BITRATE) @classmethod def to_mopidy_playlist(cls, spotify_playlist): @@ -64,5 +66,4 @@ class SpotifyTranslator(object): tracks=[ cls.to_mopidy_track(spotify_track) for spotify_track in spotify_playlist - if not spotify_track.is_local()], - ) + if not spotify_track.is_local()]) From 1207700a1550a8f3193e5105695761574a5befb5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 10:11:18 +0100 Subject: [PATCH 133/323] Convert Spotify translator to plain functions --- mopidy/backends/spotify/library.py | 5 +- mopidy/backends/spotify/session_manager.py | 8 +- mopidy/backends/spotify/translator.py | 112 ++++++++++----------- 3 files changed, 58 insertions(+), 67 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index e237a04a..bf057bee 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -6,7 +6,7 @@ from spotify import Link, SpotifyError from mopidy.backends import base from mopidy.models import Track, Playlist -from .translator import SpotifyTranslator +from . import translator logger = logging.getLogger('mopidy.backends.spotify') @@ -24,8 +24,7 @@ class SpotifyTrack(Track): if self._track: return self._track elif self._spotify_track.is_loaded(): - self._track = SpotifyTranslator.to_mopidy_track( - self._spotify_track) + self._track = translator.to_mopidy_track(self._spotify_track) return self._track else: return self._unloaded_track diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 983f3861..23b99d48 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -8,9 +8,9 @@ from mopidy import settings from mopidy.models import Playlist from mopidy.utils import process, versioning +from . import translator from .container_manager import SpotifyContainerManager from .playlist_manager import SpotifyPlaylistManager -from .translator import SpotifyTranslator logger = logging.getLogger('mopidy.backends.spotify') @@ -141,8 +141,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.debug(u'Still getting data; skipped refresh of playlists') return playlists = map( - SpotifyTranslator.to_mopidy_playlist, - self.session.playlist_container()) + translator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) @@ -154,8 +153,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): # TODO Consider launching a second search if results.total_tracks() # is larger than len(results.tracks()) playlist = Playlist(tracks=[ - SpotifyTranslator.to_mopidy_track(t) - for t in results.tracks()]) + translator.to_mopidy_track(t) for t in results.tracks()]) queue.put(playlist) self.connected.wait() self.session.search( diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index b424e4b1..4ad92fe9 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,69 +1,63 @@ -import logging - from spotify import Link from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify') + +def to_mopidy_artist(spotify_artist): + if spotify_artist is None: + return + uri = str(Link.from_artist(spotify_artist)) + if not spotify_artist.is_loaded(): + return Artist(uri=uri, name=u'[loading...]') + return Artist(uri=uri, name=spotify_artist.name()) -class SpotifyTranslator(object): - @classmethod - def to_mopidy_artist(cls, spotify_artist): - if spotify_artist is None: - return - uri = str(Link.from_artist(spotify_artist)) - if not spotify_artist.is_loaded(): - return Artist(uri=uri, name=u'[loading...]') - return Artist(uri=uri, name=spotify_artist.name()) +def to_mopidy_album(spotify_album): + if spotify_album is None: + return + uri = str(Link.from_album(spotify_album)) + if not spotify_album.is_loaded(): + return Album(uri=uri, name=u'[loading...]') + return Album( + uri=uri, + name=spotify_album.name(), + artists=[to_mopidy_artist(spotify_album.artist())], + date=spotify_album.year()) - @classmethod - def to_mopidy_album(cls, spotify_album): - if spotify_album is None: - return - uri = str(Link.from_album(spotify_album)) - if not spotify_album.is_loaded(): - return Album(uri=uri, name=u'[loading...]') - return Album( - uri=uri, - name=spotify_album.name(), - artists=[cls.to_mopidy_artist(spotify_album.artist())], - date=spotify_album.year()) - @classmethod - def to_mopidy_track(cls, spotify_track): - if spotify_track is None: - return - uri = str(Link.from_track(spotify_track, 0)) - if not spotify_track.is_loaded(): - return Track(uri=uri, name=u'[loading...]') - spotify_album = spotify_track.album() - if spotify_album is not None and spotify_album.is_loaded(): - date = spotify_album.year() - else: - date = None - return Track( - uri=uri, - name=spotify_track.name(), - artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], - album=cls.to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=date, - length=spotify_track.duration(), - bitrate=settings.SPOTIFY_BITRATE) +def to_mopidy_track(spotify_track): + if spotify_track is None: + return + uri = str(Link.from_track(spotify_track, 0)) + if not spotify_track.is_loaded(): + return Track(uri=uri, name=u'[loading...]') + spotify_album = spotify_track.album() + if spotify_album is not None and spotify_album.is_loaded(): + date = spotify_album.year() + else: + date = None + return Track( + uri=uri, + name=spotify_track.name(), + artists=[to_mopidy_artist(a) for a in spotify_track.artists()], + album=to_mopidy_album(spotify_track.album()), + track_no=spotify_track.index(), + date=date, + length=spotify_track.duration(), + bitrate=settings.SPOTIFY_BITRATE) - @classmethod - def to_mopidy_playlist(cls, spotify_playlist): - if spotify_playlist is None or spotify_playlist.type() != 'playlist': - return - uri = str(Link.from_playlist(spotify_playlist)) - if not spotify_playlist.is_loaded(): - return Playlist(uri=uri, name=u'[loading...]') - return Playlist( - uri=uri, - name=spotify_playlist.name(), - tracks=[ - cls.to_mopidy_track(spotify_track) - for spotify_track in spotify_playlist - if not spotify_track.is_local()]) + +def to_mopidy_playlist(spotify_playlist): + if spotify_playlist is None or spotify_playlist.type() != 'playlist': + return + uri = str(Link.from_playlist(spotify_playlist)) + if not spotify_playlist.is_loaded(): + return Playlist(uri=uri, name=u'[loading...]') + return Playlist( + uri=uri, + name=spotify_playlist.name(), + tracks=[ + to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local()]) From d60bb57f5f77b9adc59a374836157013e59b7818 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 10:18:40 +0100 Subject: [PATCH 134/323] Test new Album.date attribute --- tests/models_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/models_test.py b/tests/models_test.py index a3c9cc96..004c0a28 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -164,6 +164,12 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album.num_tracks, num_tracks) self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + def test_date(self): + date = '1977-01-01' + album = Album(date=date) + self.assertEqual(album.date, date) + self.assertRaises(AttributeError, setattr, album, 'date', None) + def test_musicbrainz_id(self): mb_id = u'mb-id' album = Album(musicbrainz_id=mb_id) @@ -229,6 +235,13 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) + def test_eq_date(self): + date = '1977-01-01' + album1 = Album(date=date) + album2 = Album(date=date) + self.assertEqual(album1, album2) + self.assertEqual(hash(album1), hash(album2)) + def test_eq_musibrainz_id(self): album1 = Album(musicbrainz_id=u'id') album2 = Album(musicbrainz_id=u'id') @@ -276,6 +289,12 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_date(self): + album1 = Album(date='1977-01-01') + album2 = Album(date='1977-01-02') + self.assertNotEqual(album1, album2) + self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_musicbrainz_id(self): album1 = Album(musicbrainz_id=u'id1') album2 = Album(musicbrainz_id=u'id2') From 26e0f9f69424d0991f1ef179fd931c19782f6933 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 15:15:44 +0100 Subject: [PATCH 135/323] docs: Homebrew now includes gst-python --- docs/installation/gstreamer.rst | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 42685ad0..38dbb86c 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -39,26 +39,11 @@ repository:: Installing GStreamer on OS X ============================ -.. note:: - - We have been working with `Homebrew `_ to - make all the GStreamer packages easily installable on OS X using Homebrew. - We've gotten most of our packages included, but the Homebrew guys aren't - very happy to include Python specific packages into Homebrew, even though - they are not installable by pip. If you're interested, see the discussion - in `Homebrew's issue #1612 - `_ for details. - -The following is currently the shortest path to installing GStreamer with -Python bindings on OS X using Homebrew. +We have been working with `Homebrew `_ for a +to make all the GStreamer packages easily installable on OS X. #. Install `Homebrew `_. -#. Download our Homebrew formula for ``gst-python``:: - - curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb - #. Install the required packages:: brew install gst-python gst-plugins-good gst-plugins-ugly From 19787f2850423938e33c1d3b19c3d03337ab7d25 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 30 Oct 2012 15:33:01 +0100 Subject: [PATCH 136/323] Make nosetests only look in tests/ for tests to run Without this, it will also look in mopidy/ for tests, and some modules there may raise exceptions on import time because of missing dependencies, like dbus not being available on OS X. --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index bce0a6e2..f9894674 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,4 @@ verbosity = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 +tests = tests From 89db62bc9efac50d4ac737065ba3ee47d9030935 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:55:19 +0100 Subject: [PATCH 137/323] Revert "Make nosetests only look in tests/ for tests to run" This reverts commit 19787f2850423938e33c1d3b19c3d03337ab7d25. --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index f9894674..bce0a6e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,4 +4,3 @@ verbosity = 1 cover-package = mopidy cover-inclusive = 1 cover-html = 1 -tests = tests From dd42e5684b4170b7d0e454fcbbe629c14d486b28 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:49:53 +0100 Subject: [PATCH 138/323] Use 'except ... as ...' --- mopidy/backends/local/stored_playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 1cb03425..a5841d8d 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -32,8 +32,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: tracks.append(self.backend.library.lookup(uri)) - except LookupError, e: - logger.error('Playlist item could not be added: %s', e) + except LookupError as ex: + logger.error('Playlist item could not be added: %s', ex) playlist = Playlist(tracks=tracks, name=name) # FIXME playlist name needs better handling From 855e57a74a9bfec734712c005b79bc8c5b69b9b2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:50:41 +0100 Subject: [PATCH 139/323] Use os.path.splitext to strip of file extension --- mopidy/backends/local/stored_playlists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index a5841d8d..921fa40c 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -27,7 +27,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.info('Loading playlists from %s', self._folder) for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:-len('.m3u')] + name = os.path.splitext(os.path.basename(m3u))[0] tracks = [] for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: From a679a472125b94923bc97d08131935cb1c6c74bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 11:11:04 +0100 Subject: [PATCH 140/323] Minor test updates --- tests/backends/base/stored_playlists.py | 3 +++ tests/backends/local/stored_playlists_test.py | 24 ++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 57096fd3..5d01996d 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -3,6 +3,7 @@ import shutil import tempfile import mock +import pykka from mopidy import audio, core, settings from mopidy.models import Playlist @@ -22,6 +23,8 @@ class StoredPlaylistsControllerTest(object): self.stored = self.core.stored_playlists def tearDown(self): + pykka.ActorRegistry.stop_all() + if os.path.exists(settings.LOCAL_PLAYLIST_PATH): shutil.rmtree(settings.LOCAL_PLAYLIST_PATH) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 4dc5ecdb..188eb589 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -11,8 +11,8 @@ from tests.backends.base.stored_playlists import ( from tests.backends.local import generate_song -class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, - unittest.TestCase): +class LocalStoredPlaylistsControllerTest( + StoredPlaylistsControllerTest, unittest.TestCase): backend_class = LocalBackend @@ -28,13 +28,13 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.stored.save(Playlist(name='test2')) self.assert_(os.path.exists(path)) - def test_deleted_playlist_get_removed(self): + def test_deleted_playlist_is_removed(self): playlist = self.stored.create('test') self.stored.delete(playlist) path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assert_(not os.path.exists(path)) - def test_renamed_playlist_gets_moved(self): + def test_renamed_playlist_is_moved(self): playlist = self.stored.create('test') file1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') file2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') @@ -43,7 +43,7 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, self.assert_(not os.path.exists(file1)) self.assert_(os.path.exists(file2)) - def test_playlist_contents_get_written_to_disk(self): + def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) uri = track.uri[len('file://'):] playlist = Playlist(tracks=[track], name='test') @@ -58,15 +58,17 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, def test_playlists_are_loaded_at_startup(self): track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = Playlist(tracks=[track], name='test') - + playlist = self.stored.create('test') + playlist = playlist.copy(tracks=[track]) self.stored.save(playlist) - self.backend = self.backend_class.start(audio=self.audio).proxy() + backend = self.backend_class(audio=self.audio) - self.assert_(self.stored.playlists) - self.assertEqual('test', self.stored.playlists[0].name) - self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri) + self.assert_(backend.stored_playlists.playlists) + self.assertEqual( + playlist.name, backend.stored_playlists.playlists[0].name) + self.assertEqual( + track.uri, backend.stored_playlists.playlists[0].tracks[0].uri) @unittest.SkipTest def test_santitising_of_playlist_filenames(self): From 0ddbb4e28a64d410f312945b59f87d1764a95090 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 29 Oct 2012 20:25:58 +0100 Subject: [PATCH 141/323] Make core.stored_playlists.playlists read-only (#217) --- docs/changes.rst | 7 +++++++ mopidy/core/stored_playlists.py | 7 +------ tests/backends/base/stored_playlists.py | 8 +++++--- tests/frontends/mpd/protocol/stored_playlists_test.py | 8 ++++---- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 0203a89f..7fa59a3c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,6 +56,13 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. +- The stored playlists part of the core API have been revised a bit: + + - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports + assignment to it. The `playlists` property on the backend layer still does, + and all functionality is maintained by assigning to the playlists + collections at the backend level. + **Changes** - Made the :mod:`NAD mixer ` responsive to interrupts diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 9de1545f..a3d52023 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -15,17 +15,12 @@ class StoredPlaylistsController(object): """ Currently stored playlists. - Read/write. List of :class:`mopidy.models.Playlist`. + Read-only. List of :class:`mopidy.models.Playlist`. """ futures = [b.stored_playlists.playlists for b in self.backends] results = pykka.get_all(futures) return list(itertools.chain(*results)) - @playlists.setter # noqa - def playlists(self, playlists): - # TODO Support multiple backends - self.backends[0].stored_playlists.playlists = playlists - def create(self, name): """ Create a new playlist. diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 5d01996d..fca13b93 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -65,12 +65,13 @@ class StoredPlaylistsControllerTest(object): def test_get_by_name_returns_unique_match(self): playlist = Playlist(name='b') - self.stored.playlists = [Playlist(name='a'), playlist] + self.backend.stored_playlists.playlists = [ + Playlist(name='a'), playlist] self.assertEqual(playlist, self.stored.get(name='b')) def test_get_by_name_returns_first_of_multiple_matches(self): playlist = Playlist(name='b') - self.stored.playlists = [ + self.backend.stored_playlists.playlists = [ playlist, Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='b') @@ -79,7 +80,8 @@ class StoredPlaylistsControllerTest(object): self.assertEqual(u'"name=b" match multiple playlists', e[0]) def test_get_by_name_raises_keyerror_if_no_match(self): - self.stored.playlists = [Playlist(name='a'), Playlist(name='b')] + self.backend.stored_playlists.playlists = [ + Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='c') self.fail(u'Should raise LookupError if no match') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8cfcb338..346cd37f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -7,7 +7,7 @@ from tests.frontends.mpd import protocol class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylist "name"') @@ -19,7 +19,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylistinfo "name"') @@ -35,7 +35,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') @@ -47,7 +47,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_known_playlist_appends_to_current_playlist(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] From e2474da1efa4de0425903500e3dcdec1ec3c6e9e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 09:42:35 +0100 Subject: [PATCH 142/323] Make core.stored_playlists.create() support multibackend (#217) --- mopidy/core/stored_playlists.py | 16 +++++++++++++--- tests/core/stored_playlists_test.py | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index a3d52023..55b0bdce 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -21,16 +21,26 @@ class StoredPlaylistsController(object): results = pykka.get_all(futures) return list(itertools.chain(*results)) - def create(self, name): + def create(self, name, uri_scheme=None): """ Create a new playlist. + If ``uri_scheme`` matches an URI scheme handled by a current backend, + that backend is asked to create the playlist. If ``uri_scheme`` is + :class:`None` or doesn't match a current backend, the first backend is + asked to create the playlist. + :param name: name of the new playlist :type name: string + :param uri_scheme: use the backend matching the URI scheme + :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.create(name).get() + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + else: + backend = self.backends[0] + return backend.stored_playlists.create(name).get() def delete(self, playlist): """ diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index d92b89c0..87c90137 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -37,5 +37,27 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertIn(self.pl2a, result) self.assertIn(self.pl2b, result) + def test_create_without_uri_scheme_uses_first_backend(self): + playlist = Playlist() + self.sp1.create().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.assertFalse(self.sp2.create.called) + + def test_create_with_uri_scheme_selects_the_matching_backend(self): + playlist = Playlist() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.stored_playlists.create('foo', uri_scheme='dummy2') + + self.assertEqual(playlist, result) + self.assertFalse(self.sp1.create.called) + self.sp2.create.assert_called_once_with('foo') + # TODO The rest of the stored playlists API is pending redesign before # we'll update it to support multiple backends. From 8cc1896b9df66e66970724b002ac6cbfc9cfbfc2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 09:47:31 +0100 Subject: [PATCH 143/323] Make core.stored_playlists.lookup() support multibackend (#217) --- mopidy/core/stored_playlists.py | 13 +++++++++---- tests/core/stored_playlists_test.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 55b0bdce..3525ff67 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,4 +1,5 @@ import itertools +import urlparse import pykka @@ -85,14 +86,18 @@ class StoredPlaylistsController(object): def lookup(self, uri): """ Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. + in any other playlist sources. Returns :class:`None` if not found. :param uri: playlist URI :type uri: string - :rtype: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.lookup(uri).get() + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.by_uri_scheme.get(uri_scheme, None) + if backend: + return backend.stored_playlists.lookup(uri).get() + else: + return None def refresh(self): """ diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index 87c90137..aeb22e1a 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -59,5 +59,17 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.create.called) self.sp2.create.assert_called_once_with('foo') + def test_lookup_selects_the_dummy1_backend(self): + self.core.stored_playlists.lookup('dummy1:a') + + self.sp1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.sp2.lookup.called) + + def test_lookup_selects_the_dummy2_backend(self): + self.core.stored_playlists.lookup('dummy2:a') + + self.assertFalse(self.sp1.lookup.called) + self.sp2.lookup.assert_called_once_with('dummy2:a') + # TODO The rest of the stored playlists API is pending redesign before # we'll update it to support multiple backends. From fd88b974e859825fec5d59d5c73797b775fc5029 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 10:10:53 +0100 Subject: [PATCH 144/323] Make core.stored_playlists.refresh() support multibackend (#217) --- mopidy/core/stored_playlists.py | 19 ++++++++++++++++--- tests/core/stored_playlists_test.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 3525ff67..b34b8bc1 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -99,12 +99,25 @@ class StoredPlaylistsController(object): else: return None - def refresh(self): + def refresh(self, uri_scheme=None): """ Refresh the stored playlists in :attr:`playlists`. + + If ``uri_scheme`` is :class:`None`, all backends are asked to refresh. + If ``uri_scheme`` is an URI scheme handled by a backend, only that + backend is asked to refresh. If ``uri_scheme`` doesn't match any + current backend, nothing happens. + + :param uri_scheme: limit to the backend matching the URI scheme + :type uri_scheme: string """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.refresh().get() + if uri_scheme is None: + futures = [b.stored_playlists.refresh() for b in self.backends] + pykka.get_all(futures) + else: + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + backend.stored_playlists.refresh().get() def rename(self, playlist, new_name): """ diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index aeb22e1a..2e90416e 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -71,5 +71,23 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.lookup.called) self.sp2.lookup.assert_called_once_with('dummy2:a') + def test_refresh_without_uri_scheme_refreshes_all_backends(self): + self.core.stored_playlists.refresh() + + self.sp1.refresh.assert_called_once_with() + self.sp2.refresh.assert_called_once_with() + + def test_refresh_with_uri_scheme_refreshes_matching_backend(self): + self.core.stored_playlists.refresh(uri_scheme='dummy2') + + self.assertFalse(self.sp1.refresh.called) + self.sp2.refresh.assert_called_once_with() + + def test_refresh_with_unknown_uri_scheme_refreshes_nothing(self): + self.core.stored_playlists.refresh(uri_scheme='foobar') + + self.assertFalse(self.sp1.refresh.called) + self.assertFalse(self.sp2.refresh.called) + # TODO The rest of the stored playlists API is pending redesign before # we'll update it to support multiple backends. From d8378e9284f8c42d31c29310ff1466c098e93d74 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 11:12:07 +0100 Subject: [PATCH 145/323] Set URI on local playlists when reading from disk (#217) --- mopidy/backends/local/stored_playlists.py | 8 +++++--- tests/backends/local/stored_playlists_test.py | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 921fa40c..49d6edba 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -6,6 +6,7 @@ import shutil from mopidy import settings from mopidy.backends import base from mopidy.models import Playlist +from mopidy.utils import path from .translator import parse_m3u @@ -27,14 +28,15 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.info('Loading playlists from %s', self._folder) for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): + uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] tracks = [] - for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): + for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: - tracks.append(self.backend.library.lookup(uri)) + tracks.append(self.backend.library.lookup(track_uri)) except LookupError as ex: logger.error('Playlist item could not be added: %s', ex) - playlist = Playlist(tracks=tracks, name=name) + playlist = Playlist(uri=uri, name=name, tracks=tracks) # FIXME playlist name needs better handling # FIXME tracks should come from lib. lookup diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 188eb589..d1d6989a 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -57,6 +57,8 @@ class LocalStoredPlaylistsControllerTest( self.assertEqual(uri, contents.strip()) def test_playlists_are_loaded_at_startup(self): + playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) playlist = self.stored.create('test') playlist = playlist.copy(tracks=[track]) @@ -65,6 +67,9 @@ class LocalStoredPlaylistsControllerTest( backend = self.backend_class(audio=self.audio) self.assert_(backend.stored_playlists.playlists) + self.assertEqual( + path_to_uri(playlist_path), + backend.stored_playlists.playlists[0].uri) self.assertEqual( playlist.name, backend.stored_playlists.playlists[0].name) self.assertEqual( From 51aab4f138ab019e892c16ffafacdb732278a29d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 11:39:13 +0100 Subject: [PATCH 146/323] Make local stored playlists set and use URIs (#217) --- mopidy/backends/local/stored_playlists.py | 14 ++++- tests/backends/base/stored_playlists.py | 30 ++++++---- tests/backends/local/stored_playlists_test.py | 55 +++++++++---------- 3 files changed, 57 insertions(+), 42 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 49d6edba..ef7cc92d 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -20,7 +20,9 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.refresh() def lookup(self, uri): - pass # TODO + for playlist in self._playlists: + if playlist.uri == uri: + return playlist def refresh(self): playlists = [] @@ -46,7 +48,9 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.playlists = playlists def create(self, name): - playlist = Playlist(name=name) + file_path = os.path.join(self._folder, name + '.m3u') + uri = path.path_to_uri(file_path) + playlist = Playlist(uri=uri, name=name) self.save(playlist) return playlist @@ -74,9 +78,10 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): shutil.move(src, dst) def save(self, playlist): - file_path = os.path.join(self._folder, playlist.name + '.m3u') + assert playlist.uri, 'Cannot save playlist without URI' # FIXME this should be a save_m3u function, not inside save + file_path = playlist.uri[len('file://'):] with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): @@ -84,4 +89,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): else: file_handle.write(track.uri + '\n') + original_playlist = self.lookup(playlist.uri) + if original_playlist is not None: + self._playlists.remove(original_playlist) self._playlists.append(playlist) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index fca13b93..209aad0a 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -30,11 +30,15 @@ class StoredPlaylistsControllerTest(object): settings.runtime.clear() - def test_create(self): + def test_create_returns_playlist_with_name_set(self): playlist = self.stored.create('test') self.assertEqual(playlist.name, 'test') - def test_create_in_playlists(self): + def test_create_returns_playlist_with_uri_set(self): + playlist = self.stored.create('test') + self.assert_(playlist.uri) + + def test_create_adds_playlist_to_playlists_collection(self): playlist = self.stored.create('test') self.assert_(self.stored.playlists) self.assertIn(playlist, self.stored.playlists) @@ -88,9 +92,12 @@ class StoredPlaylistsControllerTest(object): except LookupError as e: self.assertEqual(u'"name=c" match no playlists', e[0]) - @unittest.SkipTest - def test_lookup(self): - pass + def test_lookup_finds_playlist_by_uri(self): + original_playlist = self.stored.create('test') + + looked_up_playlist = self.stored.lookup(original_playlist.uri) + + self.assertEqual(original_playlist, looked_up_playlist) @unittest.SkipTest def test_refresh(self): @@ -106,11 +113,14 @@ class StoredPlaylistsControllerTest(object): test = lambda: self.stored.get(name='test2') self.assertRaises(LookupError, test) - def test_save(self): - # FIXME should we handle playlists without names? - playlist = Playlist(name='test') - self.stored.save(playlist) - self.assertIn(playlist, self.stored.playlists) + def test_save_replaces_playlist_with_updated_playlist(self): + playlist1 = self.stored.create('test1') + self.assertIn(playlist1, self.stored.playlists) + + playlist2 = playlist1.copy(name='test2') + self.stored.save(playlist2) + self.assertNotIn(playlist1, self.stored.playlists) + self.assertIn(playlist2, self.stored.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index d1d6989a..446d87f1 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -2,7 +2,7 @@ import os from mopidy import settings from mopidy.backends.local import LocalBackend -from mopidy.models import Playlist, Track +from mopidy.models import Track from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir @@ -23,38 +23,43 @@ class LocalStoredPlaylistsControllerTest( self.assert_(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - self.assert_(not os.path.exists(path)) - self.stored.save(Playlist(name='test2')) - self.assert_(os.path.exists(path)) + path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') + + playlist = self.stored.create('test') + + self.assertFalse(os.path.exists(path2)) + + self.stored.rename(playlist, 'test2') + + self.assertFalse(os.path.exists(path1)) + self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - playlist = self.stored.create('test') - self.stored.delete(playlist) path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assert_(not os.path.exists(path)) - def test_renamed_playlist_is_moved(self): + self.assertFalse(os.path.exists(path)) + playlist = self.stored.create('test') - file1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - file2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - self.assert_(not os.path.exists(file2)) - self.stored.rename(playlist, 'test2') - self.assert_(not os.path.exists(file1)) - self.assert_(os.path.exists(file2)) + + self.assertTrue(os.path.exists(path)) + + self.stored.delete(playlist) + + self.assertFalse(os.path.exists(path)) def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) - uri = track.uri[len('file://'):] - playlist = Playlist(tracks=[track], name='test') - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - + track_path = track.uri[len('file://'):] + playlist = self.stored.create('test') + playlist_path = playlist.uri[len('file://'):] + playlist = playlist.copy(tracks=[track]) self.stored.save(playlist) - with open(path) as playlist_file: + with open(playlist_path) as playlist_file: contents = playlist_file.read() - self.assertEqual(uri, contents.strip()) + self.assertEqual(track_path, contents.strip()) def test_playlists_are_loaded_at_startup(self): playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') @@ -82,11 +87,3 @@ class LocalStoredPlaylistsControllerTest( @unittest.SkipTest def test_playlist_folder_is_createad(self): pass - - @unittest.SkipTest - def test_create_sets_playlist_uri(self): - pass - - @unittest.SkipTest - def test_save_sets_playlist_uri(self): - pass From d03881f173e78cf02abc4037f1b5634d24cee281 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 15:19:04 +0100 Subject: [PATCH 147/323] Require stored_playlists.save() to return the updated playlist (#217) --- docs/changes.rst | 4 ++++ mopidy/core/stored_playlists.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 7fa59a3c..4d58ff4e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -63,6 +63,10 @@ backends: and all functionality is maintained by assigning to the playlists collections at the backend level. + - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved + playlist. The returned playlist may differ from the saved playlist, and + should thus be used instead of the saved playlist. + **Changes** - Made the :mod:`NAD mixer ` responsive to interrupts diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index b34b8bc1..8f87db58 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -136,8 +136,15 @@ class StoredPlaylistsController(object): """ Save the playlist to the set of stored playlists. + Returns the saved playlist. The return playlist may differ from the + saved playlist. E.g. if the playlist name was changed, the returned + playlist may have a different URI. The caller of this method should + throw away the playlist sent to this method, and use the returned + playlist instead. + :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` """ # TODO Support multiple backends return self.backends[0].stored_playlists.save(playlist).get() From 06bcad2db9554c0a7c0bf8217dd3c1451fa2f243 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 15:21:02 +0100 Subject: [PATCH 148/323] Make local.stored_playlists.save() capable of renaming playlists (#217) --- docs/changes.rst | 2 +- mopidy/backends/local/stored_playlists.py | 70 +++++++++++-------- tests/backends/base/stored_playlists.py | 4 +- tests/backends/local/stored_playlists_test.py | 12 ++-- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4d58ff4e..285af6b3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -65,7 +65,7 @@ backends: - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved playlist. The returned playlist may differ from the saved playlist, and - should thus be used instead of the saved playlist. + should thus be used instead of the playlist passed to ``save()``. **Changes** diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index ef7cc92d..621d37bf 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -10,6 +10,7 @@ from mopidy.utils import path from .translator import parse_m3u + logger = logging.getLogger(u'mopidy.backends.local') @@ -19,6 +20,25 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() + # TODO playlist names needs safer handling using a slug function + + def create(self, name): + file_path = os.path.join(self._folder, name + '.m3u') + uri = path.path_to_uri(file_path) + playlist = Playlist(uri=uri, name=name) + self.save(playlist) + return playlist + + def delete(self, playlist): + if playlist not in self._playlists: + return + + self._playlists.remove(playlist) + filename = os.path.join(self._folder, playlist.name + '.m3u') + + if os.path.exists(filename): + os.remove(filename) + def lookup(self, uri): for playlist in self._playlists: if playlist.uri == uri: @@ -40,30 +60,10 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): logger.error('Playlist item could not be added: %s', ex) playlist = Playlist(uri=uri, name=name, tracks=tracks) - # FIXME playlist name needs better handling - # FIXME tracks should come from lib. lookup - playlists.append(playlist) self.playlists = playlists - def create(self, name): - file_path = os.path.join(self._folder, name + '.m3u') - uri = path.path_to_uri(file_path) - playlist = Playlist(uri=uri, name=name) - self.save(playlist) - return playlist - - def delete(self, playlist): - if playlist not in self._playlists: - return - - self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) - def rename(self, playlist, name): if playlist not in self._playlists: return @@ -80,16 +80,30 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' - # FIXME this should be a save_m3u function, not inside save + old_playlist = self.lookup(playlist.uri) + + if old_playlist and playlist.name != old_playlist.name: + src = os.path.join(self._folder, old_playlist.name + '.m3u') + dst = os.path.join(self._folder, playlist.name + '.m3u') + shutil.move(src, dst) + playlist = playlist.copy(uri=path.path_to_uri(dst)) + + self._save_m3u(playlist) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist + + def _save_m3u(self, playlist): file_path = playlist.uri[len('file://'):] with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): - file_handle.write(track.uri[len('file://'):] + '\n') + uri = track.uri[len('file://'):] else: - file_handle.write(track.uri + '\n') - - original_playlist = self.lookup(playlist.uri) - if original_playlist is not None: - self._playlists.remove(original_playlist) - self._playlists.append(playlist) + uri = track.uri + file_handle.write(uri + '\n') diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 209aad0a..83c243f3 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -113,12 +113,12 @@ class StoredPlaylistsControllerTest(object): test = lambda: self.stored.get(name='test2') self.assertRaises(LookupError, test) - def test_save_replaces_playlist_with_updated_playlist(self): + def test_save_replaces_stored_playlist_with_updated_playlist(self): playlist1 = self.stored.create('test1') self.assertIn(playlist1, self.stored.playlists) playlist2 = playlist1.copy(name='test2') - self.stored.save(playlist2) + playlist2 = self.stored.save(playlist2) self.assertNotIn(playlist1, self.stored.playlists) self.assertIn(playlist2, self.stored.playlists) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 446d87f1..7025c402 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -23,14 +23,16 @@ class LocalStoredPlaylistsControllerTest( self.assert_(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - playlist = self.stored.create('test') + playlist = self.stored.create('test1') + self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - self.stored.rename(playlist, 'test2') + playlist = playlist.copy(name='test2') + playlist = self.stored.save(playlist) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) @@ -54,7 +56,7 @@ class LocalStoredPlaylistsControllerTest( playlist = self.stored.create('test') playlist_path = playlist.uri[len('file://'):] playlist = playlist.copy(tracks=[track]) - self.stored.save(playlist) + playlist = self.stored.save(playlist) with open(playlist_path) as playlist_file: contents = playlist_file.read() @@ -67,7 +69,7 @@ class LocalStoredPlaylistsControllerTest( track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) playlist = self.stored.create('test') playlist = playlist.copy(tracks=[track]) - self.stored.save(playlist) + playlist = self.stored.save(playlist) backend = self.backend_class(audio=self.audio) From f9f6f9394dbdeec8a858e9ab82089d16a6893a98 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 15:25:39 +0100 Subject: [PATCH 149/323] Remove stored_playlists.rename() (#217) --- docs/changes.rst | 3 +++ mopidy/backends/base.py | 8 -------- mopidy/backends/local/stored_playlists.py | 13 ------------- mopidy/core/stored_playlists.py | 13 ------------- tests/backends/base/stored_playlists.py | 10 ---------- 5 files changed, 3 insertions(+), 44 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 285af6b3..9c2bea62 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -67,6 +67,9 @@ backends: playlist. The returned playlist may differ from the saved playlist, and should thus be used instead of the playlist passed to ``save()``. + - :meth:`mopidy.core.StoredPlaylistsController.rename` has been removed, + since renaming can be done with ``save()``. + **Changes** - Made the :mod:`NAD mixer ` responsive to interrupts diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 7ae2c3dc..e4c40a92 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -204,14 +204,6 @@ class BaseStoredPlaylistsProvider(object): """ raise NotImplementedError - def rename(self, playlist, new_name): - """ - See :meth:`mopidy.core.StoredPlaylistsController.rename`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - def save(self, playlist): """ See :meth:`mopidy.core.StoredPlaylistsController.save`. diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 621d37bf..6d12dd46 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -64,19 +64,6 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.playlists = playlists - def rename(self, playlist, name): - if playlist not in self._playlists: - return - - src = os.path.join(self._folder, playlist.name + '.m3u') - dst = os.path.join(self._folder, name + '.m3u') - - renamed = playlist.copy(name=name) - index = self._playlists.index(playlist) - self._playlists[index] = renamed - - shutil.move(src, dst) - def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 8f87db58..af88d86b 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -119,19 +119,6 @@ class StoredPlaylistsController(object): backend = self.backends.by_uri_scheme[uri_scheme] backend.stored_playlists.refresh().get() - def rename(self, playlist, new_name): - """ - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.rename( - playlist, new_name).get() - def save(self, playlist): """ Save the playlist to the set of stored playlists. diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 83c243f3..ba907b13 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -103,16 +103,6 @@ class StoredPlaylistsControllerTest(object): def test_refresh(self): pass - def test_rename(self): - playlist = self.stored.create('test') - self.stored.rename(playlist, 'test2') - self.stored.get(name='test2') - - def test_rename_unknown_playlist(self): - self.stored.rename(Playlist(), 'test2') - test = lambda: self.stored.get(name='test2') - self.assertRaises(LookupError, test) - def test_save_replaces_stored_playlist_with_updated_playlist(self): playlist1 = self.stored.create('test1') self.assertIn(playlist1, self.stored.playlists) From 3d05f3c65f26e04fda43e57406ab761de2b896f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 16:37:07 +0100 Subject: [PATCH 150/323] Change stored_playlists.delete() to accepting an URI (#217) --- docs/changes.rst | 7 ++++++- mopidy/backends/base.py | 2 +- mopidy/backends/local/stored_playlists.py | 15 +++++++++------ mopidy/core/stored_playlists.py | 17 +++++++++++------ tests/backends/base/stored_playlists.py | 11 +++++++---- tests/backends/local/stored_playlists_test.py | 5 +---- tests/core/stored_playlists_test.py | 18 ++++++++++++++++++ 7 files changed, 53 insertions(+), 22 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 9c2bea62..1427c867 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,13 +56,18 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. -- The stored playlists part of the core API have been revised a bit: +- The stored playlists part of the core API have been revised to be more + focused around the playlist URI, and some redundant functionality have been + removed: - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports assignment to it. The `playlists` property on the backend layer still does, and all functionality is maintained by assigning to the playlists collections at the backend level. + - :meth:`mopidy.core.StoredPlaylistsController.delete` now accepts an URI, + and not a playlist object. + - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved playlist. The returned playlist may differ from the saved playlist, and should thus be used instead of the playlist passed to ``save()``. diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index e4c40a92..95cd93c4 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -180,7 +180,7 @@ class BaseStoredPlaylistsProvider(object): """ raise NotImplementedError - def delete(self, playlist): + def delete(self, uri): """ See :meth:`mopidy.core.StoredPlaylistsController.delete`. diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 6d12dd46..139cfac8 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -29,15 +29,13 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.save(playlist) return playlist - def delete(self, playlist): - if playlist not in self._playlists: + def delete(self, uri): + playlist = self.lookup(uri) + if not playlist: return self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) + self._delete_m3u(playlist) def lookup(self, uri): for playlist in self._playlists: @@ -94,3 +92,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): else: uri = track.uri file_handle.write(uri + '\n') + + def _delete_m3u(self, playlist): + file_path = playlist.uri[len('file://'):] + if os.path.exists(file_path): + os.remove(file_path) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index af88d86b..16dffdb0 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -43,15 +43,20 @@ class StoredPlaylistsController(object): backend = self.backends[0] return backend.stored_playlists.create(name).get() - def delete(self, playlist): + def delete(self, uri): """ - Delete playlist. + Delete playlist identified by the URI. - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` + If the URI doesn't match the URI schemes handled by the current + backends, nothing happens. + + :param uri: URI of the playlist to delete + :type uri: string """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.delete(playlist).get() + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.by_uri_scheme.get(uri_scheme, None) + if backend: + return backend.stored_playlists.delete(uri).get() def get(self, **criteria): """ diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index ba907b13..2b5469ac 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -47,12 +47,15 @@ class StoredPlaylistsControllerTest(object): self.assert_(not self.stored.playlists) def test_delete_non_existant_playlist(self): - self.stored.delete(Playlist()) + self.stored.delete('file:///unknown/playlist') - def test_delete_playlist(self): + def test_delete_playlist_removes_it_from_the_collection(self): playlist = self.stored.create('test') - self.stored.delete(playlist) - self.assert_(not self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) + + self.stored.delete(playlist.uri) + + self.assertNotIn(playlist, self.stored.playlists) def test_get_without_criteria(self): test = self.stored.get diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 7025c402..987c0788 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -39,15 +39,12 @@ class LocalStoredPlaylistsControllerTest( def test_deleted_playlist_is_removed(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assertFalse(os.path.exists(path)) playlist = self.stored.create('test') - self.assertTrue(os.path.exists(path)) - self.stored.delete(playlist) - + self.stored.delete(playlist.uri) self.assertFalse(os.path.exists(path)) def test_playlist_contents_is_written_to_disk(self): diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index 2e90416e..39914766 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -59,6 +59,24 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.create.called) self.sp2.create.assert_called_once_with('foo') + def test_delete_selects_the_dummy1_backend(self): + self.core.stored_playlists.delete('dummy1:a') + + self.sp1.delete.assert_called_once_with('dummy1:a') + self.assertFalse(self.sp2.delete.called) + + def test_delete_selects_the_dummy2_backend(self): + self.core.stored_playlists.delete('dummy2:a') + + self.assertFalse(self.sp1.delete.called) + self.sp2.delete.assert_called_once_with('dummy2:a') + + def test_delete_with_unknown_uri_scheme_does_nothing(self): + self.core.stored_playlists.delete('unknown:a') + + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + def test_lookup_selects_the_dummy1_backend(self): self.core.stored_playlists.lookup('dummy1:a') From 6c49a7fc525599fc2a4f5cdb2c7b4c8905751b2b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 31 Oct 2012 16:52:34 +0100 Subject: [PATCH 151/323] Make core.stored_playlists.save() support multibackend (#217) --- mopidy/core/stored_playlists.py | 32 ++++++++++++++++++------- tests/core/stored_playlists_test.py | 37 +++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 16dffdb0..c404a408 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -31,6 +31,9 @@ class StoredPlaylistsController(object): :class:`None` or doesn't match a current backend, the first backend is asked to create the playlist. + All new playlists should be created by calling this method, and **not** + by creating new instances of :class:`mopidy.models.Playlist`. + :param name: name of the new playlist :type name: string :param uri_scheme: use the backend matching the URI scheme @@ -128,15 +131,28 @@ class StoredPlaylistsController(object): """ Save the playlist to the set of stored playlists. - Returns the saved playlist. The return playlist may differ from the - saved playlist. E.g. if the playlist name was changed, the returned - playlist may have a different URI. The caller of this method should - throw away the playlist sent to this method, and use the returned - playlist instead. + For a playlist to be saveable, it must have the ``uri`` attribute set. + You should not set the ``uri`` atribute yourself, but use playlist + objects returned by :meth:`create` or retrieved from :attr:`playlists`, + which will always give you saveable playlists. + + The method returns the saved playlist. The return playlist may differ + from the saved playlist. E.g. if the playlist name was changed, the + returned playlist may have a different URI. The caller of this method + should throw away the playlist sent to this method, and use the + returned playlist instead. + + If the playlist's URI isn't set or doesn't match the URI scheme of a + current backend, nothing is done and :class:`None` is returned. :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` - :rtype: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ - # TODO Support multiple backends - return self.backends[0].stored_playlists.save(playlist).get() + if playlist.uri is None: + return + uri_scheme = urlparse.urlparse(playlist.uri).scheme + if uri_scheme not in self.backends.by_uri_scheme: + return + backend = self.backends.by_uri_scheme[uri_scheme] + return backend.stored_playlists.save(playlist).get() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index 39914766..b0d48512 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -107,5 +107,38 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) - # TODO The rest of the stored playlists API is pending redesign before - # we'll update it to support multiple backends. + def test_save_selects_the_dummy1_backend(self): + playlist = Playlist(uri='dummy1:a') + self.sp1.save().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.save(playlist) + + self.assertEqual(playlist, result) + self.sp1.save.assert_called_once_with(playlist) + self.assertFalse(self.sp2.save.called) + + def test_save_selects_the_dummy2_backend(self): + playlist = Playlist(uri='dummy2:a') + self.sp2.save().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.stored_playlists.save(playlist) + + self.assertEqual(playlist, result) + self.assertFalse(self.sp1.save.called) + self.sp2.save.assert_called_once_with(playlist) + + def test_save_does_nothing_if_playlist_uri_is_unset(self): + result = self.core.stored_playlists.save(Playlist()) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) + + def test_save_does_nothing_if_playlist_uri_has_unknown_scheme(self): + result = self.core.stored_playlists.save(Playlist(uri='foobar:a')) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) From af04808941dc7ef7e5c99e818dfbaa4158b2488a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 00:29:28 +0100 Subject: [PATCH 152/323] Make Travis use IRC notice notifications without joining the channel --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 6120e2de..bbba0a94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,3 +17,5 @@ notifications: - "irc.freenode.org#mopidy" on_success: change on_failure: change + use_notice: true + skip_join: true From a5b454acc0087197963ddc7874866abd11582a45 Mon Sep 17 00:00:00 2001 From: Fred Hatfull Date: Wed, 31 Oct 2012 23:45:13 -0700 Subject: [PATCH 153/323] Fixes support for MPD find/search by filename Extends `find_exact` and `search` in mopidy.backends.local.library to support the `filename` query field. This field can get passed in from the MPD frontend and would break with a `LookupError` when used. This patch fixes the issue and introduces two new tests to cover the added functionality. --- mopidy/backends/local/library.py | 4 ++-- tests/backends/base/library.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 600bfaaa..db37edb3 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -59,7 +59,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': + elif field == 'uri' or field == 'filename': result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) @@ -93,7 +93,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': + elif field == 'uri' or field == 'filename': result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index cc2a0004..b7510dbb 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -79,6 +79,15 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_find_exact_filename(self): + track_1_filename = 'file://' + path_to_data_dir('uri1') + result = self.library.find_exact(filename=track_1_filename) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + track_2_filename = 'file://' + path_to_data_dir('uri2') + result = self.library.find_exact(filename=track_2_filename) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -137,6 +146,13 @@ class LibraryControllerTest(object): result = self.library.search(uri=['RI2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_search_filename(self): + result = self.library.search(filename=['RI1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(filename=['RI2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, Playlist(tracks=self.tracks[:1])) From 58c190f12b925034182e4e219824f5dc8cf00005 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 12:10:17 +0100 Subject: [PATCH 154/323] Fix grammar (#217) --- docs/changes.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 1427c867..ef0d3bcd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,9 +56,8 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. -- The stored playlists part of the core API have been revised to be more - focused around the playlist URI, and some redundant functionality have been - removed: +- The stored playlists part of the core API has been revised to be more focused + around the playlist URI, and some redundant functionality has been removed: - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports assignment to it. The `playlists` property on the backend layer still does, From 078cc72fffbf72a30f2f30969d7dfe033c5ac8fb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 12:12:01 +0100 Subject: [PATCH 155/323] Remove undocumented return from core.stored_playlists.delete() (#217) --- mopidy/core/stored_playlists.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index c404a408..8c04d5ad 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -57,9 +57,9 @@ class StoredPlaylistsController(object): :type uri: string """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.by_uri_scheme.get(uri_scheme, None) - if backend: - return backend.stored_playlists.delete(uri).get() + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + backend.stored_playlists.delete(uri).get() def get(self, **criteria): """ From 8c9a3d6df232faf1f61f2aeb6cf827f506e50743 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 12:46:29 +0100 Subject: [PATCH 156/323] Slugify local playlist names to make them safe to use in paths (#217) --- mopidy/backends/local/stored_playlists.py | 23 +++++++++--- tests/backends/base/stored_playlists.py | 16 ++++----- tests/backends/local/stored_playlists_test.py | 36 ++++++++++++++----- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 139cfac8..3d488655 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -1,7 +1,9 @@ import glob import logging import os +import re import shutil +import unicodedata from mopidy import settings from mopidy.backends import base @@ -20,9 +22,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() - # TODO playlist names needs safer handling using a slug function - def create(self, name): + name = self._slugify(name) file_path = os.path.join(self._folder, name + '.m3u') uri = path.path_to_uri(file_path) playlist = Playlist(uri=uri, name=name) @@ -68,10 +69,11 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): old_playlist = self.lookup(playlist.uri) if old_playlist and playlist.name != old_playlist.name: + new_name = self._slugify(playlist.name) src = os.path.join(self._folder, old_playlist.name + '.m3u') - dst = os.path.join(self._folder, playlist.name + '.m3u') + dst = os.path.join(self._folder, new_name + '.m3u') shutil.move(src, dst) - playlist = playlist.copy(uri=path.path_to_uri(dst)) + playlist = playlist.copy(uri=path.path_to_uri(dst), name=new_name) self._save_m3u(playlist) @@ -97,3 +99,16 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): file_path = playlist.uri[len('file://'):] if os.path.exists(file_path): os.remove(file_path) + + def _slugify(self, value): + """ + Converts to lowercase, removes non-word characters (alphanumerics and + underscores) and converts spaces to hyphens. Also strips leading and + trailing whitespace. + + This function is based on Django's slugify implementation. + """ + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub('[^\w\s-]', '', value).strip().lower() + return re.sub('[-\s]+', '-', value) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 2b5469ac..267a025c 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -31,15 +31,15 @@ class StoredPlaylistsControllerTest(object): settings.runtime.clear() def test_create_returns_playlist_with_name_set(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assertEqual(playlist.name, 'test') def test_create_returns_playlist_with_uri_set(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assert_(playlist.uri) def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assert_(self.stored.playlists) self.assertIn(playlist, self.stored.playlists) @@ -50,7 +50,7 @@ class StoredPlaylistsControllerTest(object): self.stored.delete('file:///unknown/playlist') def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assertIn(playlist, self.stored.playlists) self.stored.delete(playlist.uri) @@ -66,7 +66,7 @@ class StoredPlaylistsControllerTest(object): self.assertRaises(LookupError, test) def test_get_with_right_criteria(self): - playlist1 = self.stored.create('test') + playlist1 = self.stored.create(u'test') playlist2 = self.stored.get(name='test') self.assertEqual(playlist1, playlist2) @@ -96,7 +96,7 @@ class StoredPlaylistsControllerTest(object): self.assertEqual(u'"name=c" match no playlists', e[0]) def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.stored.create('test') + original_playlist = self.stored.create(u'test') looked_up_playlist = self.stored.lookup(original_playlist.uri) @@ -107,10 +107,10 @@ class StoredPlaylistsControllerTest(object): pass def test_save_replaces_stored_playlist_with_updated_playlist(self): - playlist1 = self.stored.create('test1') + playlist1 = self.stored.create(u'test1') self.assertIn(playlist1, self.stored.playlists) - playlist2 = playlist1.copy(name='test2') + playlist2 = playlist1.copy(name=u'test2') playlist2 = self.stored.save(playlist2) self.assertNotIn(playlist1, self.stored.playlists) self.assertIn(playlist2, self.stored.playlists) diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 987c0788..cd1ecd3c 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -18,22 +18,40 @@ class LocalStoredPlaylistsControllerTest( def test_created_playlist_is_persisted(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assert_(not os.path.exists(path)) - self.stored.create('test') - self.assert_(os.path.exists(path)) + self.assertFalse(os.path.exists(path)) + + self.stored.create(u'test') + self.assertTrue(os.path.exists(path)) + + def test_create_slugifies_playlist_name(self): + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + self.assertFalse(os.path.exists(path)) + + playlist = self.stored.create(u'test FOO baR') + self.assertEqual(u'test-foo-bar', playlist.name) + self.assertTrue(os.path.exists(path)) + + def test_create_slugifies_names_which_tries_to_change_directory(self): + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + self.assertFalse(os.path.exists(path)) + + playlist = self.stored.create(u'../../test FOO baR') + self.assertEqual(u'test-foo-bar', playlist.name) + self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') - path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') + path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2-foo-bar.m3u') - playlist = self.stored.create('test1') + playlist = self.stored.create(u'test1') self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = playlist.copy(name='test2') + playlist = playlist.copy(name=u'test2 FOO baR') playlist = self.stored.save(playlist) + self.assertEqual(u'test2-foo-bar', playlist.name) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) @@ -41,7 +59,7 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') self.assertTrue(os.path.exists(path)) self.stored.delete(playlist.uri) @@ -50,7 +68,7 @@ class LocalStoredPlaylistsControllerTest( def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) track_path = track.uri[len('file://'):] - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') playlist_path = playlist.uri[len('file://'):] playlist = playlist.copy(tracks=[track]) playlist = self.stored.save(playlist) @@ -64,7 +82,7 @@ class LocalStoredPlaylistsControllerTest( playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = self.stored.create('test') + playlist = self.stored.create(u'test') playlist = playlist.copy(tracks=[track]) playlist = self.stored.save(playlist) From 82f5b376da73c06c4023572c27be60117e300eb5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 14:03:09 +0100 Subject: [PATCH 157/323] Validate the stored playlist file paths --- mopidy/backends/local/stored_playlists.py | 73 +++++++++++++++++------ 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 3d488655..9e5b72c6 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -19,16 +19,14 @@ logger = logging.getLogger(u'mopidy.backends.local') class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH + self._path = settings.LOCAL_PLAYLIST_PATH self.refresh() def create(self, name): name = self._slugify(name) - file_path = os.path.join(self._folder, name + '.m3u') - uri = path.path_to_uri(file_path) + uri = path.path_to_uri(self._get_m3u_path(name)) playlist = Playlist(uri=uri, name=name) - self.save(playlist) - return playlist + return self.save(playlist) def delete(self, uri): playlist = self.lookup(uri) @@ -36,7 +34,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return self._playlists.remove(playlist) - self._delete_m3u(playlist) + self._delete_m3u(playlist.uri) def lookup(self, uri): for playlist in self._playlists: @@ -44,21 +42,24 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return playlist def refresh(self): + logger.info('Loading playlists from %s', self._path) + playlists = [] - logger.info('Loading playlists from %s', self._folder) - - for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): + for m3u in glob.glob(os.path.join(self._path, '*.m3u')): uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] + tracks = [] for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: + # TODO We must use core.library.lookup() to support tracks + # from other backends tracks.append(self.backend.library.lookup(track_uri)) except LookupError as ex: logger.error('Playlist item could not be added: %s', ex) - playlist = Playlist(uri=uri, name=name, tracks=tracks) + playlist = Playlist(uri=uri, name=name, tracks=tracks) playlists.append(playlist) self.playlists = playlists @@ -69,11 +70,8 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): old_playlist = self.lookup(playlist.uri) if old_playlist and playlist.name != old_playlist.name: - new_name = self._slugify(playlist.name) - src = os.path.join(self._folder, old_playlist.name + '.m3u') - dst = os.path.join(self._folder, new_name + '.m3u') - shutil.move(src, dst) - playlist = playlist.copy(uri=path.path_to_uri(dst), name=new_name) + playlist = playlist.copy(name=self._slugify(playlist.name)) + playlist = self._rename_m3u(playlist) self._save_m3u(playlist) @@ -85,21 +83,58 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return playlist + def _get_m3u_path(self, name): + name = self._slugify(name) + file_path = os.path.join(self._path, name + '.m3u') + self._validate_file_path(file_path) + return file_path + def _save_m3u(self, playlist): - file_path = playlist.uri[len('file://'):] + file_path = path.uri_to_path(playlist.uri) + self._validate_file_path(file_path) with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): - uri = track.uri[len('file://'):] + uri = path.uri_to_path(track.uri) else: uri = track.uri file_handle.write(uri + '\n') - def _delete_m3u(self, playlist): - file_path = playlist.uri[len('file://'):] + def _delete_m3u(self, uri): + file_path = path.uri_to_path(uri) + self._validate_file_path(file_path) if os.path.exists(file_path): os.remove(file_path) + def _rename_m3u(self, playlist): + src_file_path = path.uri_to_path(playlist.uri) + self._validate_file_path(src_file_path) + + dst_file_path = self._get_m3u_path(playlist.name) + self._validate_file_path(dst_file_path) + + shutil.move(src_file_path, dst_file_path) + + return playlist.copy(uri=path.path_to_uri(dst_file_path)) + + def _validate_file_path(self, file_path): + assert not file_path.endswith(os.sep), ( + 'File path %s cannot end with a path separator' % file_path) + + # Expand symlinks + real_base_path = os.path.realpath(self._path) + real_file_path = os.path.realpath(file_path) + + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_dir_path = os.path.dirname(real_file_path) + + # Check if dir of file is the base path or a subdir + common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) + assert common_prefix == real_base_path, ( + 'File path %s must be in %s' % (real_file_path, real_base_path)) + def _slugify(self, value): """ Converts to lowercase, removes non-word characters (alphanumerics and From 3fe856c6ba3e6cd1444919c3eec0060d104e9079 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 14:03:38 +0100 Subject: [PATCH 158/323] Mark regexp strings as raw to please pylint --- mopidy/backends/local/stored_playlists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 9e5b72c6..9615461c 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -145,5 +145,5 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): """ value = unicodedata.normalize('NFKD', value) value = value.encode('ascii', 'ignore').decode('ascii') - value = re.sub('[^\w\s-]', '', value).strip().lower() - return re.sub('[-\s]+', '-', value) + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[-\s]+', '-', value) From c291c9c83e6429981ebc38714adc4eb23fb383af Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 19:36:11 +0100 Subject: [PATCH 159/323] Style fix --- mopidy/backends/local/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index db37edb3..09484ed0 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -59,7 +59,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri' or field == 'filename': + elif field in ('uri', 'filename'): result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) From 3ce986f61986670152f417946f5c6034b9613132 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 19:36:24 +0100 Subject: [PATCH 160/323] Update changelog with search by filename --- docs/changes.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 0203a89f..f3b2a25d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -68,6 +68,8 @@ backends: - The Spotify backend now includes release year and artist on albums. +- Added support for search by filename to local backend. + v0.8.1 (2012-10-30) =================== From 590270546b68c9db1e30da8450de1fabee4af707 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 20:15:20 +0100 Subject: [PATCH 161/323] Style fix --- mopidy/backends/local/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 09484ed0..9abdf7ed 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -93,7 +93,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri' or field == 'filename': + elif field in ('uri', 'filename'): result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) From 548dd186cfb77f043357873cdec157caa08f193c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 21:58:24 +0100 Subject: [PATCH 162/323] Don't include actor URN in MPD debug log --- mopidy/frontends/mpd/session.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index b5368a08..5d535f75 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -25,17 +25,14 @@ class MpdSession(network.LineProtocol): self.send_lines([u'OK MPD %s' % protocol.VERSION]) def on_line_received(self, line): - logger.debug( - u'Request from [%s]:%s to %s: %s', - self.host, self.port, self.actor_urn, line) + logger.debug(u'Request from [%s]:%s: %s', self.host, self.port, line) response = self.dispatcher.handle_request(line) if not response: return logger.debug( - u'Response to [%s]:%s from %s: %s', - self.host, self.port, self.actor_urn, + u'Response to [%s]:%s: %s', self.host, self.port, formatting.indent(self.terminator.join(response))) self.send_lines(response) From 60112897d20d2b321a3fc819dad1a6bc4fe0c4e4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 22:27:53 +0100 Subject: [PATCH 163/323] MPD: Support listplaylist{,info} without quotes around spaceless playlist name (fixes #218) --- docs/changes.rst | 5 +++++ .../frontends/mpd/protocol/stored_playlists.py | 2 ++ .../mpd/protocol/stored_playlists_test.py | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index f3b2a25d..34e155c9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -70,6 +70,11 @@ backends: - Added support for search by filename to local backend. +**Bug fixes** + +- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now + accepts unquotes playlist names if they don't contain spaces. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index ed1c38ab..17e5abf7 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -5,6 +5,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import playlist_to_mpd_format +@handle_request(r'^listplaylist (?P\S+)$') @handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ @@ -27,6 +28,7 @@ def listplaylist(context, name): raise MpdNoExistError(u'No such playlist', command=u'listplaylist') +@handle_request(r'^listplaylistinfo (?P\S+)$') @handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8cfcb338..ae99fe2a 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -14,6 +14,14 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'file: file:///dev/urandom') self.assertInResponse(u'OK') + def test_listplaylist_without_quotes(self): + self.core.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylist name') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'OK') + def test_listplaylist_fails_if_no_playlist_is_found(self): self.sendRequest(u'listplaylist "name"') self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') @@ -28,6 +36,16 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse(u'Pos: 0') self.assertInResponse(u'OK') + def test_listplaylistinfo_without_quotes(self): + self.core.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylistinfo name') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'Track: 0') + self.assertNotInResponse(u'Pos: 0') + self.assertInResponse(u'OK') + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.sendRequest(u'listplaylistinfo "name"') self.assertEqualResponse( From 0d16af97a5638450814ab7687e3147d4786115ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 22:52:17 +0100 Subject: [PATCH 164/323] Document 'audio' constructor arg to playback providers --- mopidy/backends/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 7ae2c3dc..9f6de405 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -70,6 +70,8 @@ class BaseLibraryProvider(object): class BasePlaybackProvider(object): """ + :param audio: the audio actor + :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` :param backend: the backend :type backend: :class:`mopidy.backends.base.Backend` """ From 0dd4aba1436d3d329d76127138831f70479e13b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 23:03:29 +0100 Subject: [PATCH 165/323] Move slugify to mopidy.utils.formatting --- mopidy/backends/local/stored_playlists.py | 23 ++++------------------- mopidy/utils/formatting.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 9615461c..5f9a18a1 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -1,14 +1,12 @@ import glob import logging import os -import re import shutil -import unicodedata from mopidy import settings from mopidy.backends import base from mopidy.models import Playlist -from mopidy.utils import path +from mopidy.utils import formatting, path from .translator import parse_m3u @@ -23,7 +21,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): self.refresh() def create(self, name): - name = self._slugify(name) + name = formatting.slugify(name) uri = path.path_to_uri(self._get_m3u_path(name)) playlist = Playlist(uri=uri, name=name) return self.save(playlist) @@ -70,7 +68,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): old_playlist = self.lookup(playlist.uri) if old_playlist and playlist.name != old_playlist.name: - playlist = playlist.copy(name=self._slugify(playlist.name)) + playlist = playlist.copy(name=formatting.slugify(playlist.name)) playlist = self._rename_m3u(playlist) self._save_m3u(playlist) @@ -84,7 +82,7 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): return playlist def _get_m3u_path(self, name): - name = self._slugify(name) + name = formatting.slugify(name) file_path = os.path.join(self._path, name + '.m3u') self._validate_file_path(file_path) return file_path @@ -134,16 +132,3 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) assert common_prefix == real_base_path, ( 'File path %s must be in %s' % (real_file_path, real_base_path)) - - def _slugify(self, value): - """ - Converts to lowercase, removes non-word characters (alphanumerics and - underscores) and converts spaces to hyphens. Also strips leading and - trailing whitespace. - - This function is based on Django's slugify implementation. - """ - value = unicodedata.normalize('NFKD', value) - value = value.encode('ascii', 'ignore').decode('ascii') - value = re.sub(r'[^\w\s-]', '', value).strip().lower() - return re.sub(r'[-\s]+', '-', value) diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py index 46459959..9091bc2a 100644 --- a/mopidy/utils/formatting.py +++ b/mopidy/utils/formatting.py @@ -1,3 +1,7 @@ +import re +import unicodedata + + def indent(string, places=4, linebreak='\n'): lines = string.split(linebreak) if len(lines) == 1: @@ -6,3 +10,17 @@ def indent(string, places=4, linebreak='\n'): for line in lines: result += linebreak + ' ' * places + line return result + + +def slugify(value): + """ + Converts to lowercase, removes non-word characters (alphanumerics and + underscores) and converts spaces to hyphens. Also strips leading and + trailing whitespace. + + This function is based on Django's slugify implementation. + """ + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[-\s]+', '-', value) From b110e6a478bbd35e05aaa0e0e4e13c6fb6166969 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 23:10:18 +0100 Subject: [PATCH 166/323] Move file path is in base dir checker to mopidy.utils.path --- mopidy/backends/local/stored_playlists.py | 28 ++++------------------- mopidy/utils/path.py | 19 +++++++++++++++ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 5f9a18a1..04406c32 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -84,12 +84,12 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def _get_m3u_path(self, name): name = formatting.slugify(name) file_path = os.path.join(self._path, name + '.m3u') - self._validate_file_path(file_path) + path.check_file_path_is_inside_base_dir(file_path, self._path) return file_path def _save_m3u(self, playlist): file_path = path.uri_to_path(playlist.uri) - self._validate_file_path(file_path) + path.check_file_path_is_inside_base_dir(file_path, self._path) with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): @@ -100,35 +100,17 @@ class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def _delete_m3u(self, uri): file_path = path.uri_to_path(uri) - self._validate_file_path(file_path) + path.check_file_path_is_inside_base_dir(file_path, self._path) if os.path.exists(file_path): os.remove(file_path) def _rename_m3u(self, playlist): src_file_path = path.uri_to_path(playlist.uri) - self._validate_file_path(src_file_path) + path.check_file_path_is_inside_base_dir(src_file_path, self._path) dst_file_path = self._get_m3u_path(playlist.name) - self._validate_file_path(dst_file_path) + path.check_file_path_is_inside_base_dir(dst_file_path, self._path) shutil.move(src_file_path, dst_file_path) return playlist.copy(uri=path.path_to_uri(dst_file_path)) - - def _validate_file_path(self, file_path): - assert not file_path.endswith(os.sep), ( - 'File path %s cannot end with a path separator' % file_path) - - # Expand symlinks - real_base_path = os.path.realpath(self._path) - real_file_path = os.path.realpath(file_path) - - # Use dir of file for prefix comparision, so we don't accept - # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a - # common prefix, /tmp/foo, which matches the base path, /tmp/foo. - real_dir_path = os.path.dirname(real_file_path) - - # Check if dir of file is the base path or a subdir - common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) - assert common_prefix == real_base_path, ( - 'File path %s must be in %s' % (real_file_path, real_base_path)) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index eef0c2db..1092534f 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -102,6 +102,25 @@ def find_files(path): yield filename +def check_file_path_is_inside_base_dir(file_path, base_path): + assert not file_path.endswith(os.sep), ( + 'File path %s cannot end with a path separator' % file_path) + + # Expand symlinks + real_base_path = os.path.realpath(base_path) + real_file_path = os.path.realpath(file_path) + + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_dir_path = os.path.dirname(real_file_path) + + # Check if dir of file is the base path or a subdir + common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) + assert common_prefix == real_base_path, ( + 'File path %s must be in %s' % (real_file_path, real_base_path)) + + # FIXME replace with mock usage in tests. class Mtime(object): def __init__(self): From d985b8be380dadec850d9c5fb29c086aa115ae0b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 1 Nov 2012 23:28:19 +0100 Subject: [PATCH 167/323] Fix plchanges so it returns nothing when nothing has changed --- docs/changes.rst | 3 +++ .../mpd/protocol/current_playlist.py | 2 +- .../mpd/protocol/current_playlist_test.py | 24 ++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 34e155c9..39ddc251 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -75,6 +75,9 @@ backends: - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now accepts unquotes playlist names if they don't contain spaces. +- The MPD command ``plchanges`` always returned the entire playlist. It now + returns an empty response when the client has seen the latest version. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 429af2cc..5a88d41b 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -307,7 +307,7 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.core.current_playlist.version: + if int(version) < context.core.current_playlist.version.get(): return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index a64b08ea..bd58cf2d 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -364,7 +364,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest(u'playlistsearch any "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented') - def test_plchanges(self): + def test_plchanges_with_lower_version_returns_changes(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) @@ -374,6 +374,28 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'Title: c') self.assertInResponse(u'OK') + def test_plchanges_with_equal_version_returns_nothing(self): + self.core.current_playlist.append( + [Track(name='a'), Track(name='b'), Track(name='c')]) + + self.assertEqual(self.core.current_playlist.version.get(), 1) + self.sendRequest(u'plchanges "1"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertInResponse(u'OK') + + def test_plchanges_with_greater_version_returns_nothing(self): + self.core.current_playlist.append( + [Track(name='a'), Track(name='b'), Track(name='c')]) + + self.assertEqual(self.core.current_playlist.version.get(), 1) + self.sendRequest(u'plchanges "2"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertInResponse(u'OK') + def test_plchanges_with_minus_one_returns_entire_playlist(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) From 4aee340b77a717e16549bfbbef77e3e45f20c791 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 3 Nov 2012 17:52:13 +0100 Subject: [PATCH 168/323] Add flake8 and pylint to test requirements --- requirements/tests.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/tests.txt b/requirements/tests.txt index e24edd3c..20aff929 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,6 +1,8 @@ coverage +flake8 mock >= 0.7 nose +pylint tox unittest2 yappi From f08fba954e9e266e5f98067cdde9d6e94c14c771 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 3 Nov 2012 22:03:26 +0100 Subject: [PATCH 169/323] Update to work with current develop --- tests/frontends/mpd/protocol/stored_playlists_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 8cfe237c..c8db3f8f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -15,7 +15,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_listplaylist_without_quotes(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylist name') @@ -37,7 +37,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_listplaylistinfo_without_quotes(self): - self.core.stored_playlists.playlists = [ + self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest(u'listplaylistinfo name') From f6e42f0f2d931533a9cdcab749feceeb5fa98982 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 12:01:03 +0100 Subject: [PATCH 170/323] Update recommended libspotify and pyspotify version --- mopidy/backends/spotify/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index bb0c805b..28813bc2 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -20,8 +20,8 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** -- libspotify >= 11, < 12 (libspotify11 package from apt.mopidy.com) -- pyspotify >= 1.7, < 1.8 (python-spotify package from apt.mopidy.com) +- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) +- pyspotify >= 1.8, < 1.9 (python-spotify package from apt.mopidy.com) **Settings:** From ddbf7b7c40ba71018be75a1ab46b1350d6309a4b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 21:44:25 +0100 Subject: [PATCH 171/323] docs: Include GStreamer and libspotify installation instructions in ToC --- docs/index.rst | 2 ++ docs/installation/index.rst | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index bdd8e4c1..c9d2577b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,8 @@ User documentation :maxdepth: 3 installation/index + installation/gstreamer + installation/libspotify settings running clients/index diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c58ba9dd..c84dcf01 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -12,12 +12,6 @@ versions. Requirements ============ -.. toctree:: - :hidden: - - gstreamer - libspotify - If you install Mopidy from the APT archive, as described below, APT will take care of all the dependencies for you. Otherwise, make sure you got the required dependencies installed. From 870d5db135c87ca7d9ff1fcfab2dc9cb1d4a3780 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 21:54:56 +0100 Subject: [PATCH 172/323] docs: Fix a broken link. Reduce amount of redirects --- docs/changes.rst | 2 +- docs/clients/mpd.rst | 2 +- docs/development.rst | 11 ++++++----- docs/index.rst | 8 ++++---- docs/installation/libspotify.rst | 4 ++-- mopidy/settings.py | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ea5a1530..473c7e37 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1143,7 +1143,7 @@ Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means we will still change APIs, add features, etc. before the final 0.1.0 release. But the software is usable as is, so we release it. Please give it a try and give us feedback, either at our IRC channel or through the `issue tracker -`_. Thanks! +`_. Thanks! **Changes** diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index c7dc3799..6949e506 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -281,7 +281,7 @@ Tested version: The `MPoD `_ iPhone/iPod Touch app can be installed from the `iTunes Store -`_. +`_. Users have reported varying success in using MPoD together with Mopidy. Thus, we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d diff --git a/docs/development.rst b/docs/development.rst index eae211b9..1fd419d0 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -3,7 +3,7 @@ Development *********** Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at -``irc.freenode.net`` and through `GitHub `_. +``irc.freenode.net`` and through `GitHub `_. Release schedule @@ -90,7 +90,8 @@ Code style Commit guidelines ================= -- We follow the development process described at http://nvie.com/git-model. +- We follow the development process described at + `nvie.com `_. - Keep commits small and on topic. @@ -133,13 +134,13 @@ To run tests with test coverage statistics:: nosetests --with-coverage For more documentation on testing, check out the `nose documentation -`_. +`_. Continuous integration ====================== -Mopidy uses the free service `Travis CI `_ +Mopidy uses the free service `Travis CI `_ for automatically running the test suite when code is pushed to GitHub. This works both for the main Mopidy repo, but also for any forks. This way, any contributions to Mopidy through GitHub will automatically be tested by Travis @@ -205,7 +206,7 @@ playlists. Writing documentation ===================== -To write documentation, we use `Sphinx `_. See their +To write documentation, we use `Sphinx `_. See their site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX from the documentation files, you need some additional dependencies. diff --git a/docs/index.rst b/docs/index.rst index c9d2577b..c86c3f0d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,16 +19,16 @@ To install Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_. If you stumble into a bug or got a feature request, please create an issue in the `issue tracker -`_. +`_. Project resources ================= - `Documentation `_ -- `Source code `_ -- `Issue tracker `_ -- `CI server `_ +- `Source code `_ +- `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index 223e4ed7..042034e7 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -3,8 +3,8 @@ libspotify installation *********************** Mopidy uses `libspotify -`_ for playing music from -the Spotify music service. To use :mod:`mopidy.backends.spotify` you must +`_ for playing music +from the Spotify music service. To use :mod:`mopidy.backends.spotify` you must install libspotify and `pyspotify `_. .. note:: diff --git a/mopidy/settings.py b/mopidy/settings.py index c1f35887..fbc71f0e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -26,7 +26,7 @@ BACKENDS = ( #: The log format used for informational logging. #: -#: See http://docs.python.org/library/logging.html#formatter-objects for +#: See http://docs.python.org/2/library/logging.html#formatter-objects for #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' From f715b4927d1573fe96a69d2bd6a08731371466bf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 21:56:34 +0100 Subject: [PATCH 173/323] docs: Remove links from README --- README.rst | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index a7df7692..352251fb 100644 --- a/README.rst +++ b/README.rst @@ -5,19 +5,18 @@ Mopidy .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop Mopidy is a music server which can play music both from your local hard drive -and from `Spotify `_. Searches returns results from -both your local hard drive and from Spotify, and you can mix tracks from both -sources in your play queue. Your Spotify playlists are also available for use, -though we don't support modifying them yet. +and from Spotify. Searches returns results from both your local hard drive and +from Spotify, and you can mix tracks from both sources in your play queue. Your +Spotify playlists are also available for use, though we don't support modifying +them yet. To control your music server, you can use the Ubuntu Sound Menu on the machine running Mopidy, any device on the same network which supports the DLNA media -controller spec (with the help of Rygel in addition to Mopidy), or any `MPD -client `_. MPD clients are available for most platforms, -including Windows, Mac OS X, Linux, Android and iOS. +controller spec (with the help of Rygel in addition to Mopidy), or any MPD +client. MPD clients are available for most platforms, including Windows, Mac OS +X, Linux, Android and iOS. -To install Mopidy, check out -`the installation docs `_. +To get started with Mopidy, check out `the docs `_. - `Documentation `_ - `Source code `_ From 76684ddb50f94dcdac1d138b8b8605d2056c43ff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 4 Nov 2012 22:07:22 +0100 Subject: [PATCH 174/323] docs: Add links from intro to relevant pages --- docs/clients/dlna.rst | 15 +++++++++++++++ docs/clients/mpd.rst | 8 +++++--- docs/clients/mpris.rst | 15 +++++++++++++++ docs/index.rst | 21 +++++++++++---------- docs/modules/backends/local.rst | 2 ++ docs/modules/backends/spotify.rst | 2 ++ 6 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 docs/clients/dlna.rst create mode 100644 docs/clients/mpris.rst diff --git a/docs/clients/dlna.rst b/docs/clients/dlna.rst new file mode 100644 index 00000000..e1eeddd2 --- /dev/null +++ b/docs/clients/dlna.rst @@ -0,0 +1,15 @@ +.. _dlna-clients: + +************ +DLNA clients +************ + +TODO + + +.. _rygel: + +Exposing Mopidy over DLNA using Rygel +===================================== + +TODO diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 6949e506..17282d8c 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -1,6 +1,8 @@ -************************ -MPD client compatability -************************ +.. _mpd-clients: + +*********** +MPD clients +*********** This is a list of MPD clients we either know works well with Mopidy, or that we know won't work well. For a more exhaustive list of MPD clients, see diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst new file mode 100644 index 00000000..b57cd0b9 --- /dev/null +++ b/docs/clients/mpris.rst @@ -0,0 +1,15 @@ +.. _mpris-clients: + +************* +MPRIS clients +************* + +TODO + + +.. _ubuntu-sound-menu: + +Ubuntu Sound Menu +================= + +TODO diff --git a/docs/index.rst b/docs/index.rst index c86c3f0d..2dc29590 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,17 +2,18 @@ Mopidy ****** -Mopidy is a music server which can play music both from your local hard drive -and from `Spotify `_. Searches returns results from -both your local hard drive and from Spotify, and you can mix tracks from both -sources in your play queue. Your Spotify playlists are also available for use, -though we don't support modifying them yet. +Mopidy is a music server which can play music both from your :ref:`local hard +drive ` and from :ref:`Spotify `. Searches +returns results from both your local hard drive and from Spotify, and you can +mix tracks from both sources in your play queue. Your Spotify playlists are +also available for use, though we don't support modifying them yet. -To control your music server, you can use the Ubuntu Sound Menu on the machine -running Mopidy, any device on the same network which supports the DLNA media -controller spec (with the help of Rygel in addition to Mopidy), or any `MPD -client `_. MPD clients are available for most platforms, -including Windows, Mac OS X, Linux, Android and iOS. +To control your music server, you can use the :ref:`Ubuntu Sound Menu +` on the machine running Mopidy, any device on the same +network which supports the :ref:`DLNA ` media controller spec +(with the help of :ref:`Rygel ` in addition to Mopidy), or any :ref:`MPD +client `. MPD clients are available for most platforms, including +Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, start by reading :ref:`installation`. diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst index b4ab7d49..9ac93bc8 100644 --- a/docs/modules/backends/local.rst +++ b/docs/modules/backends/local.rst @@ -1,3 +1,5 @@ +.. _local-backend: + ********************************************* :mod:`mopidy.backends.local` -- Local backend ********************************************* diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst index e724da27..b410f272 100644 --- a/docs/modules/backends/spotify.rst +++ b/docs/modules/backends/spotify.rst @@ -1,3 +1,5 @@ +.. _spotify-backend: + ************************************************* :mod:`mopidy.backends.spotify` -- Spotify backend ************************************************* From 331603cc35a299d37a2fe84b2c91bcd9aad69569 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 5 Nov 2012 18:07:15 +0100 Subject: [PATCH 175/323] docs: Port Raspberry Pi how tos from wiki --- docs/_static/raspberry-pi-by-jwrodgers.jpg | Bin 0 -> 52681 bytes docs/index.rst | 1 + docs/installation/raspberrypi.rst | 265 +++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 docs/_static/raspberry-pi-by-jwrodgers.jpg create mode 100644 docs/installation/raspberrypi.rst diff --git a/docs/_static/raspberry-pi-by-jwrodgers.jpg b/docs/_static/raspberry-pi-by-jwrodgers.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d093bb8836a30f2d166c202bdf37ec3c06a910d7 GIT binary patch literal 52681 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 is possible, although there are a few +significant drawbacks to doing so. This document is intended to help you get +Mopidy running on your Raspberry Pi and to document the progress made and +issues surrounding running Mopidy on the Raspberry Pi. + +Mopidy will not currently run with Spotify support on the foundation-provided +`Raspbian `_ distribution. See :ref:`not-raspbian` for +details. However, Mopidy should run with Spotify support on any ARM Debian +image that has hardware floating-point support **disabled**. + +.. image:: /_static/raspberry-pi-by-jwrodgers.jpg + :width: 640 + :height: 427 + + +.. _raspi-squeeze: + +How to for Debian 6 (Squeeze) +============================= + +The following guide illustrates how to get Mopidy running on a minimal Debian +squeeze distribution. The image used can be downloaded at +http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. +This image is a very minimal distribution and does not include many common +packages you might be used to having access to. If you find yourself trying to +complete instructions here and getting ``command not found``, try using +``apt-get`` to install the relevant packages! + +1. Flash the OS image to your SD card. See + http://elinux.org/RPi_Easy_SD_Card_Setup for help. + +2. If you have an SD card that's >2 GB, resize the disk image to use some more + space (we'll need a bit more to install some packages and stuff). See + http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi + for help. + +3. To even get to the point where we can start installing software let's + install ``sudo`` and create a user account with ``sudo`` rights so we don't + have to do everything on the ``root`` account:: + + apt-get install sudo + adduser + adduser sudo + + Log in to your Raspberry Pi again with your new user account instead of the + ``root`` account. + +4. Enable the Raspberry Pi's sound drivers: + + - To enable the Raspberry Pi's sound driver:: + + sudo modprobe snd_bcm2835 + + - To load the sound driver at boot time:: + + echo "snd_bcm2835" | sudo tee /etc/modules + +5. Let's get the Raspberry Pi up-to-date: + + - Get some tools that we need to download and run the ``rpi-update`` + script:: + + sudo apt-get install ca-certificates git-core binutils + + - Download ``rpi-update`` from Github:: + + sudo wget https://raw.github.com/Hexxeh/rpi-update/master/rpi-update + + - Move ``rpi-update`` to an appropriate location:: + + sudo mv rpi-update /usr/local/bin/rpi-update + + - Make ``rpi-update`` executable:: + + sudo chmod +x /usr/local/bin/rpi-update + + - Finally! Update your firmware:: + + sudo rpi-update + + - After firmware updating finishes, reboot your Raspberry Pi:: + + sudo reboot + +6. Install some software that we'll need to get up and running:: + + sudo apt-get install python2.7 python-dev python-pip + + This will take a little while to download and install. + +7. Start installing Mopidy's dependencies (from :ref:`installation`):: + + sudo pip install pykka + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer-tools + +8. Install libspotify and pyspotify. Note that these two pieces of software + are rather tightly coupled; thus, it's important to make sure you have two + compatible versions installed. At the time of writing, pyspotify 1.8.1 and + libspotify 12 are the most recent stable versions of these software + components. + + - Download libspotify for ARMv5:: + + wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-armv5-release.tar.gz + tar xvfz libspotify-12.1.51-Linux-armv5-release.tar.gz + cd libspotify-12.1.51-Linux-armv5-release + sudo make install + sudo ldconfig + + - Now install pyspotify:: + + sudo pip install pyspotify==1.8.1 + +9. jackd2, which should be installed at this point, seems to cause some + problems. Let's install jackd1, as it seems to work a little bit better:: + + sudo apt-get install jackd1 + +10. Add your user to the ``audio`` group:: + + sudo adduser audio + +11. Finally! Install Mopidy:: + + sudo pip install mopidy + +You may encounter some issues with your audio configuration where sound does +not play. If that happens, edit your ``/etc/asound.conf`` to read something like:: + + pcm.mmap0 { + type mmap_emul; + slave { + pcm "hw:0,0"; + } + } + + pcm.!default { + type plug; + slave { + pcm mmap0; + } + } + + +.. _raspi-wheezy: + +How to for Debian 7 (Wheezy) +============================ + +This is a very similar system to Debian 6.0 above, but with a bit newer +software packages, as Wheezy is going to be the next release of Debian. + +1. Download the latest wheezy disk image from + http://downloads.raspberrypi.org/images/debian/7/. I used the one dated + 2012-08-08. + +2. Flash the OS image to your SD card. See + http://elinux.org/RPi_Easy_SD_Card_Setup for help. + +3. If you have an SD card that's >2 GB, you don't have to resize the file + systems on another computer. Just boot up your Raspberry Pi with the + unaltered partions, and it will boot right into the ``raspi-config`` tool, + which will let you grow the root file system to fill the SD card. This tool + will also allow you do other useful stuff, like turning on the SSH server. + +4. As opposed to on Squeeze, ``sudo`` comes preinstalled. You can login to the + default user using username ``pi`` and password ``raspberry``. To become + root, just enter ``sudo -i``. + +5. As opposed to on Squeeze, the correct sound driver comes preinstalled. + +6. As opposed to on Squeeze, your kernel and GPU firmware is rather up to date + when running Wheezy. + +7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support: + + - Load the IPv6 kernel module now:: + + sudo modprobe ipv6 + + - Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is + loaded on boot:: + + echo ipv6 | sudo tee /etc/modules + +8. Installing Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. In short:: + + wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + sudo apt-get update + sudo apt-get install mopidy + +9. Opposed to on Squeeze, there is no need to add your user to the ``audio`` + group, as the ``pi`` user already is a member of that group. + +10. Since I have a HDMI cable connected, but want the sound on the analog sound + connector, I have to run:: + + amixer cset numid=3 1 + + to force it to use analog output. ``1`` means analog, ``0`` means auto, and + is the default, while ``2`` means HDMI. You can test sound output + independent of Mopidy by running:: + + aplay /usr/share/sounds/alsa/Front_Center.wav + + To make the change to analog output stick, you can add the ``amixer`` command + to e.g. ``/etc/rc.local``, which will be executed when the system is + booting. + + +Known Issues +============ + +Audio Quality +------------- + +The Raspberry Pi's audio quality can be sub-par through the analog output. This +is known and unlikely to be fixed as including any higher-quality hardware +would increase the cost of the board. If you experience crackling/hissing or +skipping audio, you may want to try a USB sound card. Additionally, you could +lower your default ALSA sampling rate to 22KHz, though this will lead to a +substantial decrease in sound quality. + + +.. _not-raspbian: + +Why Not Raspbian? +----------------- + +Mopidy with Spotify support is currently unavailable on the recommended +`Raspbian `_ Debian distribution that the Raspberry Pi +foundation has made available. This is due to Raspbian's hardware +floating-point support. The Raspberry Pi comes with a co-processor designed +specifically for floating-point computations (commonly called an FPU). Taking +advantage of the FPU can speed up many computations significantly over +software-emulated floating point routines. Most of Mopidy's dependencies are +open-source and have been (or can be) compiled to support the ``armhf`` +architecture. However, there is one component of Mopidy's stack which is +closed-source and crucial to Mopidy's Spotify support: libspotify. + +The ARM distributions of libspotify available on `Spotify's developer website +`_ are compiled for the ``armel`` architecture, +which has software floating-point support. ``armel`` and ``armhf`` software +cannot be mixed, and pyspotify links with libspotify as C extensions. Thus, +Mopidy will not run with Spotify support on ``armhf`` distributions. + +If the Spotify folks ever release builds of libspotify with ``armhf`` support, +Mopidy *should* work on Raspbian. + + +Support +======= + +If you had trouble with the above or got Mopidy working a different way on +RaspberryPi, please send us a pull request to update this page with your new +information. As usual, the folks at ``#mopidy`` on ``irc.freenode.net`` may be +able to help with any problems encountered. From f69fc4f9762b984d0604da6a1166f3f5d6ef9530 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 5 Nov 2012 22:42:47 +0100 Subject: [PATCH 176/323] docs: How to use Mopidy and Rygel with UPnP clients --- README.rst | 7 +- docs/clients/dlna.rst | 15 ---- docs/clients/upnp.rst | 117 +++++++++++++++++++++++++++++++ docs/index.rst | 7 +- docs/modules/frontends/mpris.rst | 2 + 5 files changed, 125 insertions(+), 23 deletions(-) delete mode 100644 docs/clients/dlna.rst create mode 100644 docs/clients/upnp.rst diff --git a/README.rst b/README.rst index 352251fb..c7eea228 100644 --- a/README.rst +++ b/README.rst @@ -11,10 +11,9 @@ Spotify playlists are also available for use, though we don't support modifying them yet. To control your music server, you can use the Ubuntu Sound Menu on the machine -running Mopidy, any device on the same network which supports the DLNA media -controller spec (with the help of Rygel in addition to Mopidy), or any MPD -client. MPD clients are available for most platforms, including Windows, Mac OS -X, Linux, Android and iOS. +running Mopidy, any device on the same network which can control UPnP +MediaRenderers, or any MPD client. MPD clients are available for most +platforms, including Windows, Mac OS X, Linux, Android and iOS. To get started with Mopidy, check out `the docs `_. diff --git a/docs/clients/dlna.rst b/docs/clients/dlna.rst deleted file mode 100644 index e1eeddd2..00000000 --- a/docs/clients/dlna.rst +++ /dev/null @@ -1,15 +0,0 @@ -.. _dlna-clients: - -************ -DLNA clients -************ - -TODO - - -.. _rygel: - -Exposing Mopidy over DLNA using Rygel -===================================== - -TODO diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst new file mode 100644 index 00000000..567fb04f --- /dev/null +++ b/docs/clients/upnp.rst @@ -0,0 +1,117 @@ +.. _upnp-clients: + +************ +UPnP clients +************ + +`UPnP `_ is a set of +specifications for media sharing, playing, remote control, etc, across a home +network. The specs are supported by a lot of consumer devices (like +smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA +`_ compatible or certified. + +The DLNA guidelines and UPnP specifications defines several device roles, of +which Mopidy may play two: + +DLNA Digital Media Server (DMS) / UPnP AV MediaServer: + + A MediaServer provides a library of media and is capable of streaming that + media to a MediaRenderer. If Mopidy was a MediaServer, you could browse and + play Mopidy's music on a TV, smartphone, or tablet supporting UPnP. Mopidy + does not currently support this, but we may in the future. :issue:`52` is + the relevant wishlist issue. + +DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer: + + A MediaRenderer is asked by some remote controller to play some + given media, typically served by a MediaServer. If Mopidy was a + MediaRenderer, you could use e.g. your smartphone or tablet to make Mopidy + play media. Mopidy *does already* have experimental support for being a + MediaRenderer with the help of Rygel, as you can read more about below. + + +.. _rygel: + +How to make Mopidy available as an UPnP MediaRenderer +===================================================== + +With the help of `the Rygel project `_ Mopidy can +be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's +:ref:`MPRIS frontend `, and make Mopidy available as a +MediaRenderer on the local network. Since this depends on the MPRIS frontend, +which again depends on D-Bus being available, this will only work on Linux, and +not OS X. MPRIS/D-Bus is only available to other applications on the same host, +so Rygel must be running on the same machine as Mopidy. + +1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is + working. It is activated by default, but you may miss dependencies or be + using OS X, in which case it will not work. Check the console output when + Mopidy is started for any errors related to the MPRIS frontend. If you're + unsure it is working, there are instructions for how to test it on the + :ref:`MPRIS frontend ` page. + +2. Install Rygel. On Debian/Ubuntu:: + + sudo apt-get install rygel + +3. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit ``/etc/rygel.conf``, + find the ``[MPRIS]`` section, and change ``enabled=false`` to + ``enabled=true``. + +4. Start Rygel by running:: + + rygel + + Example output:: + + $ rygel + Rygel-Message: New plugin 'MediaExport' available + Rygel-Message: New plugin 'org.mpris.MediaPlayer2.spotify' available + Rygel-Message: New plugin 'org.mpris.MediaPlayer2.mopidy' available + + Note that in the above example, both the official Spotify client and Mopidy + is running and made available through Rygel. + + +The UPnP-Inspector client +========================= + +`UPnP-Inspector `_ is a +graphical analyzer and debugging tool for UPnP services. It will detect any +UPnP devices on your network, and show these in a tree structure. This is not a +tool for your everyday music listening while relaxing on the couch, but it may +be of use for testing that your setup works correctly. + +1. Install UPnP-Inspector. On Debian/Ubuntu:: + + sudo apt-get install upnp-inspector + +2. Run it:: + + upnp-inspector + +3. Assuming that Mopidy is running with a working MPRIS frontend, and that + Rygel is running on the same machine, Mopidy should now appear in + UPnP-Inspector's device list. + +4. If you expand the tree item saying ``Mopidy + (MediaRenderer:2)`` or similiar, and then the sub element named + ``AVTransport:2`` or similar, you'll find a list of commands you can invoke. + E.g. if you double-click the ``Pause`` command, you'll get a new window + where you can press an ``Invoke`` button, and then Mopidy should be paused. + +Note that if you have a firewall on the host running Mopidy and Rygel, and you +want this to be exposed to the rest of your local network, you need to open up +your firewall for UPnP traffic. UPnP use UDP port 1900 as well as some +dynamically assigned ports. I've only verified that this procedure works across +the network by temporarily disabling the firewall on the the two hosts +involved, so I'll leave any firewall configuration as an exercise to the +reader. + + +Other clients +============= + +For a long list of UPnP clients for all possible platforms, see Wikipedia's +`List of UPnP AV media servers and clients +`_. diff --git a/docs/index.rst b/docs/index.rst index d9cba72d..0f5ed164 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,10 +10,9 @@ also available for use, though we don't support modifying them yet. To control your music server, you can use the :ref:`Ubuntu Sound Menu ` on the machine running Mopidy, any device on the same -network which supports the :ref:`DLNA ` media controller spec -(with the help of :ref:`Rygel ` in addition to Mopidy), or any :ref:`MPD -client `. MPD clients are available for most platforms, including -Windows, Mac OS X, Linux, Android and iOS. +network which can control UPnP MediaRenderers (see :ref:`upnp-clients`), or any +:ref:`MPD client `. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android, and iOS. To install Mopidy, start by reading :ref:`installation`. diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst index 2984e4c1..e0ec63da 100644 --- a/docs/modules/frontends/mpris.rst +++ b/docs/modules/frontends/mpris.rst @@ -1,3 +1,5 @@ +.. _mpris-frontend: + *********************************************** :mod:`mopidy.frontends.mpris` -- MPRIS frontend *********************************************** From 6088868d6b71b8844dbe28a5d3a657f79f2869ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 08:58:15 +0100 Subject: [PATCH 177/323] docs: Update Raspberry Pi how to for Squeeze to use APT --- docs/installation/raspberrypi.rst | 124 +++++++++++++----------------- 1 file changed, 55 insertions(+), 69 deletions(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 6b682471..9e13f583 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -24,33 +24,48 @@ How to for Debian 6 (Squeeze) ============================= The following guide illustrates how to get Mopidy running on a minimal Debian -squeeze distribution. The image used can be downloaded at -http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. -This image is a very minimal distribution and does not include many common -packages you might be used to having access to. If you find yourself trying to -complete instructions here and getting ``command not found``, try using -``apt-get`` to install the relevant packages! +squeeze distribution. -1. Flash the OS image to your SD card. See +1. The image used can be downloaded at + http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. + This image is a very minimal distribution and does not include many common + packages you might be used to having access to. If you find yourself trying + to complete instructions here and getting ``command not found``, try using + ``apt-get`` to install the relevant packages! + +2. Flash the OS image to your SD card. See http://elinux.org/RPi_Easy_SD_Card_Setup for help. -2. If you have an SD card that's >2 GB, resize the disk image to use some more +3. If you have an SD card that's >2 GB, resize the disk image to use some more space (we'll need a bit more to install some packages and stuff). See http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi for help. -3. To even get to the point where we can start installing software let's - install ``sudo`` and create a user account with ``sudo`` rights so we don't - have to do everything on the ``root`` account:: +4. To even get to the point where we can start installing software let's create + a new user and give it sudo access. - apt-get install sudo - adduser - adduser sudo + - Install ``sudo``:: - Log in to your Raspberry Pi again with your new user account instead of the - ``root`` account. + apt-get install sudo -4. Enable the Raspberry Pi's sound drivers: + - Create a user account:: + + adduser + + - Give the user sudo access by adding it to the ``sudo`` group so we don't + have to do everything on the ``root`` account:: + + adduser sudo + + - While we're at it, give your user access to the sound card by adding it to + the audio group:: + + adduser audio + + - Log in to your Raspberry Pi again with your new user account instead of + the ``root`` account. + +5. Enable the Raspberry Pi's sound drivers: - To enable the Raspberry Pi's sound driver:: @@ -60,7 +75,7 @@ complete instructions here and getting ``command not found``, try using echo "snd_bcm2835" | sudo tee /etc/modules -5. Let's get the Raspberry Pi up-to-date: +6. Let's get the Raspberry Pi up-to-date: - Get some tools that we need to download and run the ``rpi-update`` script:: @@ -87,51 +102,22 @@ complete instructions here and getting ``command not found``, try using sudo reboot -6. Install some software that we'll need to get up and running:: +7. Installing Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. In short:: - sudo apt-get install python2.7 python-dev python-pip + wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + sudo apt-get update + sudo apt-get install mopidy - This will take a little while to download and install. - -7. Start installing Mopidy's dependencies (from :ref:`installation`):: - - sudo pip install pykka - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer-tools - -8. Install libspotify and pyspotify. Note that these two pieces of software - are rather tightly coupled; thus, it's important to make sure you have two - compatible versions installed. At the time of writing, pyspotify 1.8.1 and - libspotify 12 are the most recent stable versions of these software - components. - - - Download libspotify for ARMv5:: - - wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-armv5-release.tar.gz - tar xvfz libspotify-12.1.51-Linux-armv5-release.tar.gz - cd libspotify-12.1.51-Linux-armv5-release - sudo make install - sudo ldconfig - - - Now install pyspotify:: - - sudo pip install pyspotify==1.8.1 - -9. jackd2, which should be installed at this point, seems to cause some +8. jackd2, which should be installed at this point, seems to cause some problems. Let's install jackd1, as it seems to work a little bit better:: sudo apt-get install jackd1 -10. Add your user to the ``audio`` group:: - - sudo adduser audio - -11. Finally! Install Mopidy:: - - sudo pip install mopidy - You may encounter some issues with your audio configuration where sound does -not play. If that happens, edit your ``/etc/asound.conf`` to read something like:: +not play. If that happens, edit your ``/etc/asound.conf`` to read something +like:: pcm.mmap0 { type mmap_emul; @@ -173,6 +159,9 @@ software packages, as Wheezy is going to be the next release of Debian. default user using username ``pi`` and password ``raspberry``. To become root, just enter ``sudo -i``. + Opposed to on Squeeze, there is no need to add your user to the ``audio`` + group, as the ``pi`` user already is a member of that group. + 5. As opposed to on Squeeze, the correct sound driver comes preinstalled. 6. As opposed to on Squeeze, your kernel and GPU firmware is rather up to date @@ -197,23 +186,20 @@ software packages, as Wheezy is going to be the next release of Debian. sudo apt-get update sudo apt-get install mopidy -9. Opposed to on Squeeze, there is no need to add your user to the ``audio`` - group, as the ``pi`` user already is a member of that group. +9. Since I have a HDMI cable connected, but want the sound on the analog sound + connector, I have to run:: -10. Since I have a HDMI cable connected, but want the sound on the analog sound - connector, I have to run:: + amixer cset numid=3 1 - amixer cset numid=3 1 + to force it to use analog output. ``1`` means analog, ``0`` means auto, and + is the default, while ``2`` means HDMI. You can test sound output + independent of Mopidy by running:: - to force it to use analog output. ``1`` means analog, ``0`` means auto, and - is the default, while ``2`` means HDMI. You can test sound output - independent of Mopidy by running:: + aplay /usr/share/sounds/alsa/Front_Center.wav - aplay /usr/share/sounds/alsa/Front_Center.wav - - To make the change to analog output stick, you can add the ``amixer`` command - to e.g. ``/etc/rc.local``, which will be executed when the system is - booting. + To make the change to analog output stick, you can add the ``amixer`` command + to e.g. ``/etc/rc.local``, which will be executed when the system is + booting. Known Issues From 0a2fd5fe2b89258a16f144d14680d054ed01fad8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 09:07:57 +0100 Subject: [PATCH 178/323] docs: Turn on IPv6 in Raspi on Squeeze --- docs/installation/raspberrypi.rst | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index 9e13f583..a8175ac8 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -102,7 +102,18 @@ squeeze distribution. sudo reboot -7. Installing Mopidy and its dependencies from `apt.mopidy.com +7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support: + + - Load the IPv6 kernel module now:: + + sudo modprobe ipv6 + + - Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is + loaded on boot:: + + echo ipv6 | sudo tee /etc/modules + +8. Installing Mopidy and its dependencies from `apt.mopidy.com `_, as described in :ref:`installation`. In short:: wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - @@ -110,7 +121,7 @@ squeeze distribution. sudo apt-get update sudo apt-get install mopidy -8. jackd2, which should be installed at this point, seems to cause some +9. jackd2, which should be installed at this point, seems to cause some problems. Let's install jackd1, as it seems to work a little bit better:: sudo apt-get install jackd1 From deccce6237572bb617d3f732451fa29667911d58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 09:20:24 +0100 Subject: [PATCH 179/323] docs: Formatting --- docs/installation/raspberrypi.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index a8175ac8..eaec48cd 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -257,6 +257,6 @@ Support ======= If you had trouble with the above or got Mopidy working a different way on -RaspberryPi, please send us a pull request to update this page with your new +Raspberry Pi, please send us a pull request to update this page with your new information. As usual, the folks at ``#mopidy`` on ``irc.freenode.net`` may be able to help with any problems encountered. From 8ccfc3e56e2c61c310dd4bfe7e37f11a7b04ac3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 10:17:34 +0100 Subject: [PATCH 180/323] docs: How to use the Ubuntu Sound Menu with Mopidy --- docs/_static/ubuntu-sound-menu.png | Bin 0 -> 89927 bytes docs/clients/mpris.rst | 55 +++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 docs/_static/ubuntu-sound-menu.png diff --git a/docs/_static/ubuntu-sound-menu.png b/docs/_static/ubuntu-sound-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..9362f6f49ca2bdd9818899d806f2a99103087efc GIT binary patch literal 89927 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 is short for Media Player Remote Interfacing +Specification. It's a spec that describes a standard D-Bus interface for making +media players available to other applications on the same system. + +Mopidy's :ref:`MPRIS frontend ` currently implements all +required parts of the MPRIS spec, but not the optional playlist interface. For +tracking the development of the playlist interface, see :issue:`229`. .. _ubuntu-sound-menu: @@ -12,4 +18,49 @@ TODO Ubuntu Sound Menu ================= -TODO +The `Ubuntu Sound Menu `_ is the default +sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the +Rhytmbox music player, but many other players can integrate with the sound +menu, including the official Spotify player and Mopidy. + +.. image:: /_static/ubuntu-sound-menu.png + :height: 480 + :width: 955 + +If you install Mopidy from apt.mopidy.com, the sound menu should work out of +the box. If you install Mopidy in any other way, you need to make sure that the +file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as +``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec`` +and ``Exec`` in the file points to an existing executable file, preferably your +Mopidy executable. If this isn't in place, the sound menu will not detect that +Mopidy is running. + +Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to +control Mopidy. The frontend is activated by default, so unless you've changed +the :attr:`mopidy.settings.FRONTENDS` setting, you should be good to go. Keep +an eye out for warnings or errors from the MPRIS frontend when you start +Mopidy, since it may fail because of missing dependencies or because Mopidy is +started outside of X; the frontend won't work if ``$DISPLAY`` isn't set when +Mopidy is started. + +Under normal use, if Mopidy isn't running and you open the menu and click on +"Mopidy Music Server", a terminal window will open and automatically start +Mopidy. If Mopidy is already running, you'll see that Mopidy is marked with an +arrow to the left of its name, like in the screen shot above, and the player +controls will be visible. Mopidy doesn't support the MPRIS spec's optional +playlist interface yet, so you'll not be able to select what track to play from +the sound menu. If you use an MPD client to queue a playlist, you can use the +sound menu to check what you're currently playing, pause, resume, and skip to +the next and previous track. + +In summary, Mopidy's sound menu integration is currently not a full featured +client, but it's a convenient addition to an MPD client since it's always +easily available on Unity's menu bar. + + +Rygel +===== + +Rygel is an application that will translate between Mopidy's MPRIS interface +and UPnP, and thus make Mopidy controllable from devices compatible with UPnP +and/or DLNA. To read more about this, see :ref:`upnp-clients`. From 6b85392f0086610e1d7b12201fe9913ad162f377 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 12:47:04 +0100 Subject: [PATCH 181/323] docs: Simplify install docs --- docs/development.rst | 44 +++++++ docs/installation/index.rst | 235 +++++++++++++----------------------- 2 files changed, 131 insertions(+), 148 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 1fd419d0..74436223 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -37,6 +37,50 @@ implemented, and you may add new wishlist issues if your ideas are not already represented. +.. _run-from-git: + +Run Mopidy from Git repo +======================== + +If you want to contribute to the development of Mopidy, you should run Mopidy +directly from the Git repo. + +#. First of all, install Mopidy in the recommended way for your OS and/or + distribution, like described at :ref:`installation`. You can have a + system-wide installation of the last Mopidy release in addition to the Git + repo which you run from when you code on Mopidy. + +#. Then install Git, if haven't already. For Ubuntu/Debian:: + + sudo apt-get install git-core + + On OS X using Homebrew:: + + sudo brew install git + +#. Clone the official Mopidy repository:: + + git clone git://github.com/mopidy/mopidy.git + + or your own fork of it:: + + git clone git@github.com:mygithubuser/mopidy.git + +#. You can then run Mopidy directly from the Git repository:: + + cd mopidy/ # Move into the Git repo dir + python mopidy # Run python on the mopidy source code dir + +How you update your clone depends on whether you cloned the official Mopidy +repository or your own fork, whether you have made any changes to the clone +or not, and whether you are currently working on a feature branch or not. In +other words, you'll need to learn Git. + +For an introduction to Git, please visit `git-scm.com `_. +Also, please read the rest of our developer documentation before you start +contributing. + + Code style ========== diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c84dcf01..d134ae40 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -1,57 +1,21 @@ .. _installation: -************ -Installation -************ +******************* +Mopidy installation +******************* -There are several ways to install Mopidy. What way is best depends upon your -setup and whether you want to use stable releases or less stable development -versions. +There are several ways to install Mopidy. What way is best depends upon your OS +and/or distribution. If you want to contribute to the development of Mopidy, +you should first read this page, then have a look at :ref:`run-from-git`. -Requirements -============ - -If you install Mopidy from the APT archive, as described below, APT will take -care of all the dependencies for you. Otherwise, make sure you got the required -dependencies installed. - -- Hard dependencies: - - - Python >= 2.6, < 3 - - - Pykka >= 1.0:: - - sudo pip install -U pykka - - - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer`. - -- Optional dependencies: - - - For Spotify support, you need libspotify and pyspotify. See - :doc:`libspotify`. - - - To scrobble your played tracks to Last.fm, you need pylast:: - - sudo pip install -U pylast - - - To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound Menu, you - need some additional requirements:: - - sudo apt-get install python-dbus python-indicate - - -Install latest stable release -============================= - - -From APT archive ----------------- +Debian/Ubuntu: Install from apt.mopidy.com +========================================== If you run a Debian based Linux distribution, like Ubuntu, the easiest way to -install Mopidy is from the Mopidy APT archive. When installing from the APT -archive, you will automatically get updates to Mopidy in the same way as you -get updates to the rest of your distribution. +install Mopidy is from the `Mopidy APT archive `_. When +installing from the APT archive, you will automatically get updates to Mopidy +in the same way as you get updates to the rest of your distribution. #. Add the archive's GPG key:: @@ -65,119 +29,32 @@ get updates to the rest of your distribution. deb http://apt.mopidy.com/ stable main contrib non-free deb-src http://apt.mopidy.com/ stable main contrib non-free + For the lazy, you can simply run the following command to create + ``/etc/apt/sources.list.d/mopidy.list``:: + + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + #. Install Mopidy and all dependencies:: sudo apt-get update sudo apt-get install mopidy -#. Next, you need to set a couple of :doc:`settings `, and then +#. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. -When a new release is out, and you can't wait for you system to figure it out -for itself, run the following to force an upgrade:: +When a new release of Mopidy is out, and you can't wait for you system to +figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade -From PyPI using Pip -------------------- +Arch Linux: Install from AUR +============================ -If you are on OS X or on Linux, but can't install from the APT archive, you can -install Mopidy from PyPI using Pip. - -#. When you install using Pip, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then, you need to install Pip:: - - sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian - sudo easy_install pip # On OS X - -#. To install the currently latest stable release of Mopidy:: - - sudo pip install -U Mopidy - - To upgrade Mopidy to future releases, just rerun this command. - -#. Next, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. - - -Install development version -=========================== - -If you want to follow the development of Mopidy closer, you may install a -development version of Mopidy. These are not as stable as the releases, but -you'll get access to new features earlier and may help us by reporting issues. - - -From snapshot using Pip ------------------------ - -If you want to follow Mopidy development closer, you may install a snapshot of -Mopidy's ``develop`` branch. - -#. When you install using Pip, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then, you need to install Pip:: - - sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian - sudo easy_install pip # On OS X - -#. To install the latest snapshot of Mopidy, run:: - - sudo pip install mopidy==dev - - To upgrade Mopidy to future releases, just rerun this command. - -#. Next, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. - - -From Git --------- - -If you want to contribute to Mopidy, you should install Mopidy using Git. - -#. When you install from Git, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then install Git, if haven't already:: - - sudo apt-get install git-core # On Ubuntu/Debian - sudo brew install git # On OS X using Homebrew - -#. Clone the official Mopidy repository, or your own fork of it:: - - git clone git://github.com/mopidy/mopidy.git - -#. Next, you need to set a couple of :doc:`settings `. - -#. You can then run Mopidy directly from the Git repository:: - - cd mopidy/ # Move into the Git repo dir - python mopidy # Run python on the mopidy source code dir - -#. Later, to get the latest changes to Mopidy:: - - cd mopidy/ - git pull - -For an introduction to ``git``, please visit `git-scm.com -`_. Also, please read our :doc:`developer documentation -`. - - -From AUR on ArchLinux ---------------------- - -If you are running ArchLinux, you can install a development snapshot of Mopidy -using the package found at http://aur.archlinux.org/packages.php?ID=44026. - -#. First, you should consider installing any optional dependencies not included - by the AUR package, like required for e.g. Last.fm scrobbling. +If you are running Arch Linux, you can install a development snapshot of Mopidy +using the `mopidy-git `_ +package found in AUR. #. To install Mopidy with GStreamer, libspotify and pyspotify, you can use ``packer``, ``yaourt``, or do it by hand like this:: @@ -189,5 +66,67 @@ using the package found at http://aur.archlinux.org/packages.php?ID=44026. To upgrade Mopidy to future releases, just rerun ``makepkg``. -#. Next, you need to set a couple of :doc:`settings `, and then +#. Optional: If you want to scrobble your played tracks to Last.fm, you need to + install `python2-pylast + `_ from AUR. + +#. Finally, you need to set a couple of :doc:`settings `, and then + you're ready to :doc:`run Mopidy `. + + +Otherwise: Install from source using Pip +======================================== + +If you are on OS X or on Linux, but can't install from the APT archive or from +AUR, you can install Mopidy from PyPI using Pip. + +#. First of all, you need Python >= 2.6, < 3. Check if you have Python and what + version by running:: + + python --version + +#. When you install using Pip, you need to make sure you have Pip. If you + don't, this is how you install it on Debian/Ubuntu:: + + sudo apt-get install python-setuptools python-pip + + Or on OS X:: + + sudo easy_install pip + +#. Then you'll need to install all of Mopidy's hard dependencies: + + - Pykka >= 1.0:: + + sudo pip install -U pykka + + - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer` for detailed + instructions. + +#. Optional: If you want Spotify support in Mopidy, you'll need to install + libspotify and the Python bindings, pyspotify. See :doc:`libspotify` for + detailed instructions. + +#. Optional: If you want to scrobble your played tracks to Last.fm, you need + pylast:: + + sudo pip install -U pylast + +#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound + Menu, you need some additional requirements. On Debian/Ubuntu:: + + sudo apt-get install python-dbus python-indicate + +#. Then, to install the latest release of Mopidy:: + + sudo pip install -U mopidy + + To upgrade Mopidy to future releases, just rerun this command. + + Alternatively, if you want to follow Mopidy development closer, you may + install a snapshot of Mopidy's ``develop`` Git branch using Pip:: + + sudo pip install mopidy==dev + +#. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. From 636dc6152d2044dd917e7699f62523fb24d8cba1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:11:45 +0100 Subject: [PATCH 182/323] docs: Better installation docs The GStreamer and libspotify/pyspotify docs have been merged into the main installation document. Everything related to OS X have been grouped in one section. The rest have been merged into the "from source" section. --- docs/index.rst | 2 - docs/installation/gstreamer.rst | 98 ----------------------- docs/installation/index.rst | 128 +++++++++++++++++++++++++++---- docs/installation/libspotify.rst | 112 --------------------------- docs/settings.rst | 39 +++++++++- 5 files changed, 148 insertions(+), 231 deletions(-) delete mode 100644 docs/installation/gstreamer.rst delete mode 100644 docs/installation/libspotify.rst diff --git a/docs/index.rst b/docs/index.rst index 0f5ed164..bce84b5a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,8 +39,6 @@ User documentation :maxdepth: 3 installation/index - installation/gstreamer - installation/libspotify installation/raspberrypi settings running diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst deleted file mode 100644 index 38dbb86c..00000000 --- a/docs/installation/gstreamer.rst +++ /dev/null @@ -1,98 +0,0 @@ -********************** -GStreamer installation -********************** - -To use Mopidy, you first need to install GStreamer and the GStreamer Python -bindings. - - -Installing GStreamer on Linux -============================= - -GStreamer is packaged for most popular Linux distributions. Search for -GStreamer in your package manager, and make sure to install the Python -bindings, and the "good" and "ugly" plugin sets. - - -Debian/Ubuntu -------------- - -If you use Debian/Ubuntu you can install GStreamer like this:: - - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly - -If you install Mopidy from our APT archive, you don't need to install GStreamer -yourself. The Mopidy Debian package will handle it for you. - - -Arch Linux ----------- - -If you use Arch Linux, install the following packages from the official -repository:: - - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins - - -Installing GStreamer on OS X -============================ - -We have been working with `Homebrew `_ for a -to make all the GStreamer packages easily installable on OS X. - -#. Install `Homebrew `_. - -#. Install the required packages:: - - brew install gst-python gst-plugins-good gst-plugins-ugly - -#. Make sure to include Homebrew's Python ``site-packages`` directory in your - ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer - and crash. - - You can either amend your ``PYTHONPATH`` permanently, by adding the - following statement to your shell's init file, e.g. ``~/.bashrc``:: - - export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH - - Or, you can prefix the Mopidy command every time you run it:: - - PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - - Note that you need to replace ``python2.7`` with ``python2.6`` if that's - the Python version you are using. To find your Python version, run:: - - python --version - - -Testing the installation -======================== - -If you now run the ``gst-inspect-0.10`` command (the version number may vary), -you should see a long listing of installed plugins, ending in a summary line:: - - $ gst-inspect-0.10 - ... long list of installed plugins ... - Total count: 218 plugins (1 blacklist entry not shown), 1031 features - -You should be able to produce a audible tone by running:: - - gst-launch-0.10 audiotestsrc ! autoaudiosink - -If you cannot hear any sound when running this command, you won't hear any -sound from Mopidy either, as Mopidy uses GStreamer's ``autoaudiosink`` to play -audio. Thus, make this work before you continue installing Mopidy. - - -Using a custom audio sink -========================= - -If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUT` to a partial -GStreamer pipeline description describing the GStreamer sink you want to use. - -Example of ``settings.py`` for OSS4:: - - OUTPUT = u'oss4sink' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d134ae40..d77db58d 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -1,8 +1,8 @@ .. _installation: -******************* -Mopidy installation -******************* +************ +Installation +************ There are several ways to install Mopidy. What way is best depends upon your OS and/or distribution. If you want to contribute to the development of Mopidy, @@ -74,25 +74,79 @@ package found in AUR. you're ready to :doc:`run Mopidy `. +OS X: Install from Homebrew and Pip +=================================== + +If you are running OS X, you can install everything needed with Homebrew and +Pip. + +#. Install `Homebrew `_. + + If you are already using Homebrew, make sure your installation is up to + date before you continue:: + + brew update + brew upgrade + +#. Install the required packages from Homebrew:: + + brew install gst-python gst-plugins-good gst-plugins-ugly libspotify + +#. Make sure to include Homebrew's Python ``site-packages`` directory in your + ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer + and crash. + + You can either amend your ``PYTHONPATH`` permanently, by adding the + following statement to your shell's init file, e.g. ``~/.bashrc``:: + + export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH + + Or, you can prefix the Mopidy command every time you run it:: + + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy + + Note that you need to replace ``python2.7`` with ``python2.6`` in the above + ``PYTHONPATH`` examples if you are using Python 2.6. To find your Python + version, run:: + + python --version + +#. Next up, you need to install some Python packages. To do so, we use Pip. If + you don't have the ``pip`` command, you can install it now:: + + sudo easy_install pip + +#. Then get, build, and install the latest releast of pyspotify, pylast, pykka, + and Mopidy using Pip:: + + sudo pip install -U pyspotify pylast pykka mopidy + +#. Finally, you need to set a couple of :doc:`settings `, and then + you're ready to :doc:`run Mopidy `. + + Otherwise: Install from source using Pip ======================================== -If you are on OS X or on Linux, but can't install from the APT archive or from -AUR, you can install Mopidy from PyPI using Pip. +If you are on on Linux, but can't install from the APT archive or from AUR, you +can install Mopidy from PyPI using Pip. #. First of all, you need Python >= 2.6, < 3. Check if you have Python and what version by running:: python --version -#. When you install using Pip, you need to make sure you have Pip. If you - don't, this is how you install it on Debian/Ubuntu:: +#. When you install using Pip, you need to make sure you have Pip. You'll also + need a C compiler and the Python development headers to build pyspotify + later. - sudo apt-get install python-setuptools python-pip + This is how you install it on Debian/Ubuntu:: - Or on OS X:: + sudo apt-get install build-essential python-dev python-pip - sudo easy_install pip + And on Arch Linux from the official repository:: + + sudo pacman -S base-devel python2-pip #. Then you'll need to install all of Mopidy's hard dependencies: @@ -100,12 +154,48 @@ AUR, you can install Mopidy from PyPI using Pip. sudo pip install -U pykka - - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer` for detailed - instructions. + - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most + popular Linux distributions. Search for GStreamer in your package manager, + and make sure to install the Python bindings, and the "good" and "ugly" + plugin sets. + + If you use Debian/Ubuntu you can install GStreamer like this:: + + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools + + If you use Arch Linux, install the following packages from the official + repository:: + + sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ + gstreamer0.10-ugly-plugins #. Optional: If you want Spotify support in Mopidy, you'll need to install - libspotify and the Python bindings, pyspotify. See :doc:`libspotify` for - detailed instructions. + libspotify and the Python bindings, pyspotify. + + #. First, check `pyspotify's changelog `_ to + see what's the latest version of libspotify which it supports. The + versions of libspotify and pyspotify are tightly coupled, so you'll need + to get this right. + + #. Download and install the appropriate version of libspotify for your OS and + CPU architecture from `Spotify + `_. + + For libspotify 12.1.51 for 64-bit Linux the process is as follows:: + + wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz + tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz + cd libspotify-12.1.51-Linux-x86_64-release/ + sudo make install prefix=/usr/local + sudo ldconfig + + Remember to adjust the above example for the latest libspotify version + supported by pyspotify, your OS, and your CPU architecture. + + #. Then get, build, and install the latest release of pyspotify using Pip:: + + sudo pip install -U pyspotify #. Optional: If you want to scrobble your played tracks to Last.fm, you need pylast:: @@ -113,9 +203,13 @@ AUR, you can install Mopidy from PyPI using Pip. sudo pip install -U pylast #. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound - Menu, you need some additional requirements. On Debian/Ubuntu:: + Menu or from an UPnP client via Rygel, you need some additional + dependencies: the Python bindings for libindicate, and the Python bindings + for libdbus, the reference D-Bus library. - sudo apt-get install python-dbus python-indicate + On Debian/Ubuntu:: + + sudo apt-get install python-dbus python-indicate #. Then, to install the latest release of Mopidy:: @@ -123,7 +217,7 @@ AUR, you can install Mopidy from PyPI using Pip. To upgrade Mopidy to future releases, just rerun this command. - Alternatively, if you want to follow Mopidy development closer, you may + Alternatively, if you want to track Mopidy development closer, you may install a snapshot of Mopidy's ``develop`` Git branch using Pip:: sudo pip install mopidy==dev diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst deleted file mode 100644 index 042034e7..00000000 --- a/docs/installation/libspotify.rst +++ /dev/null @@ -1,112 +0,0 @@ -*********************** -libspotify installation -*********************** - -Mopidy uses `libspotify -`_ for playing music -from the Spotify music service. To use :mod:`mopidy.backends.spotify` you must -install libspotify and `pyspotify `_. - -.. note:: - - This backend requires a paid `Spotify premium account - `_. - - -Installing libspotify -===================== - - -On Linux from APT archive -------------------------- - -If you install from APT, jump directly to :ref:`pyspotify_installation` below. - - -On Linux from source --------------------- - -First, check pyspotify's changelog to see what's the latest version of -libspotify which is supported. The versions of libspotify and pyspotify are -tightly coupled. - -Download and install the appropriate version of libspotify for your OS and CPU -architecture from https://developer.spotify.com/en/libspotify/. - -For libspotify 0.0.8 for 64-bit Linux the process is as follows:: - - wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz - tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz - cd libspotify-0.0.8-linux6-x86_64/ - sudo make install prefix=/usr/local - sudo ldconfig - -Remember to adjust for the latest libspotify version supported by pyspotify, -your OS and your CPU architecture. - -When libspotify has been installed, continue with -:ref:`pyspotify_installation`. - - -On OS X from Homebrew ---------------------- - -In OS X you need to have `XCode `_ and -`Homebrew `_ installed. Then, to install -libspotify:: - - brew install libspotify - -To update your existing libspotify installation using Homebrew:: - - brew update - brew upgrade - -When libspotify has been installed, continue with -:ref:`pyspotify_installation`. - - -.. _pyspotify_installation: - -Installing pyspotify -==================== - -When you've installed libspotify, it's time for making it available from Python -by installing pyspotify. - - -On Linux from APT archive -------------------------- - -If you run a Debian based Linux distribution, like Ubuntu, see -http://apt.mopidy.com/ for how to use the Mopidy APT archive as a software -source on your system. Then, simply run:: - - sudo apt-get install python-spotify - -This command will install both libspotify and pyspotify for you. - - -On Linux from source -------------------------- - -If you have have already installed libspotify, you can continue with installing -the libspotify Python bindings, called pyspotify. - -On Linux, you need to get the Python development files installed. On -Debian/Ubuntu systems run:: - - sudo apt-get install python-dev - -Then get, build, and install the latest releast of pyspotify using ``pip``:: - - sudo pip install -U pyspotify - - -On OS X from source -------------------- - -If you have already installed libspotify, you can get, build, and install the -latest releast of pyspotify using ``pip``:: - - sudo pip install -U pyspotify diff --git a/docs/settings.rst b/docs/settings.rst index 99064b60..5bc63d7f 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -19,8 +19,8 @@ You can either create the settings file yourself, or run the ``mopidy`` command, and it will create an empty settings file for you. When you have created the settings file, open it in a text editor, and add -settings you want to change. If you want to keep the default value for setting, -you should *not* redefine it in your own settings file. +settings you want to change. If you want to keep the default value for a +setting, you should *not* redefine it in your own settings file. A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: @@ -140,6 +140,41 @@ requirements of the `MPRIS specification `_. The ``TrackList`` and the ``Playlists`` interfaces of the spec are not supported. +Using a custom audio sink +========================= + +If you have successfully installed GStreamer, and then run the ``gst-inspect`` +or ``gst-inspect-0.10`` command, you should see a long listing of installed +plugins, ending in a summary line:: + + $ gst-inspect-0.10 + ... long list of installed plugins ... + Total count: 254 plugins (1 blacklist entry not shown), 1156 features + +Next, you should be able to produce a audible tone by running:: + + gst-launch-0.10 audiotestsrc ! sudioresample ! autoaudiosink + +If you cannot hear any sound when running this command, you won't hear any +sound from Mopidy either, as Mopidy by default uses GStreamer's +``autoaudiosink`` to play audio. Thus, make this work before you file a bug +against Mopidy. + +If you for some reason want to use some other GStreamer audio sink than +``autoaudiosink``, you can set the setting :attr:`mopidy.settings.OUTPUT` to a +partial GStreamer pipeline description describing the GStreamer sink you want +to use. + +Example of ``settings.py`` for using OSS4:: + + OUTPUT = u'oss4sink' + +Again, this is the equivalent of the following ``gst-inspect`` command, so make +this work first:: + + gst-launch-0.10 audiotestsrc ! audioresample ! oss4sink + + Streaming audio through a SHOUTcast/Icecast server ================================================== From 5c7e18e95016108747c8371b8e4eabff817138e0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:17:03 +0100 Subject: [PATCH 183/323] docs: Update with realistic test count. Reorder sections --- docs/development.rst | 86 ++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 74436223..6cab7bf1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -164,18 +164,26 @@ Then, to run all tests, go to the project directory and run:: For example:: $ nosetests - ...................................................................... - ...................................................................... - ...................................................................... - ....... - ---------------------------------------------------------------------- - Ran 217 tests in 0.267s + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................. + ----------------------------------------------------------------------------- + 1062 tests run in 7.4 seconds (1062 tests passed) - OK +To run tests with test coverage statistics, remember to specify the tests dir:: -To run tests with test coverage statistics:: - - nosetests --with-coverage + nosetests --with-coverage tests/ For more documentation on testing, check out the `nose documentation `_. @@ -247,6 +255,35 @@ both to use ``tests/data/advanced_tag_cache`` for their tag cache and playlists. +Setting profiles during development +=================================== + +While developing Mopidy switching settings back and forth can become an all too +frequent occurrence. As a quick hack to get around this you can structure your +settings file in the following way:: + + import os + profile = os.environ.get('PROFILE', '').split(',') + + if 'spotify' in profile: + BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) + elif 'local' in profile: + BACKENDS = (u'mopidy.backends.local.LocalBackend',) + LOCAL_MUSIC_PATH = u'~/music' + + if 'shoutcast' in profile: + OUTPUT = u'lame ! shout2send mount="/stream"' + elif 'silent' in profile: + OUTPUT = u'fakesink' + MIXER = None + + SPOTIFY_USERNAME = u'xxxxx' + SPOTIFY_PASSWORD = u'xxxxx' + +Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +if you for instance want to test Spotify without any actual audio output. + + Writing documentation ===================== @@ -293,32 +330,3 @@ Creating releases python setup.py sdist upload #. Spread the word. - - -Setting profiles during development -=================================== - -While developing Mopidy switching settings back and forth can become an all too -frequent occurrence. As a quick hack to get around this you can structure your -settings file in the following way:: - - import os - profile = os.environ.get('PROFILE', '').split(',') - - if 'spotify' in profile: - BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) - elif 'local' in profile: - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - LOCAL_MUSIC_PATH = u'~/music' - - if 'shoutcast' in profile: - OUTPUT = u'lame ! shout2send mount="/stream"' - elif 'silent' in profile: - OUTPUT = u'fakesink' - MIXER = None - - SPOTIFY_USERNAME = u'xxxxx' - SPOTIFY_PASSWORD = u'xxxxx' - -Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` -if you for instance want to test Spotify without any actual audio output. From 78bb341282792e5c335e23d6fd02d608fac9147c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:27:31 +0100 Subject: [PATCH 184/323] docs: Fix references to old installation docs --- docs/changes.rst | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 473c7e37..a01eb1c7 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -406,7 +406,7 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and pyspotify 1.3. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not installing from APT, follow the - instructions at :doc:`/installation/libspotify/`. + instructions at :ref:`installation`. - If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` setting, you must update your settings file. The new setting is named @@ -547,8 +547,7 @@ loading from Mopidy 0.3.0 is still present. - If you use the Spotify backend, you *should* upgrade to libspotify 0.0.7 and the latest pyspotify from the Mopidy developers. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not - installing from APT, follow the instructions at - :doc:`/installation/libspotify/`. + installing from APT, follow the instructions at :ref:`installation`. **Changes** @@ -660,7 +659,7 @@ to this problem. - If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and the latest pyspotify from the Mopidy developers. Follow the instructions at - :doc:`/installation/libspotify/`. + :ref:`installation`. - If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run ``sudo pip install --upgrade pylast`` or install Mopidy from APT. @@ -815,10 +814,10 @@ We've worked a bit on OS X support, but not all issues are completely solved yet. :issue:`25` is the one that is currently blocking OS X support. Any help solving it will be greatly appreciated! -Finally, please :ref:`update your pyspotify installation -` when upgrading to Mopidy 0.2.0. The latest pyspotify -got a fix for the segmentation fault that occurred when playing music and -searching at the same time, thanks to Valentin David. +Finally, please :ref:`update your pyspotify installation ` when +upgrading to Mopidy 0.2.0. The latest pyspotify got a fix for the segmentation +fault that occurred when playing music and searching at the same time, thanks +to Valentin David. **Important changes** @@ -883,12 +882,11 @@ fixing the OS X issues for a future release. You can track the progress at **Important changes** - License changed from GPLv2 to Apache License, version 2.0. -- GStreamer is now a required dependency. See our :doc:`GStreamer installation - docs `. +- GStreamer is now a required dependency. See our :ref:`GStreamer installation + docs `. - :mod:`mopidy.backends.libspotify` is now the default backend. :mod:`mopidy.backends.despotify` is no longer available. This means that you - need to install the :doc:`dependencies for libspotify - `. + need to install the :ref:`dependencies for libspotify `. - If you used :mod:`mopidy.backends.libspotify` previously, pyspotify must be updated when updating to this release, to get working seek functionality. - :attr:`mopidy.settings.SERVER_HOSTNAME` and From 7190052d2c42c706191995c011ec1fb8a170d216 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:55:27 +0100 Subject: [PATCH 185/323] docs: Add ToC for the installation page --- docs/installation/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index d77db58d..f54379ff 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -8,6 +8,9 @@ There are several ways to install Mopidy. What way is best depends upon your OS and/or distribution. If you want to contribute to the development of Mopidy, you should first read this page, then have a look at :ref:`run-from-git`. +.. contents:: Installation guides + :local: + Debian/Ubuntu: Install from apt.mopidy.com ========================================== From 3471a5639b28eb5b4774a150ba0523fe37618eaa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 16:55:56 +0100 Subject: [PATCH 186/323] docs: Add a section pointing to the Raspberry Pi page --- docs/installation/index.rst | 7 +++++++ docs/installation/raspberrypi.rst | 2 ++ 2 files changed, 9 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index f54379ff..587866fd 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -52,6 +52,13 @@ figure it out for itself, run the following to upgrade right away:: sudo apt-get dist-upgrade +Raspberry Pi running Debian +=========================== + +We have guides for installing a Raspberry Pi from scratch with either Debian +6.0 Squeeze or Debian 7.0 Wheezy. See :ref:`raspberrypi-installation`. + + Arch Linux: Install from AUR ============================ diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index eaec48cd..fbb07364 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -1,3 +1,5 @@ +.. _raspberrypi-installation: + **************************** Installation on Raspberry Pi **************************** From 9e622c46e27f854660777140a2e56945a8fd2336 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 19:27:20 +0100 Subject: [PATCH 187/323] docs: Add Paul Sturgess' Vagrant setup to installation page --- docs/installation/index.rst | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 587866fd..4ae04c40 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -53,10 +53,19 @@ figure it out for itself, run the following to upgrade right away:: Raspberry Pi running Debian -=========================== +--------------------------- -We have guides for installing a Raspberry Pi from scratch with either Debian -6.0 Squeeze or Debian 7.0 Wheezy. See :ref:`raspberrypi-installation`. +Fred Hatfull has created a guide for installing a Raspberry Pi from scratch +with Debian and Mopidy. See :ref:`raspberrypi-installation`. + + +Vagrant virtual machine running Ubuntu +-------------------------------------- + +Paul Sturgess has created a Vagrant and Chef setup that automatically creates +and sets up a virtual machine which runs Mopidy. Check out +https://github.com/paulsturgess/mopidy-vagrant if you're interested in trying +it out. Arch Linux: Install from AUR From 84f902081e0220a037546df0b084c0a2081171b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 20:15:09 +0100 Subject: [PATCH 188/323] docs: Add ToC to MPD clients page --- docs/clients/mpd.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 17282d8c..10795131 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -8,6 +8,9 @@ This is a list of MPD clients we either know works well with Mopidy, or that we know won't work well. For a more exhaustive list of MPD clients, see http://mpd.wikia.com/wiki/Clients. +.. contents:: Contents + :local: + Console clients =============== From 0211a86b5c7b797e56e850dd7d6d2c4ddc2993f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 20:56:28 +0100 Subject: [PATCH 189/323] docs: Update/reorder console and graphical clients --- docs/clients/mpd.rst | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 10795131..b7cb1bb4 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -15,11 +15,16 @@ http://mpd.wikia.com/wiki/Clients. Console clients =============== -mpc ---- +ncmpcpp +------- -A command line client. Version 0.14 had some issues with Mopidy (see -:issue:`5`), but 0.16 seems to work nicely. +A console client that works well with Mopidy, and is regularly used by Mopidy +developers. + +Search does not work in the "Match if tag contains search phrase (regexes +supported)" mode because the client tries to fetch all known metadata and do +the search on the client side. The two other search modes works nicely, so this +is not a problem. ncmpc @@ -29,18 +34,11 @@ A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD command, but in a resource inefficient way. -ncmpcpp -------- +mpc +--- -A console client that generally works well with Mopidy, and is regularly used -by Mopidy developers. - -Search only works in two of the three search modes: - -- "Match if tag contains search phrase (regexes supported)" -- Does not work. - The client tries to fetch all known metadata and do the search client side. -- "Match if tag contains searched phrase (no regexes)" -- Works. -- "Match only if both values are the same" -- Works. +A command line client. Version 0.16 and upwards seems to work nicely with +Mopidy. Graphical clients @@ -50,7 +48,7 @@ GMPC ---- `GMPC `_ is a graphical MPD client (GTK+) which works -well with Mopidy, and is regularly used by Mopidy developers. +well with Mopidy. GMPC may sometimes requests a lot of meta data of related albums, artists, etc. This takes more time with Mopidy, which needs to query Spotify for the data, @@ -69,8 +67,8 @@ When you search in Sonata, it only sends the first to letters of the search query to Mopidy, and then does the rest of the filtering itself on the client side. Since Spotify has a collection of millions of tracks and they only return the first 100 hits for any search query, searching for two-letter combinations -seldom returns any useful results. See :issue:`1` and the matching `Sonata -bug`_ for details. +seldom returns any useful results. See :issue:`1` and the closed `Sonata bug`_ +for details. .. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323 From d48e1218fc45703e2ebdf3ba1f9c42eaf75cccd5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 20:56:45 +0100 Subject: [PATCH 190/323] docs: Update Android MPD clients review --- docs/clients/mpd.rst | 179 ++++++++++++++++++------------------------- 1 file changed, 75 insertions(+), 104 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index b7cb1bb4..c71997be 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -12,6 +12,36 @@ http://mpd.wikia.com/wiki/Clients. :local: +Test procedure +============== + +In some cases, we've used the following test procedure to compare the feature +completeness of clients: + +#. Connect to Mopidy +#. Search for "foo", with search type "any" if it can be selected +#. Add "The Pretender" from the search results to the current playlist +#. Start playback +#. Pause and resume playback +#. Adjust volume +#. Find a playlist and append it to the current playlist +#. Skip to next track +#. Skip to previous track +#. Select the last track from the current playlist +#. Turn on repeat mode +#. Seek to 10 seconds or so before the end of the track +#. Wait for the end of the track and confirm that playback continues at the + start of the playlist +#. Turn off repeat mode +#. Turn on random mode +#. Skip to next track and confirm that it random mode works +#. Turn off random mode +#. Stop playback +#. Check if the app got support for single mode and consume mode +#. Kill Mopidy and confirm that the app handles it without crashing + + + Console clients =============== @@ -85,63 +115,47 @@ It generally works well with Mopidy. Android clients =============== -We've tested all four MPD clients we could find for Android with Mopidy 0.7.3 on -a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure: +We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 +on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test +procedure. -#. Connect to Mopidy -#. Search for ``foo``, with search type "any" if it can be selected -#. Add "The Pretender" from the search results to the current playlist -#. Start playback -#. Pause and resume playback -#. Adjust volume -#. Find a playlist and append it to the current playlist -#. Skip to next track -#. Skip to previous track -#. Select the last track from the current playlist -#. Turn on repeat mode -#. Seek to 10 seconds or so before the end of the track -#. Wait for the end of the track and confirm that playback continues at the - start of the playlist -#. Turn off repeat mode -#. Turn on random mode -#. Skip to next track and confirm that it random mode works -#. Turn off random mode -#. Stop playback -#. Check if the app got support for single mode and consume mode -#. Kill Mopidy and confirm that the app handles it without crashing -We found that all four apps crashed on Android 4.1.1. +MPDroid +------- -Combining what we managed to find before the apps crashed with our experience -from an older version of this review, using Android 2.1, we can say that: +Test date: + 2012-11-06 +Tested version: + 1.03.1 (released 2012-10-16) -- PMix can be ignored, because it is unmaintained and its fork MPDroid is - better on all fronts. +You can get `MPDroid from Google Play +`_. -- Droid MPD Client was to buggy to get an impression from. Unclear if the bugs - are due to the app or that it hasn't been updated for Android 4.x. +- MPDroid started out as a fork of PMix, and is now much better. -- BitMPC is in our experience feature complete, but ugly. +- MPDroid's user interface looks nice. -- MPDroid, now that search is in place, is probably feature complete as well, - and looks nicer than BitMPC. +- Everything in the test procedure works. -In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try -anyway, try BitMPC and MPDroid. +- In contrast to all other Android clients, MPDroid does support single mode or + consume mode. + +- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to + try to reconnect. + +MPDroid is a good MPD client, and really the only one we can recommend. BitMPC ------ Test date: - 2012-09-12 + 2012-11-06 Tested version: 1.0.0 (released 2010-04-12) -Downloads: - 5,000+ -Rating: - 3.7 stars from about 100 ratings +You can get `BitMPC from Google Play +`_. - The user interface lacks some finishing touches. E.g. you can't enter a hostname for the server. Only IPv4 addresses are allowed. @@ -155,8 +169,8 @@ Rating: - BitMPC crashed if Mopidy was killed or crashed. - When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as fire off our search, - and continued to crash on startup after that. + Mopidy without problems, but the app crashed as soon as we fired off our + search, and continued to crash on startup after that. In conclusion, BitMPC is usable if you got an older Android phone and don't care about looks. For newer Android versions, BitMPC will probably not work as @@ -167,13 +181,12 @@ Droid MPD Client ---------------- Test date: - 2012-09-12 + 2012-11-06 Tested version: 1.4.0 (released 2011-12-20) -Downloads: - 10,000+ -Rating: - 4.2 stars from 400+ ratings + +You can get `Droid MPD Client from Google Play +`_. - No intutive way to ask the app to connect to the server after adding the server hostname to the settings. @@ -190,11 +203,6 @@ Rating: - Searching for "foo" did nothing. No request was sent to the server. -- Once, I managed to get a list of stored playlists in the "Search" tab, but I - never managed to reproduce this. Opening the stored playlists doesn't work, - because Mopidy haven't implemented ``lsinfo "Playlist name"`` (see - :issue:`193`). - - Droid MPD client does not support single mode or consume mode. - Not able to complete the test procedure, due to the above problems. @@ -202,71 +210,34 @@ Rating: In conclusion, not a client we can recommend. -MPDroid -------- - -Test date: - 2012-09-12 -Tested version: - 0.7 (released 2011-06-19) -Downloads: - 10,000+ -Rating: - 4.5 stars from ~500 ratings - -- MPDroid started out as a fork of PMix. - -- First of all, MPDroid's user interface looks nice. - -- Last time we tested MPDroid (v0.6.9), we couldn't find any search - functionality. Now we found it, and it worked. - -- Last time we tested MPDroid (v0.6.9) everything in the test procedure worked - out flawlessly. - -- Like all other Android clients, MPDroid does not support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - -- When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an - empty current playlist and pressing play. - -Disregarding Android 4.x problems, MPDroid is a good MPD client. - - PMix ---- Test date: - 2012-09-12 + 2012-11-06 Tested version: 0.4.0 (released 2010-03-06) -Downloads: - 10,000+ -Rating: - 3.8 stars from >200 ratings -- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes - as soon as it connects to Mopidy. +You can get `PMix from Google Play +`_. -- Last time we tested the same version of PMix using Android 2.1, we found - that: +PMix haven't been updated for 2.5 years, and has less working features than +it's fork MPDroid. Ignore PMix and use MPDroid instead. - - PMix does not support search. - - I could not find stored playlists. +MPD Remote +---------- - - Other than that, I was able to complete the test procedure. +Test date: + 2012-11-06 +Tested version: + 1.0 (released 2012-05-01) - - PMix crashed once during testing. +You can get `MPD Remote from Google Play +`_. - - PMix handled the killing of Mopidy just as nicely as MPDroid. - - - It does not support single mode or consume mode. - -All in all, PMix works but can do less than MPDroid. Use MPDroid instead. +This app looks terrible in the screen shots, got just 100+ downloads, and got a +terrible rating. I honestly didn't take the time to test it. .. _ios_mpd_clients: From f19695ccd5d90f046d043100ed2653a8ed418f65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 21:22:55 +0100 Subject: [PATCH 191/323] docs: Updated link to Theremin --- docs/clients/mpd.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index c71997be..670c339d 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -106,8 +106,8 @@ for details. Theremin -------- -`Theremin `_ is a graphical MPD client for OS X. -It generally works well with Mopidy. +`Theremin `_ is a graphical MPD +client for OS X. It is unmaintained, but generally works well with Mopidy. .. _android_mpd_clients: From 6b8a7ab356d69d2dfc2f0ce6495804f6cc376ba9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 21:59:45 +0100 Subject: [PATCH 192/323] docs: Add MPaD review --- docs/clients/mpd.rst | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 670c339d..5d3eef49 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -303,5 +303,21 @@ we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d MPaD ---- -The `MPaD `_ iPad app works -with Mopidy. A complete review may appear here in the future. +Test date: + 2012-11-06 +Tested version: + 1.7.1 + +The `MPaD `_ iPad app can be +purchased from `MPaD at iTunes Store +`_ + +- The user interface looks nice, though I would like to be able to view the + current playlist in the large part of the split view. + +- All features exercised in the test procedure worked with MPaD. + +- Single mode and consume mode is support. + +- The server menu can be very slow top open, and there is no visible feedback + when waiting for the connection to a server to succeed. From 38607fc2be9c9a81d55b7eb92ccb9a44ecdbb00a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 22:26:36 +0100 Subject: [PATCH 193/323] docs: Update MPoD review --- docs/clients/mpd.rst | 59 ++++++++++++-------------------------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 5d3eef49..6f349945 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -245,59 +245,28 @@ terrible rating. I honestly didn't take the time to test it. iOS clients =========== -MPod +MPoD ---- Test date: - 2011-01-19 + 2012-11-06 Tested version: - 1.5.1 + 1.7.1 The `MPoD `_ iPhone/iPod Touch -app can be installed from the `iTunes Store +app can be installed from `MPoD at iTunes Store `_. -Users have reported varying success in using MPoD together with Mopidy. Thus, -we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d -(pre-0.3) on an iPod Touch 3rd generation. The following are our findings: +- The user interface looks nice. -- **Works:** Playback control generally works, including stop, play, pause, - previous, next, repeat, random, seek, and volume control. +- All features exercised in the test procedure worked with MPaD, except seek, + which I didn't figure out to do. -- **Bug:** Search does not work, neither in the artist, album, or song - tabs. Mopidy gets no requests at all from MPoD when executing searches. Seems - like MPoD only searches in local cache, even if "Use local cache" is turned - off in MPoD's settings. Until this is fixed by the MPoD developer, MPoD will - be much less useful with Mopidy. +- Search only works in the "Browse" tab, and not under in the "Artist", + "Album", or "Song" tabs. For the tabs where search doesn't work, no queries + are sent to Mopidy when searching. -- **Bug:** When adding another playlist to the current playlist in MPoD, - the currently playing track restarts at the beginning. I do not currently - know enough about this bug, because I'm not sure if MPoD was in the "add to - active playlist" or "replace active playlist" mode when I tested it. I only - later learned what that button was for. Anyway, what I experienced was: - - #. I play a track - #. I select a new playlist - #. MPoD reconnects to Mopidy for unknown reason - #. MPoD issues MPD command ``load "a playlist name"`` - #. MPoD issues MPD command ``play "-1"`` - #. MPoD issues MPD command ``playlistinfo "-1"`` - #. I hear that the currently playing tracks restarts playback - -- **Tips:** MPoD seems to cache stored playlists, but they won't work if the - server hasn't loaded stored playlists from e.g. Spotify yet. A trick to force - refetching of playlists from Mopidy is to add a new empty playlist in MPoD. - -- **Wishlist:** Modifying the current playlists is not supported by MPoD it - seems. - -- **Wishlist:** MPoD supports playback of Last.fm radio streams through the MPD - server. Mopidy does not currently support this, but there is a wishlist bug - at :issue:`38`. - -- **Wishlist:** MPoD supports autodetection/-configuration of MPD servers - through the use of Bonjour. Mopidy does not currently support this, but there - is a wishlist bug at :issue:`39`. +- Single mode and consume mode is supported. MPaD @@ -317,7 +286,11 @@ purchased from `MPaD at iTunes Store - All features exercised in the test procedure worked with MPaD. -- Single mode and consume mode is support. +- Search only works in the "Browse" tab, and not under in the "Artist", + "Album", or "Song" tabs. For the tabs where search doesn't work, no queries + are sent to Mopidy when searching. + +- Single mode and consume mode is supported. - The server menu can be very slow top open, and there is no visible feedback when waiting for the connection to a server to succeed. From faafa076d11c0469c19ab1910481a4baa5710a9e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 22:38:41 +0100 Subject: [PATCH 194/323] docs: Add screen shots of MPD clients --- docs/_static/mpd-client-gmpc.png | Bin 0 -> 178750 bytes docs/_static/mpd-client-mpad.jpg | Bin 0 -> 61090 bytes docs/_static/mpd-client-mpdroid.jpg | Bin 0 -> 34880 bytes docs/_static/mpd-client-mpod.jpg | Bin 0 -> 35287 bytes docs/_static/mpd-client-ncmpcpp.png | Bin 0 -> 22418 bytes docs/_static/mpd-client-sonata.png | Bin 0 -> 48000 bytes docs/clients/mpd.rst | 24 ++++++++++++++++++++++++ 7 files changed, 24 insertions(+) create mode 100644 docs/_static/mpd-client-gmpc.png create mode 100644 docs/_static/mpd-client-mpad.jpg create mode 100644 docs/_static/mpd-client-mpdroid.jpg create mode 100644 docs/_static/mpd-client-mpod.jpg create mode 100644 docs/_static/mpd-client-ncmpcpp.png create mode 100644 docs/_static/mpd-client-sonata.png diff --git a/docs/_static/mpd-client-gmpc.png b/docs/_static/mpd-client-gmpc.png new file mode 100644 index 0000000000000000000000000000000000000000..aa85c273a0d9a908b7c6db766f96ffda9589f1ee GIT binary patch literal 178750 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

Z0fNZhWOqYsE{_ssK= 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 literal 0 HcmV?d00001 diff --git a/docs/_static/mpd-client-mpad.jpg b/docs/_static/mpd-client-mpad.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6a3357dc4a70a9d70df50c78475709ad3aedb57a GIT binary patch literal 61090 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 QPtJuZ{zIq){Ljq)19JeXqW}N^ literal 0 HcmV?d00001 diff --git a/docs/_static/mpd-client-ncmpcpp.png b/docs/_static/mpd-client-ncmpcpp.png new file mode 100644 index 0000000000000000000000000000000000000000..975639c65e933b3d4f624b1eb69e62999c81424e GIT binary patch literal 22418 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

IOY4zURaC{(wN|&BJ(}-nv@zM1UC^7YbV1K87 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 literal 0 HcmV?d00001 diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 6f349945..59b2e103 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -48,6 +48,10 @@ Console clients ncmpcpp ------- +.. image:: /_static/mpd-client-ncmpcpp.png + :width: 575 + :height: 426 + A console client that works well with Mopidy, and is regularly used by Mopidy developers. @@ -77,6 +81,10 @@ Graphical clients GMPC ---- +.. image:: /_static/mpd-client-gmpc.png + :width: 1000 + :height: 565 + `GMPC `_ is a graphical MPD client (GTK+) which works well with Mopidy. @@ -90,6 +98,10 @@ before it will catch up. Sonata ------ +.. image:: /_static/mpd-client-sonata.png + :width: 475 + :height: 424 + `Sonata `_ is a graphical MPD client (GTK+). It generally works well with Mopidy, except for search. @@ -123,6 +135,10 @@ procedure. MPDroid ------- +.. image:: /_static/mpd-client-mpdroid.jpg + :width: 288 + :height: 512 + Test date: 2012-11-06 Tested version: @@ -248,6 +264,10 @@ iOS clients MPoD ---- +.. image:: /_static/mpd-client-mpod.jpg + :width: 320 + :height: 480 + Test date: 2012-11-06 Tested version: @@ -272,6 +292,10 @@ app can be installed from `MPoD at iTunes Store MPaD ---- +.. image:: /_static/mpd-client-mpad.jpg + :width: 480 + :height: 360 + Test date: 2012-11-06 Tested version: From 926873b5273ff662250f99e56937e79943895e9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 6 Nov 2012 22:40:53 +0100 Subject: [PATCH 195/323] docs: Move MPD client screen shots to work better with RTD stylesheet --- docs/clients/mpd.rst | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 59b2e103..a8cae367 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -48,13 +48,13 @@ Console clients ncmpcpp ------- +A console client that works well with Mopidy, and is regularly used by Mopidy +developers. + .. image:: /_static/mpd-client-ncmpcpp.png :width: 575 :height: 426 -A console client that works well with Mopidy, and is regularly used by Mopidy -developers. - Search does not work in the "Match if tag contains search phrase (regexes supported)" mode because the client tries to fetch all known metadata and do the search on the client side. The two other search modes works nicely, so this @@ -81,13 +81,13 @@ Graphical clients GMPC ---- +`GMPC `_ is a graphical MPD client (GTK+) which works +well with Mopidy. + .. image:: /_static/mpd-client-gmpc.png :width: 1000 :height: 565 -`GMPC `_ is a graphical MPD client (GTK+) which works -well with Mopidy. - GMPC may sometimes requests a lot of meta data of related albums, artists, etc. This takes more time with Mopidy, which needs to query Spotify for the data, than with a normal MPD server, which has a local cache of meta data. Thus, GMPC @@ -98,13 +98,13 @@ before it will catch up. Sonata ------ +`Sonata `_ is a graphical MPD client (GTK+). +It generally works well with Mopidy, except for search. + .. image:: /_static/mpd-client-sonata.png :width: 475 :height: 424 -`Sonata `_ is a graphical MPD client (GTK+). -It generally works well with Mopidy, except for search. - When you search in Sonata, it only sends the first to letters of the search query to Mopidy, and then does the rest of the filtering itself on the client side. Since Spotify has a collection of millions of tracks and they only return @@ -135,15 +135,15 @@ procedure. MPDroid ------- -.. image:: /_static/mpd-client-mpdroid.jpg - :width: 288 - :height: 512 - Test date: 2012-11-06 Tested version: 1.03.1 (released 2012-10-16) +.. image:: /_static/mpd-client-mpdroid.jpg + :width: 288 + :height: 512 + You can get `MPDroid from Google Play `_. @@ -264,15 +264,15 @@ iOS clients MPoD ---- -.. image:: /_static/mpd-client-mpod.jpg - :width: 320 - :height: 480 - Test date: 2012-11-06 Tested version: 1.7.1 +.. image:: /_static/mpd-client-mpod.jpg + :width: 320 + :height: 480 + The `MPoD `_ iPhone/iPod Touch app can be installed from `MPoD at iTunes Store `_. @@ -292,15 +292,15 @@ app can be installed from `MPoD at iTunes Store MPaD ---- -.. image:: /_static/mpd-client-mpad.jpg - :width: 480 - :height: 360 - Test date: 2012-11-06 Tested version: 1.7.1 +.. image:: /_static/mpd-client-mpad.jpg + :width: 480 + :height: 360 + The `MPaD `_ iPad app can be purchased from `MPaD at iTunes Store `_ From 197447c0cb4f14025d9bcecf895253e167793e58 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 14:42:06 +0100 Subject: [PATCH 196/323] Remove ancient despotify settings check --- mopidy/utils/settings.py | 7 ------- tests/utils/settings_test.py | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index d6c5d644..87e5952a 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -151,13 +151,6 @@ def validate_settings(defaults, settings): errors[setting] = u'Deprecated setting. Use %s.' % ( changed[setting],) - elif setting == 'BACKENDS': - if 'mopidy.backends.despotify.DespotifyBackend' in value: - errors[setting] = ( - u'Deprecated setting value. ' - u'"mopidy.backends.despotify.DespotifyBackend" is no ' - u'longer available.') - elif setting == 'OUTPUTS': errors[setting] = ( u'Deprecated setting, please change to OUTPUT. OUTPUT expects ' diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 5ce643cb..a57ed729 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -39,16 +39,6 @@ class ValidateSettingsTest(unittest.TestCase): result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') - def test_deprecated_setting_value_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, - {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) - self.assertEqual( - result['BACKENDS'], - u'Deprecated setting value. ' - u'"mopidy.backends.despotify.DespotifyBackend" is no longer ' - u'available.') - def test_unavailable_bitrate_setting_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'SPOTIFY_BITRATE': 50}) From 49cf1ab8aac61722e39e3a145c57b98ff3e9fd9b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 14:43:21 +0100 Subject: [PATCH 197/323] Require at least one frontend and one backend --- mopidy/utils/settings.py | 9 +++++++++ tests/utils/settings_test.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 87e5952a..5760106b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -143,6 +143,11 @@ def validate_settings(defaults, settings): 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } + list_of_one_or_more = [ + 'BACKENDS', + 'FRONTENDS', + ] + for setting, value in settings.iteritems(): if setting in changed: if changed[setting] is None: @@ -167,6 +172,10 @@ def validate_settings(defaults, settings): u'Deprecated setting, please set the value via the GStreamer ' u'bin in OUTPUT.') + elif setting in list_of_one_or_more: + if not value: + errors[setting] = u'Must contain at least one value.' + elif setting not in defaults: errors[setting] = u'Unknown setting.' suggestion = did_you_mean(setting, defaults) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index a57ed729..c98527cd 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -9,6 +9,8 @@ from tests import unittest class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { + 'BACKENDS': ['a'], + 'FRONTENDS': ['a'], 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, 'SPOTIFY_BITRATE': 160, @@ -66,6 +68,18 @@ class ValidateSettingsTest(unittest.TestCase): 'SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) + def test_empty_frontends_list_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': []}) + self.assertEqual( + result['FRONTENDS'], u'Must contain at least one value.') + + def test_empty_backends_list_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'BACKENDS': []}) + self.assertEqual( + result['BACKENDS'], u'Must contain at least one value.') + class SettingsProxyTest(unittest.TestCase): def setUp(self): From 81a3b41bc40494063c52d5447e184ebef5020241 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 8 Nov 2012 22:54:35 +0100 Subject: [PATCH 198/323] Add flag and setting for thread deadlock debug tool. --- mopidy/__main__.py | 14 ++++++++++---- mopidy/settings.py | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index b312840e..de905d15 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -40,14 +40,16 @@ logger = logging.getLogger('mopidy.main') def main(): - debug_thread = process.DebugThread() - debug_thread.start() - - signal.signal(signal.SIGUSR1, debug_thread.handler) signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() options = parse_options() + + if options.debug_thread or settings.DEBUG_THREAD: + debug_thread = process.DebugThread() + debug_thread.start() + signal.signal(signal.SIGUSR1, debug_thread.handler) + try: log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() @@ -104,6 +106,10 @@ def parse_options(): '--list-deps', action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') + parser.add_option( + '--debug-thread', + action='store_true', dest='debug_thread', + help='run background thread that dumps tracebacks on SIGUSR1') return parser.parse_args(args=mopidy_args)[0] diff --git a/mopidy/settings.py b/mopidy/settings.py index fbc71f0e..12acd281 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -45,6 +45,14 @@ DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ #: DEBUG_LOG_FILENAME = u'mopidy.log' DEBUG_LOG_FILENAME = u'mopidy.log' +#: If we should start a background thread that dumps thread's traceback when we +#: get a SIGUSR1. Mainly a debug tool for figuring out deadlocks. +#: +#: Default:: +#: +#: DEBUG_THREAD = False +DEBUG_THREAD = False + #: Location of the Mopidy .desktop file. #: #: Used by :mod:`mopidy.frontends.mpris`. From a4caf998bdf87c59fa1e2c0a8453e19f42c4d2b6 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 8 Nov 2012 22:57:11 +0100 Subject: [PATCH 199/323] Add debug thread feature to changelog. --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index a01eb1c7..594b5496 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -88,6 +88,10 @@ backends: - Added support for search by filename to local backend. +- Added optional background thread for debuging deadlocks. When the feature is + enabled via the ``--debug-thread`` or ``settings.DEBUG_THREAD`` a ``SIGUSR1`` + signal will dump the traceback for all running threads. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now From 812733205fa6c62c6ea6ec1d4229c9d6abd60131 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 8 Nov 2012 23:06:38 +0100 Subject: [PATCH 200/323] Add notes about debug thread to development docs. --- docs/changes.rst | 2 +- docs/development.rst | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 594b5496..a36ee180 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -88,7 +88,7 @@ backends: - Added support for search by filename to local backend. -- Added optional background thread for debuging deadlocks. When the feature is +- Added optional background thread for debugging deadlocks. When the feature is enabled via the ``--debug-thread`` or ``settings.DEBUG_THREAD`` a ``SIGUSR1`` signal will dump the traceback for all running threads. diff --git a/docs/development.rst b/docs/development.rst index 6cab7bf1..00a1d46a 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -284,6 +284,17 @@ Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` if you for instance want to test Spotify without any actual audio output. +Debugging deadlocks +=================== + +Between the numerous pykka threads and gstreamer interactions there can +sometimes be a potential for deadlocks. In an effort to make these slightly +simpler to debug ``settings.DEBUG_THREAD`` or ``--debug-thread`` +can be used to turn on an extra debug thread. This thread is not linked to +the regular program flow, and it's only task is to dump traceback showing +the other threads state when we get a ``SIGUSR1``. + + Writing documentation ===================== From c23baf859ec6e95b6133db1f4c3345b300ab105c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 23:15:13 +0100 Subject: [PATCH 201/323] docs: Formatting --- docs/changes.rst | 5 +++-- docs/development.rst | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a36ee180..3499fb04 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -89,8 +89,9 @@ backends: - Added support for search by filename to local backend. - Added optional background thread for debugging deadlocks. When the feature is - enabled via the ``--debug-thread`` or ``settings.DEBUG_THREAD`` a ``SIGUSR1`` - signal will dump the traceback for all running threads. + enabled via the ``--debug-thread`` option or + :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump + the traceback for all running threads. **Bug fixes** diff --git a/docs/development.rst b/docs/development.rst index 00a1d46a..5c01d6d0 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -287,12 +287,12 @@ if you for instance want to test Spotify without any actual audio output. Debugging deadlocks =================== -Between the numerous pykka threads and gstreamer interactions there can +Between the numerous Pykka threads and GStreamer interactions there can sometimes be a potential for deadlocks. In an effort to make these slightly -simpler to debug ``settings.DEBUG_THREAD`` or ``--debug-thread`` -can be used to turn on an extra debug thread. This thread is not linked to -the regular program flow, and it's only task is to dump traceback showing -the other threads state when we get a ``SIGUSR1``. +simpler to debug the setting :attr:`mopidy.settings.DEBUG_THREAD` or the option +``--debug-thread`` can be used to turn on an extra debug thread. This thread is +not linked to the regular program flow, and it's only task is to dump traceback +showing the other threads state when we get a ``SIGUSR1``. Writing documentation From 515bc450d64b6663a815ab722d94692ddc66779d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 23:23:55 +0100 Subject: [PATCH 202/323] travis: Don't break the build when apt-get update fails --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index bbba0a94..df08679b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ language: python install: - "wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - - "sudo apt-get update" + - "sudo apt-get update || true" - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" before_script: From bd7c713fbf6a58e821bdd8e5a0c367a4e8203b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erling=20B=C3=B8rresen?= Date: Sun, 11 Nov 2012 14:05:26 +0100 Subject: [PATCH 203/323] Docs: Fix typo in gst-launch command --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 5bc63d7f..0449b458 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -153,7 +153,7 @@ plugins, ending in a summary line:: Next, you should be able to produce a audible tone by running:: - gst-launch-0.10 audiotestsrc ! sudioresample ! autoaudiosink + gst-launch-0.10 audiotestsrc ! audioresample ! autoaudiosink If you cannot hear any sound when running this command, you won't hear any sound from Mopidy either, as Mopidy by default uses GStreamer's From 0b25a6f11f2b848e3364742a3f1acaef483c99ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 12 Nov 2012 21:40:14 +0100 Subject: [PATCH 204/323] Extend the backends API to support optional providers --- mopidy/backends/base.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index de33e6e5..4eecd242 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -9,20 +9,36 @@ class Backend(object): audio = None #: The library provider. An instance of - # :class:`mopidy.backends.base.BaseLibraryProvider`. + #: :class:`mopidy.backends.base.BaseLibraryProvider`, or :class:`None` if + #: the backend doesn't provide a library. library = None #: The playback provider. An instance of - #: :class:`mopidy.backends.base.BasePlaybackProvider`. + #: :class:`mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if + #: the backend doesn't provide playback. playback = None #: The stored playlists provider. An instance of - #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. + #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`, or + #: class:`None` if the backend doesn't provide stored playlists. stored_playlists = None #: List of URI schemes this backend can handle. uri_schemes = [] + # Because the providers is marked as pykka_traversible, we can't get() them + # from another actor, and need helper methods to check if the providers are + # set or None. + + def has_library(self): + return self.library is not None + + def has_playback(self): + return self.playback is not None + + def has_stored_playlists(self): + return self.stored_playlists is not None + class BaseLibraryProvider(object): """ From 429e87fe6e30b38ef2dca4e3a56afd6370c1d3fe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 12 Nov 2012 21:41:02 +0100 Subject: [PATCH 205/323] Extend Backends class to filter backends by capabilties --- mopidy/core/actor.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 482868ad..9d0cad18 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -58,10 +58,18 @@ class Backends(list): def __init__(self, backends): super(Backends, self).__init__(backends) + # These lists keeps the backends in the original order, but only + # includes those which implements the required backend provider. Since + # it is important to keep the order, we can't simply use .values() on + # the X_by_uri_scheme dicts below. + self.with_library = [b for b in backends if b.has_library().get()] + self.with_playback = [b for b in backends if b.has_playback().get()] + self.with_stored_playlists = [b for b in backends + if b.has_stored_playlists().get()] + self.by_uri_scheme = {} for backend in backends: - uri_schemes = backend.uri_schemes.get() - for uri_scheme in uri_schemes: + for uri_scheme in backend.uri_schemes.get(): assert uri_scheme not in self.by_uri_scheme, ( 'Cannot add URI scheme %s for %s, ' 'it is already handled by %s' @@ -69,3 +77,15 @@ class Backends(list): uri_scheme, backend.__class__.__name__, self.by_uri_scheme[uri_scheme].__class__.__name__) self.by_uri_scheme[uri_scheme] = backend + + self.with_library_by_uri_scheme = {} + self.with_playback_by_uri_scheme = {} + self.with_stored_playlists_by_uri_scheme = {} + + for uri_scheme, backend in self.by_uri_scheme.items(): + if backend.has_library().get(): + self.with_library_by_uri_scheme[uri_scheme] = backend + if backend.has_playback().get(): + self.with_playback_by_uri_scheme[uri_scheme] = backend + if backend.has_stored_playlists().get(): + self.with_stored_playlists_by_uri_scheme[uri_scheme] = backend From 6f32d72792156147f56eb123c9b1d578fbe2d268 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 12 Nov 2012 21:41:48 +0100 Subject: [PATCH 206/323] Update the library controller to support backends without a library --- mopidy/core/library.py | 11 +++++++---- tests/core/library_test.py | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/mopidy/core/library.py b/mopidy/core/library.py index f7514fd8..5c34e269 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -15,7 +15,7 @@ class LibraryController(object): def _get_backend(self, uri): uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.by_uri_scheme.get(uri_scheme, None) + return self.backends.with_library_by_uri_scheme.get(uri_scheme, None) def find_exact(self, **query): """ @@ -34,7 +34,8 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [b.library.find_exact(**query) for b in self.backends] + futures = [b.library.find_exact(**query) + for b in self.backends.with_library] results = pykka.get_all(futures) return Playlist(tracks=[ track for playlist in results for track in playlist.tracks]) @@ -65,7 +66,8 @@ class LibraryController(object): if backend: backend.library.refresh(uri).get() else: - futures = [b.library.refresh(uri) for b in self.backends] + futures = [b.library.refresh(uri) + for b in self.backends.with_library] pykka.get_all(futures) def search(self, **query): @@ -85,7 +87,8 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [b.library.search(**query) for b in self.backends] + futures = [b.library.search(**query) + for b in self.backends.with_library] results = pykka.get_all(futures) track_lists = [playlist.tracks for playlist in results] tracks = list(itertools.chain(*track_lists)) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 04f19909..8a714588 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -19,7 +19,13 @@ class CoreLibraryTest(unittest.TestCase): self.library2 = mock.Mock(spec=base.BaseLibraryProvider) self.backend2.library = self.library2 - self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + # A backend without the optional library provider + self.backend3 = mock.Mock() + self.backend3.uri_schemes.get.return_value = ['dummy3'] + self.backend3.has_library().get.return_value = False + + self.core = Core(audio=None, backends=[ + self.backend1, self.backend2, self.backend3]) def test_lookup_selects_dummy1_backend(self): self.core.library.lookup('dummy1:a') @@ -33,6 +39,13 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library1.lookup.called) self.library2.lookup.assert_called_once_with('dummy2:a') + def test_lookup_fails_for_dummy3_track(self): + result = self.core.library.lookup('dummy3:a') + + self.assertIsNone(result) + self.assertFalse(self.library1.lookup.called) + self.assertFalse(self.library2.lookup.called) + def test_refresh_with_uri_selects_dummy1_backend(self): self.core.library.refresh('dummy1:a') @@ -45,6 +58,12 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library1.refresh.called) self.library2.refresh.assert_called_once_with('dummy2:a') + def test_refresh_with_uri_fails_silently_for_dummy3_uri(self): + self.core.library.refresh('dummy3:a') + + self.assertFalse(self.library1.refresh.called) + self.assertFalse(self.library2.refresh.called) + def test_refresh_without_uri_calls_all_backends(self): self.core.library.refresh() From d748c07dafd57e8c5acd285103d6d275db02aac8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 12 Nov 2012 22:19:27 +0100 Subject: [PATCH 207/323] Update playback controller to support backends without playback support --- mopidy/core/playback.py | 27 ++++++++---- tests/core/playback_test.py | 86 +++++++++++++++++++++++++++++++------ 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 74f4bebd..0d0875f0 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -90,7 +90,7 @@ class PlaybackController(object): return None uri = self.current_cp_track.track.uri uri_scheme = urlparse.urlparse(uri).scheme - return self.backends.by_uri_scheme[uri_scheme] + return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) def _get_cpid(self, cp_track): if cp_track is None: @@ -298,9 +298,10 @@ class PlaybackController(object): def time_position(self): """Time position in milliseconds.""" backend = self._get_backend() - if backend is None: + if backend: + return backend.playback.get_time_position().get() + else: return 0 - return backend.playback.get_time_position().get() @property def volume(self): @@ -387,7 +388,7 @@ class PlaybackController(object): def pause(self): """Pause playback.""" backend = self._get_backend() - if backend is None or backend.playback.pause().get(): + if not backend or backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -419,7 +420,8 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self._get_backend().playback.play(cp_track.track).get(): + backend = self._get_backend() + if not backend or not backend.playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -445,8 +447,10 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if (self.state == PlaybackState.PAUSED and - self._get_backend().playback.resume().get()): + if self.state != PlaybackState.PAUSED: + return + backend = self._get_backend() + if backend and backend.playback.resume().get(): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -472,7 +476,11 @@ class PlaybackController(object): self.next() return True - success = self._get_backend().playback.seek(time_position).get() + backend = self._get_backend() + if not backend: + return False + + success = backend.playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -486,7 +494,8 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self._get_backend().playback.stop().get(): + backend = self._get_backend() + if not backend or backend.playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index b3a75773..e67aa0c3 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -1,7 +1,7 @@ import mock from mopidy.backends import base -from mopidy.core import Core +from mopidy.core import Core, PlaybackState from mopidy.models import Track from tests import unittest @@ -19,17 +19,24 @@ class CorePlaybackTest(unittest.TestCase): self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) self.backend2.playback = self.playback2 + # A backend without the optional playback provider + self.backend3 = mock.Mock() + self.backend3.uri_schemes.get.return_value = ['dummy3'] + self.backend3.has_playback().get.return_value = False + self.tracks = [ - Track(uri='dummy1://foo', length=40000), - Track(uri='dummy1://bar', length=40000), - Track(uri='dummy2://foo', length=40000), - Track(uri='dummy2://bar', length=40000), + Track(uri='dummy1:a', length=40000), + Track(uri='dummy2:a', length=40000), + Track(uri='dummy3:a', length=40000), # Unplayable + Track(uri='dummy1:b', length=40000), ] - self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core = Core(audio=None, backends=[ + self.backend1, self.backend2, self.backend3]) self.core.current_playlist.append(self.tracks) self.cp_tracks = self.core.current_playlist.cp_tracks + self.unplayable_cp_track = self.cp_tracks[2] def test_play_selects_dummy1_backend(self): self.core.playback.play(self.cp_tracks[0]) @@ -38,10 +45,19 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.play.called) def test_play_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[2]) + self.core.playback.play(self.cp_tracks[1]) self.assertFalse(self.playback1.play.called) - self.playback2.play.assert_called_once_with(self.tracks[2]) + self.playback2.play.assert_called_once_with(self.tracks[1]) + + def test_play_skips_to_next_on_unplayable_track(self): + self.core.playback.play(self.unplayable_cp_track) + + self.playback1.play.assert_called_once_with(self.tracks[3]) + self.assertFalse(self.playback2.play.called) + + self.assertEqual(self.core.playback.current_cp_track, + self.cp_tracks[3]) def test_pause_selects_dummy1_backend(self): self.core.playback.play(self.cp_tracks[0]) @@ -51,12 +67,20 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.pause.called) def test_pause_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[2]) + self.core.playback.play(self.cp_tracks[1]) self.core.playback.pause() self.assertFalse(self.playback1.pause.called) self.playback2.pause.assert_called_once_with() + def test_pause_changes_state_even_if_track_is_unplayable(self): + self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.pause() + + self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) + self.assertFalse(self.playback1.pause.called) + self.assertFalse(self.playback2.pause.called) + def test_resume_selects_dummy1_backend(self): self.core.playback.play(self.cp_tracks[0]) self.core.playback.pause() @@ -66,13 +90,22 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.resume.called) def test_resume_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[2]) + self.core.playback.play(self.cp_tracks[1]) self.core.playback.pause() self.core.playback.resume() self.assertFalse(self.playback1.resume.called) self.playback2.resume.assert_called_once_with() + def test_resume_does_nothing_if_track_is_unplayable(self): + self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.state = PlaybackState.PAUSED + self.core.playback.resume() + + self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) + self.assertFalse(self.playback1.resume.called) + self.assertFalse(self.playback2.resume.called) + def test_stop_selects_dummy1_backend(self): self.core.playback.play(self.cp_tracks[0]) self.core.playback.stop() @@ -81,12 +114,21 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.stop.called) def test_stop_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[2]) + self.core.playback.play(self.cp_tracks[1]) self.core.playback.stop() self.assertFalse(self.playback1.stop.called) self.playback2.stop.assert_called_once_with() + def test_stop_changes_state_even_if_track_is_unplayable(self): + self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.state = PlaybackState.PAUSED + self.core.playback.stop() + + self.assertEqual(self.core.playback.state, PlaybackState.STOPPED) + self.assertFalse(self.playback1.stop.called) + self.assertFalse(self.playback2.stop.called) + def test_seek_selects_dummy1_backend(self): self.core.playback.play(self.cp_tracks[0]) self.core.playback.seek(10000) @@ -95,12 +137,21 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.seek.called) def test_seek_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[2]) + self.core.playback.play(self.cp_tracks[1]) self.core.playback.seek(10000) self.assertFalse(self.playback1.seek.called) self.playback2.seek.assert_called_once_with(10000) + def test_seek_fails_for_unplayable_track(self): + self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.state = PlaybackState.PLAYING + success = self.core.playback.seek(1000) + + self.assertFalse(success) + self.assertFalse(self.playback1.seek.called) + self.assertFalse(self.playback2.seek.called) + def test_time_position_selects_dummy1_backend(self): self.core.playback.play(self.cp_tracks[0]) self.core.playback.seek(10000) @@ -110,9 +161,18 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) def test_time_position_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[2]) + self.core.playback.play(self.cp_tracks[1]) self.core.playback.seek(10000) self.core.playback.time_position self.assertFalse(self.playback1.get_time_position.called) self.playback2.get_time_position.assert_called_once_with() + + def test_time_position_returns_0_if_track_is_unplayable(self): + self.core.playback.current_cp_track = self.unplayable_cp_track + + result = self.core.playback.time_position + + self.assertEqual(result, 0) + self.assertFalse(self.playback1.get_time_position.called) + self.assertFalse(self.playback2.get_time_position.called) From 92bd599ecf5d1048ce04593dd06fa9940a756b39 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 12 Nov 2012 22:38:49 +0100 Subject: [PATCH 208/323] Update stored playlists controller to support backends without playlists --- mopidy/core/stored_playlists.py | 31 +++++++++++-------- tests/core/stored_playlists_test.py | 46 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 8c04d5ad..24d54b7b 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -18,7 +18,8 @@ class StoredPlaylistsController(object): Read-only. List of :class:`mopidy.models.Playlist`. """ - futures = [b.stored_playlists.playlists for b in self.backends] + futures = [b.stored_playlists.playlists + for b in self.backends.with_stored_playlists] results = pykka.get_all(futures) return list(itertools.chain(*results)) @@ -40,10 +41,10 @@ class StoredPlaylistsController(object): :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - if uri_scheme in self.backends.by_uri_scheme: + if uri_scheme in self.backends.with_stored_playlists_by_uri_scheme: backend = self.backends.by_uri_scheme[uri_scheme] else: - backend = self.backends[0] + backend = self.backends.with_stored_playlists[0] return backend.stored_playlists.create(name).get() def delete(self, uri): @@ -57,8 +58,9 @@ class StoredPlaylistsController(object): :type uri: string """ uri_scheme = urlparse.urlparse(uri).scheme - if uri_scheme in self.backends.by_uri_scheme: - backend = self.backends.by_uri_scheme[uri_scheme] + backend = self.backends.with_stored_playlists_by_uri_scheme.get( + uri_scheme, None) + if backend: backend.stored_playlists.delete(uri).get() def get(self, **criteria): @@ -101,7 +103,8 @@ class StoredPlaylistsController(object): :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.by_uri_scheme.get(uri_scheme, None) + backend = self.backends.with_stored_playlists_by_uri_scheme.get( + uri_scheme, None) if backend: return backend.stored_playlists.lookup(uri).get() else: @@ -120,11 +123,13 @@ class StoredPlaylistsController(object): :type uri_scheme: string """ if uri_scheme is None: - futures = [b.stored_playlists.refresh() for b in self.backends] + futures = [b.stored_playlists.refresh() + for b in self.backends.with_stored_playlists] pykka.get_all(futures) else: - if uri_scheme in self.backends.by_uri_scheme: - backend = self.backends.by_uri_scheme[uri_scheme] + backend = self.backends.with_stored_playlists_by_uri_scheme.get( + uri_scheme, None) + if backend: backend.stored_playlists.refresh().get() def save(self, playlist): @@ -152,7 +157,7 @@ class StoredPlaylistsController(object): if playlist.uri is None: return uri_scheme = urlparse.urlparse(playlist.uri).scheme - if uri_scheme not in self.backends.by_uri_scheme: - return - backend = self.backends.by_uri_scheme[uri_scheme] - return backend.stored_playlists.save(playlist).get() + backend = self.backends.with_stored_playlists_by_uri_scheme.get( + uri_scheme, None) + if backend: + return backend.stored_playlists.save(playlist).get() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index b0d48512..d9b4c08a 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -19,6 +19,12 @@ class StoredPlaylistsTest(unittest.TestCase): self.sp2 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) self.backend2.stored_playlists = self.sp2 + # A backend without the optional stored playlists provider + self.backend3 = mock.Mock() + self.backend3.uri_schemes.get.return_value = ['dummy3'] + self.backend3.has_stored_playlists().get.return_value = False + self.backend3.stored_playlists = None + self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] @@ -27,7 +33,8 @@ class StoredPlaylistsTest(unittest.TestCase): self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core = Core(audio=None, backends=[ + self.backend3, self.backend1, self.backend2]) def test_get_playlists_combines_result_from_backends(self): result = self.core.stored_playlists.playlists @@ -59,6 +66,17 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.create.called) self.sp2.create.assert_called_once_with('foo') + def test_create_with_unsupported_uri_scheme_uses_first_backend(self): + playlist = Playlist() + self.sp1.create().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.create('foo', uri_scheme='dummy3') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.assertFalse(self.sp2.create.called) + def test_delete_selects_the_dummy1_backend(self): self.core.stored_playlists.delete('dummy1:a') @@ -77,6 +95,12 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) + def test_delete_ignores_backend_without_playlist_support(self): + self.core.stored_playlists.delete('dummy3:a') + + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + def test_lookup_selects_the_dummy1_backend(self): self.core.stored_playlists.lookup('dummy1:a') @@ -89,6 +113,13 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.lookup.called) self.sp2.lookup.assert_called_once_with('dummy2:a') + def test_lookup_track_in_backend_without_playlists_fails(self): + result = self.core.stored_playlists.lookup('dummy3:a') + + self.assertIsNone(result) + self.assertFalse(self.sp1.lookup.called) + self.assertFalse(self.sp2.lookup.called) + def test_refresh_without_uri_scheme_refreshes_all_backends(self): self.core.stored_playlists.refresh() @@ -107,6 +138,12 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) + def test_refresh_ignores_backend_without_playlist_support(self): + self.core.stored_playlists.refresh(uri_scheme='dummy3') + + self.assertFalse(self.sp1.refresh.called) + self.assertFalse(self.sp2.refresh.called) + def test_save_selects_the_dummy1_backend(self): playlist = Playlist(uri='dummy1:a') self.sp1.save().get.return_value = playlist @@ -142,3 +179,10 @@ class StoredPlaylistsTest(unittest.TestCase): self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) + + def test_save_ignores_backend_without_playlist_support(self): + result = self.core.stored_playlists.save(Playlist(uri='dummy3:a')) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) From a2d7f2f504f36a1548086b767398131ad8501903 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 12 Nov 2012 23:11:32 +0100 Subject: [PATCH 209/323] spotify: Update stored playlists interface --- mopidy/backends/spotify/stored_playlists.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index 9a2328c4..cb068f63 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -5,7 +5,7 @@ class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): pass # TODO - def delete(self, playlist): + def delete(self, uri): pass # TODO def lookup(self, uri): @@ -14,8 +14,5 @@ class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def refresh(self): pass # TODO - def rename(self, playlist, new_name): - pass # TODO - def save(self, playlist): pass # TODO From 6acaa490e91255e2cb2494059ae1b1a63e3729ff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 00:17:45 +0100 Subject: [PATCH 210/323] Make all strings unicode by default (fixes #224) --- bin/mopidy-scan | 22 +- docs/changes.rst | 3 + docs/conf.py | 10 +- docs/development.rst | 16 + mopidy/__init__.py | 6 +- mopidy/__main__.py | 16 +- mopidy/audio/__init__.py | 2 + mopidy/audio/actor.py | 24 +- mopidy/audio/listener.py | 2 + mopidy/audio/mixers/__init__.py | 2 + mopidy/audio/mixers/auto.py | 2 + mopidy/audio/mixers/fake.py | 2 + mopidy/audio/mixers/nad.py | 18 +- mopidy/audio/mixers/utils.py | 2 + mopidy/backends/__init__.py | 1 + mopidy/backends/base.py | 2 + mopidy/backends/dummy.py | 4 +- mopidy/backends/local/__init__.py | 2 + mopidy/backends/local/actor.py | 6 +- mopidy/backends/local/library.py | 6 +- mopidy/backends/local/stored_playlists.py | 4 +- mopidy/backends/local/translator.py | 12 +- mopidy/backends/spotify/__init__.py | 2 + mopidy/backends/spotify/actor.py | 8 +- mopidy/backends/spotify/container_manager.py | 12 +- mopidy/backends/spotify/library.py | 26 +- mopidy/backends/spotify/playback.py | 2 + mopidy/backends/spotify/playlist_manager.py | 42 ++- mopidy/backends/spotify/session_manager.py | 32 +- mopidy/backends/spotify/stored_playlists.py | 2 + mopidy/backends/spotify/translator.py | 10 +- mopidy/core/__init__.py | 2 + mopidy/core/actor.py | 2 + mopidy/core/current_playlist.py | 10 +- mopidy/core/library.py | 2 + mopidy/core/listener.py | 2 + mopidy/core/playback.py | 24 +- mopidy/core/stored_playlists.py | 2 + mopidy/exceptions.py | 3 + mopidy/frontends/__init__.py | 1 + mopidy/frontends/lastfm.py | 22 +- mopidy/frontends/mpd/__init__.py | 2 + mopidy/frontends/mpd/actor.py | 6 +- mopidy/frontends/mpd/dispatcher.py | 28 +- mopidy/frontends/mpd/exceptions.py | 14 +- mopidy/frontends/mpd/protocol/__init__.py | 10 +- mopidy/frontends/mpd/protocol/audio_output.py | 2 + mopidy/frontends/mpd/protocol/command_list.py | 6 +- mopidy/frontends/mpd/protocol/connection.py | 6 +- .../mpd/protocol/current_playlist.py | 25 +- mopidy/frontends/mpd/protocol/empty.py | 2 + mopidy/frontends/mpd/protocol/music_db.py | 34 +- mopidy/frontends/mpd/protocol/playback.py | 8 +- mopidy/frontends/mpd/protocol/reflection.py | 4 +- mopidy/frontends/mpd/protocol/status.py | 14 +- mopidy/frontends/mpd/protocol/stickers.py | 2 + .../mpd/protocol/stored_playlists.py | 13 +- mopidy/frontends/mpd/session.py | 14 +- mopidy/frontends/mpd/translator.py | 6 +- mopidy/frontends/mpris/__init__.py | 2 + mopidy/frontends/mpris/actor.py | 28 +- mopidy/frontends/mpris/objects.py | 66 ++-- mopidy/models.py | 6 +- mopidy/scanner.py | 4 +- mopidy/settings.py | 44 +-- mopidy/utils/__init__.py | 1 + mopidy/utils/deps.py | 2 + mopidy/utils/encoding.py | 2 + mopidy/utils/formatting.py | 4 +- mopidy/utils/importing.py | 3 + mopidy/utils/log.py | 8 +- mopidy/utils/network.py | 38 +- mopidy/utils/path.py | 10 +- mopidy/utils/process.py | 30 +- mopidy/utils/settings.py | 46 +-- mopidy/utils/versioning.py | 2 + setup.py | 2 + tests/__init__.py | 2 + tests/__main__.py | 2 + tests/audio_test.py | 2 + tests/backends/__init__.py | 1 + tests/backends/base/__init__.py | 3 + tests/backends/base/current_playlist.py | 10 +- tests/backends/base/library.py | 2 + tests/backends/base/playback.py | 2 + tests/backends/base/stored_playlists.py | 26 +- tests/backends/events_test.py | 2 + tests/backends/local/__init__.py | 3 + tests/backends/local/current_playlist_test.py | 2 + tests/backends/local/library_test.py | 2 + tests/backends/local/playback_test.py | 2 + tests/backends/local/stored_playlists_test.py | 24 +- tests/backends/local/translator_test.py | 10 +- tests/core/__init__.py | 1 + tests/core/actor_test.py | 6 +- tests/core/library_test.py | 2 + tests/core/listener_test.py | 2 + tests/core/playback_test.py | 2 + tests/core/stored_playlists_test.py | 2 + tests/frontends/__init__.py | 1 + tests/frontends/mpd/__init__.py | 1 + tests/frontends/mpd/dispatcher_test.py | 8 +- tests/frontends/mpd/exception_test.py | 20 +- tests/frontends/mpd/protocol/__init__.py | 8 +- .../mpd/protocol/audio_output_test.py | 20 +- .../mpd/protocol/authentication_test.py | 28 +- .../mpd/protocol/command_list_test.py | 48 +-- .../frontends/mpd/protocol/connection_test.py | 38 +- .../mpd/protocol/current_playlist_test.py | 342 +++++++++--------- tests/frontends/mpd/protocol/idle_test.py | 148 ++++---- tests/frontends/mpd/protocol/music_db_test.py | 326 ++++++++--------- tests/frontends/mpd/protocol/playback_test.py | 246 ++++++------- .../frontends/mpd/protocol/reflection_test.py | 78 ++-- .../frontends/mpd/protocol/regression_test.py | 58 +-- tests/frontends/mpd/protocol/status_test.py | 40 +- tests/frontends/mpd/protocol/stickers_test.py | 26 +- .../mpd/protocol/stored_playlists_test.py | 86 ++--- tests/frontends/mpd/serializer_test.py | 18 +- tests/frontends/mpd/status_test.py | 2 + tests/frontends/mpris/__init__.py | 1 + tests/frontends/mpris/events_test.py | 2 + .../frontends/mpris/player_interface_test.py | 2 + tests/frontends/mpris/root_interface_test.py | 2 + tests/help_test.py | 2 + tests/models_test.py | 218 +++++------ tests/outputs/__init__.py | 1 + tests/scanner_test.py | 10 +- tests/utils/__init__.py | 1 + tests/utils/deps_test.py | 2 + tests/utils/encoding_test.py | 12 +- tests/utils/importing_test.py | 2 + tests/utils/network/__init__.py | 1 + tests/utils/network/connection_test.py | 2 + tests/utils/network/lineprotocol_test.py | 30 +- tests/utils/network/server_test.py | 2 + tests/utils/network/utils_test.py | 2 + tests/utils/path_test.py | 40 +- tests/utils/settings_test.py | 38 +- tests/version_test.py | 2 + tools/debug-proxy.py | 2 + tools/idle.py | 2 + 141 files changed, 1595 insertions(+), 1297 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index 869aa662..001ea372 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -1,5 +1,7 @@ #!/usr/bin/env python +from __future__ import unicode_literals + import sys import logging @@ -8,20 +10,26 @@ from mopidy.utils.log import setup_console_logging, setup_root_logger from mopidy.scanner import Scanner, translator from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format + setup_root_logger() setup_console_logging(2) + tracks = [] + def store(data): track = translator(data) tracks.append(track) - logging.debug(u'Added %s', track.uri) + logging.debug('Added %s', track.uri) + def debug(uri, error, debug): - logging.error(u'Failed %s: %s - %s', uri, error, debug) + logging.error('Failed %s: %s - %s', uri, error, debug) + + +logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) -logging.info(u'Scanning %s', settings.LOCAL_MUSIC_PATH) scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) try: @@ -29,10 +37,12 @@ try: except KeyboardInterrupt: scanner.stop() -logging.info(u'Done') + +logging.info('Done') + for a in tracks_to_tag_cache_format(tracks): if len(a) == 1: - print (u'%s' % a).encode('utf-8') + print ('%s' % a).encode('utf-8') else: - print (u'%s: %s' % a).encode('utf-8') + print ('%s: %s' % a).encode('utf-8') diff --git a/docs/changes.rst b/docs/changes.rst index 3499fb04..670921d9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -93,6 +93,9 @@ backends: :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump the traceback for all running threads. +- Make the entire code base use unicode strings by default, and only fall back + to bytestrings where it is required. Another step closer to Python 3. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/docs/conf.py b/docs/conf.py index d02303df..d5debb46 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. +from __future__ import unicode_literals + import os import sys @@ -89,8 +91,8 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'Mopidy' -copyright = u'2010-2012, Stein Magnus Jodal and contributors' +project = 'Mopidy' +copyright = '2010-2012, Stein Magnus Jodal and contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -231,8 +233,8 @@ latex_documents = [ ( 'index', 'Mopidy.tex', - u'Mopidy Documentation', - u'Stein Magnus Jodal', + 'Mopidy Documentation', + 'Stein Magnus Jodal', 'manual' ), ] diff --git a/docs/development.rst b/docs/development.rst index 5c01d6d0..1211cec4 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -84,6 +84,22 @@ contributing. Code style ========== +- Always import ``unicode_literals`` and use unicode literals for everything + except where you're explicitly working with bytes, which are marked with the + ``b`` prefix. + + Do this:: + + from __future__ import unicode_literals + + foo = 'I am a unicode string, which is a sane default' + bar = b'I am a bytestring' + + Not this:: + + foo = u'I am a unicode string' + bar = 'I am a bytestring, but was it intentional?' + - Follow :pep:`8` unless otherwise noted. `pep8.py `_ or `flake8 `_ can be used to check your code diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 3010acf4..072a604c 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + # pylint: disable = E0611,F0401 from distutils.version import StrictVersion as SV # pylint: enable = E0611,F0401 @@ -9,13 +11,13 @@ import pykka if not (2, 6) <= sys.version_info < (3,): sys.exit( - u'Mopidy requires Python >= 2.6, < 3, but found %s' % + 'Mopidy requires Python >= 2.6, < 3, but found %s' % '.'.join(map(str, sys.version_info[:3]))) if (isinstance(pykka.__version__, basestring) and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): sys.exit( - u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) + 'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) warnings.filterwarnings('ignore', 'could not open display') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index de905d15..952f158c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import optparse import os @@ -62,7 +64,7 @@ def main(): except exceptions.SettingsError as ex: logger.error(ex.message) except KeyboardInterrupt: - logger.info(u'Interrupted. Exiting...') + logger.info('Interrupted. Exiting...') except Exception as ex: logger.exception(ex) finally: @@ -76,7 +78,7 @@ def main(): def parse_options(): parser = optparse.OptionParser( - version=u'Mopidy %s' % versioning.get_version()) + version='Mopidy %s' % versioning.get_version()) parser.add_option( '--help-gst', action='store_true', dest='help_gst', @@ -114,15 +116,15 @@ def parse_options(): def check_old_folders(): - old_settings_folder = os.path.expanduser(u'~/.mopidy') + old_settings_folder = os.path.expanduser('~/.mopidy') if not os.path.isdir(old_settings_folder): return logger.warning( - u'Old settings folder found at %s, settings.py should be moved ' - u'to %s, any cache data should be deleted. See release notes for ' - u'further instructions.', old_settings_folder, path.SETTINGS_PATH) + 'Old settings folder found at %s, settings.py should be moved ' + 'to %s, any cache data should be deleted. See release notes for ' + 'further instructions.', old_settings_folder, path.SETTINGS_PATH) def setup_settings(interactive): @@ -171,7 +173,7 @@ def setup_frontends(core): try: importing.get_class(frontend_class_name).start(core=core) except exceptions.OptionalDependencyError as ex: - logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) + logger.info('Disabled: %s (%s)', frontend_class_name, ex) def stop_frontends(): diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index ba76bd84..c3fbc0c9 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + # flake8: noqa from .actor import Audio from .listener import AudioListener diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 852d5d57..633a9b00 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pygst pygst.require('0.10') import gst @@ -70,9 +72,9 @@ class Audio(pykka.ThreadingActor): # These caps matches the audio data provided by libspotify default_caps = gst.Caps( - 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' - 'width=(int)16, depth=(int)16, signed=(boolean)true, ' - 'rate=(int)44100') + b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + b'width=(int)16, depth=(int)16, signed=(boolean)true, ' + b'rate=(int)44100') source = element.get_property('source') source.set_property('caps', default_caps) @@ -109,7 +111,7 @@ class Audio(pykka.ThreadingActor): return # We assume that the bin will contain a single mixer. - mixer = mixerbin.get_by_interface('GstMixer') + mixer = mixerbin.get_by_interface(b'GstMixer') if not mixer: logger.warning( 'Did not find any audio mixers in "%s"', settings.MIXER) @@ -162,14 +164,14 @@ class Audio(pykka.ThreadingActor): self._trigger_reached_end_of_stream_event() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() - logger.error(u'%s %s', error, debug) + logger.error('%s %s', error, debug) self.stop_playback() elif message.type == gst.MESSAGE_WARNING: error, debug = message.parse_warning() - logger.warning(u'%s %s', error, debug) + logger.warning('%s %s', error, debug) def _trigger_reached_end_of_stream_event(self): - logger.debug(u'Triggering reached end of stream event') + logger.debug('Triggering reached end of stream event') AudioListener.send('reached_end_of_stream') def set_uri(self, uri): @@ -389,12 +391,12 @@ class Audio(pykka.ThreadingActor): # Default to blank data to trick shoutcast into clearing any previous # values it might have. - taglist[gst.TAG_ARTIST] = u' ' - taglist[gst.TAG_TITLE] = u' ' - taglist[gst.TAG_ALBUM] = u' ' + taglist[gst.TAG_ARTIST] = ' ' + taglist[gst.TAG_TITLE] = ' ' + taglist[gst.TAG_ALBUM] = ' ' if artists: - taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + taglist[gst.TAG_ARTIST] = ', '.join([a.name for a in artists]) if track.name: taglist[gst.TAG_TITLE] = track.name diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 54fe058d..42c85e1e 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index 034b0fa9..feaccc3d 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pygst pygst.require('0.10') import gst diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 05294801..bd61445e 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -12,6 +12,8 @@ This is Mopidy's default mixer. to ``autoaudiomixer`` to use this mixer. """ +from __future__ import unicode_literals + import pygst pygst.require('0.10') import gst diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 10710466..948ab82e 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -9,6 +9,8 @@ - Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. """ +from __future__ import unicode_literals + import pygst pygst.require('0.10') import gobject diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 1a807e39..b5cb522d 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -45,6 +45,8 @@ Configuration examples:: u'source=aux speakers-a=on speakers-b=off') """ +from __future__ import unicode_literals + import logging import pygst @@ -107,7 +109,7 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): def do_change_state(self, transition): if transition == gst.STATE_CHANGE_NULL_TO_READY: if serial is None: - logger.warning(u'nadmixer dependency python-serial not found') + logger.warning('nadmixer dependency python-serial not found') return gst.STATE_CHANGE_FAILURE self._start_nad_talker() return gst.STATE_CHANGE_SUCCESS @@ -164,7 +166,7 @@ class NadTalker(pykka.ThreadingActor): self._set_device_to_known_state() def _open_connection(self): - logger.info(u'NAD amplifier: Connecting through "%s"', self.port) + logger.info('NAD amplifier: Connecting through "%s"', self.port) self._device = serial.Serial( port=self.port, baudrate=self.BAUDRATE, @@ -183,7 +185,7 @@ class NadTalker(pykka.ThreadingActor): def _get_device_model(self): model = self._ask_device('Main.Model') - logger.info(u'NAD amplifier: Connected to model "%s"', model) + logger.info('NAD amplifier: Connected to model "%s"', model) return model def _power_device_on(self): @@ -212,19 +214,19 @@ class NadTalker(pykka.ThreadingActor): if current_nad_volume is None: current_nad_volume = self.VOLUME_LEVELS if current_nad_volume == self.VOLUME_LEVELS: - logger.info(u'NAD amplifier: Calibrating by setting volume to 0') + logger.info('NAD amplifier: Calibrating by setting volume to 0') self._nad_volume = current_nad_volume if self._decrease_volume(): current_nad_volume -= 1 if current_nad_volume == 0: - logger.info(u'NAD amplifier: Done calibrating') + logger.info('NAD amplifier: Done calibrating') else: self.actor_ref.proxy().calibrate_volume(current_nad_volume) def set_volume(self, volume): # Increase or decrease the amplifier volume until it matches the given # target volume. - logger.debug(u'Setting volume to %d' % volume) + logger.debug('Setting volume to %d' % volume) target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) if self._nad_volume is None: return # Calibration needed @@ -250,12 +252,12 @@ class NadTalker(pykka.ThreadingActor): if self._ask_device(key) == value: return logger.info( - u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', + 'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', key, value, attempt) self._command_device(key, value) if self._ask_device(key) != value: logger.info( - u'NAD amplifier: Gave up on setting "%s" to "%s"', + 'NAD amplifier: Gave up on setting "%s" to "%s"', key, value) def _ask_device(self, key): diff --git a/mopidy/audio/mixers/utils.py b/mopidy/audio/mixers/utils.py index c257ffd7..8d0ce280 100644 --- a/mopidy/audio/mixers/utils.py +++ b/mopidy/audio/mixers/utils.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pygst pygst.require('0.10') import gst diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index e69de29b..baffc488 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index de33e6e5..386d19b4 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import copy diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 51129200..34a176e5 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -14,6 +14,8 @@ The backend handles URIs starting with ``dummy:``. - None """ +from __future__ import unicode_literals + import pykka from mopidy.backends import base @@ -28,7 +30,7 @@ class DummyBackend(pykka.ThreadingActor, base.Backend): self.playback = DummyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) - self.uri_schemes = [u'dummy'] + self.uri_schemes = ['dummy'] class DummyLibraryProvider(base.BaseLibraryProvider): diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 6f049474..8ee58d3b 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -20,5 +20,7 @@ https://github.com/mopidy/mopidy/issues?labels=Local+backend - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` """ +from __future__ import unicode_literals + # flake8: noqa from .actor import LocalBackend diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 70351ed1..fb287468 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import pykka @@ -7,7 +9,7 @@ from mopidy.backends import base from .library import LocalLibraryProvider from .stored_playlists import LocalStoredPlaylistsProvider -logger = logging.getLogger(u'mopidy.backends.local') +logger = logging.getLogger('mopidy.backends.local') class LocalBackend(pykka.ThreadingActor, base.Backend): @@ -18,4 +20,4 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) - self.uri_schemes = [u'file'] + self.uri_schemes = ['file'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 9abdf7ed..9232225a 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging from mopidy import settings @@ -6,7 +8,7 @@ from mopidy.models import Playlist, Album from .translator import parse_mpd_tag_cache -logger = logging.getLogger(u'mopidy.backends.local') +logger = logging.getLogger('mopidy.backends.local') class LocalLibraryProvider(base.BaseLibraryProvider): @@ -30,7 +32,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): try: return self._uri_mapping[uri] except KeyError: - logger.debug(u'Failed to lookup %r', uri) + logger.debug('Failed to lookup %r', uri) return None def find_exact(self, **query): diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py index 04406c32..f521fc2e 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/stored_playlists.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import glob import logging import os @@ -11,7 +13,7 @@ from mopidy.utils import formatting, path from .translator import parse_m3u -logger = logging.getLogger(u'mopidy.backends.local') +logger = logging.getLogger('mopidy.backends.local') class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 01aad440..21e389ea 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging from mopidy.models import Track, Artist, Album @@ -68,19 +70,19 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''): current = {} state = None - for line in contents.split('\n'): - if line == 'songList begin': + for line in contents.split(b'\n'): + if line == b'songList begin': state = 'songs' continue - elif line == 'songList end': + elif line == b'songList end': state = None continue elif not state: continue - key, value = line.split(': ', 1) + key, value = line.split(b': ', 1) - if key == 'key': + if key == b'key': _convert_mpd_data(current, tracks, music_dir) current.clear() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 28813bc2..fa6feb99 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -30,5 +30,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend - :attr:`mopidy.settings.SPOTIFY_PASSWORD` """ +from __future__ import unicode_literals + # flake8: noqa from .actor import SpotifyBackend diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 943600fc..a5b23071 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import pykka @@ -24,7 +26,7 @@ class SpotifyBackend(pykka.ThreadingActor, base.Backend): self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) - self.uri_schemes = [u'spotify'] + self.uri_schemes = ['spotify'] # Fail early if settings are not present username = settings.SPOTIFY_USERNAME @@ -34,8 +36,8 @@ class SpotifyBackend(pykka.ThreadingActor, base.Backend): username, password, audio=audio, backend_ref=self.actor_ref) def on_start(self): - logger.info(u'Mopidy uses SPOTIFY(R) CORE') - logger.debug(u'Connecting to Spotify') + logger.info('Mopidy uses SPOTIFY(R) CORE') + logger.debug('Connecting to Spotify') self.spotify.start() def on_stop(self): diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index e3388e0b..dc498a02 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging from spotify.manager import SpotifyContainerManager as \ @@ -13,7 +15,7 @@ class SpotifyContainerManager(PyspotifyContainerManager): def container_loaded(self, container, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: playlist container loaded') + logger.debug('Callback called: playlist container loaded') self.session_manager.refresh_stored_playlists() @@ -22,12 +24,12 @@ class SpotifyContainerManager(PyspotifyContainerManager): if playlist.type() == 'playlist': self.session_manager.playlist_manager.watch(playlist) count += 1 - logger.debug(u'Watching %d playlist(s) for changes', count) + logger.debug('Watching %d playlist(s) for changes', count) def playlist_added(self, container, playlist, position, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: playlist added at position %d', position) + 'Callback called: playlist added at position %d', position) # container_loaded() is called after this callback, so we do not need # to handle this callback. @@ -35,7 +37,7 @@ class SpotifyContainerManager(PyspotifyContainerManager): userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: playlist "%s" moved from position %d to %d', + 'Callback called: playlist "%s" moved from position %d to %d', playlist.name(), old_position, new_position) # container_loaded() is called after this callback, so we do not need # to handle this callback. @@ -43,7 +45,7 @@ class SpotifyContainerManager(PyspotifyContainerManager): def playlist_removed(self, container, playlist, position, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: playlist "%s" removed from position %d', + 'Callback called: playlist "%s" removed from position %d', playlist.name(), position) # container_loaded() is called after this callback, so we do not need # to handle this callback. diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index bf057bee..18900d28 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import Queue @@ -16,7 +18,7 @@ class SpotifyTrack(Track): def __init__(self, uri): super(SpotifyTrack, self).__init__() self._spotify_track = Link.from_string(uri).as_track() - self._unloaded_track = Track(uri=uri, name=u'[loading...]') + self._unloaded_track = Track(uri=uri, name='[loading...]') self._track = None @property @@ -57,7 +59,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): try: return SpotifyTrack(uri) except SpotifyError as e: - logger.debug(u'Failed to lookup "%s": %s', uri, e) + logger.debug('Failed to lookup "%s": %s', uri, e) return None def refresh(self, uri=None): @@ -73,22 +75,22 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return Playlist(tracks=tracks) spotify_query = [] for (field, values) in query.iteritems(): - if field == u'track': - field = u'title' - if field == u'date': - field = u'year' + if field == 'track': + field = 'title' + if field == 'date': + field = 'year' if not hasattr(values, '__iter__'): values = [values] for value in values: - if field == u'any': + if field == 'any': spotify_query.append(value) - elif field == u'year': + elif field == 'year': value = int(value.split('-')[0]) # Extract year - spotify_query.append(u'%s:%d' % (field, value)) + spotify_query.append('%s:%d' % (field, value)) else: - spotify_query.append(u'%s:"%s"' % (field, value)) - spotify_query = u' '.join(spotify_query) - logger.debug(u'Spotify search query: %s' % spotify_query) + 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: diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 40868745..de82464a 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import time diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index 645a574c..a3deff7e 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import datetime import logging @@ -14,90 +16,90 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def tracks_added(self, playlist, tracks, position, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: ' - u'%d track(s) added to position %d in playlist "%s"', + 'Callback called: ' + '%d track(s) added to position %d in playlist "%s"', len(tracks), position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_moved(self, playlist, tracks, new_position, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: ' - u'%d track(s) moved to position %d in playlist "%s"', + 'Callback called: ' + '%d track(s) moved to position %d in playlist "%s"', len(tracks), new_position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_removed(self, playlist, tracks, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: ' - u'%d track(s) removed from playlist "%s"', + 'Callback called: ' + '%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_renamed(self, playlist, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Playlist renamed to "%s"', playlist.name()) + 'Callback called: Playlist renamed to "%s"', playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_state_changed(self, playlist, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: The state of playlist "%s" changed', + 'Callback called: The state of playlist "%s" changed', playlist.name()) def playlist_update_in_progress(self, playlist, done, userdata): """Callback used by pyspotify""" if done: logger.debug( - u'Callback called: Update of playlist "%s" done', + 'Callback called: Update of playlist "%s" done', playlist.name()) else: logger.debug( - u'Callback called: Update of playlist "%s" in progress', + 'Callback called: Update of playlist "%s" in progress', playlist.name()) def playlist_metadata_updated(self, playlist, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Metadata updated for playlist "%s"', + 'Callback called: Metadata updated for playlist "%s"', playlist.name()) def track_created_changed(self, playlist, position, user, when, userdata): """Callback used by pyspotify""" when = datetime.datetime.fromtimestamp(when) logger.debug( - u'Callback called: Created by/when for track %d in playlist ' - u'"%s" changed to user "N/A" and time "%s"', + 'Callback called: Created by/when for track %d in playlist ' + '"%s" changed to user "N/A" and time "%s"', position, playlist.name(), when) def track_message_changed(self, playlist, position, message, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Message for track %d in playlist ' - u'"%s" changed to "%s"', position, playlist.name(), message) + 'Callback called: Message for track %d in playlist ' + '"%s" changed to "%s"', position, playlist.name(), message) def track_seen_changed(self, playlist, position, seen, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Seen attribute for track %d in playlist ' - u'"%s" changed to "%s"', position, playlist.name(), seen) + 'Callback called: Seen attribute for track %d in playlist ' + '"%s" changed to "%s"', position, playlist.name(), seen) def description_changed(self, playlist, description, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Description changed for playlist "%s" to "%s"', + 'Callback called: Description changed for playlist "%s" to "%s"', playlist.name(), description) def subscribers_changed(self, playlist, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Subscribers changed for playlist "%s"', + 'Callback called: Subscribers changed for playlist "%s"', playlist.name()) def image_changed(self, playlist, image, userdata): """Callback used by pyspotify""" logger.debug( - u'Callback called: Image changed for playlist "%s"', + 'Callback called: Image changed for playlist "%s"', playlist.name()) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 23b99d48..62eecde3 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import os import threading @@ -50,14 +52,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logged_in(self, session, error): """Callback used by pyspotify""" if error: - logger.error(u'Spotify login error: %s', error) + logger.error('Spotify login error: %s', error) return - logger.info(u'Connected to Spotify') + logger.info('Connected to Spotify') self.session = session logger.debug( - u'Preferred Spotify bitrate is %s kbps', + 'Preferred Spotify bitrate is %s kbps', settings.SPOTIFY_BITRATE) self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) @@ -70,30 +72,30 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logged_out(self, session): """Callback used by pyspotify""" - logger.info(u'Disconnected from Spotify') + logger.info('Disconnected from Spotify') def metadata_updated(self, session): """Callback used by pyspotify""" - logger.debug(u'Callback called: Metadata updated') + logger.debug('Callback called: Metadata updated') def connection_error(self, session, error): """Callback used by pyspotify""" if error is None: - logger.info(u'Spotify connection OK') + logger.info('Spotify connection OK') else: - logger.error(u'Spotify connection error: %s', error) + logger.error('Spotify connection error: %s', error) self.backend.playback.pause() def message_to_user(self, session, message): """Callback used by pyspotify""" - logger.debug(u'User message: %s', message.strip()) + logger.debug('User message: %s', message.strip()) def music_delivery(self, session, frames, frame_size, num_frames, sample_type, sample_rate, channels): """Callback used by pyspotify""" # pylint: disable = R0913 # Too many arguments (8/5) - assert sample_type == 0, u'Expects 16-bit signed integer samples' + assert sample_type == 0, 'Expects 16-bit signed integer samples' capabilites = """ audio/x-raw-int, endianness=(int)1234, @@ -111,12 +113,12 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def play_token_lost(self, session): """Callback used by pyspotify""" - logger.debug(u'Play token lost') + logger.debug('Play token lost') self.backend.playback.pause() def log_message(self, session, data): """Callback used by pyspotify""" - logger.debug(u'System message: %s' % data.strip()) + logger.debug('System message: %s' % data.strip()) if 'offline-mgr' in data and 'files unlocked' in data: # XXX This is a very very fragile and ugly hack, but we get no # proper event when libspotify is done with initial data loading. @@ -131,20 +133,20 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" - logger.debug(u'End of data stream reached') + logger.debug('End of data stream reached') self.audio.emit_end_of_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data from Spotify""" if not self._initial_data_receive_completed: - logger.debug(u'Still getting data; skipped refresh of playlists') + logger.debug('Still getting data; skipped refresh of playlists') return playlists = map( translator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists - logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) + logger.info('Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): """Search method used by Mopidy backend""" @@ -161,6 +163,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logout(self): """Log out from spotify""" - logger.debug(u'Logging out from Spotify') + logger.debug('Logging out from Spotify') if self.session: self.session.logout() diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index cb068f63..559ffd99 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.backends import base diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 4ad92fe9..834b34d8 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from spotify import Link from mopidy import settings @@ -9,7 +11,7 @@ def to_mopidy_artist(spotify_artist): return uri = str(Link.from_artist(spotify_artist)) if not spotify_artist.is_loaded(): - return Artist(uri=uri, name=u'[loading...]') + return Artist(uri=uri, name='[loading...]') return Artist(uri=uri, name=spotify_artist.name()) @@ -18,7 +20,7 @@ def to_mopidy_album(spotify_album): return uri = str(Link.from_album(spotify_album)) if not spotify_album.is_loaded(): - return Album(uri=uri, name=u'[loading...]') + return Album(uri=uri, name='[loading...]') return Album( uri=uri, name=spotify_album.name(), @@ -31,7 +33,7 @@ def to_mopidy_track(spotify_track): return uri = str(Link.from_track(spotify_track, 0)) if not spotify_track.is_loaded(): - return Track(uri=uri, name=u'[loading...]') + return Track(uri=uri, name='[loading...]') spotify_album = spotify_track.album() if spotify_album is not None and spotify_album.is_loaded(): date = spotify_album.year() @@ -53,7 +55,7 @@ def to_mopidy_playlist(spotify_playlist): return uri = str(Link.from_playlist(spotify_playlist)) if not spotify_playlist.is_loaded(): - return Playlist(uri=uri, name=u'[loading...]') + return Playlist(uri=uri, name='[loading...]') return Playlist( uri=uri, name=spotify_playlist.name(), diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 7fecfd79..c8648766 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + # flake8: noqa from .actor import Core from .current_playlist import CurrentPlaylistController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 482868ad..7ced97ed 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import itertools import pykka diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index fb296a52..bd4f7b46 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from copy import copy import logging import random @@ -73,7 +75,7 @@ class CurrentPlaylistController(object): was added to the current playlist playlist """ assert at_position <= len(self._cp_tracks), \ - u'at_position can not be greater than playlist length' + 'at_position can not be greater than playlist length' cp_track = CpTrack(self.cp_id, track) if at_position is not None: self._cp_tracks.insert(at_position, cp_track) @@ -132,9 +134,9 @@ class CurrentPlaylistController(object): criteria_string = ', '.join( ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) if len(matches) == 0: - raise LookupError(u'"%s" match no tracks' % criteria_string) + raise LookupError('"%s" match no tracks' % criteria_string) else: - raise LookupError(u'"%s" match multiple tracks' % criteria_string) + raise LookupError('"%s" match multiple tracks' % criteria_string) def index(self, cp_track): """ @@ -237,5 +239,5 @@ class CurrentPlaylistController(object): return [copy(cp_track) for cp_track in self._cp_tracks[start:end]] def _trigger_playlist_changed(self): - logger.debug(u'Triggering playlist changed event') + logger.debug('Triggering playlist changed event') listener.CoreListener.send('playlist_changed') diff --git a/mopidy/core/library.py b/mopidy/core/library.py index f7514fd8..801ed983 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import itertools import urlparse diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index ed7dae2f..9c8bf4bc 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 74f4bebd..4d8c8699 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import random import urlparse @@ -28,13 +30,13 @@ class PlaybackState(object): """ #: Constant representing the paused state. - PAUSED = u'paused' + PAUSED = 'paused' #: Constant representing the playing state. - PLAYING = u'playing' + PLAYING = 'playing' #: Constant representing the stopped state. - STOPPED = u'stopped' + STOPPED = 'stopped' class PlaybackController(object): @@ -290,7 +292,7 @@ class PlaybackController(object): @state.setter # noqa def state(self, new_state): (old_state, self._state) = (self.state, new_state) - logger.debug(u'Changing state: %s -> %s', old_state, new_state) + logger.debug('Changing state: %s -> %s', old_state, new_state) self._trigger_playback_state_changed(old_state, new_state) @@ -493,7 +495,7 @@ class PlaybackController(object): self.current_cp_track = None def _trigger_track_playback_paused(self): - logger.debug(u'Triggering track playback paused event') + logger.debug('Triggering track playback paused event') if self.current_track is None: return listener.CoreListener.send( @@ -501,7 +503,7 @@ class PlaybackController(object): track=self.current_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): - logger.debug(u'Triggering track playback resumed event') + logger.debug('Triggering track playback resumed event') if self.current_track is None: return listener.CoreListener.send( @@ -509,14 +511,14 @@ class PlaybackController(object): track=self.current_track, time_position=self.time_position) def _trigger_track_playback_started(self): - logger.debug(u'Triggering track playback started event') + logger.debug('Triggering track playback started event') if self.current_track is None: return listener.CoreListener.send( 'track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): - logger.debug(u'Triggering track playback ended event') + logger.debug('Triggering track playback ended event') if self.current_track is None: return listener.CoreListener.send( @@ -524,15 +526,15 @@ class PlaybackController(object): track=self.current_track, time_position=self.time_position) def _trigger_playback_state_changed(self, old_state, new_state): - logger.debug(u'Triggering playback state change event') + logger.debug('Triggering playback state change event') listener.CoreListener.send( 'playback_state_changed', old_state=old_state, new_state=new_state) def _trigger_options_changed(self): - logger.debug(u'Triggering options changed event') + logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') def _trigger_seeked(self, time_position): - logger.debug(u'Triggering seeked event') + logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index 8c04d5ad..2c5d2c54 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import itertools import urlparse diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 6e0c575e..b8d183fb 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -1,3 +1,6 @@ +from __future__ import unicode_literals + + class MopidyException(Exception): def __init__(self, message, *args, **kwargs): super(MopidyException, self).__init__(message, *args, **kwargs) diff --git a/mopidy/frontends/__init__.py b/mopidy/frontends/__init__.py index e69de29b..baffc488 100644 --- a/mopidy/frontends/__init__.py +++ b/mopidy/frontends/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index aaf55ec1..7f367262 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -22,6 +22,8 @@ Make sure :attr:`mopidy.settings.FRONTENDS` includes the Last.fm frontend. """ +from __future__ import unicode_literals + import logging import time @@ -54,21 +56,21 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): self.lastfm = pylast.LastFMNetwork( api_key=API_KEY, api_secret=API_SECRET, username=username, password_hash=password_hash) - logger.info(u'Connected to Last.fm') + logger.info('Connected to Last.fm') except exceptions.SettingsError as e: - logger.info(u'Last.fm scrobbler not started') - logger.debug(u'Last.fm settings error: %s', e) + logger.info('Last.fm scrobbler not started') + logger.debug('Last.fm settings error: %s', e) self.stop() except (pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError) as e: - logger.error(u'Error during Last.fm setup: %s', e) + logger.error('Error during Last.fm setup: %s', e) self.stop() def track_playback_started(self, 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()) - logger.debug(u'Now playing track: %s - %s', artists, track.name) + logger.debug('Now playing track: %s - %s', artists, track.name) try: self.lastfm.update_now_playing( artists, @@ -79,22 +81,22 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): mbid=(track.musicbrainz_id or '')) except (pylast.ScrobblingError, pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError) as e: - logger.warning(u'Error submitting playing track to Last.fm: %s', e) + logger.warning('Error submitting playing track to Last.fm: %s', e) def track_playback_ended(self, track, time_position): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 time_position = time_position // 1000 if duration < 30: - logger.debug(u'Track too short to scrobble. (30s)') + logger.debug('Track too short to scrobble. (30s)') return if time_position < duration // 2 and time_position < 240: logger.debug( - u'Track not played long enough to scrobble. (50% or 240s)') + 'Track not played long enough to scrobble. (50% or 240s)') return if self.last_start_time is None: self.last_start_time = int(time.time()) - duration - logger.debug(u'Scrobbling track: %s - %s', artists, track.name) + logger.debug('Scrobbling track: %s - %s', artists, track.name) try: self.lastfm.scrobble( artists, @@ -106,4 +108,4 @@ class LastfmFrontend(pykka.ThreadingActor, CoreListener): mbid=(track.musicbrainz_id or '')) except (pylast.ScrobblingError, pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError) as e: - logger.warning(u'Error submitting played track to Last.fm: %s', e) + logger.warning('Error submitting played track to Last.fm: %s', e) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index a6cfd386..572192ef 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -21,5 +21,7 @@ Make sure :attr:`mopidy.settings.FRONTENDS` includes frontend. """ +from __future__ import unicode_literals + # flake8: noqa from .actor import MpdFrontend diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index e136ddee..3ba6378c 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import sys @@ -24,11 +26,11 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) except IOError as error: logger.error( - u'MPD server startup failed: %s', + 'MPD server startup failed: %s', encoding.locale_decode(error)) sys.exit(1) - logger.info(u'MPD server running at [%s]:%s', hostname, port) + logger.info('MPD server running at [%s]:%s', hostname, port) def on_stop(self): process.stop_actors_by_class(session.MpdSession) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 148fe443..4f0001ac 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import re @@ -52,8 +54,8 @@ class MpdDispatcher(object): response = [] for subsystem in subsystems: - response.append(u'changed: %s' % subsystem) - response.append(u'OK') + response.append('changed: %s' % subsystem) + response.append('OK') self.context.subscriptions = set() self.context.events = set() self.context.session.send_lines(response) @@ -103,26 +105,26 @@ class MpdDispatcher(object): response = self._call_next_filter(request, response, filter_chain) if (self._is_receiving_command_list(request) or self._is_processing_command_list(request)): - if response and response[-1] == u'OK': + if response and response[-1] == 'OK': response = response[:-1] return response def _is_receiving_command_list(self, request): return ( - self.command_list_receiving and request != u'command_list_end') + self.command_list_receiving and request != 'command_list_end') def _is_processing_command_list(self, request): return ( self.command_list_index is not None and - request != u'command_list_end') + request != 'command_list_end') ### Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): logger.debug( - u'Client sent us %s, only %s is allowed while in ' - u'the idle state', repr(request), repr(u'noidle')) + 'Client sent us %s, only %s is allowed while in ' + 'the idle state', repr(request), repr('noidle')) self.context.session.close() return [] @@ -144,11 +146,11 @@ class MpdDispatcher(object): def _add_ok_filter(self, request, response, filter_chain): response = self._call_next_filter(request, response, filter_chain) if not self._has_error(response): - response.append(u'OK') + response.append('OK') return response def _has_error(self, response): - return response and response[-1].startswith(u'ACK') + return response and response[-1].startswith('ACK') ### Filter: call handler @@ -157,7 +159,7 @@ class MpdDispatcher(object): response = self._format_response(self._call_handler(request)) return self._call_next_filter(request, response, filter_chain) except pykka.ActorDeadError as e: - logger.warning(u'Tried to communicate with dead actor.') + logger.warning('Tried to communicate with dead actor.') raise exceptions.MpdSystemError(e) def _call_handler(self, request): @@ -173,7 +175,7 @@ class MpdDispatcher(object): command_name = request.split(' ')[0] if command_name in [command.name for command in protocol.mpd_commands]: raise exceptions.MpdArgError( - u'incorrect arguments', command=command_name) + 'incorrect arguments', command=command_name) raise exceptions.MpdUnknownCommand(command=command_name) def _format_response(self, response): @@ -202,10 +204,10 @@ class MpdDispatcher(object): def _format_lines(self, line): if isinstance(line, dict): - return [u'%s: %s' % (key, value) for (key, value) in line.items()] + return ['%s: %s' % (key, value) for (key, value) in line.items()] if isinstance(line, tuple): (key, value) = line - return [u'%s: %s' % (key, value)] + return ['%s: %s' % (key, value)] return [line] diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index 5925d6bc..db3212d8 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.exceptions import MopidyException @@ -19,7 +21,7 @@ class MpdAckError(MopidyException): error_code = 0 - def __init__(self, message=u'', index=0, command=u''): + def __init__(self, message='', index=0, command=''): super(MpdAckError, self).__init__(message, index, command) self.message = message self.index = index @@ -31,7 +33,7 @@ class MpdAckError(MopidyException): ACK [%(error_code)i@%(index)i] {%(command)s} description """ - return u'ACK [%i@%i] {%s} %s' % ( + return 'ACK [%i@%i] {%s} %s' % ( self.__class__.error_code, self.index, self.command, self.message) @@ -48,7 +50,7 @@ class MpdPermissionError(MpdAckError): def __init__(self, *args, **kwargs): super(MpdPermissionError, self).__init__(*args, **kwargs) - self.message = u'you don\'t have permission for "%s"' % self.command + self.message = 'you don\'t have permission for "%s"' % self.command class MpdUnknownCommand(MpdAckError): @@ -56,8 +58,8 @@ class MpdUnknownCommand(MpdAckError): def __init__(self, *args, **kwargs): super(MpdUnknownCommand, self).__init__(*args, **kwargs) - self.message = u'unknown command "%s"' % self.command - self.command = u'' + self.message = 'unknown command "%s"' % self.command + self.command = '' class MpdNoExistError(MpdAckError): @@ -73,4 +75,4 @@ class MpdNotImplemented(MpdAckError): def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) - self.message = u'Not implemented' + self.message = 'Not implemented' diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 968a7dac..3a9f3674 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -10,17 +10,19 @@ implement our own MPD server which is compatible with the numerous existing `MPD clients `_. """ +from __future__ import unicode_literals + from collections import namedtuple import re #: The MPD protocol uses UTF-8 for encoding all data. -ENCODING = u'UTF-8' +ENCODING = 'UTF-8' #: The MPD protocol uses ``\n`` as line terminator. -LINE_TERMINATOR = u'\n' +LINE_TERMINATOR = '\n' #: The MPD protocol version is 0.16.0. -VERSION = u'0.16.0' +VERSION = '0.16.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) @@ -55,7 +57,7 @@ def handle_request(pattern, auth_required=True): mpd_commands.add( MpdCommand(name=match.group(), auth_required=auth_required)) if pattern in request_handlers: - raise ValueError(u'Tried to redefine handler for %s with %s' % ( + raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) request_handlers[pattern] = func func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % ( diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 7e50c8c0..b4d491e5 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index d422f97e..8d5769ef 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdUnknownCommand @@ -40,10 +42,10 @@ def command_list_end(context): command, current_command_list_index=index) command_list_response.extend(response) if (command_list_response and - command_list_response[-1].startswith(u'ACK')): + command_list_response[-1].startswith('ACK')): return command_list_response if command_list_ok: - command_list_response.append(u'list_OK') + command_list_response.append('list_OK') return command_list_response diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 3228807f..f7898d21 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import settings from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import ( @@ -25,7 +27,7 @@ def kill(context): Kills MPD. """ - raise MpdPermissionError(command=u'kill') + raise MpdPermissionError(command='kill') @handle_request(r'^password "(?P[^"]+)"$', auth_required=False) @@ -41,7 +43,7 @@ def password_(context, password): if password == settings.MPD_SERVER_PASSWORD: context.dispatcher.authenticated = True else: - raise MpdPasswordError(u'incorrect password', command=u'password') + raise MpdPasswordError('incorrect password', command='password') @handle_request(r'^ping$', auth_required=False) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 5a88d41b..57b06e1a 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd import translator from mopidy.frontends.mpd.exceptions import ( MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -26,8 +28,7 @@ def add(context, uri): if track is not None: context.core.current_playlist.add(track) return - raise MpdNoExistError( - u'directory or file not found', command=u'add') + raise MpdNoExistError('directory or file not found', command='add') @handle_request(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') @@ -50,14 +51,14 @@ def addid(context, uri, songpos=None): - ``addid ""`` should return an error. """ if not uri: - raise MpdNoExistError(u'No such song', command=u'addid') + raise MpdNoExistError('No such song', command='addid') if songpos is not None: songpos = int(songpos) track = context.core.library.lookup(uri).get() if track is None: - raise MpdNoExistError(u'No such song', command=u'addid') + raise MpdNoExistError('No such song', command='addid') if songpos and songpos > context.core.current_playlist.length.get(): - raise MpdArgError(u'Bad song index', command=u'addid') + raise MpdArgError('Bad song index', command='addid') cp_track = context.core.current_playlist.add( track, at_position=songpos).get() return ('Id', cp_track.cpid) @@ -79,7 +80,7 @@ def delete_range(context, start, end=None): end = context.core.current_playlist.length.get() cp_tracks = context.core.current_playlist.slice(start, end).get() if not cp_tracks: - raise MpdArgError(u'Bad song index', command=u'delete') + raise MpdArgError('Bad song index', command='delete') for (cpid, _) in cp_tracks: context.core.current_playlist.remove(cpid=cpid) @@ -93,7 +94,7 @@ def delete_songpos(context, songpos): songpos, songpos + 1).get()[0] context.core.current_playlist.remove(cpid=cpid) except IndexError: - raise MpdArgError(u'Bad song index', command=u'delete') + raise MpdArgError('Bad song index', command='delete') @handle_request(r'^deleteid "(?P\d+)"$') @@ -111,7 +112,7 @@ def deleteid(context, cpid): context.core.playback.next() return context.core.current_playlist.remove(cpid=cpid).get() except LookupError: - raise MpdNoExistError(u'No such song', command=u'deleteid') + raise MpdNoExistError('No such song', command='deleteid') @handle_request(r'^clear$') @@ -227,7 +228,7 @@ def playlistid(context, cpid=None): position = context.core.current_playlist.index(cp_track).get() return translator.track_to_mpd_format(cp_track, position=position) except LookupError: - raise MpdNoExistError(u'No such song', command=u'playlistid') + raise MpdNoExistError('No such song', command='playlistid') else: return translator.tracks_to_mpd_format( context.core.current_playlist.cp_tracks.get()) @@ -261,7 +262,7 @@ def playlistinfo(context, songpos=None, start=None, end=None): start = 0 start = int(start) if not (0 <= start <= context.core.current_playlist.length.get()): - raise MpdArgError(u'Bad song index', command=u'playlistinfo') + raise MpdArgError('Bad song index', command='playlistinfo') if end is not None: end = int(end) if end > context.core.current_playlist.length.get(): @@ -331,8 +332,8 @@ def plchangesposid(context, version): result = [] for (position, (cpid, _)) in enumerate( context.core.current_playlist.cp_tracks.get()): - result.append((u'cpos', position)) - result.append((u'Id', cpid)) + result.append(('cpos', position)) + result.append(('Id', cpid)) return result diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index f2ee4757..dfd610a9 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd.protocol import handle_request diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index a5d5b214..49c52d34 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import re import shlex @@ -20,8 +22,8 @@ def _build_query(mpd_query): for query_part in query_parts: m = re.match(query_part_pattern, query_part) field = m.groupdict()['field'].lower() - if field == u'title': - field = u'track' + if field == 'title': + field = 'track' field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'].lower() if field in query: @@ -183,13 +185,13 @@ def list_(context, field, mpd_query=None): """ field = field.lower() query = _list_build_query(field, mpd_query) - if field == u'artist': + if field == 'artist': return _list_artist(context, query) - elif field == u'album': + elif field == 'album': return _list_album(context, query) - elif field == u'date': + elif field == 'date': return _list_date(context, query) - elif field == u'genre': + elif field == 'genre': pass # TODO We don't have genre in our internal data structures yet @@ -202,16 +204,16 @@ def _list_build_query(field, mpd_query): tokens = shlex.split(mpd_query.encode('utf-8')) except ValueError as error: if str(error) == 'No closing quotation': - raise MpdArgError(u'Invalid unquoted character', command=u'list') + raise MpdArgError('Invalid unquoted character', command='list') else: raise tokens = [t.decode('utf-8') for t in tokens] if len(tokens) == 1: - if field == u'album': + if field == 'album': return {'artist': [tokens[0]]} else: raise MpdArgError( - u'should be "Album" for 3 arguments', command=u'list') + 'should be "Album" for 3 arguments', command='list') elif len(tokens) % 2 == 0: query = {} while tokens: @@ -219,15 +221,15 @@ def _list_build_query(field, mpd_query): key = str(key) # Needed for kwargs keys on OS X and Windows value = tokens[1] tokens = tokens[2:] - if key not in (u'artist', u'album', u'date', u'genre'): - raise MpdArgError(u'not able to parse args', command=u'list') + if key not in ('artist', 'album', 'date', 'genre'): + raise MpdArgError('not able to parse args', command='list') if key in query: query[key].append(value) else: query[key] = [value] return query else: - raise MpdArgError(u'not able to parse args', command=u'list') + raise MpdArgError('not able to parse args', command='list') def _list_artist(context, query): @@ -235,7 +237,7 @@ def _list_artist(context, query): playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: for artist in track.artists: - artists.add((u'Artist', artist.name)) + artists.add(('Artist', artist.name)) return artists @@ -244,7 +246,7 @@ def _list_album(context, query): playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.album is not None: - albums.add((u'Album', track.album.name)) + albums.add(('Album', track.album.name)) return albums @@ -253,7 +255,7 @@ def _list_date(context, query): playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: - dates.add((u'Date', track.date)) + dates.add(('Date', track.date)) return dates @@ -300,7 +302,7 @@ def lsinfo(context, uri=None): directories located at the root level, for both ``lsinfo``, ``lsinfo ""``, and ``lsinfo "/"``. """ - if uri is None or uri == u'/' or uri == u'': + if uri is None or uri == '/' or uri == '': return stored_playlists.listplaylists(context) raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 7851ebe0..35ceddad 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import ( @@ -153,7 +155,7 @@ def playid(context, cpid): cp_track = context.core.current_playlist.get(cpid=cpid).get() return context.core.playback.play(cp_track).get() except LookupError: - raise MpdNoExistError(u'No such song', command=u'playid') + raise MpdNoExistError('No such song', command='playid') @handle_request(r'^play (?P-?\d+)$') @@ -187,7 +189,7 @@ def playpos(context, songpos): songpos, songpos + 1).get()[0] return context.core.playback.play(cp_track).get() except IndexError: - raise MpdArgError(u'Bad song index', command=u'play') + raise MpdArgError('Bad song index', command='play') def _play_minus_one(context): @@ -311,7 +313,7 @@ def replay_gain_status(context): Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ - return u'off' # TODO + return 'off' # TODO @handle_request(r'^seek (?P\d+) (?P\d+)$') diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index bc18eb3a..5af86a1a 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented @@ -93,5 +95,5 @@ def urlhandlers(context): Gets a list of available URL handlers. """ return [ - (u'handler', uri_scheme) + ('handler', uri_scheme) for uri_scheme in context.core.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index b8e207d1..c5b283da 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka from mopidy.core import PlaybackState @@ -94,7 +96,7 @@ def idle(context, subsystems=None): context.subscriptions = set() for subsystem in active: - response.append(u'changed: %s' % subsystem) + response.append('changed: %s' % subsystem) return response @@ -257,21 +259,21 @@ def _status_songpos(futures): def _status_state(futures): state = futures['playback.state'].get() if state == PlaybackState.PLAYING: - return u'play' + return 'play' elif state == PlaybackState.STOPPED: - return u'stop' + return 'stop' elif state == PlaybackState.PAUSED: - return u'pause' + return 'pause' def _status_time(futures): - return u'%d:%d' % ( + return '%d:%d' % ( futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) def _status_time_elapsed(futures): - return u'%.3f' % (futures['playback.time_position'].get() / 1000.0) + return '%.3f' % (futures['playback.time_position'].get() / 1000.0) def _status_time_total(futures): diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index 074a306d..439d8d5b 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 17e5abf7..fc618201 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import datetime as dt from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented @@ -25,7 +27,7 @@ def listplaylist(context, name): playlist = context.core.stored_playlists.get(name=name).get() return ['file: %s' % t.uri for t in playlist.tracks] except LookupError: - raise MpdNoExistError(u'No such playlist', command=u'listplaylist') + raise MpdNoExistError('No such playlist', command='listplaylist') @handle_request(r'^listplaylistinfo (?P\S+)$') @@ -47,8 +49,7 @@ def listplaylistinfo(context, name): playlist = context.core.stored_playlists.get(name=name).get() return playlist_to_mpd_format(playlist) except LookupError: - raise MpdNoExistError( - u'No such playlist', command=u'listplaylistinfo') + raise MpdNoExistError('No such playlist', command='listplaylistinfo') @handle_request(r'^listplaylists$') @@ -74,7 +75,7 @@ def listplaylists(context): """ result = [] for playlist in context.core.stored_playlists.playlists.get(): - result.append((u'playlist', playlist.name)) + result.append(('playlist', playlist.name)) last_modified = ( playlist.last_modified or dt.datetime.now()).isoformat() # Remove microseconds @@ -82,7 +83,7 @@ def listplaylists(context): # Add time zone information # TODO Convert to UTC before adding Z last_modified = last_modified + 'Z' - result.append((u'Last-Modified', last_modified)) + result.append(('Last-Modified', last_modified)) return result @@ -103,7 +104,7 @@ def load(context, name): playlist = context.core.stored_playlists.get(name=name).get() context.core.current_playlist.append(playlist.tracks) except LookupError: - raise MpdNoExistError(u'No such playlist', command=u'load') + raise MpdNoExistError('No such playlist', command='load') @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 5d535f75..8a5deecd 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging from mopidy.frontends.mpd import dispatcher, protocol @@ -21,18 +23,18 @@ class MpdSession(network.LineProtocol): self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) def on_start(self): - logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) - self.send_lines([u'OK MPD %s' % protocol.VERSION]) + logger.info('New MPD connection from [%s]:%s', self.host, self.port) + self.send_lines(['OK MPD %s' % protocol.VERSION]) def on_line_received(self, line): - logger.debug(u'Request from [%s]:%s: %s', self.host, self.port, line) + logger.debug('Request from [%s]:%s: %s', self.host, self.port, line) response = self.dispatcher.handle_request(line) if not response: return logger.debug( - u'Response to [%s]:%s: %s', self.host, self.port, + 'Response to [%s]:%s: %s', self.host, self.port, formatting.indent(self.terminator.join(response))) self.send_lines(response) @@ -45,8 +47,8 @@ class MpdSession(network.LineProtocol): return super(MpdSession, self).decode(line.decode('string_escape')) except ValueError: logger.warning( - u'Stopping actor due to unescaping error, data ' - u'supplied by client was not valid.') + 'Stopping actor due to unescaping error, data ' + 'supplied by client was not valid.') self.stop() def close(self): diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 0ab28271..0f4aed68 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os import re @@ -93,7 +95,7 @@ def artists_to_mpd_format(artists): """ artists = list(artists) artists.sort(key=lambda a: a.name) - return u', '.join([a.name for a in artists if a.name]) + return ', '.join([a.name for a in artists if a.name]) def tracks_to_mpd_format(tracks, start=0, end=None): @@ -178,7 +180,7 @@ def _add_to_tag_cache(result, folders, files): def tracks_to_directory_tree(tracks): directories = ({}, []) for track in tracks: - path = u'' + path = '' current = directories local_folder = settings.LOCAL_MUSIC_PATH diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 38deac7a..2be6efea 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -50,5 +50,7 @@ Now you can control Mopidy through the player object. Examples: player.Quit(dbus_interface='org.mpris.MediaPlayer2') """ +from __future__ import unicode_literals + # flake8: noqa from .actor import MprisFrontend diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 5d8d5492..81a44fbb 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import pykka @@ -12,7 +14,7 @@ try: import indicate except ImportError as import_error: indicate = None # noqa - logger.debug(u'Startup notification will not be sent (%s)', import_error) + logger.debug('Startup notification will not be sent (%s)', import_error) class MprisFrontend(pykka.ThreadingActor, CoreListener): @@ -27,20 +29,20 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.mpris_object = objects.MprisObject(self.core) self._send_startup_notification() except Exception as e: - logger.error(u'MPRIS frontend setup failed (%s)', e) + logger.error('MPRIS frontend setup failed (%s)', e) self.stop() def on_stop(self): - logger.debug(u'Removing MPRIS object from D-Bus connection...') + logger.debug('Removing MPRIS object from D-Bus connection...') if self.mpris_object: self.mpris_object.remove_from_connection() self.mpris_object = None - logger.debug(u'Removed MPRIS object from D-Bus connection') + logger.debug('Removed MPRIS object from D-Bus connection') def _send_startup_notification(self): """ Send startup notification using libindicate to make Mopidy appear in - e.g. `Ubuntu's sound menu `_. + e.g. `Ubunt's sound menu `_. A reference to the libindicate server is kept for as long as Mopidy is running. When Mopidy exits, the server will be unreferenced and Mopidy @@ -48,12 +50,12 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): """ if not indicate: return - logger.debug(u'Sending startup notification...') + logger.debug('Sending startup notification...') self.indicate_server = indicate.Server() self.indicate_server.set_type('music.mopidy') self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) self.indicate_server.show() - logger.debug(u'Startup notification sent') + logger.debug('Startup notification sent') def _emit_properties_changed(self, *changed_properties): if self.mpris_object is None: @@ -65,25 +67,25 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): objects.PLAYER_IFACE, dict(props_with_new_values), []) def track_playback_paused(self, track, time_position): - logger.debug(u'Received track playback paused event') + logger.debug('Received track playback paused event') self._emit_properties_changed('PlaybackStatus') def track_playback_resumed(self, track, time_position): - logger.debug(u'Received track playback resumed event') + logger.debug('Received track playback resumed event') self._emit_properties_changed('PlaybackStatus') def track_playback_started(self, track): - logger.debug(u'Received track playback started event') + logger.debug('Received track playback started event') self._emit_properties_changed('PlaybackStatus', 'Metadata') def track_playback_ended(self, track, time_position): - logger.debug(u'Received track playback ended event') + logger.debug('Received track playback ended event') self._emit_properties_changed('PlaybackStatus', 'Metadata') def volume_changed(self): - logger.debug(u'Received volume changed event') + logger.debug('Received volume changed event') self._emit_properties_changed('Volume') def seeked(self, time_position_in_ms): - logger.debug(u'Received seeked event') + logger.debug('Received seeked event') self.mpris_object.Seeked(time_position_in_ms * 1000) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 4d4efe1e..0f8426a8 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import os @@ -75,11 +77,11 @@ class MprisObject(dbus.service.Object): } def _connect_to_dbus(self): - logger.debug(u'Connecting to D-Bus...') + logger.debug('Connecting to D-Bus...') mainloop = dbus.mainloop.glib.DBusGMainLoop() bus_name = dbus.service.BusName( BUS_NAME, dbus.SessionBus(mainloop=mainloop)) - logger.info(u'Connected to D-Bus') + logger.info('Connected to D-Bus') return bus_name def _get_track_id(self, cp_track): @@ -95,7 +97,7 @@ class MprisObject(dbus.service.Object): in_signature='ss', out_signature='v') def Get(self, interface, prop): logger.debug( - u'%s.Get(%s, %s) called', + '%s.Get(%s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) (getter, _) = self.properties[interface][prop] if callable(getter): @@ -107,7 +109,7 @@ class MprisObject(dbus.service.Object): in_signature='s', out_signature='a{sv}') def GetAll(self, interface): logger.debug( - u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) + '%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) getters = {} for key, (getter, _) in self.properties[interface].iteritems(): getters[key] = getter() if callable(getter) else getter @@ -117,7 +119,7 @@ class MprisObject(dbus.service.Object): in_signature='ssv', out_signature='') def Set(self, interface, prop, value): logger.debug( - u'%s.Set(%s, %s, %s) called', + '%s.Set(%s, %s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) _, setter = self.properties[interface][prop] if setter is not None: @@ -130,7 +132,7 @@ class MprisObject(dbus.service.Object): def PropertiesChanged(self, interface, changed_properties, invalidated_properties): logger.debug( - u'%s.PropertiesChanged(%s, %s, %s) signaled', + '%s.PropertiesChanged(%s, %s, %s) signaled', dbus.PROPERTIES_IFACE, interface, changed_properties, invalidated_properties) @@ -138,12 +140,12 @@ class MprisObject(dbus.service.Object): @dbus.service.method(dbus_interface=ROOT_IFACE) def Raise(self): - logger.debug(u'%s.Raise called', ROOT_IFACE) + logger.debug('%s.Raise called', ROOT_IFACE) # Do nothing, as we do not have a GUI @dbus.service.method(dbus_interface=ROOT_IFACE) def Quit(self): - logger.debug(u'%s.Quit called', ROOT_IFACE) + logger.debug('%s.Quit called', ROOT_IFACE) exit_process() ### Root interface properties @@ -158,33 +160,33 @@ class MprisObject(dbus.service.Object): @dbus.service.method(dbus_interface=PLAYER_IFACE) def Next(self): - logger.debug(u'%s.Next called', PLAYER_IFACE) + logger.debug('%s.Next called', PLAYER_IFACE) if not self.get_CanGoNext(): - logger.debug(u'%s.Next not allowed', PLAYER_IFACE) + logger.debug('%s.Next not allowed', PLAYER_IFACE) return self.core.playback.next().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Previous(self): - logger.debug(u'%s.Previous called', PLAYER_IFACE) + logger.debug('%s.Previous called', PLAYER_IFACE) if not self.get_CanGoPrevious(): - logger.debug(u'%s.Previous not allowed', PLAYER_IFACE) + logger.debug('%s.Previous not allowed', PLAYER_IFACE) return self.core.playback.previous().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Pause(self): - logger.debug(u'%s.Pause called', PLAYER_IFACE) + logger.debug('%s.Pause called', PLAYER_IFACE) if not self.get_CanPause(): - logger.debug(u'%s.Pause not allowed', PLAYER_IFACE) + logger.debug('%s.Pause not allowed', PLAYER_IFACE) return self.core.playback.pause().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def PlayPause(self): - logger.debug(u'%s.PlayPause called', PLAYER_IFACE) + logger.debug('%s.PlayPause called', PLAYER_IFACE) if not self.get_CanPause(): - logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) + logger.debug('%s.PlayPause not allowed', PLAYER_IFACE) return state = self.core.playback.state.get() if state == PlaybackState.PLAYING: @@ -196,17 +198,17 @@ class MprisObject(dbus.service.Object): @dbus.service.method(dbus_interface=PLAYER_IFACE) def Stop(self): - logger.debug(u'%s.Stop called', PLAYER_IFACE) + logger.debug('%s.Stop called', PLAYER_IFACE) if not self.get_CanControl(): - logger.debug(u'%s.Stop not allowed', PLAYER_IFACE) + logger.debug('%s.Stop not allowed', PLAYER_IFACE) return self.core.playback.stop().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Play(self): - logger.debug(u'%s.Play called', PLAYER_IFACE) + logger.debug('%s.Play called', PLAYER_IFACE) if not self.get_CanPlay(): - logger.debug(u'%s.Play not allowed', PLAYER_IFACE) + logger.debug('%s.Play not allowed', PLAYER_IFACE) return state = self.core.playback.state.get() if state == PlaybackState.PAUSED: @@ -216,9 +218,9 @@ class MprisObject(dbus.service.Object): @dbus.service.method(dbus_interface=PLAYER_IFACE) def Seek(self, offset): - logger.debug(u'%s.Seek called', PLAYER_IFACE) + logger.debug('%s.Seek called', PLAYER_IFACE) if not self.get_CanSeek(): - logger.debug(u'%s.Seek not allowed', PLAYER_IFACE) + logger.debug('%s.Seek not allowed', PLAYER_IFACE) return offset_in_milliseconds = offset // 1000 current_position = self.core.playback.time_position.get() @@ -227,9 +229,9 @@ class MprisObject(dbus.service.Object): @dbus.service.method(dbus_interface=PLAYER_IFACE) def SetPosition(self, track_id, position): - logger.debug(u'%s.SetPosition called', PLAYER_IFACE) + logger.debug('%s.SetPosition called', PLAYER_IFACE) if not self.get_CanSeek(): - logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE) + logger.debug('%s.SetPosition not allowed', PLAYER_IFACE) return position = position // 1000 current_cp_track = self.core.playback.current_cp_track.get() @@ -245,11 +247,11 @@ class MprisObject(dbus.service.Object): @dbus.service.method(dbus_interface=PLAYER_IFACE) def OpenUri(self, uri): - logger.debug(u'%s.OpenUri called', PLAYER_IFACE) + logger.debug('%s.OpenUri called', PLAYER_IFACE) if not self.get_CanPlay(): # NOTE The spec does not explictly require this check, but guarding # the other methods doesn't help much if OpenUri is open for use. - logger.debug(u'%s.Play not allowed', PLAYER_IFACE) + logger.debug('%s.Play not allowed', PLAYER_IFACE) return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. @@ -261,13 +263,13 @@ class MprisObject(dbus.service.Object): cp_track = self.core.current_playlist.add(track).get() self.core.playback.play(cp_track) else: - logger.debug(u'Track with URI "%s" not found in library.', uri) + logger.debug('Track with URI "%s" not found in library.', uri) ### Player interface signals @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') def Seeked(self, position): - logger.debug(u'%s.Seeked signaled', PLAYER_IFACE) + logger.debug('%s.Seeked signaled', PLAYER_IFACE) # Do nothing, as just calling the method is enough to emit the signal. ### Player interface properties @@ -294,7 +296,7 @@ class MprisObject(dbus.service.Object): def set_LoopStatus(self, value): if not self.get_CanControl(): - logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE) + logger.debug('Setting %s.LoopStatus not allowed', PLAYER_IFACE) return if value == 'None': self.core.playback.repeat = False @@ -310,7 +312,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanControl(): # NOTE The spec does not explictly require this check, but it was # added to be consistent with all the other property setters. - logger.debug(u'Setting %s.Rate not allowed', PLAYER_IFACE) + logger.debug('Setting %s.Rate not allowed', PLAYER_IFACE) return if value == 0: self.Pause() @@ -320,7 +322,7 @@ class MprisObject(dbus.service.Object): def set_Shuffle(self, value): if not self.get_CanControl(): - logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE) + logger.debug('Setting %s.Shuffle not allowed', PLAYER_IFACE) return if value: self.core.playback.random = True @@ -364,7 +366,7 @@ class MprisObject(dbus.service.Object): def set_Volume(self, value): if not self.get_CanControl(): - logger.debug(u'Setting %s.Volume not allowed', PLAYER_IFACE) + logger.debug('Setting %s.Volume not allowed', PLAYER_IFACE) return if value is None: return diff --git a/mopidy/models.py b/mopidy/models.py index 77561fe3..feb512f6 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from collections import namedtuple @@ -14,7 +16,7 @@ class ImmutableObject(object): for key, value in kwargs.items(): if not hasattr(self, key): raise TypeError( - u"__init__() got an unexpected keyword argument '%s'" % + '__init__() got an unexpected keyword argument "%s"' % key) self.__dict__[key] = value @@ -73,7 +75,7 @@ class ImmutableObject(object): data[key] = values.pop(key) if values: raise TypeError( - u"copy() got an unexpected keyword argument '%s'" % key) + 'copy() got an unexpected keyword argument "%s"' % key) return self.__class__(**data) def serialize(self): diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 2c12d26a..e5e484e5 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import gobject gobject.threads_init() @@ -62,7 +64,7 @@ class Scanner(object): fakesink = gst.element_factory_make('fakesink') self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property('caps', gst.Caps('audio/x-raw-int')) + self.uribin.set_property('caps', gst.Caps(b'audio/x-raw-int')) self.uribin.connect( 'pad-added', self.process_new_pad, fakesink.get_pad('sink')) diff --git a/mopidy/settings.py b/mopidy/settings.py index 12acd281..897745d7 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -7,6 +7,8 @@ All available settings and their default values. file called ``~/.config/mopidy/settings.py`` and redefine settings there. """ +from __future__ import unicode_literals + #: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: @@ -20,21 +22,21 @@ All available settings and their default values. #: u'mopidy.backends.spotify.SpotifyBackend', #: ) BACKENDS = ( - u'mopidy.backends.local.LocalBackend', - u'mopidy.backends.spotify.SpotifyBackend', + 'mopidy.backends.local.LocalBackend', + 'mopidy.backends.spotify.SpotifyBackend', ) #: The log format used for informational logging. #: #: See http://docs.python.org/2/library/logging.html#formatter-objects for #: details on the format. -CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' +CONSOLE_LOG_FORMAT = '%(levelname)-8s %(message)s' #: The log format used for debug logging. #: #: See http://docs.python.org/library/logging.html#formatter-objects for #: details on the format. -DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ +DEBUG_LOG_FORMAT = '%(levelname)-8s %(asctime)s' + \ ' [%(process)d:%(threadName)s] %(name)s\n %(message)s' #: The file to dump debug log data to when Mopidy is run with the @@ -43,13 +45,13 @@ DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ #: Default:: #: #: DEBUG_LOG_FILENAME = u'mopidy.log' -DEBUG_LOG_FILENAME = u'mopidy.log' +DEBUG_LOG_FILENAME = 'mopidy.log' #: If we should start a background thread that dumps thread's traceback when we #: get a SIGUSR1. Mainly a debug tool for figuring out deadlocks. #: #: Default:: -#: +#: #: DEBUG_THREAD = False DEBUG_THREAD = False @@ -60,7 +62,7 @@ DEBUG_THREAD = False #: Default:: #: #: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' -DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' +DESKTOP_FILE = '/usr/share/applications/mopidy.desktop' #: List of server frontends to use. See :ref:`frontend-implementations` for #: available frontends. @@ -73,20 +75,20 @@ DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' #: u'mopidy.frontends.mpris.MprisFrontend', #: ) FRONTENDS = ( - u'mopidy.frontends.mpd.MpdFrontend', - u'mopidy.frontends.lastfm.LastfmFrontend', - u'mopidy.frontends.mpris.MprisFrontend', + 'mopidy.frontends.mpd.MpdFrontend', + 'mopidy.frontends.lastfm.LastfmFrontend', + 'mopidy.frontends.mpris.MprisFrontend', ) #: Your `Last.fm `_ username. #: #: Used by :mod:`mopidy.frontends.lastfm`. -LASTFM_USERNAME = u'' +LASTFM_USERNAME = '' #: Your `Last.fm `_ password. #: #: Used by :mod:`mopidy.frontends.lastfm`. -LASTFM_PASSWORD = u'' +LASTFM_PASSWORD = '' #: Path to folder with local music. #: @@ -95,7 +97,7 @@ LASTFM_PASSWORD = u'' #: Default:: #: #: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' -LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' +LOCAL_MUSIC_PATH = '$XDG_MUSIC_DIR' #: Path to playlist folder with m3u files for local music. #: @@ -104,7 +106,7 @@ LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' #: Default:: #: #: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' -LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' +LOCAL_PLAYLIST_PATH = '$XDG_DATA_DIR/mopidy/playlists' #: Path to tag cache for local music. #: @@ -113,7 +115,7 @@ LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: Default:: #: #: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' -LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' +LOCAL_TAG_CACHE_FILE = '$XDG_DATA_DIR/mopidy/tag_cache' #: Audio mixer to use. #: @@ -126,7 +128,7 @@ LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' #: Default:: #: #: MIXER = u'autoaudiomixer' -MIXER = u'autoaudiomixer' +MIXER = 'autoaudiomixer' #: Audio mixer track to use. #: @@ -153,7 +155,7 @@ MIXER_TRACK = None #: Listens on all IPv4 interfaces. #: ``::`` #: Listens on all interfaces, both IPv4 and IPv6. -MPD_SERVER_HOSTNAME = u'127.0.0.1' +MPD_SERVER_HOSTNAME = '127.0.0.1' #: Which TCP port Mopidy's MPD server should listen to. #: @@ -185,7 +187,7 @@ MPD_SERVER_MAX_CONNECTIONS = 20 #: Default:: #: #: OUTPUT = u'autoaudiosink' -OUTPUT = u'autoaudiosink' +OUTPUT = 'autoaudiosink' #: Path to the Spotify cache. #: @@ -194,17 +196,17 @@ OUTPUT = u'autoaudiosink' #: Default:: #: #: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' -SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' +SPOTIFY_CACHE_PATH = '$XDG_CACHE_DIR/mopidy/spotify' #: Your Spotify Premium username. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_USERNAME = u'' +SPOTIFY_USERNAME = '' #: Your Spotify Premium password. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_PASSWORD = u'' +SPOTIFY_PASSWORD = '' #: Spotify preferred bitrate. #: diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index e69de29b..baffc488 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 32949f55..41fd513d 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os import platform import sys diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py index 888896c5..a21b3384 100644 --- a/mopidy/utils/encoding.py +++ b/mopidy/utils/encoding.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import locale diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py index 9091bc2a..ba311fb5 100644 --- a/mopidy/utils/formatting.py +++ b/mopidy/utils/formatting.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import re import unicodedata @@ -6,7 +8,7 @@ def indent(string, places=4, linebreak='\n'): lines = string.split(linebreak) if len(lines) == 1: return string - result = u'' + result = '' for line in lines: result += linebreak + ' ' * places + line return result diff --git a/mopidy/utils/importing.py b/mopidy/utils/importing.py index 3df6abe4..591071a1 100644 --- a/mopidy/utils/importing.py +++ b/mopidy/utils/importing.py @@ -1,6 +1,9 @@ +from __future__ import unicode_literals + import logging import sys + logger = logging.getLogger('mopidy.utils') diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index bb966a1d..e503ff9f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import logging.handlers @@ -14,9 +16,9 @@ def setup_logging(verbosity_level, save_debug_log): # New in Python 2.7 logging.captureWarnings(True) logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s', versioning.get_version()) - logger.info(u'%(name)s: %(version)s', deps.platform_info()) - logger.info(u'%(name)s: %(version)s', deps.python_info()) + logger.info('Starting Mopidy %s', versioning.get_version()) + logger.info('%(name)s: %(version)s', deps.platform_info()) + logger.info('%(name)s: %(version)s', deps.python_info()) def setup_root_logger(): diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index e56f6a81..91831871 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import errno import gobject import logging @@ -26,8 +28,8 @@ def try_ipv6_socket(): return True except IOError as error: logger.debug( - u'Platform supports IPv6, but socket creation failed, ' - u'disabling: %s', + 'Platform supports IPv6, but socket creation failed, ' + 'disabling: %s', encoding.locale_decode(error)) return False @@ -107,7 +109,7 @@ class Server(object): def reject_connection(self, sock, addr): # FIXME provide more context in logging? - logger.warning(u'Rejected connection from [%s]:%s', addr[0], addr[1]) + logger.warning('Rejected connection from [%s]:%s', addr[0], addr[1]) try: sock.close() except socket.error: @@ -190,7 +192,7 @@ class Connection(object): except socket.error as e: if e.errno in (errno.EWOULDBLOCK, errno.EINTR): return data - self.stop(u'Unexpected client error: %s' % e) + self.stop('Unexpected client error: %s' % e) return '' def enable_timeout(self): @@ -219,7 +221,7 @@ class Connection(object): gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.recv_callback) except socket.error as e: - self.stop(u'Problem with connection: %s' % e) + self.stop('Problem with connection: %s' % e) def disable_recv(self): if self.recv_id is None: @@ -237,7 +239,7 @@ class Connection(object): gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.send_callback) except socket.error as e: - self.stop(u'Problem with connection: %s' % e) + self.stop('Problem with connection: %s' % e) def disable_send(self): if self.send_id is None: @@ -248,30 +250,30 @@ class Connection(object): def recv_callback(self, fd, flags): if flags & (gobject.IO_ERR | gobject.IO_HUP): - self.stop(u'Bad client flags: %s' % flags) + self.stop('Bad client flags: %s' % flags) return True try: data = self.sock.recv(4096) except socket.error as e: if e.errno not in (errno.EWOULDBLOCK, errno.EINTR): - self.stop(u'Unexpected client error: %s' % e) + self.stop('Unexpected client error: %s' % e) return True if not data: - self.stop(u'Client most likely disconnected.') + self.stop('Client most likely disconnected.') return True try: self.actor_ref.tell({'received': data}) except pykka.ActorDeadError: - self.stop(u'Actor is dead.') + self.stop('Actor is dead.') return True def send_callback(self, fd, flags): if flags & (gobject.IO_ERR | gobject.IO_HUP): - self.stop(u'Bad client flags: %s' % flags) + self.stop('Bad client flags: %s' % flags) return True # If with can't get the lock, simply try again next time socket is @@ -289,7 +291,7 @@ class Connection(object): return True def timeout_callback(self): - self.stop(u'Client timeout out after %s seconds' % self.timeout) + self.stop('Client timeout out after %s seconds' % self.timeout) return False @@ -356,7 +358,7 @@ class LineProtocol(pykka.ThreadingActor): def on_stop(self): """Ensure that cleanup when actor stops.""" - self.connection.stop(u'Actor is shutting down.') + self.connection.stop('Actor is shutting down.') def parse_lines(self): """Consume new data and yield any lines found.""" @@ -375,8 +377,8 @@ class LineProtocol(pykka.ThreadingActor): return line.encode(self.encoding) except UnicodeError: logger.warning( - u'Stopping actor due to encode problem, data ' - u'supplied by client was not valid %s', + 'Stopping actor due to encode problem, data ' + 'supplied by client was not valid %s', self.encoding) self.stop() @@ -390,14 +392,14 @@ class LineProtocol(pykka.ThreadingActor): return line.decode(self.encoding) except UnicodeError: logger.warning( - u'Stopping actor due to decode problem, data ' - u'supplied by client was not valid %s', + 'Stopping actor due to decode problem, data ' + 'supplied by client was not valid %s', self.encoding) self.stop() def join_lines(self, lines): if not lines: - return u'' + return '' return self.terminator.join(lines) + self.terminator def send_lines(self, lines): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 1092534f..0c06eedd 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import os import re @@ -25,10 +27,10 @@ def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): raise OSError( - u'A file with the same name as the desired dir, ' - u'"%s", already exists.' % folder) + 'A file with the same name as the desired dir, ' + '"%s", already exists.' % folder) elif not os.path.isdir(folder): - logger.info(u'Creating dir %s', folder) + logger.info('Creating dir %s', folder) os.makedirs(folder, 0755) return folder @@ -36,7 +38,7 @@ def get_or_create_folder(folder): def get_or_create_file(filename): filename = os.path.expanduser(filename) if not os.path.isfile(filename): - logger.info(u'Creating file %s', filename) + logger.info('Creating file %s', filename) open(filename, 'w') return filename diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c6b27533..27e312de 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import signal import sys @@ -10,26 +12,29 @@ from pykka.registry import ActorRegistry from mopidy import exceptions + logger = logging.getLogger('mopidy.utils.process') + SIGNALS = dict((k, v) for v, k in signal.__dict__.iteritems() if v.startswith('SIG') and not v.startswith('SIG_')) + def exit_process(): - logger.debug(u'Interrupting main...') + logger.debug('Interrupting main...') thread.interrupt_main() - logger.debug(u'Interrupted main') + logger.debug('Interrupted main') def exit_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal.""" - logger.info(u'Got %s signal', SIGNALS[signum]) + logger.info('Got %s signal', SIGNALS[signum]) exit_process() def stop_actors_by_class(klass): actors = ActorRegistry.get_by_class(klass) - logger.debug(u'Stopping %d instance(s) of %s', len(actors), klass.__name__) + logger.debug('Stopping %d instance(s) of %s', len(actors), klass.__name__) for actor in actors: actor.stop() @@ -38,15 +43,15 @@ def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: logger.error( - u'There are actor threads still running, this is probably a bug') + 'There are actor threads still running, this is probably a bug') logger.debug( - u'Seeing %d actor and %d non-actor thread(s): %s', + 'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) - logger.debug(u'Stopping %d actor(s)...', num_actors) + logger.debug('Stopping %d actor(s)...', num_actors) ActorRegistry.stop_all() num_actors = len(ActorRegistry.get_all()) - logger.debug(u'All actors stopped.') + logger.debug('All actors stopped.') class BaseThread(threading.Thread): @@ -56,11 +61,11 @@ class BaseThread(threading.Thread): self.daemon = True def run(self): - logger.debug(u'%s: Starting thread', self.name) + logger.debug('%s: Starting thread', self.name) try: self.run_inside_try() except KeyboardInterrupt: - logger.info(u'Interrupted by user') + logger.info('Interrupted by user') except exceptions.SettingsError as e: logger.error(e.message) except ImportError as e: @@ -69,11 +74,12 @@ class BaseThread(threading.Thread): logger.warning(e) except Exception as e: logger.exception(e) - logger.debug(u'%s: Exiting thread', self.name) + logger.debug('%s: Exiting thread', self.name) def run_inside_try(self): raise NotImplementedError + class DebugThread(threading.Thread): daemon = True name = 'DebugThread' @@ -81,7 +87,7 @@ class DebugThread(threading.Thread): event = threading.Event() def handler(self, signum, frame): - logger.info(u'Got %s signal', SIGNALS[signum]) + logger.info('Got %s signal', SIGNALS[signum]) self.event.set() def run(self): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 5760106b..105a94e3 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,6 +1,6 @@ # Absolute import needed to import ~/.config/mopidy/settings.py and not # ourselves -from __future__ import absolute_import +from __future__ import absolute_import, unicode_literals import copy import getpass @@ -53,11 +53,11 @@ class SettingsProxy(object): current = self.current # bind locally to avoid copying+updates if attr not in current: - raise exceptions.SettingsError(u'Setting "%s" is not set.' % attr) + raise exceptions.SettingsError('Setting "%s" is not set.' % attr) value = current[attr] if isinstance(value, basestring) and len(value) == 0: - raise exceptions.SettingsError(u'Setting "%s" is empty.' % attr) + raise exceptions.SettingsError('Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): @@ -75,17 +75,17 @@ class SettingsProxy(object): self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): logger.error( - u'Settings validation errors: %s', + 'Settings validation errors: %s', formatting.indent(self.get_errors_as_string())) - raise exceptions.SettingsError(u'Settings validation failed.') + raise exceptions.SettingsError('Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): for setting, value in sorted(current.iteritems()): if isinstance(value, basestring) and len(value) == 0: - runtime[setting] = self._read_from_stdin(setting + u': ') + runtime[setting] = self._read_from_stdin(setting + ': ') def _read_from_stdin(self, prompt): - if u'_PASSWORD' in prompt: + if '_PASSWORD' in prompt: return ( getpass.getpass(prompt) .decode(sys.stdin.encoding, 'ignore')) @@ -101,7 +101,7 @@ class SettingsProxy(object): def get_errors_as_string(self): lines = [] for (setting, error) in self.get_errors().iteritems(): - lines.append(u'%s: %s' % (setting, error)) + lines.append('%s: %s' % (setting, error)) return '\n'.join(lines) @@ -151,37 +151,37 @@ def validate_settings(defaults, settings): for setting, value in settings.iteritems(): if setting in changed: if changed[setting] is None: - errors[setting] = u'Deprecated setting. It may be removed.' + errors[setting] = 'Deprecated setting. It may be removed.' else: - errors[setting] = u'Deprecated setting. Use %s.' % ( + errors[setting] = 'Deprecated setting. Use %s.' % ( changed[setting],) elif setting == 'OUTPUTS': errors[setting] = ( - u'Deprecated setting, please change to OUTPUT. OUTPUT expects ' - u'a GStreamer bin description string for your desired output.') + 'Deprecated setting, please change to OUTPUT. OUTPUT expects ' + 'a GStreamer bin description string for your desired output.') elif setting == 'SPOTIFY_BITRATE': if value not in (96, 160, 320): errors[setting] = ( - u'Unavailable Spotify bitrate. Available bitrates are 96, ' - u'160, and 320.') + 'Unavailable Spotify bitrate. Available bitrates are 96, ' + '160, and 320.') elif setting.startswith('SHOUTCAST_OUTPUT_'): errors[setting] = ( - u'Deprecated setting, please set the value via the GStreamer ' - u'bin in OUTPUT.') + 'Deprecated setting, please set the value via the GStreamer ' + 'bin in OUTPUT.') elif setting in list_of_one_or_more: if not value: - errors[setting] = u'Must contain at least one value.' + errors[setting] = 'Must contain at least one value.' elif setting not in defaults: - errors[setting] = u'Unknown setting.' + errors[setting] = 'Unknown setting.' suggestion = did_you_mean(setting, defaults) if suggestion: - errors[setting] += u' Did you mean %s?' % suggestion + errors[setting] += ' Did you mean %s?' % suggestion return errors @@ -204,20 +204,20 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % ( + lines.append('%s: %s' % ( key, formatting.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append( - u' Default: %s' % + ' Default: %s' % formatting.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: - lines.append(u' Error: %s' % errors[key]) + lines.append(' Error: %s' % errors[key]) return '\n'.join(lines) def mask_value_if_secret(key, value): if key.endswith('PASSWORD') and value: - return u'********' + return '********' else: return value diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py index 8e7d55bd..3ad72458 100644 --- a/mopidy/utils/versioning.py +++ b/mopidy/utils/versioning.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from subprocess import PIPE, Popen from mopidy import __version__ diff --git a/setup.py b/setup.py index 99fb7f49..d559d0c9 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,8 @@ Most of this file is taken from the Django project, which is BSD licensed. """ +from __future__ import unicode_literals + from distutils.core import setup from distutils.command.install_data import install_data from distutils.command.install import INSTALL_SCHEMES diff --git a/tests/__init__.py b/tests/__init__.py index 5d9ea2b5..7f7a9c36 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os import sys diff --git a/tests/__main__.py b/tests/__main__.py index 69113580..11757cbb 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import nose import yappi diff --git a/tests/audio_test.py b/tests/audio_test.py index 852ce36b..b8b65e83 100644 --- a/tests/audio_test.py +++ b/tests/audio_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import audio, settings from mopidy.utils.path import path_to_uri diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py index e69de29b..baffc488 100644 --- a/tests/backends/__init__.py +++ b/tests/backends/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index 84eee193..34b18f2c 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -1,3 +1,6 @@ +from __future__ import unicode_literals + + def populate_playlist(func): def wrapper(self): for track in self.tracks: diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 2ba77ee3..6446ffd6 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock import random @@ -93,18 +95,18 @@ class CurrentPlaylistControllerTest(object): self.controller.append([Track(uri='z'), track, track]) try: self.controller.get(uri='a') - self.fail(u'Should raise LookupError if multiple matches') + self.fail('Should raise LookupError if multiple matches') except LookupError as e: - self.assertEqual(u'"uri=a" match multiple tracks', e[0]) + self.assertEqual('"uri=a" match multiple tracks', e[0]) def test_get_by_uri_raises_error_if_no_match(self): self.controller.playlist = Playlist( tracks=[Track(uri='z'), Track(uri='y')]) try: self.controller.get(uri='a') - self.fail(u'Should raise LookupError if no match') + self.fail('Should raise LookupError if no match') except LookupError as e: - self.assertEqual(u'"uri=a" match no tracks', e[0]) + self.assertEqual('"uri=a" match no tracks', e[0]) def test_get_by_multiple_criteria_returns_elements_matching_all(self): track1 = Track(uri='a', name='x') diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index b7510dbb..d2e44140 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka from mopidy import core diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index cd55668c..bd42a87b 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock import random import time diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 267a025c..42c7baa7 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os import shutil import tempfile @@ -31,15 +33,15 @@ class StoredPlaylistsControllerTest(object): settings.runtime.clear() def test_create_returns_playlist_with_name_set(self): - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') self.assertEqual(playlist.name, 'test') def test_create_returns_playlist_with_uri_set(self): - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') self.assert_(playlist.uri) def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') self.assert_(self.stored.playlists) self.assertIn(playlist, self.stored.playlists) @@ -50,7 +52,7 @@ class StoredPlaylistsControllerTest(object): self.stored.delete('file:///unknown/playlist') def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') self.assertIn(playlist, self.stored.playlists) self.stored.delete(playlist.uri) @@ -66,7 +68,7 @@ class StoredPlaylistsControllerTest(object): self.assertRaises(LookupError, test) def test_get_with_right_criteria(self): - playlist1 = self.stored.create(u'test') + playlist1 = self.stored.create('test') playlist2 = self.stored.get(name='test') self.assertEqual(playlist1, playlist2) @@ -82,21 +84,21 @@ class StoredPlaylistsControllerTest(object): playlist, Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='b') - self.fail(u'Should raise LookupError if multiple matches') + self.fail('Should raise LookupError if multiple matches') except LookupError as e: - self.assertEqual(u'"name=b" match multiple playlists', e[0]) + self.assertEqual('"name=b" match multiple playlists', e[0]) def test_get_by_name_raises_keyerror_if_no_match(self): self.backend.stored_playlists.playlists = [ Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='c') - self.fail(u'Should raise LookupError if no match') + self.fail('Should raise LookupError if no match') except LookupError as e: - self.assertEqual(u'"name=c" match no playlists', e[0]) + self.assertEqual('"name=c" match no playlists', e[0]) def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.stored.create(u'test') + original_playlist = self.stored.create('test') looked_up_playlist = self.stored.lookup(original_playlist.uri) @@ -107,10 +109,10 @@ class StoredPlaylistsControllerTest(object): pass def test_save_replaces_stored_playlist_with_updated_playlist(self): - playlist1 = self.stored.create(u'test1') + playlist1 = self.stored.create('test1') self.assertIn(playlist1, self.stored.playlists) - playlist2 = playlist1.copy(name=u'test2') + playlist2 = playlist1.copy(name='test2') playlist2 = self.stored.save(playlist2) self.assertNotIn(playlist1, self.stored.playlists) self.assertIn(playlist2, self.stored.playlists) diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index 600dbf6c..eaf5863b 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock import pykka diff --git a/tests/backends/local/__init__.py b/tests/backends/local/__init__.py index d2213297..684e12d8 100644 --- a/tests/backends/local/__init__.py +++ b/tests/backends/local/__init__.py @@ -1,6 +1,9 @@ +from __future__ import unicode_literals + from mopidy.utils.path import path_to_uri from tests import path_to_data_dir + song = path_to_data_dir('song%s.wav') generate_song = lambda i: path_to_uri(song % i) diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index 52fa9eb5..fa326501 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 75cebdbc..7324d85f 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import settings from mopidy.backends.local import LocalBackend diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index fea93ae3..b669d5c0 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.core import PlaybackState diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index cd1ecd3c..a99b8c23 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os from mopidy import settings @@ -20,38 +22,38 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) - self.stored.create(u'test') + self.stored.create('test') self.assertTrue(os.path.exists(path)) def test_create_slugifies_playlist_name(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create(u'test FOO baR') - self.assertEqual(u'test-foo-bar', playlist.name) + playlist = self.stored.create('test FOO baR') + self.assertEqual('test-foo-bar', playlist.name) self.assertTrue(os.path.exists(path)) def test_create_slugifies_names_which_tries_to_change_directory(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create(u'../../test FOO baR') - self.assertEqual(u'test-foo-bar', playlist.name) + playlist = self.stored.create('../../test FOO baR') + self.assertEqual('test-foo-bar', playlist.name) self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2-foo-bar.m3u') - playlist = self.stored.create(u'test1') + playlist = self.stored.create('test1') self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) - playlist = playlist.copy(name=u'test2 FOO baR') + playlist = playlist.copy(name='test2 FOO baR') playlist = self.stored.save(playlist) - self.assertEqual(u'test2-foo-bar', playlist.name) + self.assertEqual('test2-foo-bar', playlist.name) self.assertFalse(os.path.exists(path1)) self.assertTrue(os.path.exists(path2)) @@ -59,7 +61,7 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') self.assertTrue(os.path.exists(path)) self.stored.delete(playlist.uri) @@ -68,7 +70,7 @@ class LocalStoredPlaylistsControllerTest( def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) track_path = track.uri[len('file://'):] - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') playlist_path = playlist.uri[len('file://'):] playlist = playlist.copy(tracks=[track]) playlist = self.stored.save(playlist) @@ -82,7 +84,7 @@ class LocalStoredPlaylistsControllerTest( playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = self.stored.create(u'test') + playlist = self.stored.create('test') playlist = playlist.copy(tracks=[track]) playlist = self.stored.save(playlist) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 6f754399..e18b13fe 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -1,5 +1,7 @@ # encoding: utf-8 +from __future__ import unicode_literals + import os import tempfile @@ -12,7 +14,7 @@ from tests import unittest, path_to_data_dir data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') -encoded_path = path_to_data_dir(u'æøå.mp3') +encoded_path = path_to_data_dir('æøå.mp3') song1_uri = path_to_uri(song1_path) song2_uri = path_to_uri(song2_path) encoded_uri = path_to_uri(encoded_path) @@ -138,10 +140,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase): path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) - artists = [Artist(name=u'æøå')] - album = Album(name=u'æøå', artists=artists) + artists = [Artist(name='æøå')] + album = Album(name='æøå', artists=artists) track = Track( - uri=uri, name=u'æøå', artists=artists, album=album, length=4000) + uri=uri, name='æøå', artists=artists, album=album, length=4000) self.assertEqual(track, list(tracks)[0]) diff --git a/tests/core/__init__.py b/tests/core/__init__.py index e69de29b..baffc488 100644 --- a/tests/core/__init__.py +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py index 8212c1da..d86b8702 100644 --- a/tests/core/actor_test.py +++ b/tests/core/actor_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock import pykka @@ -26,8 +28,8 @@ class CoreActorTest(unittest.TestCase): self.assertIn('dummy2', result) def test_backends_with_colliding_uri_schemes_fails(self): - self.backend1.__class__.__name__ = 'B1' - self.backend2.__class__.__name__ = 'B2' + self.backend1.__class__.__name__ = b'B1' + self.backend2.__class__.__name__ = b'B2' self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] self.assertRaisesRegexp( AssertionError, diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 04f19909..cb590428 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock from mopidy.backends import base diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 2abd9479..0bc3f8fd 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.core import CoreListener, PlaybackState from mopidy.models import Track diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index b3a75773..db39e716 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock from mopidy.backends import base diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py index b0d48512..4efb9acf 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/stored_playlists_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock from mopidy.backends import base diff --git a/tests/frontends/__init__.py b/tests/frontends/__init__.py index e69de29b..baffc488 100644 --- a/tests/frontends/__init__.py +++ b/tests/frontends/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/frontends/mpd/__init__.py b/tests/frontends/mpd/__init__.py index e69de29b..baffc488 100644 --- a/tests/frontends/mpd/__init__.py +++ b/tests/frontends/mpd/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 9b047641..3404db95 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka from mopidy import core @@ -34,7 +36,7 @@ class MpdDispatcherTest(unittest.TestCase): except MpdAckError as e: self.assertEqual( e.get_mpd_ack(), - u'ACK [5@0] {} unknown command "an_unknown_command"') + 'ACK [5@0] {} unknown command "an_unknown_command"') def test_find_handler_for_known_command_returns_handler_and_kwargs(self): expected_handler = lambda x: None @@ -48,11 +50,11 @@ class MpdDispatcherTest(unittest.TestCase): def test_handling_unknown_request_yields_error(self): result = self.dispatcher.handle_request('an unhandled request') - self.assertEqual(result[0], u'ACK [5@0] {} unknown command "an"') + self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"') def test_handling_known_request(self): expected = 'magic' request_handlers['known request'] = lambda x: expected result = self.dispatcher.handle_request('known request') - self.assertIn(u'OK', result) + self.assertIn('OK', result) self.assertIn(expected, result) diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index 8fb0c933..fe834673 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.frontends.mpd.exceptions import ( MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, MpdNotImplemented) @@ -9,35 +11,35 @@ class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): try: try: - raise KeyError(u'Track X not found') + raise KeyError('Track X not found') except KeyError as e: raise MpdAckError(e[0]) except MpdAckError as e: - self.assertEqual(e.message, u'Track X not found') + self.assertEqual(e.message, 'Track X not found') def test_mpd_not_implemented_is_a_mpd_ack_error(self): try: raise MpdNotImplemented except MpdAckError as e: - self.assertEqual(e.message, u'Not implemented') + self.assertEqual(e.message, 'Not implemented') def test_get_mpd_ack_with_default_values(self): e = MpdAckError('A description') - self.assertEqual(e.get_mpd_ack(), u'ACK [0@0] {} A description') + self.assertEqual(e.get_mpd_ack(), 'ACK [0@0] {} A description') def test_get_mpd_ack_with_values(self): try: raise MpdAckError('A description', index=7, command='foo') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), u'ACK [0@7] {foo} A description') + self.assertEqual(e.get_mpd_ack(), 'ACK [0@7] {foo} A description') def test_mpd_unknown_command(self): try: - raise MpdUnknownCommand(command=u'play') + raise MpdUnknownCommand(command='play') except MpdAckError as e: self.assertEqual( e.get_mpd_ack(), - u'ACK [5@0] {} unknown command "play"') + 'ACK [5@0] {} unknown command "play"') def test_mpd_system_error(self): try: @@ -45,7 +47,7 @@ class MpdExceptionsTest(unittest.TestCase): except MpdSystemError as e: self.assertEqual( e.get_mpd_ack(), - u'ACK [52@0] {} foo') + 'ACK [52@0] {} foo') def test_mpd_permission_error(self): try: @@ -53,4 +55,4 @@ class MpdExceptionsTest(unittest.TestCase): except MpdPermissionError as e: self.assertEqual( e.get_mpd_ack(), - u'ACK [4@0] {foo} you don\'t have permission for "foo"') + 'ACK [4@0] {foo} you don\'t have permission for "foo"') diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index f7b055fc..00594206 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock import pykka @@ -46,20 +48,20 @@ class BaseTestCase(unittest.TestCase): def assertInResponse(self, value): self.assertIn( value, self.connection.response, - u'Did not find %s in %s' % ( + 'Did not find %s in %s' % ( repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): matched = len([r for r in self.connection.response if r == value]) self.assertEqual( 1, matched, - u'Expected to find %s once in %s' % ( + 'Expected to find %s once in %s' % ( repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): self.assertNotIn( value, self.connection.response, - u'Found %s in %s' % ( + 'Found %s in %s' % ( repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index 3bb8dce8..11cd249e 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -1,18 +1,20 @@ +from __future__ import unicode_literals + from tests.frontends.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): def test_enableoutput(self): - self.sendRequest(u'enableoutput "0"') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('enableoutput "0"') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_disableoutput(self): - self.sendRequest(u'disableoutput "0"') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('disableoutput "0"') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_outputs(self): - self.sendRequest(u'outputs') - self.assertInResponse(u'outputid: 0') - self.assertInResponse(u'outputname: None') - self.assertInResponse(u'outputenabled: 1') - self.assertInResponse(u'OK') + self.sendRequest('outputs') + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: None') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py index 0f0d9c86..26b03f45 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import settings from tests.frontends.mpd import protocol @@ -7,28 +9,28 @@ class AuthenticationTest(protocol.BaseTestCase): def test_authentication_with_valid_password_is_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'password "topsecret"') + self.sendRequest('password "topsecret"') self.assertTrue(self.dispatcher.authenticated) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_authentication_with_invalid_password_is_not_accepted(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'password "secret"') + self.sendRequest('password "secret"') self.assertFalse(self.dispatcher.authenticated) - self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + self.assertEqualResponse('ACK [3@0] {password} incorrect password') def test_authentication_with_anything_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None - self.sendRequest(u'any request at all') + self.sendRequest('any request at all') self.assertTrue(self.dispatcher.authenticated) self.assertEqualResponse('ACK [5@0] {} unknown command "any"') def test_anything_when_not_authenticated_should_fail(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'any request at all') + self.sendRequest('any request at all') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( u'ACK [4@0] {any} you don\'t have permission for "any"') @@ -36,26 +38,26 @@ class AuthenticationTest(protocol.BaseTestCase): def test_close_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'close') + self.sendRequest('close') self.assertFalse(self.dispatcher.authenticated) def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'commands') + self.sendRequest('commands') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_notcommands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'notcommands') + self.sendRequest('notcommands') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_ping_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'ping') + self.sendRequest('ping') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') + self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index dbd7f9c9..222dcb61 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -1,59 +1,61 @@ +from __future__ import unicode_literals + from tests.frontends.mpd import protocol class CommandListsTest(protocol.BaseTestCase): def test_command_list_begin(self): - response = self.sendRequest(u'command_list_begin') + response = self.sendRequest('command_list_begin') self.assertEquals([], response) def test_command_list_end(self): - self.sendRequest(u'command_list_begin') - self.sendRequest(u'command_list_end') - self.assertInResponse(u'OK') + self.sendRequest('command_list_begin') + self.sendRequest('command_list_end') + self.assertInResponse('OK') def test_command_list_end_without_start_first_is_an_unknown_command(self): - self.sendRequest(u'command_list_end') + self.sendRequest('command_list_end') self.assertEqualResponse( - u'ACK [5@0] {} unknown command "command_list_end"') + 'ACK [5@0] {} unknown command "command_list_end"') def test_command_list_with_ping(self): - self.sendRequest(u'command_list_begin') + self.sendRequest('command_list_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.sendRequest(u'ping') - self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest('ping') + self.assertIn('ping', self.dispatcher.command_list) - self.sendRequest(u'command_list_end') - self.assertInResponse(u'OK') + self.sendRequest('command_list_end') + self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): - self.sendRequest(u'command_list_begin') - self.sendRequest(u'play') # Known command - self.sendRequest(u'paly') # Unknown command - self.sendRequest(u'command_list_end') - self.assertEqualResponse(u'ACK [5@1] {} unknown command "paly"') + self.sendRequest('command_list_begin') + self.sendRequest('play') # Known command + self.sendRequest('paly') # Unknown command + self.sendRequest('command_list_end') + self.assertEqualResponse('ACK [5@1] {} unknown command "paly"') def test_command_list_ok_begin(self): - response = self.sendRequest(u'command_list_ok_begin') + response = self.sendRequest('command_list_ok_begin') self.assertEquals([], response) def test_command_list_ok_with_ping(self): - self.sendRequest(u'command_list_ok_begin') + self.sendRequest('command_list_ok_begin') self.assertTrue(self.dispatcher.command_list_receiving) self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.sendRequest(u'ping') - self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest('ping') + self.assertIn('ping', self.dispatcher.command_list) - self.sendRequest(u'command_list_end') - self.assertInResponse(u'list_OK') - self.assertInResponse(u'OK') + self.sendRequest('command_list_end') + self.assertInResponse('list_OK') + self.assertInResponse('OK') self.assertFalse(self.dispatcher.command_list_receiving) self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py index 9b8972d3..840ce48f 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mock import patch from mopidy import settings @@ -8,37 +10,37 @@ from tests.frontends.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: - self.sendRequest(u'close') + self.sendRequest('close') close_mock.assertEqualResponsecalled_once_with() - self.assertEqualResponse(u'OK') + self.assertEqualResponse('OK') def test_empty_request(self): - self.sendRequest(u'') - self.assertEqualResponse(u'OK') + self.sendRequest('') + self.assertEqualResponse('OK') - self.sendRequest(u' ') - self.assertEqualResponse(u'OK') + self.sendRequest(' ') + self.assertEqualResponse('OK') def test_kill(self): - self.sendRequest(u'kill') + self.sendRequest('kill') self.assertEqualResponse( - u'ACK [4@0] {kill} you don\'t have permission for "kill"') + 'ACK [4@0] {kill} you don\'t have permission for "kill"') def test_valid_password_is_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'password "topsecret"') - self.assertEqualResponse(u'OK') + settings.MPD_SERVER_PASSWORD = 'topsecret' + self.sendRequest('password "topsecret"') + self.assertEqualResponse('OK') def test_invalid_password_is_not_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest(u'password "secret"') - self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + settings.MPD_SERVER_PASSWORD = 'topsecret' + self.sendRequest('password "secret"') + self.assertEqualResponse('ACK [3@0] {password} incorrect password') def test_any_password_is_not_accepted_when_password_check_turned_off(self): settings.MPD_SERVER_PASSWORD = None - self.sendRequest(u'password "secret"') - self.assertEqualResponse(u'ACK [3@0] {password} incorrect password') + self.sendRequest('password "secret"') + self.assertEqualResponse('ACK [3@0] {password} incorrect password') def test_ping(self): - self.sendRequest(u'ping') - self.assertEqualResponse(u'OK') + self.sendRequest('ping') + self.assertEqualResponse('OK') diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index bd58cf2d..184f7a9c 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.models import Track from tests.frontends.mpd import protocol @@ -12,20 +14,20 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'add "dummy://foo"') + self.sendRequest('add "dummy://foo"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) - self.assertEqualResponse(u'OK') + self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): - self.sendRequest(u'add "dummy://foo"') + self.sendRequest('add "dummy://foo"') self.assertEqualResponse( - u'ACK [50@0] {add} directory or file not found') + 'ACK [50@0] {add} directory or file not found') def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): - self.sendRequest(u'add ""') + self.sendRequest('add ""') # TODO check that we add all tracks (we currently don't) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') @@ -35,16 +37,16 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'addid "dummy://foo"') + self.sendRequest('addid "dummy://foo"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) self.assertInResponse( - u'Id: %d' % self.core.current_playlist.cp_tracks.get()[5][0]) - self.assertInResponse(u'OK') + 'Id: %d' % self.core.current_playlist.cp_tracks.get()[5][0]) + self.assertInResponse('OK') def test_addid_with_empty_uri_acks(self): - self.sendRequest(u'addid ""') - self.assertEqualResponse(u'ACK [50@0] {addid} No such song') + self.sendRequest('addid ""') + self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') @@ -54,12 +56,12 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'addid "dummy://foo" "3"') + self.sendRequest('addid "dummy://foo" "3"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) self.assertInResponse( - u'Id: %d' % self.core.current_playlist.cp_tracks.get()[3][0]) - self.assertInResponse(u'OK') + 'Id: %d' % self.core.current_playlist.cp_tracks.get()[3][0]) + self.assertInResponse('OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') @@ -69,22 +71,22 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'addid "dummy://foo" "6"') - self.assertEqualResponse(u'ACK [2@0] {addid} Bad song index') + self.sendRequest('addid "dummy://foo" "6"') + self.assertEqualResponse('ACK [2@0] {addid} Bad song index') def test_addid_with_uri_not_found_in_library_should_ack(self): - self.sendRequest(u'addid "dummy://foo"') - self.assertEqualResponse(u'ACK [50@0] {addid} No such song') + self.sendRequest('addid "dummy://foo"') + self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_clear(self): self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'clear') + self.sendRequest('clear') self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) self.assertEqual(self.core.playback.current_track.get(), None) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_delete_songpos(self): self.core.current_playlist.append( @@ -92,61 +94,61 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest( - u'delete "%d"' % self.core.current_playlist.cp_tracks.get()[2][0]) + 'delete "%d"' % self.core.current_playlist.cp_tracks.get()[2][0]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_delete_songpos_out_of_bounds(self): self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "5"') + self.sendRequest('delete "5"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') + self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "1:"') + self.sendRequest('delete "1:"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_delete_closed_range(self): self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "1:3"') + self.sendRequest('delete "1:3"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 3) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_delete_range_out_of_bounds(self): self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "5:7"') + self.sendRequest('delete "5:7"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') + self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_deleteid(self): self.core.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.sendRequest(u'deleteid "1"') + self.sendRequest('deleteid "1"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_deleteid_does_not_exist(self): self.core.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.sendRequest(u'deleteid "12345"') + self.sendRequest('deleteid "12345"') self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) - self.assertEqualResponse(u'ACK [50@0] {deleteid} No such song') + self.assertEqualResponse('ACK [50@0] {deleteid} No such song') def test_move_songpos(self): self.core.current_playlist.append([ @@ -154,7 +156,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'move "1" "0"') + self.sendRequest('move "1" "0"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') @@ -162,7 +164,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_move_open_range(self): self.core.current_playlist.append([ @@ -170,7 +172,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'move "2:" "0"') + self.sendRequest('move "2:" "0"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') @@ -178,7 +180,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[3].name, 'f') self.assertEqual(tracks[4].name, 'a') self.assertEqual(tracks[5].name, 'b') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_move_closed_range(self): self.core.current_playlist.append([ @@ -186,7 +188,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'move "1:3" "0"') + self.sendRequest('move "1:3" "0"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') @@ -194,7 +196,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_moveid(self): self.core.current_playlist.append([ @@ -202,7 +204,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'moveid "4" "2"') + self.sendRequest('moveid "4" "2"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') @@ -210,58 +212,58 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[3].name, 'c') self.assertEqual(tracks[4].name, 'd') self.assertEqual(tracks[5].name, 'f') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playlist_returns_same_as_playlistinfo(self): - playlist_response = self.sendRequest(u'playlist') - playlistinfo_response = self.sendRequest(u'playlistinfo') + playlist_response = self.sendRequest('playlist') + playlistinfo_response = self.sendRequest('playlistinfo') self.assertEqual(playlist_response, playlistinfo_response) def test_playlistfind(self): - self.sendRequest(u'playlistfind "tag" "needle"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistfind "tag" "needle"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_playlistfind_by_filename_not_in_current_playlist(self): - self.sendRequest(u'playlistfind "filename" "file:///dev/null"') - self.assertEqualResponse(u'OK') + self.sendRequest('playlistfind "filename" "file:///dev/null"') + self.assertEqualResponse('OK') def test_playlistfind_by_filename_without_quotes(self): - self.sendRequest(u'playlistfind filename "file:///dev/null"') - self.assertEqualResponse(u'OK') + self.sendRequest('playlistfind filename "file:///dev/null"') + self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_current_playlist(self): self.core.current_playlist.append([ Track(uri='file:///exists')]) - self.sendRequest(u'playlistfind filename "file:///exists"') - self.assertInResponse(u'file: file:///exists') - self.assertInResponse(u'Id: 0') - self.assertInResponse(u'Pos: 0') - self.assertInResponse(u'OK') + self.sendRequest('playlistfind filename "file:///exists"') + self.assertInResponse('file: file:///exists') + self.assertInResponse('Id: 0') + self.assertInResponse('Pos: 0') + self.assertInResponse('OK') def test_playlistid_without_songid(self): self.core.current_playlist.append([Track(name='a'), Track(name='b')]) - self.sendRequest(u'playlistid') - self.assertInResponse(u'Title: a') - self.assertInResponse(u'Title: b') - self.assertInResponse(u'OK') + self.sendRequest('playlistid') + self.assertInResponse('Title: a') + self.assertInResponse('Title: b') + self.assertInResponse('OK') def test_playlistid_with_songid(self): self.core.current_playlist.append([Track(name='a'), Track(name='b')]) - self.sendRequest(u'playlistid "1"') - self.assertNotInResponse(u'Title: a') - self.assertNotInResponse(u'Id: 0') - self.assertInResponse(u'Title: b') - self.assertInResponse(u'Id: 1') - self.assertInResponse(u'OK') + self.sendRequest('playlistid "1"') + self.assertNotInResponse('Title: a') + self.assertNotInResponse('Id: 0') + self.assertInResponse('Title: b') + self.assertInResponse('Id: 1') + self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): self.core.current_playlist.append([Track(name='a'), Track(name='b')]) - self.sendRequest(u'playlistid "25"') - self.assertEqualResponse(u'ACK [50@0] {playlistid} No such song') + self.sendRequest('playlistid "25"') + self.assertEqualResponse('ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): self.core.current_playlist.append([ @@ -269,20 +271,20 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'playlistinfo') - self.assertInResponse(u'Title: a') - self.assertInResponse(u'Pos: 0') - self.assertInResponse(u'Title: b') - self.assertInResponse(u'Pos: 1') - self.assertInResponse(u'Title: c') - self.assertInResponse(u'Pos: 2') - self.assertInResponse(u'Title: d') - self.assertInResponse(u'Pos: 3') - self.assertInResponse(u'Title: e') - self.assertInResponse(u'Pos: 4') - self.assertInResponse(u'Title: f') - self.assertInResponse(u'Pos: 5') - self.assertInResponse(u'OK') + self.sendRequest('playlistinfo') + self.assertInResponse('Title: a') + self.assertInResponse('Pos: 0') + self.assertInResponse('Title: b') + self.assertInResponse('Pos: 1') + self.assertInResponse('Title: c') + self.assertInResponse('Pos: 2') + self.assertInResponse('Title: d') + self.assertInResponse('Pos: 3') + self.assertInResponse('Title: e') + self.assertInResponse('Pos: 4') + self.assertInResponse('Title: f') + self.assertInResponse('Pos: 5') + self.assertInResponse('OK') def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position @@ -292,24 +294,24 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'playlistinfo "4"') - self.assertNotInResponse(u'Title: a') - self.assertNotInResponse(u'Pos: 0') - self.assertNotInResponse(u'Title: b') - self.assertNotInResponse(u'Pos: 1') - self.assertNotInResponse(u'Title: c') - self.assertNotInResponse(u'Pos: 2') - self.assertNotInResponse(u'Title: d') - self.assertNotInResponse(u'Pos: 3') - self.assertInResponse(u'Title: e') - self.assertInResponse(u'Pos: 4') - self.assertNotInResponse(u'Title: f') - self.assertNotInResponse(u'Pos: 5') - self.assertInResponse(u'OK') + self.sendRequest('playlistinfo "4"') + self.assertNotInResponse('Title: a') + self.assertNotInResponse('Pos: 0') + self.assertNotInResponse('Title: b') + self.assertNotInResponse('Pos: 1') + self.assertNotInResponse('Title: c') + self.assertNotInResponse('Pos: 2') + self.assertNotInResponse('Title: d') + self.assertNotInResponse('Pos: 3') + self.assertInResponse('Title: e') + self.assertInResponse('Pos: 4') + self.assertNotInResponse('Title: f') + self.assertNotInResponse('Pos: 5') + self.assertInResponse('OK') def test_playlistinfo_with_negative_songpos_same_as_playlistinfo(self): - response1 = self.sendRequest(u'playlistinfo "-1"') - response2 = self.sendRequest(u'playlistinfo') + response1 = self.sendRequest('playlistinfo "-1"') + response2 = self.sendRequest('playlistinfo') self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): @@ -318,20 +320,20 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'playlistinfo "2:"') - self.assertNotInResponse(u'Title: a') - self.assertNotInResponse(u'Pos: 0') - self.assertNotInResponse(u'Title: b') - self.assertNotInResponse(u'Pos: 1') - self.assertInResponse(u'Title: c') - self.assertInResponse(u'Pos: 2') - self.assertInResponse(u'Title: d') - self.assertInResponse(u'Pos: 3') - self.assertInResponse(u'Title: e') - self.assertInResponse(u'Pos: 4') - self.assertInResponse(u'Title: f') - self.assertInResponse(u'Pos: 5') - self.assertInResponse(u'OK') + self.sendRequest('playlistinfo "2:"') + self.assertNotInResponse('Title: a') + self.assertNotInResponse('Pos: 0') + self.assertNotInResponse('Title: b') + self.assertNotInResponse('Pos: 1') + self.assertInResponse('Title: c') + self.assertInResponse('Pos: 2') + self.assertInResponse('Title: d') + self.assertInResponse('Pos: 3') + self.assertInResponse('Title: e') + self.assertInResponse('Pos: 4') + self.assertInResponse('Title: f') + self.assertInResponse('Pos: 5') + self.assertInResponse('OK') def test_playlistinfo_with_closed_range(self): self.core.current_playlist.append([ @@ -339,95 +341,95 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'playlistinfo "2:4"') - self.assertNotInResponse(u'Title: a') - self.assertNotInResponse(u'Title: b') - self.assertInResponse(u'Title: c') - self.assertInResponse(u'Title: d') - self.assertNotInResponse(u'Title: e') - self.assertNotInResponse(u'Title: f') - self.assertInResponse(u'OK') + self.sendRequest('playlistinfo "2:4"') + self.assertNotInResponse('Title: a') + self.assertNotInResponse('Title: b') + self.assertInResponse('Title: c') + self.assertInResponse('Title: d') + self.assertNotInResponse('Title: e') + self.assertNotInResponse('Title: f') + self.assertInResponse('OK') def test_playlistinfo_with_too_high_start_of_range_returns_arg_error(self): - self.sendRequest(u'playlistinfo "10:20"') - self.assertEqualResponse(u'ACK [2@0] {playlistinfo} Bad song index') + self.sendRequest('playlistinfo "10:20"') + self.assertEqualResponse('ACK [2@0] {playlistinfo} Bad song index') def test_playlistinfo_with_too_high_end_of_range_returns_ok(self): - self.sendRequest(u'playlistinfo "0:20"') - self.assertInResponse(u'OK') + self.sendRequest('playlistinfo "0:20"') + self.assertInResponse('OK') def test_playlistsearch(self): - self.sendRequest(u'playlistsearch "any" "needle"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistsearch "any" "needle"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_playlistsearch_without_quotes(self): - self.sendRequest(u'playlistsearch any "needle"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistsearch any "needle"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest(u'plchanges "0"') - self.assertInResponse(u'Title: a') - self.assertInResponse(u'Title: b') - self.assertInResponse(u'Title: c') - self.assertInResponse(u'OK') + self.sendRequest('plchanges "0"') + self.assertInResponse('Title: a') + self.assertInResponse('Title: b') + self.assertInResponse('Title: c') + self.assertInResponse('OK') def test_plchanges_with_equal_version_returns_nothing(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.current_playlist.version.get(), 1) - self.sendRequest(u'plchanges "1"') - self.assertNotInResponse(u'Title: a') - self.assertNotInResponse(u'Title: b') - self.assertNotInResponse(u'Title: c') - self.assertInResponse(u'OK') + self.sendRequest('plchanges "1"') + self.assertNotInResponse('Title: a') + self.assertNotInResponse('Title: b') + self.assertNotInResponse('Title: c') + self.assertInResponse('OK') def test_plchanges_with_greater_version_returns_nothing(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.current_playlist.version.get(), 1) - self.sendRequest(u'plchanges "2"') - self.assertNotInResponse(u'Title: a') - self.assertNotInResponse(u'Title: b') - self.assertNotInResponse(u'Title: c') - self.assertInResponse(u'OK') + self.sendRequest('plchanges "2"') + self.assertNotInResponse('Title: a') + self.assertNotInResponse('Title: b') + self.assertNotInResponse('Title: c') + self.assertInResponse('OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest(u'plchanges "-1"') - self.assertInResponse(u'Title: a') - self.assertInResponse(u'Title: b') - self.assertInResponse(u'Title: c') - self.assertInResponse(u'OK') + self.sendRequest('plchanges "-1"') + self.assertInResponse('Title: a') + self.assertInResponse('Title: b') + self.assertInResponse('Title: c') + self.assertInResponse('OK') def test_plchanges_without_quotes_works(self): self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.sendRequest(u'plchanges 0') - self.assertInResponse(u'Title: a') - self.assertInResponse(u'Title: b') - self.assertInResponse(u'Title: c') - self.assertInResponse(u'OK') + self.sendRequest('plchanges 0') + self.assertInResponse('Title: a') + self.assertInResponse('Title: b') + self.assertInResponse('Title: c') + self.assertInResponse('OK') def test_plchangesposid(self): self.core.current_playlist.append([Track(), Track(), Track()]) - self.sendRequest(u'plchangesposid "0"') + self.sendRequest('plchangesposid "0"') cp_tracks = self.core.current_playlist.cp_tracks.get() - self.assertInResponse(u'cpos: 0') - self.assertInResponse(u'Id: %d' % cp_tracks[0][0]) - self.assertInResponse(u'cpos: 2') - self.assertInResponse(u'Id: %d' % cp_tracks[1][0]) - self.assertInResponse(u'cpos: 2') - self.assertInResponse(u'Id: %d' % cp_tracks[2][0]) - self.assertInResponse(u'OK') + self.assertInResponse('cpos: 0') + self.assertInResponse('Id: %d' % cp_tracks[0][0]) + self.assertInResponse('cpos: 2') + self.assertInResponse('Id: %d' % cp_tracks[1][0]) + self.assertInResponse('cpos: 2') + self.assertInResponse('Id: %d' % cp_tracks[2][0]) + self.assertInResponse('OK') def test_shuffle_without_range(self): self.core.current_playlist.append([ @@ -436,9 +438,9 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.current_playlist.version.get() - self.sendRequest(u'shuffle') + self.sendRequest('shuffle') self.assertLess(version, self.core.current_playlist.version.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_shuffle_with_open_range(self): self.core.current_playlist.append([ @@ -447,14 +449,14 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.current_playlist.version.get() - self.sendRequest(u'shuffle "4:"') + self.sendRequest('shuffle "4:"') self.assertLess(version, self.core.current_playlist.version.get()) tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') self.assertEqual(tracks[3].name, 'd') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_shuffle_with_closed_range(self): self.core.current_playlist.append([ @@ -463,14 +465,14 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): ]) version = self.core.current_playlist.version.get() - self.sendRequest(u'shuffle "1:3"') + self.sendRequest('shuffle "1:3"') self.assertLess(version, self.core.current_playlist.version.get()) tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') self.assertEqual(tracks[5].name, 'f') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_swap(self): self.core.current_playlist.append([ @@ -478,7 +480,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'swap "1" "4"') + self.sendRequest('swap "1" "4"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -486,7 +488,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_swapid(self): self.core.current_playlist.append([ @@ -494,7 +496,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): Track(name='d'), Track(name='e'), Track(name='f'), ]) - self.sendRequest(u'swapid "1" "4"') + self.sendRequest('swapid "1" "4"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') @@ -502,4 +504,4 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') - self.assertInResponse(u'OK') + self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/idle_test.py b/tests/frontends/mpd/protocol/idle_test.py index ae23c88e..e6910988 100644 --- a/tests/frontends/mpd/protocol/idle_test.py +++ b/tests/frontends/mpd/protocol/idle_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mock import patch from mopidy.frontends.mpd.protocol.status import SUBSYSTEMS @@ -27,180 +29,180 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNoResponse() def test_idle(self): - self.sendRequest(u'idle') + self.sendRequest('idle') self.assertEqualSubscriptions(SUBSYSTEMS) self.assertNoEvents() self.assertNoResponse() def test_idle_disables_timeout(self): - self.sendRequest(u'idle') + self.sendRequest('idle') self.connection.disable_timeout.assert_called_once_with() def test_noidle(self): - self.sendRequest(u'noidle') + self.sendRequest('noidle') self.assertNoSubscriptions() self.assertNoEvents() self.assertNoResponse() def test_idle_player(self): - self.sendRequest(u'idle player') + self.sendRequest('idle player') self.assertEqualSubscriptions(['player']) self.assertNoEvents() self.assertNoResponse() def test_idle_player_playlist(self): - self.sendRequest(u'idle player playlist') + self.sendRequest('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) self.assertNoEvents() self.assertNoResponse() def test_idle_then_noidle(self): - self.sendRequest(u'idle') - self.sendRequest(u'noidle') + self.sendRequest('idle') + self.sendRequest('noidle') self.assertNoSubscriptions() self.assertNoEvents() - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('OK') def test_idle_then_noidle_enables_timeout(self): - self.sendRequest(u'idle') - self.sendRequest(u'noidle') + self.sendRequest('idle') + self.sendRequest('noidle') self.connection.enable_timeout.assert_called_once_with() def test_idle_then_play(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest(u'idle') - self.sendRequest(u'play') + self.sendRequest('idle') + self.sendRequest('play') stop_mock.assert_called_once_with() def test_idle_then_idle(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest(u'idle') - self.sendRequest(u'idle') + self.sendRequest('idle') + self.sendRequest('idle') stop_mock.assert_called_once_with() def test_idle_player_then_play(self): with patch.object(self.session, 'stop') as stop_mock: - self.sendRequest(u'idle player') - self.sendRequest(u'play') + self.sendRequest('idle player') + self.sendRequest('play') stop_mock.assert_called_once_with() def test_idle_then_player(self): - self.sendRequest(u'idle') - self.idleEvent(u'player') + self.sendRequest('idle') + self.idleEvent('player') self.assertNoSubscriptions() self.assertNoEvents() - self.assertOnceInResponse(u'changed: player') - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('changed: player') + self.assertOnceInResponse('OK') def test_idle_player_then_event_player(self): - self.sendRequest(u'idle player') - self.idleEvent(u'player') + self.sendRequest('idle player') + self.idleEvent('player') self.assertNoSubscriptions() self.assertNoEvents() - self.assertOnceInResponse(u'changed: player') - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('changed: player') + self.assertOnceInResponse('OK') def test_idle_player_then_noidle(self): - self.sendRequest(u'idle player') - self.sendRequest(u'noidle') + self.sendRequest('idle player') + self.sendRequest('noidle') self.assertNoSubscriptions() self.assertNoEvents() - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('OK') def test_idle_player_playlist_then_noidle(self): - self.sendRequest(u'idle player playlist') - self.sendRequest(u'noidle') + self.sendRequest('idle player playlist') + self.sendRequest('noidle') self.assertNoEvents() self.assertNoSubscriptions() - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('OK') def test_idle_player_playlist_then_player(self): - self.sendRequest(u'idle player playlist') - self.idleEvent(u'player') + self.sendRequest('idle player playlist') + self.idleEvent('player') self.assertNoEvents() self.assertNoSubscriptions() - self.assertOnceInResponse(u'changed: player') - self.assertNotInResponse(u'changed: playlist') - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('changed: player') + self.assertNotInResponse('changed: playlist') + self.assertOnceInResponse('OK') def test_idle_playlist_then_player(self): - self.sendRequest(u'idle playlist') - self.idleEvent(u'player') + self.sendRequest('idle playlist') + self.idleEvent('player') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_idle_playlist_then_player_then_playlist(self): - self.sendRequest(u'idle playlist') - self.idleEvent(u'player') - self.idleEvent(u'playlist') + self.sendRequest('idle playlist') + self.idleEvent('player') + self.idleEvent('playlist') self.assertNoEvents() self.assertNoSubscriptions() - self.assertNotInResponse(u'changed: player') - self.assertOnceInResponse(u'changed: playlist') - self.assertOnceInResponse(u'OK') + self.assertNotInResponse('changed: player') + self.assertOnceInResponse('changed: playlist') + self.assertOnceInResponse('OK') def test_player(self): - self.idleEvent(u'player') + self.idleEvent('player') self.assertEqualEvents(['player']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle_player(self): - self.idleEvent(u'player') - self.sendRequest(u'idle player') + self.idleEvent('player') + self.sendRequest('idle player') self.assertNoEvents() self.assertNoSubscriptions() - self.assertOnceInResponse(u'changed: player') - self.assertNotInResponse(u'changed: playlist') - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('changed: player') + self.assertNotInResponse('changed: playlist') + self.assertOnceInResponse('OK') def test_player_then_playlist(self): - self.idleEvent(u'player') - self.idleEvent(u'playlist') + self.idleEvent('player') + self.idleEvent('playlist') self.assertEqualEvents(['player', 'playlist']) self.assertNoSubscriptions() self.assertNoResponse() def test_player_then_idle(self): - self.idleEvent(u'player') - self.sendRequest(u'idle') + self.idleEvent('player') + self.sendRequest('idle') self.assertNoEvents() self.assertNoSubscriptions() - self.assertOnceInResponse(u'changed: player') - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('changed: player') + self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle(self): - self.idleEvent(u'player') - self.idleEvent(u'playlist') - self.sendRequest(u'idle') + self.idleEvent('player') + self.idleEvent('playlist') + self.sendRequest('idle') self.assertNoEvents() self.assertNoSubscriptions() - self.assertOnceInResponse(u'changed: player') - self.assertOnceInResponse(u'changed: playlist') - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('changed: player') + self.assertOnceInResponse('changed: playlist') + self.assertOnceInResponse('OK') def test_player_then_idle_playlist(self): - self.idleEvent(u'player') - self.sendRequest(u'idle playlist') + self.idleEvent('player') + self.sendRequest('idle playlist') self.assertEqualEvents(['player']) self.assertEqualSubscriptions(['playlist']) self.assertNoResponse() def test_player_then_idle_playlist_then_noidle(self): - self.idleEvent(u'player') - self.sendRequest(u'idle playlist') - self.sendRequest(u'noidle') + self.idleEvent('player') + self.sendRequest('idle playlist') + self.sendRequest('noidle') self.assertNoEvents() self.assertNoSubscriptions() - self.assertOnceInResponse(u'OK') + self.assertOnceInResponse('OK') def test_player_then_playlist_then_idle_playlist(self): - self.idleEvent(u'player') - self.idleEvent(u'playlist') - self.sendRequest(u'idle playlist') + self.idleEvent('player') + self.idleEvent('playlist') + self.sendRequest('idle playlist') self.assertNoEvents() self.assertNoSubscriptions() - self.assertNotInResponse(u'changed: player') - self.assertOnceInResponse(u'changed: playlist') - self.assertOnceInResponse(u'OK') + self.assertNotInResponse('changed: player') + self.assertOnceInResponse('changed: playlist') + self.assertOnceInResponse('OK') diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 088502c4..e1c571f5 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -1,344 +1,346 @@ +from __future__ import unicode_literals + from tests.frontends.mpd import protocol class MusicDatabaseHandlerTest(protocol.BaseTestCase): def test_count(self): - self.sendRequest(u'count "tag" "needle"') - self.assertInResponse(u'songs: 0') - self.assertInResponse(u'playtime: 0') - self.assertInResponse(u'OK') + self.sendRequest('count "tag" "needle"') + self.assertInResponse('songs: 0') + self.assertInResponse('playtime: 0') + self.assertInResponse('OK') def test_findadd(self): - self.sendRequest(u'findadd "album" "what"') - self.assertInResponse(u'OK') + self.sendRequest('findadd "album" "what"') + self.assertInResponse('OK') def test_listall(self): - self.sendRequest(u'listall "file:///dev/urandom"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('listall "file:///dev/urandom"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_listallinfo(self): - self.sendRequest(u'listallinfo "file:///dev/urandom"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('listallinfo "file:///dev/urandom"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest(u'lsinfo') - listplaylists_response = self.sendRequest(u'listplaylists') + lsinfo_response = self.sendRequest('lsinfo') + listplaylists_response = self.sendRequest('listplaylists') self.assertEqual(lsinfo_response, listplaylists_response) def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest(u'lsinfo ""') - listplaylists_response = self.sendRequest(u'listplaylists') + lsinfo_response = self.sendRequest('lsinfo ""') + listplaylists_response = self.sendRequest('listplaylists') self.assertEqual(lsinfo_response, listplaylists_response) def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_response = self.sendRequest(u'lsinfo "/"') - listplaylists_response = self.sendRequest(u'listplaylists') + lsinfo_response = self.sendRequest('lsinfo "/"') + listplaylists_response = self.sendRequest('listplaylists') self.assertEqual(lsinfo_response, listplaylists_response) def test_update_without_uri(self): - self.sendRequest(u'update') - self.assertInResponse(u'updating_db: 0') - self.assertInResponse(u'OK') + self.sendRequest('update') + self.assertInResponse('updating_db: 0') + self.assertInResponse('OK') def test_update_with_uri(self): - self.sendRequest(u'update "file:///dev/urandom"') - self.assertInResponse(u'updating_db: 0') - self.assertInResponse(u'OK') + self.sendRequest('update "file:///dev/urandom"') + self.assertInResponse('updating_db: 0') + self.assertInResponse('OK') def test_rescan_without_uri(self): - self.sendRequest(u'rescan') - self.assertInResponse(u'updating_db: 0') - self.assertInResponse(u'OK') + self.sendRequest('rescan') + self.assertInResponse('updating_db: 0') + self.assertInResponse('OK') def test_rescan_with_uri(self): - self.sendRequest(u'rescan "file:///dev/urandom"') - self.assertInResponse(u'updating_db: 0') - self.assertInResponse(u'OK') + self.sendRequest('rescan "file:///dev/urandom"') + self.assertInResponse('updating_db: 0') + self.assertInResponse('OK') class MusicDatabaseFindTest(protocol.BaseTestCase): def test_find_album(self): - self.sendRequest(u'find "album" "what"') - self.assertInResponse(u'OK') + self.sendRequest('find "album" "what"') + self.assertInResponse('OK') def test_find_album_without_quotes(self): - self.sendRequest(u'find album "what"') - self.assertInResponse(u'OK') + self.sendRequest('find album "what"') + self.assertInResponse('OK') def test_find_artist(self): - self.sendRequest(u'find "artist" "what"') - self.assertInResponse(u'OK') + self.sendRequest('find "artist" "what"') + self.assertInResponse('OK') def test_find_artist_without_quotes(self): - self.sendRequest(u'find artist "what"') - self.assertInResponse(u'OK') + self.sendRequest('find artist "what"') + self.assertInResponse('OK') def test_find_title(self): - self.sendRequest(u'find "title" "what"') - self.assertInResponse(u'OK') + self.sendRequest('find "title" "what"') + self.assertInResponse('OK') def test_find_title_without_quotes(self): - self.sendRequest(u'find title "what"') - self.assertInResponse(u'OK') + self.sendRequest('find title "what"') + self.assertInResponse('OK') def test_find_date(self): - self.sendRequest(u'find "date" "2002-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('find "date" "2002-01-01"') + self.assertInResponse('OK') def test_find_date_without_quotes(self): - self.sendRequest(u'find date "2002-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('find date "2002-01-01"') + self.assertInResponse('OK') def test_find_date_with_capital_d_and_incomplete_date(self): - self.sendRequest(u'find Date "2005"') - self.assertInResponse(u'OK') + self.sendRequest('find Date "2005"') + self.assertInResponse('OK') def test_find_else_should_fail(self): - self.sendRequest(u'find "somethingelse" "what"') - self.assertEqualResponse(u'ACK [2@0] {find} incorrect arguments') + self.sendRequest('find "somethingelse" "what"') + self.assertEqualResponse('ACK [2@0] {find} incorrect arguments') def test_find_album_and_artist(self): - self.sendRequest(u'find album "album_what" artist "artist_what"') - self.assertInResponse(u'OK') + self.sendRequest('find album "album_what" artist "artist_what"') + self.assertInResponse('OK') class MusicDatabaseListTest(protocol.BaseTestCase): def test_list_foo_returns_ack(self): - self.sendRequest(u'list "foo"') - self.assertEqualResponse(u'ACK [2@0] {list} incorrect arguments') + self.sendRequest('list "foo"') + self.assertEqualResponse('ACK [2@0] {list} incorrect arguments') ### Artist def test_list_artist_with_quotes(self): - self.sendRequest(u'list "artist"') - self.assertInResponse(u'OK') + self.sendRequest('list "artist"') + self.assertInResponse('OK') def test_list_artist_without_quotes(self): - self.sendRequest(u'list artist') - self.assertInResponse(u'OK') + self.sendRequest('list artist') + self.assertInResponse('OK') def test_list_artist_without_quotes_and_capitalized(self): - self.sendRequest(u'list Artist') - self.assertInResponse(u'OK') + self.sendRequest('list Artist') + self.assertInResponse('OK') def test_list_artist_with_query_of_one_token(self): - self.sendRequest(u'list "artist" "anartist"') + self.sendRequest('list "artist" "anartist"') self.assertEqualResponse( - u'ACK [2@0] {list} should be "Album" for 3 arguments') + 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_artist_with_unknown_field_in_query_returns_ack(self): - self.sendRequest(u'list "artist" "foo" "bar"') - self.assertEqualResponse(u'ACK [2@0] {list} not able to parse args') + self.sendRequest('list "artist" "foo" "bar"') + self.assertEqualResponse('ACK [2@0] {list} not able to parse args') def test_list_artist_by_artist(self): - self.sendRequest(u'list "artist" "artist" "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('list "artist" "artist" "anartist"') + self.assertInResponse('OK') def test_list_artist_by_album(self): - self.sendRequest(u'list "artist" "album" "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('list "artist" "album" "analbum"') + self.assertInResponse('OK') def test_list_artist_by_full_date(self): - self.sendRequest(u'list "artist" "date" "2001-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('list "artist" "date" "2001-01-01"') + self.assertInResponse('OK') def test_list_artist_by_year(self): - self.sendRequest(u'list "artist" "date" "2001"') - self.assertInResponse(u'OK') + self.sendRequest('list "artist" "date" "2001"') + self.assertInResponse('OK') def test_list_artist_by_genre(self): - self.sendRequest(u'list "artist" "genre" "agenre"') - self.assertInResponse(u'OK') + self.sendRequest('list "artist" "genre" "agenre"') + self.assertInResponse('OK') def test_list_artist_by_artist_and_album(self): self.sendRequest( - u'list "artist" "artist" "anartist" "album" "analbum"') - self.assertInResponse(u'OK') + 'list "artist" "artist" "anartist" "album" "analbum"') + self.assertInResponse('OK') ### Album def test_list_album_with_quotes(self): - self.sendRequest(u'list "album"') - self.assertInResponse(u'OK') + self.sendRequest('list "album"') + self.assertInResponse('OK') def test_list_album_without_quotes(self): - self.sendRequest(u'list album') - self.assertInResponse(u'OK') + self.sendRequest('list album') + self.assertInResponse('OK') def test_list_album_without_quotes_and_capitalized(self): - self.sendRequest(u'list Album') - self.assertInResponse(u'OK') + self.sendRequest('list Album') + self.assertInResponse('OK') def test_list_album_with_artist_name(self): - self.sendRequest(u'list "album" "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('list "album" "anartist"') + self.assertInResponse('OK') def test_list_album_by_artist(self): - self.sendRequest(u'list "album" "artist" "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('list "album" "artist" "anartist"') + self.assertInResponse('OK') def test_list_album_by_album(self): - self.sendRequest(u'list "album" "album" "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('list "album" "album" "analbum"') + self.assertInResponse('OK') def test_list_album_by_full_date(self): - self.sendRequest(u'list "album" "date" "2001-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('list "album" "date" "2001-01-01"') + self.assertInResponse('OK') def test_list_album_by_year(self): - self.sendRequest(u'list "album" "date" "2001"') - self.assertInResponse(u'OK') + self.sendRequest('list "album" "date" "2001"') + self.assertInResponse('OK') def test_list_album_by_genre(self): - self.sendRequest(u'list "album" "genre" "agenre"') - self.assertInResponse(u'OK') + self.sendRequest('list "album" "genre" "agenre"') + self.assertInResponse('OK') def test_list_album_by_artist_and_album(self): self.sendRequest( - u'list "album" "artist" "anartist" "album" "analbum"') - self.assertInResponse(u'OK') + 'list "album" "artist" "anartist" "album" "analbum"') + self.assertInResponse('OK') ### Date def test_list_date_with_quotes(self): - self.sendRequest(u'list "date"') - self.assertInResponse(u'OK') + self.sendRequest('list "date"') + self.assertInResponse('OK') def test_list_date_without_quotes(self): - self.sendRequest(u'list date') - self.assertInResponse(u'OK') + self.sendRequest('list date') + self.assertInResponse('OK') def test_list_date_without_quotes_and_capitalized(self): - self.sendRequest(u'list Date') - self.assertInResponse(u'OK') + self.sendRequest('list Date') + self.assertInResponse('OK') def test_list_date_with_query_of_one_token(self): - self.sendRequest(u'list "date" "anartist"') + self.sendRequest('list "date" "anartist"') self.assertEqualResponse( - u'ACK [2@0] {list} should be "Album" for 3 arguments') + 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_date_by_artist(self): - self.sendRequest(u'list "date" "artist" "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('list "date" "artist" "anartist"') + self.assertInResponse('OK') def test_list_date_by_album(self): - self.sendRequest(u'list "date" "album" "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('list "date" "album" "analbum"') + self.assertInResponse('OK') def test_list_date_by_full_date(self): - self.sendRequest(u'list "date" "date" "2001-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('list "date" "date" "2001-01-01"') + self.assertInResponse('OK') def test_list_date_by_year(self): - self.sendRequest(u'list "date" "date" "2001"') - self.assertInResponse(u'OK') + self.sendRequest('list "date" "date" "2001"') + self.assertInResponse('OK') def test_list_date_by_genre(self): - self.sendRequest(u'list "date" "genre" "agenre"') - self.assertInResponse(u'OK') + self.sendRequest('list "date" "genre" "agenre"') + self.assertInResponse('OK') def test_list_date_by_artist_and_album(self): - self.sendRequest(u'list "date" "artist" "anartist" "album" "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('list "date" "artist" "anartist" "album" "analbum"') + self.assertInResponse('OK') ### Genre def test_list_genre_with_quotes(self): - self.sendRequest(u'list "genre"') - self.assertInResponse(u'OK') + self.sendRequest('list "genre"') + self.assertInResponse('OK') def test_list_genre_without_quotes(self): - self.sendRequest(u'list genre') - self.assertInResponse(u'OK') + self.sendRequest('list genre') + self.assertInResponse('OK') def test_list_genre_without_quotes_and_capitalized(self): - self.sendRequest(u'list Genre') - self.assertInResponse(u'OK') + self.sendRequest('list Genre') + self.assertInResponse('OK') def test_list_genre_with_query_of_one_token(self): - self.sendRequest(u'list "genre" "anartist"') + self.sendRequest('list "genre" "anartist"') self.assertEqualResponse( - u'ACK [2@0] {list} should be "Album" for 3 arguments') + 'ACK [2@0] {list} should be "Album" for 3 arguments') def test_list_genre_by_artist(self): - self.sendRequest(u'list "genre" "artist" "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('list "genre" "artist" "anartist"') + self.assertInResponse('OK') def test_list_genre_by_album(self): - self.sendRequest(u'list "genre" "album" "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('list "genre" "album" "analbum"') + self.assertInResponse('OK') def test_list_genre_by_full_date(self): - self.sendRequest(u'list "genre" "date" "2001-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('list "genre" "date" "2001-01-01"') + self.assertInResponse('OK') def test_list_genre_by_year(self): - self.sendRequest(u'list "genre" "date" "2001"') - self.assertInResponse(u'OK') + self.sendRequest('list "genre" "date" "2001"') + self.assertInResponse('OK') def test_list_genre_by_genre(self): - self.sendRequest(u'list "genre" "genre" "agenre"') - self.assertInResponse(u'OK') + self.sendRequest('list "genre" "genre" "agenre"') + self.assertInResponse('OK') def test_list_genre_by_artist_and_album(self): self.sendRequest( - u'list "genre" "artist" "anartist" "album" "analbum"') - self.assertInResponse(u'OK') + 'list "genre" "artist" "anartist" "album" "analbum"') + self.assertInResponse('OK') class MusicDatabaseSearchTest(protocol.BaseTestCase): def test_search_album(self): - self.sendRequest(u'search "album" "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('search "album" "analbum"') + self.assertInResponse('OK') def test_search_album_without_quotes(self): - self.sendRequest(u'search album "analbum"') - self.assertInResponse(u'OK') + self.sendRequest('search album "analbum"') + self.assertInResponse('OK') def test_search_artist(self): - self.sendRequest(u'search "artist" "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('search "artist" "anartist"') + self.assertInResponse('OK') def test_search_artist_without_quotes(self): - self.sendRequest(u'search artist "anartist"') - self.assertInResponse(u'OK') + self.sendRequest('search artist "anartist"') + self.assertInResponse('OK') def test_search_filename(self): - self.sendRequest(u'search "filename" "afilename"') - self.assertInResponse(u'OK') + self.sendRequest('search "filename" "afilename"') + self.assertInResponse('OK') def test_search_filename_without_quotes(self): - self.sendRequest(u'search filename "afilename"') - self.assertInResponse(u'OK') + self.sendRequest('search filename "afilename"') + self.assertInResponse('OK') def test_search_title(self): - self.sendRequest(u'search "title" "atitle"') - self.assertInResponse(u'OK') + self.sendRequest('search "title" "atitle"') + self.assertInResponse('OK') def test_search_title_without_quotes(self): - self.sendRequest(u'search title "atitle"') - self.assertInResponse(u'OK') + self.sendRequest('search title "atitle"') + self.assertInResponse('OK') def test_search_any(self): - self.sendRequest(u'search "any" "anything"') - self.assertInResponse(u'OK') + self.sendRequest('search "any" "anything"') + self.assertInResponse('OK') def test_search_any_without_quotes(self): - self.sendRequest(u'search any "anything"') - self.assertInResponse(u'OK') + self.sendRequest('search any "anything"') + self.assertInResponse('OK') def test_search_date(self): - self.sendRequest(u'search "date" "2002-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('search "date" "2002-01-01"') + self.assertInResponse('OK') def test_search_date_without_quotes(self): - self.sendRequest(u'search date "2002-01-01"') - self.assertInResponse(u'OK') + self.sendRequest('search date "2002-01-01"') + self.assertInResponse('OK') def test_search_date_with_capital_d_and_incomplete_date(self): - self.sendRequest(u'search Date "2005"') - self.assertInResponse(u'OK') + self.sendRequest('search Date "2005"') + self.assertInResponse('OK') def test_search_else_should_fail(self): - self.sendRequest(u'search "sometype" "something"') - self.assertEqualResponse(u'ACK [2@0] {search} incorrect arguments') + self.sendRequest('search "sometype" "something"') + self.assertEqualResponse('ACK [2@0] {search} incorrect arguments') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 202ac649..b09ac481 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.core import PlaybackState from mopidy.models import Track @@ -12,140 +14,140 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): - self.sendRequest(u'consume "0"') + self.sendRequest('consume "0"') self.assertFalse(self.core.playback.consume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_consume_off_without_quotes(self): - self.sendRequest(u'consume 0') + self.sendRequest('consume 0') self.assertFalse(self.core.playback.consume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_consume_on(self): - self.sendRequest(u'consume "1"') + self.sendRequest('consume "1"') self.assertTrue(self.core.playback.consume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_consume_on_without_quotes(self): - self.sendRequest(u'consume 1') + self.sendRequest('consume 1') self.assertTrue(self.core.playback.consume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_crossfade(self): - self.sendRequest(u'crossfade "10"') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('crossfade "10"') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_random_off(self): - self.sendRequest(u'random "0"') + self.sendRequest('random "0"') self.assertFalse(self.core.playback.random.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_random_off_without_quotes(self): - self.sendRequest(u'random 0') + self.sendRequest('random 0') self.assertFalse(self.core.playback.random.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_random_on(self): - self.sendRequest(u'random "1"') + self.sendRequest('random "1"') self.assertTrue(self.core.playback.random.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_random_on_without_quotes(self): - self.sendRequest(u'random 1') + self.sendRequest('random 1') self.assertTrue(self.core.playback.random.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_repeat_off(self): - self.sendRequest(u'repeat "0"') + self.sendRequest('repeat "0"') self.assertFalse(self.core.playback.repeat.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_repeat_off_without_quotes(self): - self.sendRequest(u'repeat 0') + self.sendRequest('repeat 0') self.assertFalse(self.core.playback.repeat.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_repeat_on(self): - self.sendRequest(u'repeat "1"') + self.sendRequest('repeat "1"') self.assertTrue(self.core.playback.repeat.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_repeat_on_without_quotes(self): - self.sendRequest(u'repeat 1') + self.sendRequest('repeat 1') self.assertTrue(self.core.playback.repeat.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_below_min(self): - self.sendRequest(u'setvol "-10"') + self.sendRequest('setvol "-10"') self.assertEqual(0, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_min(self): - self.sendRequest(u'setvol "0"') + self.sendRequest('setvol "0"') self.assertEqual(0, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_middle(self): - self.sendRequest(u'setvol "50"') + self.sendRequest('setvol "50"') self.assertEqual(50, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_max(self): - self.sendRequest(u'setvol "100"') + self.sendRequest('setvol "100"') self.assertEqual(100, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_above_max(self): - self.sendRequest(u'setvol "110"') + self.sendRequest('setvol "110"') self.assertEqual(100, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_plus_is_ignored(self): - self.sendRequest(u'setvol "+10"') + self.sendRequest('setvol "+10"') self.assertEqual(10, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_setvol_without_quotes(self): - self.sendRequest(u'setvol 50') + self.sendRequest('setvol 50') self.assertEqual(50, self.core.playback.volume.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_single_off(self): - self.sendRequest(u'single "0"') + self.sendRequest('single "0"') self.assertFalse(self.core.playback.single.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_single_off_without_quotes(self): - self.sendRequest(u'single 0') + self.sendRequest('single 0') self.assertFalse(self.core.playback.single.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_single_on(self): - self.sendRequest(u'single "1"') + self.sendRequest('single "1"') self.assertTrue(self.core.playback.single.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_single_on_without_quotes(self): - self.sendRequest(u'single 1') + self.sendRequest('single 1') self.assertTrue(self.core.playback.single.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_replay_gain_mode_off(self): - self.sendRequest(u'replay_gain_mode "off"') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('replay_gain_mode "off"') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_replay_gain_mode_track(self): - self.sendRequest(u'replay_gain_mode "track"') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('replay_gain_mode "track"') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_replay_gain_mode_album(self): - self.sendRequest(u'replay_gain_mode "album"') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('replay_gain_mode "album"') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_replay_gain_status_default(self): - self.sendRequest(u'replay_gain_status') - self.assertInResponse(u'OK') - self.assertInResponse(u'off') + self.sendRequest('replay_gain_status') + self.assertInResponse('OK') + self.assertInResponse('off') @unittest.SkipTest def test_replay_gain_status_off(self): @@ -162,68 +164,68 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_next(self): - self.sendRequest(u'next') - self.assertInResponse(u'OK') + self.sendRequest('next') + self.assertInResponse('OK') def test_pause_off(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'play "0"') - self.sendRequest(u'pause "1"') - self.sendRequest(u'pause "0"') + self.sendRequest('play "0"') + self.sendRequest('pause "1"') + self.sendRequest('pause "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_pause_on(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'play "0"') - self.sendRequest(u'pause "1"') + self.sendRequest('play "0"') + self.sendRequest('pause "1"') self.assertEqual(PAUSED, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_pause_toggle(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'play "0"') + self.sendRequest('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') - self.sendRequest(u'pause') + self.sendRequest('pause') self.assertEqual(PAUSED, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') - self.sendRequest(u'pause') + self.sendRequest('pause') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_without_pos(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'play') + self.sendRequest('play') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_with_pos(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'play "0"') + self.sendRequest('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'play 0') + self.sendRequest('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): self.core.current_playlist.append([]) - self.sendRequest(u'play "0"') + self.sendRequest('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) - self.assertInResponse(u'ACK [2@0] {play} Bad song index') + self.assertInResponse('ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) @@ -232,11 +234,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): Track(uri='dummy:b'), ]) - self.sendRequest(u'play "-1"') + self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual('dummy:a', self.core.playback.current_track.get().uri) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): self.core.current_playlist.append([ @@ -249,19 +251,19 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.stop() self.assertNotEqual(self.core.playback.current_track.get(), None) - self.sendRequest(u'play "-1"') + self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual('dummy:b', self.core.playback.current_track.get().uri) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): self.core.current_playlist.clear() - self.sendRequest(u'play "-1"') + self.sendRequest('play "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): self.core.current_playlist.append([ @@ -271,11 +273,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) - self.sendRequest(u'play "-1"') + self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): self.core.current_playlist.append([ @@ -287,25 +289,25 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) - self.sendRequest(u'play "-1"') + self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'playid "0"') + self.sendRequest('playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_without_quotes(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'playid 0') + self.sendRequest('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) @@ -314,11 +316,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): Track(uri='dummy:b'), ]) - self.sendRequest(u'playid "-1"') + self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual('dummy:a', self.core.playback.current_track.get().uri) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): self.core.current_playlist.append([ @@ -331,19 +333,19 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.stop() self.assertNotEqual(None, self.core.playback.current_track.get()) - self.sendRequest(u'playid "-1"') + self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertEqual('dummy:b', self.core.playback.current_track.get().uri) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): self.core.current_playlist.clear() - self.sendRequest(u'playid "-1"') + self.sendRequest('playid "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertEqual(None, self.core.playback.current_track.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) @@ -352,11 +354,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.time_position.get(), 30000) self.assertEquals(PLAYING, self.core.playback.state.get()) - self.sendRequest(u'playid "-1"') + self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) @@ -367,66 +369,66 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.core.playback.pause() self.assertEquals(PAUSED, self.core.playback.state.get()) - self.sendRequest(u'playid "-1"') + self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_playid_which_does_not_exist(self): self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'playid "12345"') - self.assertInResponse(u'ACK [50@0] {playid} No such song') + self.sendRequest('playid "12345"') + self.assertInResponse('ACK [50@0] {playid} No such song') def test_previous(self): - self.sendRequest(u'previous') - self.assertInResponse(u'OK') + self.sendRequest('previous') + self.assertInResponse('OK') def test_seek(self): self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) - self.sendRequest(u'seek "0"') - self.sendRequest(u'seek "0" "30"') + self.sendRequest('seek "0"') + self.sendRequest('seek "0" "30"') self.assertGreaterEqual(self.core.playback.time_position, 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_seek_with_songpos(self): seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( [Track(uri='dummy:a', length=40000), seek_track]) - self.sendRequest(u'seek "1" "30"') + self.sendRequest('seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_seek_without_quotes(self): self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) - self.sendRequest(u'seek 0') - self.sendRequest(u'seek 0 30') + self.sendRequest('seek 0') + self.sendRequest('seek 0 30') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_seekid(self): self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) - self.sendRequest(u'seekid "0" "30"') + self.sendRequest('seekid "0" "30"') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_seekid_with_cpid(self): seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( [Track(uri='dummy:a', length=40000), seek_track]) - self.sendRequest(u'seekid "1" "30"') + self.sendRequest('seekid "1" "30"') self.assertEqual(1, self.core.playback.current_cpid.get()) self.assertEqual(seek_track, self.core.playback.current_track.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_stop(self): - self.sendRequest(u'stop') + self.sendRequest('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) - self.assertInResponse(u'OK') + self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py index 8bd9b7e0..33032d73 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import settings from tests.frontends.mpd import protocol @@ -5,63 +7,63 @@ from tests.frontends.mpd import protocol class ReflectionHandlerTest(protocol.BaseTestCase): def test_commands_returns_list_of_all_commands(self): - self.sendRequest(u'commands') + self.sendRequest('commands') # Check if some random commands are included - self.assertInResponse(u'command: commands') - self.assertInResponse(u'command: play') - self.assertInResponse(u'command: status') + self.assertInResponse('command: commands') + self.assertInResponse('command: play') + self.assertInResponse('command: status') # Check if commands you do not have access to are not present - self.assertNotInResponse(u'command: kill') + self.assertNotInResponse('command: kill') # Check if the blacklisted commands are not present - self.assertNotInResponse(u'command: command_list_begin') - self.assertNotInResponse(u'command: command_list_ok_begin') - self.assertNotInResponse(u'command: command_list_end') - self.assertNotInResponse(u'command: idle') - self.assertNotInResponse(u'command: noidle') - self.assertNotInResponse(u'command: sticker') - self.assertInResponse(u'OK') + self.assertNotInResponse('command: command_list_begin') + self.assertNotInResponse('command: command_list_ok_begin') + self.assertNotInResponse('command: command_list_end') + self.assertNotInResponse('command: idle') + self.assertNotInResponse('command: noidle') + self.assertNotInResponse('command: sticker') + self.assertInResponse('OK') def test_commands_show_less_if_auth_required_and_not_authed(self): settings.MPD_SERVER_PASSWORD = u'secret' - self.sendRequest(u'commands') + self.sendRequest('commands') # Not requiring auth - self.assertInResponse(u'command: close') - self.assertInResponse(u'command: commands') - self.assertInResponse(u'command: notcommands') - self.assertInResponse(u'command: password') - self.assertInResponse(u'command: ping') + self.assertInResponse('command: close') + self.assertInResponse('command: commands') + self.assertInResponse('command: notcommands') + self.assertInResponse('command: password') + self.assertInResponse('command: ping') # Requiring auth - self.assertNotInResponse(u'command: play') - self.assertNotInResponse(u'command: status') + self.assertNotInResponse('command: play') + self.assertNotInResponse('command: status') def test_decoders(self): - self.sendRequest(u'decoders') - self.assertInResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('decoders') + self.assertInResponse('ACK [0@0] {} Not implemented') def test_notcommands_returns_only_kill_and_ok(self): - response = self.sendRequest(u'notcommands') + response = self.sendRequest('notcommands') self.assertEqual(2, len(response)) - self.assertInResponse(u'command: kill') - self.assertInResponse(u'OK') + self.assertInResponse('command: kill') + self.assertInResponse('OK') def test_notcommands_returns_more_if_auth_required_and_not_authed(self): settings.MPD_SERVER_PASSWORD = u'secret' - self.sendRequest(u'notcommands') + self.sendRequest('notcommands') # Not requiring auth - self.assertNotInResponse(u'command: close') - self.assertNotInResponse(u'command: commands') - self.assertNotInResponse(u'command: notcommands') - self.assertNotInResponse(u'command: password') - self.assertNotInResponse(u'command: ping') + self.assertNotInResponse('command: close') + self.assertNotInResponse('command: commands') + self.assertNotInResponse('command: notcommands') + self.assertNotInResponse('command: password') + self.assertNotInResponse('command: ping') # Requiring auth - self.assertInResponse(u'command: play') - self.assertInResponse(u'command: status') + self.assertInResponse('command: play') + self.assertInResponse('command: status') def test_tagtypes(self): - self.sendRequest(u'tagtypes') - self.assertInResponse(u'OK') + self.sendRequest('tagtypes') + self.assertInResponse('OK') def test_urlhandlers(self): - self.sendRequest(u'urlhandlers') - self.assertInResponse(u'OK') - self.assertInResponse(u'handler: dummy') + self.sendRequest('urlhandlers') + self.assertInResponse('OK') + self.assertInResponse('handler: dummy') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index a90e37ab..ede93d88 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import random from mopidy.models import Track @@ -26,21 +28,21 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): ]) random.seed(1) # Playlist order: abcfde - self.sendRequest(u'play') + self.sendRequest('play') self.assertEquals('dummy:a', self.core.playback.current_track.get().uri) - self.sendRequest(u'random "1"') - self.sendRequest(u'next') + self.sendRequest('random "1"') + self.sendRequest('next') self.assertEquals('dummy:b', self.core.playback.current_track.get().uri) - self.sendRequest(u'next') + self.sendRequest('next') # Should now be at track 'c', but playback fails and it skips ahead self.assertEquals('dummy:f', self.core.playback.current_track.get().uri) - self.sendRequest(u'next') + self.sendRequest('next') self.assertEquals('dummy:d', self.core.playback.current_track.get().uri) - self.sendRequest(u'next') + self.sendRequest('next') self.assertEquals('dummy:e', self.core.playback.current_track.get().uri) @@ -62,17 +64,17 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) - self.sendRequest(u'play') - self.sendRequest(u'random "1"') - self.sendRequest(u'next') - self.sendRequest(u'random "0"') - self.sendRequest(u'next') + self.sendRequest('play') + self.sendRequest('random "1"') + self.sendRequest('next') + self.sendRequest('random "0"') + self.sendRequest('next') - self.sendRequest(u'next') + self.sendRequest('next') cp_track_1 = self.core.playback.current_cp_track.get() - self.sendRequest(u'next') + self.sendRequest('next') cp_track_2 = self.core.playback.current_cp_track.get() - self.sendRequest(u'next') + self.sendRequest('next') cp_track_3 = self.core.playback.current_cp_track.get() self.assertNotEqual(cp_track_1, cp_track_2) @@ -98,15 +100,15 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) - self.sendRequest(u'play') - self.sendRequest(u'random "1"') - self.sendRequest(u'deleteid "1"') - self.sendRequest(u'deleteid "2"') - self.sendRequest(u'deleteid "3"') - self.sendRequest(u'deleteid "4"') - self.sendRequest(u'deleteid "5"') - self.sendRequest(u'deleteid "6"') - self.sendRequest(u'status') + self.sendRequest('play') + self.sendRequest('random "1"') + self.sendRequest('deleteid "1"') + self.sendRequest('deleteid "2"') + self.sendRequest('deleteid "3"') + self.sendRequest('deleteid "4"') + self.sendRequest('deleteid "5"') + self.sendRequest('deleteid "6"') + self.sendRequest('status') class IssueGH69RegressionTest(protocol.BaseTestCase): @@ -126,10 +128,10 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) - self.sendRequest(u'play') - self.sendRequest(u'stop') - self.sendRequest(u'clear') - self.sendRequest(u'load "foo"') + self.sendRequest('play') + self.sendRequest('stop') + self.sendRequest('clear') + self.sendRequest('load "foo"') self.assertNotInResponse('song: None') @@ -149,7 +151,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): self.core.stored_playlists.create( u'all lart spotify:track:\w\{22\} pastes') - self.sendRequest(u'lsinfo "/"') + self.sendRequest('lsinfo "/"') self.assertInResponse( u'playlist: all lart spotify:track:\w\{22\} pastes') diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index e2f0df9c..6d406961 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.models import Track from tests.frontends.mpd import protocol @@ -5,33 +7,33 @@ from tests.frontends.mpd import protocol class StatusHandlerTest(protocol.BaseTestCase): def test_clearerror(self): - self.sendRequest(u'clearerror') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('clearerror') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_currentsong(self): track = Track() self.core.current_playlist.append([track]) self.core.playback.play() - self.sendRequest(u'currentsong') - self.assertInResponse(u'file: ') - self.assertInResponse(u'Time: 0') - self.assertInResponse(u'Artist: ') - self.assertInResponse(u'Title: ') - self.assertInResponse(u'Album: ') - self.assertInResponse(u'Track: 0') - self.assertInResponse(u'Date: ') - self.assertInResponse(u'Pos: 0') - self.assertInResponse(u'Id: 0') - self.assertInResponse(u'OK') + self.sendRequest('currentsong') + self.assertInResponse('file: ') + self.assertInResponse('Time: 0') + self.assertInResponse('Artist: ') + self.assertInResponse('Title: ') + self.assertInResponse('Album: ') + self.assertInResponse('Track: 0') + self.assertInResponse('Date: ') + self.assertInResponse('Pos: 0') + self.assertInResponse('Id: 0') + self.assertInResponse('OK') def test_currentsong_without_song(self): - self.sendRequest(u'currentsong') - self.assertInResponse(u'OK') + self.sendRequest('currentsong') + self.assertInResponse('OK') def test_stats_command(self): - self.sendRequest(u'stats') - self.assertInResponse(u'OK') + self.sendRequest('stats') + self.assertInResponse('OK') def test_status_command(self): - self.sendRequest(u'status') - self.assertInResponse(u'OK') + self.sendRequest('status') + self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/stickers_test.py b/tests/frontends/mpd/protocol/stickers_test.py index 3e8b687f..de610521 100644 --- a/tests/frontends/mpd/protocol/stickers_test.py +++ b/tests/frontends/mpd/protocol/stickers_test.py @@ -1,33 +1,35 @@ +from __future__ import unicode_literals + from tests.frontends.mpd import protocol class StickersHandlerTest(protocol.BaseTestCase): def test_sticker_get(self): self.sendRequest( - u'sticker get "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + 'sticker get "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_sticker_set(self): self.sendRequest( - u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + 'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_sticker_delete_with_name(self): self.sendRequest( - u'sticker delete "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + 'sticker delete "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_sticker_delete_without_name(self): self.sendRequest( - u'sticker delete "song" "file:///dev/urandom"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + 'sticker delete "song" "file:///dev/urandom"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_sticker_list(self): self.sendRequest( - u'sticker list "song" "file:///dev/urandom"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + 'sticker list "song" "file:///dev/urandom"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_sticker_find(self): self.sendRequest( - u'sticker find "song" "file:///dev/urandom" "a_name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + 'sticker find "song" "file:///dev/urandom" "a_name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index c8db3f8f..e2eefbd4 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import datetime from mopidy.models import Track, Playlist @@ -10,57 +12,57 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - self.sendRequest(u'listplaylist "name"') - self.assertInResponse(u'file: file:///dev/urandom') - self.assertInResponse(u'OK') + self.sendRequest('listplaylist "name"') + self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('OK') def test_listplaylist_without_quotes(self): self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - self.sendRequest(u'listplaylist name') - self.assertInResponse(u'file: file:///dev/urandom') - self.assertInResponse(u'OK') + self.sendRequest('listplaylist name') + self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('OK') def test_listplaylist_fails_if_no_playlist_is_found(self): - self.sendRequest(u'listplaylist "name"') - self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') + self.sendRequest('listplaylist "name"') + self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - self.sendRequest(u'listplaylistinfo "name"') - self.assertInResponse(u'file: file:///dev/urandom') - self.assertInResponse(u'Track: 0') - self.assertNotInResponse(u'Pos: 0') - self.assertInResponse(u'OK') + self.sendRequest('listplaylistinfo "name"') + self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('Track: 0') + self.assertNotInResponse('Pos: 0') + self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): self.backend.stored_playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] - self.sendRequest(u'listplaylistinfo name') - self.assertInResponse(u'file: file:///dev/urandom') - self.assertInResponse(u'Track: 0') - self.assertNotInResponse(u'Pos: 0') - self.assertInResponse(u'OK') + self.sendRequest('listplaylistinfo name') + self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('Track: 0') + self.assertNotInResponse('Pos: 0') + self.assertInResponse('OK') def test_listplaylistinfo_fails_if_no_playlist_is_found(self): - self.sendRequest(u'listplaylistinfo "name"') + self.sendRequest('listplaylistinfo "name"') self.assertEqualResponse( - u'ACK [50@0] {listplaylistinfo} No such playlist') + 'ACK [50@0] {listplaylistinfo} No such playlist') def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) self.backend.stored_playlists.playlists = [ Playlist(name='a', last_modified=last_modified)] - self.sendRequest(u'listplaylists') - self.assertInResponse(u'playlist: a') + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a') # Date without microseconds and with time zone information - self.assertInResponse(u'Last-Modified: 2001-03-17T13:41:17Z') - self.assertInResponse(u'OK') + self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') + self.assertInResponse('OK') def test_load_known_playlist_appends_to_current_playlist(self): self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) @@ -69,7 +71,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): Playlist(name='A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] - self.sendRequest(u'load "A-list"') + self.sendRequest('load "A-list"') tracks = self.core.current_playlist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) @@ -77,37 +79,37 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqual('c', tracks[2].uri) self.assertEqual('d', tracks[3].uri) self.assertEqual('e', tracks[4].uri) - self.assertInResponse(u'OK') + self.assertInResponse('OK') def test_load_unknown_playlist_acks(self): - self.sendRequest(u'load "unknown playlist"') + self.sendRequest('load "unknown playlist"') self.assertEqual(0, len(self.core.current_playlist.tracks.get())) - self.assertEqualResponse(u'ACK [50@0] {load} No such playlist') + self.assertEqualResponse('ACK [50@0] {load} No such playlist') def test_playlistadd(self): - self.sendRequest(u'playlistadd "name" "file:///dev/urandom"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistadd "name" "file:///dev/urandom"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_playlistclear(self): - self.sendRequest(u'playlistclear "name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistclear "name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_playlistdelete(self): - self.sendRequest(u'playlistdelete "name" "5"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistdelete "name" "5"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_playlistmove(self): - self.sendRequest(u'playlistmove "name" "5" "10"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('playlistmove "name" "5" "10"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_rename(self): - self.sendRequest(u'rename "old_name" "new_name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('rename "old_name" "new_name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_rm(self): - self.sendRequest(u'rm "name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('rm "name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_save(self): - self.sendRequest(u'save "name"') - self.assertEqualResponse(u'ACK [0@0] {} Not implemented') + self.sendRequest('save "name"') + self.assertEqualResponse('ACK [0@0] {} Not implemented') diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index 2d2a9f87..b1f59076 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import datetime import os @@ -11,11 +13,11 @@ from tests import unittest class TrackMpdFormatTest(unittest.TestCase): track = Track( - uri=u'a uri', - artists=[Artist(name=u'an artist')], - name=u'a name', - album=Album(name=u'an album', num_tracks=13, - artists=[Artist(name=u'an other artist')]), + uri='a uri', + artists=[Artist(name='an artist')], + name='a name', + album=Album(name='an album', num_tracks=13, + artists=[Artist(name='an other artist')]), track_no=7, date=datetime.date(1977, 1, 1), length=137000, @@ -94,14 +96,14 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) def test_artists_to_mpd_format(self): - artists = [Artist(name=u'ABBA'), Artist(name=u'Beatles')] + artists = [Artist(name='ABBA'), Artist(name='Beatles')] translated = translator.artists_to_mpd_format(artists) - self.assertEqual(translated, u'ABBA, Beatles') + self.assertEqual(translated, 'ABBA, Beatles') def test_artists_to_mpd_format_artist_with_no_name(self): artists = [Artist(name=None)] translated = translator.artists_to_mpd_format(artists) - self.assertEqual(translated, u'') + self.assertEqual(translated, '') class PlaylistMpdFormatTest(unittest.TestCase): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 9f2395e5..7d71b0bd 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka from mopidy import core diff --git a/tests/frontends/mpris/__init__.py b/tests/frontends/mpris/__init__.py index e69de29b..baffc488 100644 --- a/tests/frontends/mpris/__init__.py +++ b/tests/frontends/mpris/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index a4efe344..94f48115 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import sys import mock diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 620845e4..6043551a 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import sys import mock diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 79a8b07f..9e16c6bb 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import sys import mock diff --git a/tests/help_test.py b/tests/help_test.py index a2803b72..fdef0f52 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os import subprocess import sys diff --git a/tests/models_test.py b/tests/models_test.py index 004c0a28..b59ed0e4 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import datetime from mopidy.models import Artist, Album, CpTrack, Track, Playlist @@ -52,19 +54,19 @@ class GenericCopyTets(unittest.TestCase): class ArtistTest(unittest.TestCase): def test_uri(self): - uri = u'an_uri' + uri = 'an_uri' artist = Artist(uri=uri) self.assertEqual(artist.uri, uri) self.assertRaises(AttributeError, setattr, artist, 'uri', None) def test_name(self): - name = u'a name' + name = 'a name' artist = Artist(name=name) self.assertEqual(artist.name, name) self.assertRaises(AttributeError, setattr, artist, 'name', None) def test_musicbrainz_id(self): - mb_id = u'mb-id' + mb_id = 'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) self.assertRaises( @@ -76,7 +78,7 @@ class ArtistTest(unittest.TestCase): def test_repr(self): self.assertEquals( - "Artist(name='name', uri='uri')", + "Artist(name=u'name', uri=u'uri')", repr(Artist(uri='uri', name='name'))) def test_serialize(self): @@ -85,26 +87,26 @@ class ArtistTest(unittest.TestCase): Artist(uri='uri', name='name').serialize()) def test_eq_name(self): - artist1 = Artist(name=u'name') - artist2 = Artist(name=u'name') + artist1 = Artist(name='name') + artist2 = Artist(name='name') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq_uri(self): - artist1 = Artist(uri=u'uri') - artist2 = Artist(uri=u'uri') + artist1 = Artist(uri='uri') + artist2 = Artist(uri='uri') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq_musibrainz_id(self): - artist1 = Artist(musicbrainz_id=u'id') - artist2 = Artist(musicbrainz_id=u'id') + artist1 = Artist(musicbrainz_id='id') + artist2 = Artist(musicbrainz_id='id') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) def test_eq(self): - artist1 = Artist(uri=u'uri', name=u'name', musicbrainz_id='id') - artist2 = Artist(uri=u'uri', name=u'name', musicbrainz_id='id') + artist1 = Artist(uri='uri', name='name', musicbrainz_id='id') + artist2 = Artist(uri='uri', name='name', musicbrainz_id='id') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) @@ -115,39 +117,39 @@ class ArtistTest(unittest.TestCase): self.assertNotEqual(Artist(), 'other') def test_ne_name(self): - artist1 = Artist(name=u'name1') - artist2 = Artist(name=u'name2') + artist1 = Artist(name='name1') + artist2 = Artist(name='name2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) def test_ne_uri(self): - artist1 = Artist(uri=u'uri1') - artist2 = Artist(uri=u'uri2') + artist1 = Artist(uri='uri1') + artist2 = Artist(uri='uri2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) def test_ne_musicbrainz_id(self): - artist1 = Artist(musicbrainz_id=u'id1') - artist2 = Artist(musicbrainz_id=u'id2') + artist1 = Artist(musicbrainz_id='id1') + artist2 = Artist(musicbrainz_id='id2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) def test_ne(self): - artist1 = Artist(uri=u'uri1', name=u'name1', musicbrainz_id='id1') - artist2 = Artist(uri=u'uri2', name=u'name2', musicbrainz_id='id2') + artist1 = Artist(uri='uri1', name='name1', musicbrainz_id='id1') + artist2 = Artist(uri='uri2', name='name2', musicbrainz_id='id2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) class AlbumTest(unittest.TestCase): def test_uri(self): - uri = u'an_uri' + uri = 'an_uri' album = Album(uri=uri) self.assertEqual(album.uri, uri) self.assertRaises(AttributeError, setattr, album, 'uri', None) def test_name(self): - name = u'a name' + name = 'a name' album = Album(name=name) self.assertEqual(album.name, name) self.assertRaises(AttributeError, setattr, album, 'name', None) @@ -171,7 +173,7 @@ class AlbumTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, album, 'date', None) def test_musicbrainz_id(self): - mb_id = u'mb-id' + mb_id = 'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) self.assertRaises( @@ -183,12 +185,12 @@ class AlbumTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEquals( - "Album(artists=[], name='name', uri='uri')", + "Album(artists=[], name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEquals( - "Album(artists=[Artist(name='foo')], name='name', uri='uri')", + "Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -203,14 +205,14 @@ class AlbumTest(unittest.TestCase): Album(uri='uri', name='name', artists=[artist]).serialize()) def test_eq_name(self): - album1 = Album(name=u'name') - album2 = Album(name=u'name') + album1 = Album(name='name') + album2 = Album(name='name') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq_uri(self): - album1 = Album(uri=u'uri') - album2 = Album(uri=u'uri') + album1 = Album(uri='uri') + album2 = Album(uri='uri') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) @@ -222,8 +224,8 @@ class AlbumTest(unittest.TestCase): self.assertEqual(hash(album1), hash(album2)) def test_eq_artists_order(self): - artist1 = Artist(name=u'name1') - artist2 = Artist(name=u'name2') + artist1 = Artist(name='name1') + artist2 = Artist(name='name2') album1 = Album(artists=[artist1, artist2]) album2 = Album(artists=[artist2, artist1]) self.assertEqual(album1, album2) @@ -243,18 +245,18 @@ class AlbumTest(unittest.TestCase): self.assertEqual(hash(album1), hash(album2)) def test_eq_musibrainz_id(self): - album1 = Album(musicbrainz_id=u'id') - album2 = Album(musicbrainz_id=u'id') + album1 = Album(musicbrainz_id='id') + album2 = Album(musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) def test_eq(self): artists = [Artist()] album1 = Album( - name=u'name', uri=u'uri', artists=artists, num_tracks=2, + name='name', uri='uri', artists=artists, num_tracks=2, musicbrainz_id='id') album2 = Album( - name=u'name', uri=u'uri', artists=artists, num_tracks=2, + name='name', uri='uri', artists=artists, num_tracks=2, musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) @@ -266,20 +268,20 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(Album(), 'other') def test_ne_name(self): - album1 = Album(name=u'name1') - album2 = Album(name=u'name2') + album1 = Album(name='name1') + album2 = Album(name='name2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_uri(self): - album1 = Album(uri=u'uri1') - album2 = Album(uri=u'uri2') + album1 = Album(uri='uri1') + album2 = Album(uri='uri2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne_artists(self): - album1 = Album(artists=[Artist(name=u'name1')]) - album2 = Album(artists=[Artist(name=u'name2')]) + album1 = Album(artists=[Artist(name='name1')]) + album2 = Album(artists=[Artist(name='name2')]) self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) @@ -296,17 +298,17 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) def test_ne_musicbrainz_id(self): - album1 = Album(musicbrainz_id=u'id1') - album2 = Album(musicbrainz_id=u'id2') + album1 = Album(musicbrainz_id='id1') + album2 = Album(musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) def test_ne(self): album1 = Album( - name=u'name1', uri=u'uri1', artists=[Artist(name=u'name1')], + name='name1', uri='uri1', artists=[Artist(name='name1')], num_tracks=1, musicbrainz_id='id1') album2 = Album( - name=u'name2', uri=u'uri2', artists=[Artist(name=u'name2')], + name='name2', uri='uri2', artists=[Artist(name='name2')], num_tracks=2, musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) @@ -329,19 +331,19 @@ class CpTrackTest(unittest.TestCase): class TrackTest(unittest.TestCase): def test_uri(self): - uri = u'an_uri' + uri = 'an_uri' track = Track(uri=uri) self.assertEqual(track.uri, uri) self.assertRaises(AttributeError, setattr, track, 'uri', None) def test_name(self): - name = u'a name' + name = 'a name' track = Track(name=name) self.assertEqual(track.name, name) self.assertRaises(AttributeError, setattr, track, 'name', None) def test_artists(self): - artists = [Artist(name=u'name1'), Artist(name=u'name2')] + artists = [Artist(name='name1'), Artist(name='name2')] track = Track(artists=artists) self.assertEqual(set(track.artists), set(artists)) self.assertRaises(AttributeError, setattr, track, 'artists', None) @@ -377,7 +379,7 @@ class TrackTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, track, 'bitrate', None) def test_musicbrainz_id(self): - mb_id = u'mb-id' + mb_id = 'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) self.assertRaises( @@ -389,12 +391,12 @@ class TrackTest(unittest.TestCase): def test_repr_without_artists(self): self.assertEquals( - "Track(artists=[], name='name', uri='uri')", + "Track(artists=[], name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name'))) def test_repr_with_artists(self): self.assertEquals( - "Track(artists=[Artist(name='foo')], name='name', uri='uri')", + "Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) def test_serialize_without_artists(self): @@ -415,14 +417,14 @@ class TrackTest(unittest.TestCase): Track(uri='uri', name='name', album=album).serialize()) def test_eq_uri(self): - track1 = Track(uri=u'uri1') - track2 = Track(uri=u'uri1') + track1 = Track(uri='uri1') + track2 = Track(uri='uri1') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) def test_eq_name(self): - track1 = Track(name=u'name1') - track2 = Track(name=u'name1') + track1 = Track(name='name1') + track2 = Track(name='name1') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -434,8 +436,8 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_artists_order(self): - artist1 = Artist(name=u'name1') - artist2 = Artist(name=u'name2') + artist1 = Artist(name='name1') + artist2 = Artist(name='name2') track1 = Track(artists=[artist1, artist2]) track2 = Track(artists=[artist2, artist1]) self.assertEqual(track1, track2) @@ -474,8 +476,8 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_musibrainz_id(self): - track1 = Track(musicbrainz_id=u'id') - track2 = Track(musicbrainz_id=u'id') + track1 = Track(musicbrainz_id='id') + track2 = Track(musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -484,10 +486,10 @@ class TrackTest(unittest.TestCase): artists = [Artist()] album = Album() track1 = Track( - uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + uri='uri', name='name', artists=artists, album=album, track_no=1, date=date, length=100, bitrate=100, musicbrainz_id='id') track2 = Track( - uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + uri='uri', name='name', artists=artists, album=album, track_no=1, date=date, length=100, bitrate=100, musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -499,26 +501,26 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(Track(), 'other') def test_ne_uri(self): - track1 = Track(uri=u'uri1') - track2 = Track(uri=u'uri2') + track1 = Track(uri='uri1') + track2 = Track(uri='uri2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_name(self): - track1 = Track(name=u'name1') - track2 = Track(name=u'name2') + track1 = Track(name='name1') + track2 = Track(name='name2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_artists(self): - track1 = Track(artists=[Artist(name=u'name1')]) - track2 = Track(artists=[Artist(name=u'name2')]) + track1 = Track(artists=[Artist(name='name1')]) + track2 = Track(artists=[Artist(name='name2')]) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne_album(self): - track1 = Track(album=Album(name=u'name1')) - track2 = Track(album=Album(name=u'name2')) + track1 = Track(album=Album(name='name1')) + track2 = Track(album=Album(name='name2')) self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -547,19 +549,19 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne_musicbrainz_id(self): - track1 = Track(musicbrainz_id=u'id1') - track2 = Track(musicbrainz_id=u'id2') + track1 = Track(musicbrainz_id='id1') + track2 = Track(musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) def test_ne(self): track1 = Track( - uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], - album=Album(name=u'name1'), track_no=1, date='1977-01-01', + uri='uri1', name='name1', artists=[Artist(name='name1')], + album=Album(name='name1'), track_no=1, date='1977-01-01', length=100, bitrate=100, musicbrainz_id='id1') track2 = Track( - uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], - album=Album(name=u'name2'), track_no=2, date='1977-01-02', + uri='uri2', name='name2', artists=[Artist(name='name2')], + album=Album(name='name2'), track_no=2, date='1977-01-02', length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -567,13 +569,13 @@ class TrackTest(unittest.TestCase): class PlaylistTest(unittest.TestCase): def test_uri(self): - uri = u'an_uri' + uri = 'an_uri' playlist = Playlist(uri=uri) self.assertEqual(playlist.uri, uri) self.assertRaises(AttributeError, setattr, playlist, 'uri', None) def test_name(self): - name = u'a name' + name = 'a name' playlist = Playlist(name=name) self.assertEqual(playlist.name, name) self.assertRaises(AttributeError, setattr, playlist, 'name', None) @@ -600,11 +602,11 @@ class PlaylistTest(unittest.TestCase): tracks = [Track()] last_modified = datetime.datetime.now() playlist = Playlist( - uri=u'an uri', name=u'a name', tracks=tracks, + uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(uri=u'another uri') - self.assertEqual(new_playlist.uri, u'another uri') - self.assertEqual(new_playlist.name, u'a name') + new_playlist = playlist.copy(uri='another uri') + self.assertEqual(new_playlist.uri, 'another uri') + self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) self.assertEqual(new_playlist.last_modified, last_modified) @@ -612,11 +614,11 @@ class PlaylistTest(unittest.TestCase): tracks = [Track()] last_modified = datetime.datetime.now() playlist = Playlist( - uri=u'an uri', name=u'a name', tracks=tracks, + uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.copy(name=u'another name') - self.assertEqual(new_playlist.uri, u'an uri') - self.assertEqual(new_playlist.name, u'another name') + new_playlist = playlist.copy(name='another name') + self.assertEqual(new_playlist.uri, 'an uri') + self.assertEqual(new_playlist.name, 'another name') self.assertEqual(list(new_playlist.tracks), tracks) self.assertEqual(new_playlist.last_modified, last_modified) @@ -624,12 +626,12 @@ class PlaylistTest(unittest.TestCase): tracks = [Track()] last_modified = datetime.datetime.now() playlist = Playlist( - uri=u'an uri', name=u'a name', tracks=tracks, + uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] new_playlist = playlist.copy(tracks=new_tracks) - self.assertEqual(new_playlist.uri, u'an uri') - self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.uri, 'an uri') + self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), new_tracks) self.assertEqual(new_playlist.last_modified, last_modified) @@ -638,11 +640,11 @@ class PlaylistTest(unittest.TestCase): last_modified = datetime.datetime.now() new_last_modified = last_modified + datetime.timedelta(1) playlist = Playlist( - uri=u'an uri', name=u'a name', tracks=tracks, + uri='an uri', name='a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(last_modified=new_last_modified) - self.assertEqual(new_playlist.uri, u'an uri') - self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.uri, 'an uri') + self.assertEqual(new_playlist.name, 'a name') self.assertEqual(list(new_playlist.tracks), tracks) self.assertEqual(new_playlist.last_modified, new_last_modified) @@ -652,13 +654,13 @@ class PlaylistTest(unittest.TestCase): def test_repr_without_tracks(self): self.assertEquals( - "Playlist(name='name', tracks=[], uri='uri')", + "Playlist(name=u'name', tracks=[], uri=u'uri')", repr(Playlist(uri='uri', name='name'))) def test_repr_with_tracks(self): self.assertEquals( - "Playlist(name='name', tracks=[Track(artists=[], name='foo')], " - "uri='uri')", + "Playlist(name=u'name', tracks=[Track(artists=[], name=u'foo')], " + "uri=u'uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) def test_serialize_without_tracks(self): @@ -673,14 +675,14 @@ class PlaylistTest(unittest.TestCase): Playlist(uri='uri', name='name', tracks=[track]).serialize()) def test_eq_name(self): - playlist1 = Playlist(name=u'name') - playlist2 = Playlist(name=u'name') + playlist1 = Playlist(name='name') + playlist2 = Playlist(name='name') self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) def test_eq_uri(self): - playlist1 = Playlist(uri=u'uri') - playlist2 = Playlist(uri=u'uri') + playlist1 = Playlist(uri='uri') + playlist2 = Playlist(uri='uri') self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) @@ -700,9 +702,9 @@ class PlaylistTest(unittest.TestCase): def test_eq(self): tracks = [Track()] playlist1 = Playlist( - uri=u'uri', name=u'name', tracks=tracks, last_modified=1) + uri='uri', name='name', tracks=tracks, last_modified=1) playlist2 = Playlist( - uri=u'uri', name=u'name', tracks=tracks, last_modified=1) + uri='uri', name='name', tracks=tracks, last_modified=1) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) @@ -713,20 +715,20 @@ class PlaylistTest(unittest.TestCase): self.assertNotEqual(Playlist(), 'other') def test_ne_name(self): - playlist1 = Playlist(name=u'name1') - playlist2 = Playlist(name=u'name2') + playlist1 = Playlist(name='name1') + playlist2 = Playlist(name='name2') self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne_uri(self): - playlist1 = Playlist(uri=u'uri1') - playlist2 = Playlist(uri=u'uri2') + playlist1 = Playlist(uri='uri1') + playlist2 = Playlist(uri='uri2') self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne_tracks(self): - playlist1 = Playlist(tracks=[Track(uri=u'uri1')]) - playlist2 = Playlist(tracks=[Track(uri=u'uri2')]) + playlist1 = Playlist(tracks=[Track(uri='uri1')]) + playlist2 = Playlist(tracks=[Track(uri='uri2')]) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) @@ -738,10 +740,10 @@ class PlaylistTest(unittest.TestCase): def test_ne(self): playlist1 = Playlist( - uri=u'uri1', name=u'name2', tracks=[Track(uri=u'uri1')], + uri='uri1', name='name2', tracks=[Track(uri='uri1')], last_modified=1) playlist2 = Playlist( - uri=u'uri2', name=u'name2', tracks=[Track(uri=u'uri2')], + uri='uri2', name='name2', tracks=[Track(uri='uri2')], last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) diff --git a/tests/outputs/__init__.py b/tests/outputs/__init__.py index e69de29b..baffc488 100644 --- a/tests/outputs/__init__.py +++ b/tests/outputs/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 6af48bb5..08784458 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from datetime import date from mopidy.scanner import Scanner, translator @@ -17,14 +19,14 @@ class TranslatorTest(unittest.TestCase): def setUp(self): self.data = { 'uri': 'uri', - 'album': u'albumname', + 'album': 'albumname', 'track-number': 1, - 'artist': u'name', + 'artist': 'name', 'album-artist': 'albumartistname', - 'title': u'trackname', + 'title': 'trackname', 'track-count': 2, 'date': FakeGstDate(2006, 1, 1,), - 'container-format': u'ID3 tag', + 'container-format': 'ID3 tag', 'duration': 4531, 'musicbrainz-trackid': 'mbtrackid', 'musicbrainz-albumid': 'mbalbumid', diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index e69de29b..baffc488 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 42c8b299..168f98e5 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import platform import pygst diff --git a/tests/utils/encoding_test.py b/tests/utils/encoding_test.py index da50d9be..1a4e56c5 100644 --- a/tests/utils/encoding_test.py +++ b/tests/utils/encoding_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import mock from mopidy.utils.encoding import locale_decode @@ -11,22 +13,22 @@ class LocaleDecodeTest(unittest.TestCase): mock.return_value = 'UTF-8' result = locale_decode( - '[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') + b'[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') - self.assertEquals(u'[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result) + self.assertEquals('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result) def test_can_decode_an_ioerror_with_french_content(self, mock): mock.return_value = 'UTF-8' - error = IOError(98, 'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') + error = IOError(98, b'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e') result = locale_decode(error) - self.assertEquals(u'[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result) + self.assertEquals('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result) def test_does_not_use_locale_to_decode_unicode_strings(self, mock): mock.return_value = 'UTF-8' - locale_decode(u'abc') + locale_decode('abc') self.assertFalse(mock.called) diff --git a/tests/utils/importing_test.py b/tests/utils/importing_test.py index 271f9dbe..5be4078b 100644 --- a/tests/utils/importing_test.py +++ b/tests/utils/importing_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy.utils import importing from tests import unittest diff --git a/tests/utils/network/__init__.py b/tests/utils/network/__init__.py index e69de29b..baffc488 100644 --- a/tests/utils/network/__init__.py +++ b/tests/utils/network/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index c9fe9a05..3e63cdfc 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import errno import gobject import logging diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index 9a19e12e..530c708c 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -1,4 +1,6 @@ -#encoding: utf-8 +# encoding: utf-8 + +from __future__ import unicode_literals import re from mock import sentinel, Mock @@ -159,10 +161,10 @@ class LineProtocolTest(unittest.TestCase): def test_parse_lines_unicode(self): self.mock.delimiter = re.compile(r'\n') - self.mock.recv_buffer = u'æøå\n'.encode('utf-8') + self.mock.recv_buffer = 'æøå\n'.encode('utf-8') lines = network.LineProtocol.parse_lines(self.mock) - self.assertEqual(u'æøå'.encode('utf-8'), lines.next()) + self.assertEqual('æøå'.encode('utf-8'), lines.next()) self.assertRaises(StopIteration, lines.next) self.assertEqual('', self.mock.recv_buffer) @@ -208,10 +210,10 @@ class LineProtocolTest(unittest.TestCase): def test_send_line_encodes_joined_lines_with_final_terminator(self): self.mock.connection = Mock(spec=network.Connection) - self.mock.join_lines.return_value = u'lines\n' + self.mock.join_lines.return_value = 'lines\n' network.LineProtocol.send_lines(self.mock, sentinel.lines) - self.mock.encode.assert_called_once_with(u'lines\n') + self.mock.encode.assert_called_once_with('lines\n') def test_send_lines_sends_encoded_string(self): self.mock.connection = Mock(spec=network.Connection) @@ -222,11 +224,11 @@ class LineProtocolTest(unittest.TestCase): self.mock.connection.queue_send.assert_called_once_with(sentinel.data) def test_join_lines_returns_empty_string_for_no_lines(self): - self.assertEqual(u'', network.LineProtocol.join_lines(self.mock, [])) + self.assertEqual('', network.LineProtocol.join_lines(self.mock, [])) def test_join_lines_returns_joined_lines(self): - self.assertEqual(u'1\n2\n', network.LineProtocol.join_lines( - self.mock, [u'1', u'2'])) + self.assertEqual('1\n2\n', network.LineProtocol.join_lines( + self.mock, ['1', '2'])) def test_decode_calls_decode_on_string(self): string = Mock() @@ -236,13 +238,13 @@ class LineProtocolTest(unittest.TestCase): def test_decode_plain_ascii(self): result = network.LineProtocol.decode(self.mock, 'abc') - self.assertEqual(u'abc', result) + self.assertEqual('abc', result) self.assertEqual(unicode, type(result)) def test_decode_utf8(self): result = network.LineProtocol.decode( - self.mock, u'æøå'.encode('utf-8')) - self.assertEqual(u'æøå', result) + self.mock, 'æøå'.encode('utf-8')) + self.assertEqual('æøå', result) self.assertEqual(unicode, type(result)) def test_decode_invalid_data(self): @@ -259,13 +261,13 @@ class LineProtocolTest(unittest.TestCase): string.encode.assert_called_once_with(self.mock.encoding) def test_encode_plain_ascii(self): - result = network.LineProtocol.encode(self.mock, u'abc') + result = network.LineProtocol.encode(self.mock, 'abc') self.assertEqual('abc', result) self.assertEqual(str, type(result)) def test_encode_utf8(self): - result = network.LineProtocol.encode(self.mock, u'æøå') - self.assertEqual(u'æøå'.encode('utf-8'), result) + result = network.LineProtocol.encode(self.mock, 'æøå') + self.assertEqual('æøå'.encode('utf-8'), result) self.assertEqual(str, type(result)) def test_encode_invalid_data(self): diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py index 6090077d..3f7da337 100644 --- a/tests/utils/network/server_test.py +++ b/tests/utils/network/server_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import errno import gobject import socket diff --git a/tests/utils/network/utils_test.py b/tests/utils/network/utils_test.py index f28aeb4b..ff8af9bd 100644 --- a/tests/utils/network/utils_test.py +++ b/tests/utils/network/utils_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import socket from mock import patch, Mock diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 91951ac7..512a3ba1 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -1,5 +1,7 @@ # encoding: utf-8 +from __future__ import unicode_literals + import glib import os import shutil @@ -58,61 +60,61 @@ class GetOrCreateFolderTest(unittest.TestCase): class PathToFileURITest(unittest.TestCase): def test_simple_path(self): if sys.platform == 'win32': - result = path.path_to_uri(u'C:/WINDOWS/clock.avi') + result = path.path_to_uri('C:/WINDOWS/clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path.path_to_uri(u'/etc/fstab') + result = path.path_to_uri('/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') def test_folder_and_path(self): if sys.platform == 'win32': - result = path.path_to_uri(u'C:/WINDOWS/', u'clock.avi') + result = path.path_to_uri('C:/WINDOWS/', 'clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path.path_to_uri(u'/etc', u'fstab') - self.assertEqual(result, u'file:///etc/fstab') + result = path.path_to_uri('/etc', 'fstab') + self.assertEqual(result, 'file:///etc/fstab') def test_space_in_path(self): if sys.platform == 'win32': - result = path.path_to_uri(u'C:/test this') + result = path.path_to_uri('C:/test this') self.assertEqual(result, 'file:///C://test%20this') else: - result = path.path_to_uri(u'/tmp/test this') - self.assertEqual(result, u'file:///tmp/test%20this') + result = path.path_to_uri('/tmp/test this') + self.assertEqual(result, 'file:///tmp/test%20this') def test_unicode_in_path(self): if sys.platform == 'win32': - result = path.path_to_uri(u'C:/æøå') + result = path.path_to_uri('C:/æøå') self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') else: - result = path.path_to_uri(u'/tmp/æøå') - self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.path_to_uri('/tmp/æøå') + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://WINDOWS/clock.avi') - self.assertEqual(result, u'C:/WINDOWS/clock.avi') + self.assertEqual(result, 'C:/WINDOWS/clock.avi') else: result = path.uri_to_path('file:///etc/fstab') - self.assertEqual(result, u'/etc/fstab') + self.assertEqual(result, '/etc/fstab') def test_space_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://test%20this') - self.assertEqual(result, u'C:/test this') + self.assertEqual(result, 'C:/test this') else: - result = path.uri_to_path(u'file:///tmp/test%20this') - self.assertEqual(result, u'/tmp/test this') + result = path.uri_to_path('file:///tmp/test%20this') + self.assertEqual(result, '/tmp/test this') def test_unicode_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, u'C:/æøå') + self.assertEqual(result, 'C:/æøå') else: - result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, u'/tmp/æøå') + result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') + self.assertEqual(result, '/tmp/æøå') class SplitPathTest(unittest.TestCase): diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index c98527cd..0362dee3 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os from mopidy import exceptions, settings @@ -25,29 +27,29 @@ class ValidateSettingsTest(unittest.TestCase): self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) self.assertEqual( result['MPD_SERVER_HOSTNMAE'], - u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') + 'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) self.assertEqual( result['SERVER_HOSTNAME'], - u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') + 'Deprecated setting. Use MPD_SERVER_HOSTNAME.') def test_unneeded_settings_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) self.assertEqual( result['SPOTIFY_LIB_APPKEY'], - u'Deprecated setting. It may be removed.') + 'Deprecated setting. It may be removed.') def test_unavailable_bitrate_setting_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'SPOTIFY_BITRATE': 50}) self.assertEqual( result['SPOTIFY_BITRATE'], - u'Unavailable Spotify bitrate. ' - u'Available bitrates are 96, 160, and 320.') + 'Unavailable Spotify bitrate. ' + 'Available bitrates are 96, 160, and 320.') def test_two_errors_are_both_reported(self): result = setting_utils.validate_settings( @@ -56,7 +58,7 @@ class ValidateSettingsTest(unittest.TestCase): def test_masks_value_if_secret(self): secret = setting_utils.mask_value_if_secret('SPOTIFY_PASSWORD', 'bar') - self.assertEqual(u'********', secret) + self.assertEqual('********', secret) def test_does_not_mask_value_if_not_secret(self): not_secret = setting_utils.mask_value_if_secret( @@ -72,13 +74,13 @@ class ValidateSettingsTest(unittest.TestCase): result = setting_utils.validate_settings( self.defaults, {'FRONTENDS': []}) self.assertEqual( - result['FRONTENDS'], u'Must contain at least one value.') + result['FRONTENDS'], 'Must contain at least one value.') def test_empty_backends_list_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'BACKENDS': []}) self.assertEqual( - result['BACKENDS'], u'Must contain at least one value.') + result['BACKENDS'], 'Must contain at least one value.') class SettingsProxyTest(unittest.TestCase): @@ -93,17 +95,17 @@ class SettingsProxyTest(unittest.TestCase): def test_getattr_raises_error_on_missing_setting(self): try: self.settings.TEST - self.fail(u'Should raise exception') + self.fail('Should raise exception') except exceptions.SettingsError as e: - self.assertEqual(u'Setting "TEST" is not set.', e.message) + self.assertEqual('Setting "TEST" is not set.', e.message) def test_getattr_raises_error_on_empty_setting(self): - self.settings.TEST = u'' + self.settings.TEST = '' try: self.settings.TEST - self.fail(u'Should raise exception') + self.fail('Should raise exception') except exceptions.SettingsError as e: - self.assertEqual(u'Setting "TEST" is empty.', e.message) + self.assertEqual('Setting "TEST" is empty.', e.message) def test_getattr_does_not_raise_error_if_setting_is_false(self): self.settings.TEST = False @@ -191,12 +193,12 @@ class FormatSettingListTest(unittest.TestCase): self.settings = setting_utils.SettingsProxy(settings) def test_contains_the_setting_name(self): - self.settings.TEST = u'test' + self.settings.TEST = 'test' result = setting_utils.format_settings_list(self.settings) self.assertIn('TEST:', result, result) def test_repr_of_a_string_value(self): - self.settings.TEST = u'test' + self.settings.TEST = 'test' result = setting_utils.format_settings_list(self.settings) self.assertIn("TEST: u'test'", result, result) @@ -206,18 +208,18 @@ class FormatSettingListTest(unittest.TestCase): self.assertIn("TEST: 123", result, result) def test_repr_of_a_tuple_value(self): - self.settings.TEST = (123, u'abc') + self.settings.TEST = (123, 'abc') result = setting_utils.format_settings_list(self.settings) self.assertIn("TEST: (123, u'abc')", result, result) def test_passwords_are_masked(self): - self.settings.TEST_PASSWORD = u'secret' + self.settings.TEST_PASSWORD = 'secret' result = setting_utils.format_settings_list(self.settings) self.assertNotIn("TEST_PASSWORD: u'secret'", result, result) self.assertIn("TEST_PASSWORD: u'********'", result, result) def test_short_values_are_not_pretty_printed(self): - self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) + self.settings.FRONTEND = ('mopidy.frontends.mpd.MpdFrontend',) result = setting_utils.format_settings_list(self.settings) self.assertIn( "FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) diff --git a/tests/version_test.py b/tests/version_test.py index 2689a716..978660b0 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from distutils.version import StrictVersion as SV from mopidy import __version__ diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py index 4fb39b5b..938afa57 100755 --- a/tools/debug-proxy.py +++ b/tools/debug-proxy.py @@ -1,5 +1,7 @@ #! /usr/bin/env python +from __future__ import unicode_literals + import argparse import difflib import sys diff --git a/tools/idle.py b/tools/idle.py index fc9cb021..122e998d 100644 --- a/tools/idle.py +++ b/tools/idle.py @@ -3,6 +3,8 @@ # This script is helper to systematicly test the behaviour of MPD's idle # command. It is simply provided as a quick hack, expect nothing more. +from __future__ import unicode_literals + import logging import pprint import socket From 1b5b7abfdd2d44fd7c9bd1007a96afa14cd29dd8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 00:44:07 +0100 Subject: [PATCH 211/323] Allow settings prefixed with 'CUSTOM_' (fixes #204) --- docs/changes.rst | 3 +++ docs/settings.rst | 15 +++++++++++++++ mopidy/utils/settings.py | 3 +-- tests/utils/settings_test.py | 5 +++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 670921d9..81a27d33 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -96,6 +96,9 @@ backends: - Make the entire code base use unicode strings by default, and only fall back to bytestrings where it is required. Another step closer to Python 3. +- The settings validator will now allow any setting prefixed with ``CUSTOM_`` + to exist in the settings file. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/docs/settings.rst b/docs/settings.rst index 0449b458..cb47a71f 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -200,6 +200,21 @@ can use with the ``gst-launch-0.10`` command can be plugged into :attr:`mopidy.settings.OUTPUT`. +Custom settings +=============== + +Mopidy's settings validator will stop you from defining any settings in your +settings file that Mopidy doesn't know about. This may sound obnoxious, but it +helps you detect typos in your settings, and deprecated settings that should be +removed or updated. + +If you're extending Mopidy in some way, and want to use Mopidy's settings +system, you can prefix your settings with ``CUSTOM_`` to get around the +settings validator. We recommend that you choose names like +``CUSTOM_MYAPP_MYSETTING`` so that multiple custom extensions to Mopidy can be +used at the same time without any danger of naming collisions. + + Available settings ================== diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 105a94e3..fee5252d 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -121,7 +121,6 @@ def validate_settings(defaults, settings): errors = {} changed = { - 'CUSTOM_OUTPUT': 'OUTPUT', 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', @@ -176,7 +175,7 @@ def validate_settings(defaults, settings): if not value: errors[setting] = 'Must contain at least one value.' - elif setting not in defaults: + elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' suggestion = did_you_mean(setting, defaults) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 0362dee3..0ecbb90f 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -29,6 +29,11 @@ class ValidateSettingsTest(unittest.TestCase): result['MPD_SERVER_HOSTNMAE'], 'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') + def test_custom_settings_does_not_return_errors(self): + result = setting_utils.validate_settings( + self.defaults, {'CUSTOM_MYAPP_SETTING': 'foobar'}) + self.assertNotIn('CUSTOM_MYAPP_SETTING', result) + def test_not_renamed_setting_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) From bba9548b27a8c805b37206a13098f8ede63847bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 01:09:36 +0100 Subject: [PATCH 212/323] Rename 'current playlist' to 'tracklist' --- docs/api/concepts.rst | 6 +- docs/api/core.rst | 8 +- docs/changes.rst | 3 + mopidy/core/__init__.py | 2 +- mopidy/core/actor.py | 14 +- mopidy/core/playback.py | 176 +++++++++--------- .../{current_playlist.py => tracklist.py} | 92 ++++----- .../mpd/protocol/current_playlist.py | 123 ++++++------ mopidy/frontends/mpd/protocol/playback.py | 41 ++-- mopidy/frontends/mpd/protocol/status.py | 46 ++--- .../mpd/protocol/stored_playlists.py | 2 +- mopidy/frontends/mpd/translator.py | 16 +- mopidy/frontends/mpris/objects.py | 34 ++-- mopidy/models.py | 2 +- tests/backends/base/__init__.py | 2 +- tests/backends/base/playback.py | 90 ++++----- .../{current_playlist.py => tracklist.py} | 46 ++--- tests/backends/events_test.py | 10 +- tests/backends/local/playback_test.py | 2 +- ...ent_playlist_test.py => tracklist_test.py} | 10 +- tests/core/playback_test.py | 46 ++--- .../mpd/protocol/current_playlist_test.py | 174 ++++++++--------- tests/frontends/mpd/protocol/playback_test.py | 54 +++--- .../frontends/mpd/protocol/regression_test.py | 18 +- tests/frontends/mpd/protocol/status_test.py | 2 +- .../mpd/protocol/stored_playlists_test.py | 10 +- tests/frontends/mpd/serializer_test.py | 12 +- tests/frontends/mpd/status_test.py | 16 +- .../frontends/mpris/player_interface_test.py | 126 ++++++------- tests/models_test.py | 20 +- 30 files changed, 599 insertions(+), 604 deletions(-) rename mopidy/core/{current_playlist.py => tracklist.py} (72%) rename tests/backends/base/{current_playlist.py => tracklist.py} (88%) rename tests/backends/local/{current_playlist_test.py => tracklist_test.py} (58%) diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 203418de..2fc4d9b2 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -43,14 +43,14 @@ every request from a frontend it calls out to one or more backends which does the real work, and when the backends respond, the core actor is responsible for combining the responses into a single response to the requesting frontend. -The core actor also keeps track of the current playlist, since it doesn't -belong to a specific backend. +The core actor also keeps track of the tracklist, since it doesn't belong to a +specific backend. See :ref:`core-api` for more details. .. digraph:: core_architecture - Core -> "Current\nplaylist\ncontroller" + Core -> "Tracklist\ncontroller" Core -> "Library\ncontroller" Core -> "Playback\ncontroller" Core -> "Stored\nplaylists\ncontroller" diff --git a/docs/api/core.rst b/docs/api/core.rst index eb1b9683..9f5d43d2 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -26,12 +26,12 @@ seek, and volume control. :members: -Current playlist controller -=========================== +Tracklist controller +==================== -Manages everything related to the currently loaded playlist. +Manages everything related to the tracks we are currently playing. -.. autoclass:: mopidy.core.CurrentPlaylistController +.. autoclass:: mopidy.core.TracklistController :members: diff --git a/docs/changes.rst b/docs/changes.rst index 81a27d33..a82dafe4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -99,6 +99,9 @@ backends: - The settings validator will now allow any setting prefixed with ``CUSTOM_`` to exist in the settings file. +- Renamed "current playlist" to "tracklist" everywhere, including the core API + used by frontends. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index c8648766..eaa50ec6 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -2,8 +2,8 @@ from __future__ import unicode_literals # flake8: noqa from .actor import Core -from .current_playlist import CurrentPlaylistController from .library import LibraryController from .listener import CoreListener from .playback import PlaybackController, PlaybackState from .stored_playlists import StoredPlaylistsController +from .tracklist import TracklistController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 52027d96..731e5309 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -6,17 +6,13 @@ import pykka from mopidy.audio import AudioListener -from .current_playlist import CurrentPlaylistController from .library import LibraryController from .playback import PlaybackController from .stored_playlists import StoredPlaylistsController +from .tracklist import TracklistController class Core(pykka.ThreadingActor, AudioListener): - #: The current playlist controller. An instance of - #: :class:`mopidy.core.CurrentPlaylistController`. - current_playlist = None - #: The library controller. An instance of # :class:`mopidy.core.LibraryController`. library = None @@ -29,13 +25,15 @@ class Core(pykka.ThreadingActor, AudioListener): #: :class:`mopidy.core.StoredPlaylistsController`. stored_playlists = None + #: The tracklist controller. An instance of + #: :class:`mopidy.core.TracklistController`. + tracklist = None + def __init__(self, audio=None, backends=None): super(Core, self).__init__() self.backends = Backends(backends) - self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backends=self.backends, core=self) self.playback = PlaybackController( @@ -44,6 +42,8 @@ class Core(pykka.ThreadingActor, AudioListener): self.stored_playlists = StoredPlaylistsController( backends=self.backends, core=self) + self.tracklist = TracklistController(core=self) + @property def uri_schemes(self): """List of URI schemes we can handle""" diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 5f517c66..54364ec2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -53,9 +53,9 @@ class PlaybackController(object): #: The currently playing or selected track. #: - #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or + #: A two-tuple of (TLID integer, :class:`mopidy.models.Track`) or #: :class:`None`. - current_cp_track = None + current_tl_track = None #: :class:`True` #: Tracks are selected at random from the playlist. @@ -88,53 +88,52 @@ class PlaybackController(object): self._volume = None def _get_backend(self): - if self.current_cp_track is None: + if self.current_tl_track is None: return None - uri = self.current_cp_track.track.uri + uri = self.current_tl_track.track.uri uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) - def _get_cpid(self, cp_track): - if cp_track is None: + def _get_tlid(self, tl_track): + if tl_track is None: return None - return cp_track.cpid + return tl_track.tlid - def _get_track(self, cp_track): - if cp_track is None: + def _get_track(self, tl_track): + if tl_track is None: return None - return cp_track.track + return tl_track.track @property - def current_cpid(self): + def current_tlid(self): """ - The CPID (current playlist ID) of the currently playing or selected + The TLID (tracklist ID) of the currently playing or selected track. - Read-only. Extracted from :attr:`current_cp_track` for convenience. + Read-only. Extracted from :attr:`current_tl_track` for convenience. """ - return self._get_cpid(self.current_cp_track) + return self._get_tlid(self.current_tl_track) @property def current_track(self): """ The currently playing or selected :class:`mopidy.models.Track`. - Read-only. Extracted from :attr:`current_cp_track` for convenience. + Read-only. Extracted from :attr:`current_tl_track` for convenience. """ - return self._get_track(self.current_cp_track) + return self._get_track(self.current_tl_track) @property - def current_playlist_position(self): + def tracklist_position(self): """ - The position of the current track in the current playlist. + The position of the current track in the tracklist. Read-only. """ - if self.current_cp_track is None: + if self.current_tl_track is None: return None try: - return self.core.current_playlist.cp_tracks.index( - self.current_cp_track) + return self.core.tracklist.tl_tracks.index(self.current_tl_track) except ValueError: return None @@ -144,49 +143,48 @@ class PlaybackController(object): The track that will be played at the end of the current track. Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_eot` for convenience. + :attr:`tl_track_at_eot` for convenience. """ - return self._get_track(self.cp_track_at_eot) + return self._get_track(self.tl_track_at_eot) @property - def cp_track_at_eot(self): + def tl_track_at_eot(self): """ The track that will be played at the end of the current track. - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + Read-only. A two-tuple of (TLID integer, :class:`mopidy.models.Track`). - Not necessarily the same track as :attr:`cp_track_at_next`. + Not necessarily the same track as :attr:`tl_track_at_next`. """ # pylint: disable = R0911 # Too many return statements - cp_tracks = self.core.current_playlist.cp_tracks + tl_tracks = self.core.tracklist.tl_tracks - if not cp_tracks: + if not tl_tracks: return None if self.random and not self._shuffled: if self.repeat or self._first_shuffle: logger.debug('Shuffling tracks') - self._shuffled = cp_tracks + self._shuffled = tl_tracks random.shuffle(self._shuffled) self._first_shuffle = False if self.random and self._shuffled: return self._shuffled[0] - if self.current_cp_track is None: - return cp_tracks[0] + if self.current_tl_track is None: + return tl_tracks[0] if self.repeat and self.single: - return cp_tracks[self.current_playlist_position] + return tl_tracks[self.tracklist_position] if self.repeat and not self.single: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] + return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] try: - return cp_tracks[self.current_playlist_position + 1] + return tl_tracks[self.tracklist_position + 1] except IndexError: return None @@ -196,46 +194,45 @@ class PlaybackController(object): The track that will be played if calling :meth:`next()`. Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_next` for convenience. + :attr:`tl_track_at_next` for convenience. """ - return self._get_track(self.cp_track_at_next) + return self._get_track(self.tl_track_at_next) @property - def cp_track_at_next(self): + def tl_track_at_next(self): """ The track that will be played if calling :meth:`next()`. - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + Read-only. A two-tuple of (TLID integer, :class:`mopidy.models.Track`). For normal playback this is the next track in the playlist. If repeat is enabled the next track can loop around the playlist. When random is enabled this should be a random track, all tracks should be played once before the list repeats. """ - cp_tracks = self.core.current_playlist.cp_tracks + tl_tracks = self.core.tracklist.tl_tracks - if not cp_tracks: + if not tl_tracks: return None if self.random and not self._shuffled: if self.repeat or self._first_shuffle: logger.debug('Shuffling tracks') - self._shuffled = cp_tracks + self._shuffled = tl_tracks random.shuffle(self._shuffled) self._first_shuffle = False if self.random and self._shuffled: return self._shuffled[0] - if self.current_cp_track is None: - return cp_tracks[0] + if self.current_tl_track is None: + return tl_tracks[0] if self.repeat: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] + return tl_tracks[(self.tracklist_position + 1) % len(tl_tracks)] try: - return cp_tracks[self.current_playlist_position + 1] + return tl_tracks[self.tracklist_position + 1] except IndexError: return None @@ -245,29 +242,28 @@ class PlaybackController(object): The track that will be played if calling :meth:`previous()`. Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_previous` for convenience. + :attr:`tl_track_at_previous` for convenience. """ - return self._get_track(self.cp_track_at_previous) + return self._get_track(self.tl_track_at_previous) @property - def cp_track_at_previous(self): + def tl_track_at_previous(self): """ The track that will be played if calling :meth:`previous()`. - A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + A two-tuple of (TLID integer, :class:`mopidy.models.Track`). For normal playback this is the previous track in the playlist. If random and/or consume is enabled it should return the current track instead. """ if self.repeat or self.consume or self.random: - return self.current_cp_track + return self.current_tl_track - if self.current_playlist_position in (None, 0): + if self.tracklist_position in (None, 0): return None - return self.core.current_playlist.cp_tracks[ - self.current_playlist_position - 1] + return self.core.tracklist.tl_tracks[self.tracklist_position - 1] @property def state(self): @@ -322,12 +318,12 @@ class PlaybackController(object): # For testing self._volume = volume - def change_track(self, cp_track, on_error_step=1): + def change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. - :param cp_track: track to change to - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + :param tl_track: track to change to + :type tl_track: two-tuple (TLID integer, :class:`mopidy.models.Track`) or :class:`None` :param on_error_step: direction to step at play error, 1 for next track (default), -1 for previous track @@ -336,7 +332,7 @@ class PlaybackController(object): """ old_state = self.state self.stop() - self.current_cp_track = cp_track + self.current_tl_track = tl_track if old_state == PlaybackState.PLAYING: self.play(on_error_step=on_error_step) elif old_state == PlaybackState.PAUSED: @@ -349,18 +345,18 @@ class PlaybackController(object): if self.state == PlaybackState.STOPPED: return - original_cp_track = self.current_cp_track + original_tl_track = self.current_tl_track - if self.cp_track_at_eot: + if self.tl_track_at_eot: self._trigger_track_playback_ended() - self.play(self.cp_track_at_eot) + self.play(self.tl_track_at_eot) else: self.stop(clear_current_track=True) if self.consume: - self.core.current_playlist.remove(cpid=original_cp_track.cpid) + self.core.tracklist.remove(tlid=original_tl_track.tlid) - def on_current_playlist_change(self): + def on_tracklist_change(self): """ Tell the playback controller that the current playlist has changed. @@ -369,9 +365,9 @@ class PlaybackController(object): self._first_shuffle = True self._shuffled = [] - if (not self.core.current_playlist.cp_tracks or - self.current_cp_track not in - self.core.current_playlist.cp_tracks): + if (not self.core.tracklist.tl_tracks or + self.current_tl_track not in + self.core.tracklist.tl_tracks): self.stop(clear_current_track=True) def next(self): @@ -381,9 +377,9 @@ class PlaybackController(object): The current playback state will be kept. If it was playing, playing will continue. If it was paused, it will still be paused, etc. """ - if self.cp_track_at_next: + if self.tl_track_at_next: self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_next) + self.change_track(self.tl_track_at_next) else: self.stop(clear_current_track=True) @@ -394,46 +390,46 @@ class PlaybackController(object): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() - def play(self, cp_track=None, on_error_step=1): + def play(self, tl_track=None, on_error_step=1): """ Play the given track, or if the given track is :class:`None`, play the currently active track. - :param cp_track: track to play - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + :param tl_track: track to play + :type tl_track: two-tuple (TLID integer, :class:`mopidy.models.Track`) or :class:`None` :param on_error_step: direction to step at play error, 1 for next track (default), -1 for previous track :type on_error_step: int, -1 or 1 """ - if cp_track is not None: - assert cp_track in self.core.current_playlist.cp_tracks - elif cp_track is None: + if tl_track is not None: + assert tl_track in self.core.tracklist.tl_tracks + elif tl_track is None: if self.state == PlaybackState.PAUSED: return self.resume() - elif self.current_cp_track is not None: - cp_track = self.current_cp_track - elif self.current_cp_track is None and on_error_step == 1: - cp_track = self.cp_track_at_next - elif self.current_cp_track is None and on_error_step == -1: - cp_track = self.cp_track_at_previous + elif self.current_tl_track is not None: + tl_track = self.current_tl_track + elif self.current_tl_track is None and on_error_step == 1: + tl_track = self.tl_track_at_next + elif self.current_tl_track is None and on_error_step == -1: + tl_track = self.tl_track_at_previous - if cp_track is not None: - self.current_cp_track = cp_track + if tl_track is not None: + self.current_tl_track = tl_track self.state = PlaybackState.PLAYING backend = self._get_backend() - if not backend or not backend.playback.play(cp_track.track).get(): + if not backend or not backend.playback.play(tl_track.track).get(): # Track is not playable if self.random and self._shuffled: - self._shuffled.remove(cp_track) + self._shuffled.remove(tl_track) if on_error_step == 1: self.next() elif on_error_step == -1: self.previous() - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) + if self.random and self.current_tl_track in self._shuffled: + self._shuffled.remove(self.current_tl_track) self._trigger_track_playback_started() @@ -445,7 +441,7 @@ class PlaybackController(object): will continue. If it was paused, it will still be paused, etc. """ self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_previous, on_error_step=-1) + self.change_track(self.tl_track_at_previous, on_error_step=-1) def resume(self): """If paused, resume playing the current track.""" @@ -464,7 +460,7 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - if not self.core.current_playlist.tracks: + if not self.core.tracklist.tracks: return False if self.state == PlaybackState.STOPPED: @@ -501,7 +497,7 @@ class PlaybackController(object): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: - self.current_cp_track = None + self.current_tl_track = None def _trigger_track_playback_paused(self): logger.debug('Triggering track playback paused event') diff --git a/mopidy/core/current_playlist.py b/mopidy/core/tracklist.py similarity index 72% rename from mopidy/core/current_playlist.py rename to mopidy/core/tracklist.py index bd4f7b46..529d2a7a 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/tracklist.py @@ -4,7 +4,7 @@ from copy import copy import logging import random -from mopidy.models import CpTrack +from mopidy.models import TlTrack from . import listener @@ -12,23 +12,23 @@ from . import listener logger = logging.getLogger('mopidy.core') -class CurrentPlaylistController(object): +class TracklistController(object): pykka_traversable = True def __init__(self, core): self.core = core - self.cp_id = 0 - self._cp_tracks = [] + self.tlid = 0 + self._tl_tracks = [] self._version = 0 @property - def cp_tracks(self): + def tl_tracks(self): """ - List of two-tuples of (CPID integer, :class:`mopidy.models.Track`). + List of two-tuples of (TLID integer, :class:`mopidy.models.Track`). Read-only. """ - return [copy(cp_track) for cp_track in self._cp_tracks] + return [copy(tl_track) for tl_track in self._tl_tracks] @property def tracks(self): @@ -37,14 +37,14 @@ class CurrentPlaylistController(object): Read-only. """ - return [cp_track.track for cp_track in self._cp_tracks] + return [tl_track.track for tl_track in self._tl_tracks] @property def length(self): """ Length of the current playlist. """ - return len(self._cp_tracks) + return len(self._tl_tracks) @property def version(self): @@ -57,7 +57,7 @@ class CurrentPlaylistController(object): @version.setter # noqa def version(self, version): self._version = version - self.core.playback.on_current_playlist_change() + self.core.playback.on_tracklist_change() self._trigger_playlist_changed() def add(self, track, at_position=None, increase_version=True): @@ -71,20 +71,20 @@ class CurrentPlaylistController(object): :type at_position: int or :class:`None` :param increase_version: if the playlist version should be increased :type increase_version: :class:`True` or :class:`False` - :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that + :rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) that was added to the current playlist playlist """ - assert at_position <= len(self._cp_tracks), \ + assert at_position <= len(self._tl_tracks), \ 'at_position can not be greater than playlist length' - cp_track = CpTrack(self.cp_id, track) + tl_track = TlTrack(self.tlid, track) if at_position is not None: - self._cp_tracks.insert(at_position, cp_track) + self._tl_tracks.insert(at_position, tl_track) else: - self._cp_tracks.append(cp_track) + self._tl_tracks.append(tl_track) if increase_version: self.version += 1 - self.cp_id += 1 - return cp_track + self.tlid += 1 + return tl_track def append(self, tracks): """ @@ -101,7 +101,7 @@ class CurrentPlaylistController(object): def clear(self): """Clear the current playlist.""" - self._cp_tracks = [] + self._tl_tracks = [] self.version += 1 def get(self, **criteria): @@ -112,7 +112,7 @@ class CurrentPlaylistController(object): Examples:: - get(cpid=7) # Returns track with CPID 7 + get(tlid=7) # Returns track with TLID 7 # (current playlist ID) get(id=1) # Returns track with ID 1 get(uri='xyz') # Returns track with URI 'xyz' @@ -120,12 +120,12 @@ class CurrentPlaylistController(object): :param criteria: on or more criteria to match by :type criteria: dict - :rtype: two-tuple (CPID integer, :class:`mopidy.models.Track`) + :rtype: two-tuple (TLID integer, :class:`mopidy.models.Track`) """ - matches = self._cp_tracks + matches = self._tl_tracks for (key, value) in criteria.iteritems(): - if key == 'cpid': - matches = filter(lambda ct: ct.cpid == value, matches) + if key == 'tlid': + matches = filter(lambda ct: ct.tlid == value, matches) else: matches = filter( lambda ct: getattr(ct.track, key) == value, matches) @@ -138,18 +138,18 @@ class CurrentPlaylistController(object): else: raise LookupError('"%s" match multiple tracks' % criteria_string) - def index(self, cp_track): + def index(self, tl_track): """ - Get index of the given (CPID integer, :class:`mopidy.models.Track`) + Get index of the given (TLID integer, :class:`mopidy.models.Track`) two-tuple in the current playlist. Raises :exc:`ValueError` if not found. - :param cp_track: track to find the index of - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + :param tl_track: track to find the index of + :type tl_track: two-tuple (TLID integer, :class:`mopidy.models.Track`) :rtype: int """ - return self._cp_tracks.index(cp_track) + return self._tl_tracks.index(tl_track) def move(self, start, end, to_position): """ @@ -165,21 +165,21 @@ class CurrentPlaylistController(object): if start == end: end += 1 - cp_tracks = self._cp_tracks + tl_tracks = self._tl_tracks assert start < end, 'start must be smaller than end' assert start >= 0, 'start must be at least zero' - assert end <= len(cp_tracks), \ + assert end <= len(tl_tracks), \ 'end can not be larger than playlist length' assert to_position >= 0, 'to_position must be at least zero' - assert to_position <= len(cp_tracks), \ + assert to_position <= len(tl_tracks), \ 'to_position can not be larger than playlist length' - new_cp_tracks = cp_tracks[:start] + cp_tracks[end:] - for cp_track in cp_tracks[start:end]: - new_cp_tracks.insert(to_position, cp_track) + new_tl_tracks = tl_tracks[:start] + tl_tracks[end:] + for tl_track in tl_tracks[start:end]: + new_tl_tracks.insert(to_position, tl_track) to_position += 1 - self._cp_tracks = new_cp_tracks + self._tl_tracks = new_tl_tracks self.version += 1 def remove(self, **criteria): @@ -191,9 +191,9 @@ class CurrentPlaylistController(object): :param criteria: on or more criteria to match by :type criteria: dict """ - cp_track = self.get(**criteria) - position = self._cp_tracks.index(cp_track) - del self._cp_tracks[position] + tl_track = self.get(**criteria) + position = self._tl_tracks.index(tl_track) + del self._tl_tracks[position] self.version += 1 def shuffle(self, start=None, end=None): @@ -206,7 +206,7 @@ class CurrentPlaylistController(object): :param end: position after last track to shuffle :type end: int or :class:`None` """ - cp_tracks = self._cp_tracks + tl_tracks = self._tl_tracks if start is not None and end is not None: assert start < end, 'start must be smaller than end' @@ -215,14 +215,14 @@ class CurrentPlaylistController(object): assert start >= 0, 'start must be at least zero' if end is not None: - assert end <= len(cp_tracks), 'end can not be larger than ' + \ + assert end <= len(tl_tracks), 'end can not be larger than ' + \ 'playlist length' - before = cp_tracks[:start or 0] - shuffled = cp_tracks[start:end] - after = cp_tracks[end or len(cp_tracks):] + before = tl_tracks[:start or 0] + shuffled = tl_tracks[start:end] + after = tl_tracks[end or len(tl_tracks):] random.shuffle(shuffled) - self._cp_tracks = before + shuffled + after + self._tl_tracks = before + shuffled + after self.version += 1 def slice(self, start, end): @@ -234,9 +234,9 @@ class CurrentPlaylistController(object): :type start: int :param end: position after last track to include in slice :type end: int - :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) + :rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) """ - return [copy(cp_track) for cp_track in self._cp_tracks[start:end]] + return [copy(tl_track) for tl_track in self._tl_tracks[start:end]] def _trigger_playlist_changed(self): logger.debug('Triggering playlist changed event') diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 57b06e1a..500e88a8 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -26,7 +26,7 @@ def add(context, uri): if uri.startswith(uri_scheme): track = context.core.library.lookup(uri).get() if track is not None: - context.core.current_playlist.add(track) + context.core.tracklist.add(track) return raise MpdNoExistError('directory or file not found', command='add') @@ -57,11 +57,10 @@ def addid(context, uri, songpos=None): track = context.core.library.lookup(uri).get() if track is None: raise MpdNoExistError('No such song', command='addid') - if songpos and songpos > context.core.current_playlist.length.get(): + if songpos and songpos > context.core.tracklist.length.get(): raise MpdArgError('Bad song index', command='addid') - cp_track = context.core.current_playlist.add( - track, at_position=songpos).get() - return ('Id', cp_track.cpid) + tl_track = context.core.tracklist.add(track, at_position=songpos).get() + return ('Id', tl_track.tlid) @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') @@ -77,12 +76,12 @@ def delete_range(context, start, end=None): if end is not None: end = int(end) else: - end = context.core.current_playlist.length.get() - cp_tracks = context.core.current_playlist.slice(start, end).get() - if not cp_tracks: + end = context.core.tracklist.length.get() + tl_tracks = context.core.tracklist.slice(start, end).get() + if not tl_tracks: raise MpdArgError('Bad song index', command='delete') - for (cpid, _) in cp_tracks: - context.core.current_playlist.remove(cpid=cpid) + for (tlid, _) in tl_tracks: + context.core.tracklist.remove(tlid=tlid) @handle_request(r'^delete "(?P\d+)"$') @@ -90,15 +89,15 @@ def delete_songpos(context, songpos): """See :meth:`delete_range`""" try: songpos = int(songpos) - (cpid, _) = context.core.current_playlist.slice( + (tlid, _) = context.core.tracklist.slice( songpos, songpos + 1).get()[0] - context.core.current_playlist.remove(cpid=cpid) + context.core.tracklist.remove(tlid=tlid) except IndexError: raise MpdArgError('Bad song index', command='delete') -@handle_request(r'^deleteid "(?P\d+)"$') -def deleteid(context, cpid): +@handle_request(r'^deleteid "(?P\d+)"$') +def deleteid(context, tlid): """ *musicpd.org, current playlist section:* @@ -107,10 +106,10 @@ def deleteid(context, cpid): Deletes the song ``SONGID`` from the playlist """ try: - cpid = int(cpid) - if context.core.playback.current_cpid.get() == cpid: + tlid = int(tlid) + if context.core.playback.current_tlid.get() == tlid: context.core.playback.next() - return context.core.current_playlist.remove(cpid=cpid).get() + return context.core.tracklist.remove(tlid=tlid).get() except LookupError: raise MpdNoExistError('No such song', command='deleteid') @@ -124,7 +123,7 @@ def clear(context): Clears the current playlist. """ - context.core.current_playlist.clear() + context.core.tracklist.clear() @handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') @@ -138,11 +137,11 @@ def move_range(context, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = context.core.current_playlist.length.get() + end = context.core.tracklist.length.get() start = int(start) end = int(end) to = int(to) - context.core.current_playlist.move(start, end, to) + context.core.tracklist.move(start, end, to) @handle_request(r'^move "(?P\d+)" "(?P\d+)"$') @@ -150,11 +149,11 @@ def move_songpos(context, songpos, to): """See :meth:`move_range`.""" songpos = int(songpos) to = int(to) - context.core.current_playlist.move(songpos, songpos + 1, to) + context.core.tracklist.move(songpos, songpos + 1, to) -@handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') -def moveid(context, cpid, to): +@handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') +def moveid(context, tlid, to): """ *musicpd.org, current playlist section:* @@ -164,11 +163,11 @@ def moveid(context, cpid, to): the playlist. If ``TO`` is negative, it is relative to the current song in the playlist (if there is one). """ - cpid = int(cpid) + tlid = int(tlid) to = int(to) - cp_track = context.core.current_playlist.get(cpid=cpid).get() - position = context.core.current_playlist.index(cp_track).get() - context.core.current_playlist.move(position, position + 1, to) + tl_track = context.core.tracklist.get(tlid=tlid).get() + position = context.core.tracklist.index(tl_track).get() + context.core.tracklist.move(position, position + 1, to) @handle_request(r'^playlist$') @@ -203,16 +202,16 @@ def playlistfind(context, tag, needle): """ if tag == 'filename': try: - cp_track = context.core.current_playlist.get(uri=needle).get() - position = context.core.current_playlist.index(cp_track).get() - return translator.track_to_mpd_format(cp_track, position=position) + tl_track = context.core.tracklist.get(uri=needle).get() + position = context.core.tracklist.index(tl_track).get() + return translator.track_to_mpd_format(tl_track, position=position) except LookupError: return None raise MpdNotImplemented # TODO -@handle_request(r'^playlistid( "(?P\d+)")*$') -def playlistid(context, cpid=None): +@handle_request(r'^playlistid( "(?P\d+)")*$') +def playlistid(context, tlid=None): """ *musicpd.org, current playlist section:* @@ -221,17 +220,17 @@ def playlistid(context, cpid=None): Displays a list of songs in the playlist. ``SONGID`` is optional and specifies a single song to display info for. """ - if cpid is not None: + if tlid is not None: try: - cpid = int(cpid) - cp_track = context.core.current_playlist.get(cpid=cpid).get() - position = context.core.current_playlist.index(cp_track).get() - return translator.track_to_mpd_format(cp_track, position=position) + tlid = int(tlid) + tl_track = context.core.tracklist.get(tlid=tlid).get() + position = context.core.tracklist.index(tl_track).get() + return translator.track_to_mpd_format(tl_track, position=position) except LookupError: raise MpdNoExistError('No such song', command='playlistid') else: return translator.tracks_to_mpd_format( - context.core.current_playlist.cp_tracks.get()) + context.core.tracklist.tl_tracks.get()) @handle_request(r'^playlistinfo$') @@ -255,20 +254,20 @@ def playlistinfo(context, songpos=None, start=None, end=None): """ if songpos is not None: songpos = int(songpos) - cp_track = context.core.current_playlist.cp_tracks.get()[songpos] - return translator.track_to_mpd_format(cp_track, position=songpos) + tl_track = context.core.tracklist.tl_tracks.get()[songpos] + return translator.track_to_mpd_format(tl_track, position=songpos) else: if start is None: start = 0 start = int(start) - if not (0 <= start <= context.core.current_playlist.length.get()): + if not (0 <= start <= context.core.tracklist.length.get()): raise MpdArgError('Bad song index', command='playlistinfo') if end is not None: end = int(end) - if end > context.core.current_playlist.length.get(): + if end > context.core.tracklist.length.get(): end = None - cp_tracks = context.core.current_playlist.cp_tracks.get() - return translator.tracks_to_mpd_format(cp_tracks, start, end) + tl_tracks = context.core.tracklist.tl_tracks.get() + return translator.tracks_to_mpd_format(tl_tracks, start, end) @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @@ -308,9 +307,9 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.core.current_playlist.version.get(): + if int(version) < context.core.tracklist.version.get(): return translator.tracks_to_mpd_format( - context.core.current_playlist.cp_tracks.get()) + context.core.tracklist.tl_tracks.get()) @handle_request(r'^plchangesposid "(?P\d+)"$') @@ -328,12 +327,12 @@ def plchangesposid(context, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != context.core.current_playlist.version.get(): + if int(version) != context.core.tracklist.version.get(): result = [] - for (position, (cpid, _)) in enumerate( - context.core.current_playlist.cp_tracks.get()): + for (position, (tlid, _)) in enumerate( + context.core.tracklist.tl_tracks.get()): result.append(('cpos', position)) - result.append(('Id', cpid)) + result.append(('Id', tlid)) return result @@ -352,7 +351,7 @@ def shuffle(context, start=None, end=None): start = int(start) if end is not None: end = int(end) - context.core.current_playlist.shuffle(start, end) + context.core.tracklist.shuffle(start, end) @handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') @@ -366,19 +365,19 @@ def swap(context, songpos1, songpos2): """ songpos1 = int(songpos1) songpos2 = int(songpos2) - tracks = context.core.current_playlist.tracks.get() + tracks = context.core.tracklist.tracks.get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) - context.core.current_playlist.clear() - context.core.current_playlist.append(tracks) + context.core.tracklist.clear() + context.core.tracklist.append(tracks) -@handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') -def swapid(context, cpid1, cpid2): +@handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') +def swapid(context, tlid1, tlid2): """ *musicpd.org, current playlist section:* @@ -386,10 +385,10 @@ def swapid(context, cpid1, cpid2): Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). """ - cpid1 = int(cpid1) - cpid2 = int(cpid2) - cp_track1 = context.core.current_playlist.get(cpid=cpid1).get() - cp_track2 = context.core.current_playlist.get(cpid=cpid2).get() - position1 = context.core.current_playlist.index(cp_track1).get() - position2 = context.core.current_playlist.index(cp_track2).get() + tlid1 = int(tlid1) + tlid2 = int(tlid2) + tl_track1 = context.core.tracklist.get(tlid=tlid1).get() + tl_track2 = context.core.tracklist.get(tlid=tlid2).get() + position1 = context.core.tracklist.index(tl_track1).get() + position2 = context.core.tracklist.index(tl_track2).get() swap(context, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 35ceddad..74ecfb1c 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -129,9 +129,9 @@ def play(context): return context.core.playback.play().get() -@handle_request(r'^playid (?P-?\d+)$') -@handle_request(r'^playid "(?P-?\d+)"$') -def playid(context, cpid): +@handle_request(r'^playid (?P-?\d+)$') +@handle_request(r'^playid "(?P-?\d+)"$') +def playid(context, tlid): """ *musicpd.org, playback section:* @@ -148,12 +148,12 @@ def playid(context, cpid): - ``playid "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. """ - cpid = int(cpid) - if cpid == -1: + tlid = int(tlid) + if tlid == -1: return _play_minus_one(context) try: - cp_track = context.core.current_playlist.get(cpid=cpid).get() - return context.core.playback.play(cp_track).get() + tl_track = context.core.tracklist.get(tlid=tlid).get() + return context.core.playback.play(tl_track).get() except LookupError: raise MpdNoExistError('No such song', command='playid') @@ -185,9 +185,8 @@ def playpos(context, songpos): if songpos == -1: return _play_minus_one(context) try: - cp_track = context.core.current_playlist.slice( - songpos, songpos + 1).get()[0] - return context.core.playback.play(cp_track).get() + tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0] + return context.core.playback.play(tl_track).get() except IndexError: raise MpdArgError('Bad song index', command='play') @@ -197,12 +196,12 @@ def _play_minus_one(context): return # Nothing to do elif (context.core.playback.state.get() == PlaybackState.PAUSED): return context.core.playback.resume().get() - elif context.core.playback.current_cp_track.get() is not None: - cp_track = context.core.playback.current_cp_track.get() - return context.core.playback.play(cp_track).get() - elif context.core.current_playlist.slice(0, 1).get(): - cp_track = context.core.current_playlist.slice(0, 1).get()[0] - return context.core.playback.play(cp_track).get() + elif context.core.playback.current_tl_track.get() is not None: + tl_track = context.core.playback.current_tl_track.get() + return context.core.playback.play(tl_track).get() + elif context.core.tracklist.slice(0, 1).get(): + tl_track = context.core.tracklist.slice(0, 1).get()[0] + return context.core.playback.play(tl_track).get() else: return # Fail silently @@ -331,13 +330,13 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.core.playback.current_playlist_position != songpos: + if context.core.playback.tracklist_position != songpos: playpos(context, songpos) context.core.playback.seek(int(seconds) * 1000) -@handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') -def seekid(context, cpid, seconds): +@handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') +def seekid(context, tlid, seconds): """ *musicpd.org, playback section:* @@ -345,8 +344,8 @@ def seekid(context, cpid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - if context.core.playback.current_cpid != cpid: - playid(context, cpid) + if context.core.playback.current_tlid != tlid: + playid(context, tlid) context.core.playback.seek(int(seconds) * 1000) diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index c5b283da..34e2fa64 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -36,10 +36,10 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - current_cp_track = context.core.playback.current_cp_track.get() - if current_cp_track is not None: - position = context.core.playback.current_playlist_position.get() - return track_to_mpd_format(current_cp_track, position=position) + current_tl_track = context.core.playback.current_tl_track.get() + if current_tl_track is not None: + position = context.core.playback.tracklist_position.get() + return track_to_mpd_format(current_tl_track, position=position) @handle_request(r'^idle$') @@ -175,17 +175,17 @@ def status(context): decimal places for millisecond precision. """ futures = { - 'current_playlist.length': context.core.current_playlist.length, - 'current_playlist.version': context.core.current_playlist.version, + 'tracklist.length': context.core.tracklist.length, + 'tracklist.version': context.core.tracklist.version, 'playback.volume': context.core.playback.volume, 'playback.consume': context.core.playback.consume, 'playback.random': context.core.playback.random, 'playback.repeat': context.core.playback.repeat, 'playback.single': context.core.playback.single, 'playback.state': context.core.playback.state, - 'playback.current_cp_track': context.core.playback.current_cp_track, - 'playback.current_playlist_position': ( - context.core.playback.current_playlist_position), + 'playback.current_tl_track': context.core.playback.current_tl_track, + 'playback.tracklist_position': ( + context.core.playback.tracklist_position), 'playback.time_position': context.core.playback.time_position, } pykka.get_all(futures.values()) @@ -200,7 +200,7 @@ def status(context): ('xfade', _status_xfade(futures)), ('state', _status_state(futures)), ] - if futures['playback.current_cp_track'].get() is not None: + if futures['playback.current_tl_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) if futures['playback.state'].get() in ( @@ -212,9 +212,9 @@ def status(context): def _status_bitrate(futures): - current_cp_track = futures['playback.current_cp_track'].get() - if current_cp_track is not None: - return current_cp_track.track.bitrate + current_tl_track = futures['playback.current_tl_track'].get() + if current_tl_track is not None: + return current_tl_track.track.bitrate def _status_consume(futures): @@ -225,11 +225,11 @@ def _status_consume(futures): def _status_playlist_length(futures): - return futures['current_playlist.length'].get() + return futures['tracklist.length'].get() def _status_playlist_version(futures): - return futures['current_playlist.version'].get() + return futures['tracklist.version'].get() def _status_random(futures): @@ -245,15 +245,15 @@ def _status_single(futures): def _status_songid(futures): - current_cp_track = futures['playback.current_cp_track'].get() - if current_cp_track is not None: - return current_cp_track.cpid + current_tl_track = futures['playback.current_tl_track'].get() + if current_tl_track is not None: + return current_tl_track.tlid else: return _status_songpos(futures) def _status_songpos(futures): - return futures['playback.current_playlist_position'].get() + return futures['playback.tracklist_position'].get() def _status_state(futures): @@ -277,13 +277,13 @@ def _status_time_elapsed(futures): def _status_time_total(futures): - current_cp_track = futures['playback.current_cp_track'].get() - if current_cp_track is None: + current_tl_track = futures['playback.current_tl_track'].get() + if current_tl_track is None: return 0 - elif current_cp_track.track.length is None: + elif current_tl_track.track.length is None: return 0 else: - return current_cp_track.track.length + return current_tl_track.track.length def _status_volume(futures): diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index fc618201..e81b3ab0 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -102,7 +102,7 @@ def load(context, name): """ try: playlist = context.core.stored_playlists.get(name=name).get() - context.core.current_playlist.append(playlist.tracks) + context.core.tracklist.append(playlist.tracks) except LookupError: raise MpdNoExistError('No such playlist', command='load') diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 0f4aed68..36b00772 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -5,7 +5,7 @@ import re from mopidy import settings from mopidy.frontends.mpd import protocol -from mopidy.models import CpTrack +from mopidy.models import TlTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path @@ -14,7 +14,7 @@ def track_to_mpd_format(track, position=None): Format track for output to MPD client. :param track: the track - :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.CpTrack` + :type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack` :param position: track's position in playlist :type position: integer :param key: if we should set key @@ -23,10 +23,10 @@ def track_to_mpd_format(track, position=None): :type mtime: boolean :rtype: list of two-tuples """ - if isinstance(track, CpTrack): - (cpid, track) = track + if isinstance(track, TlTrack): + (tlid, track) = track else: - (cpid, track) = (None, track) + (tlid, track) = (None, track) result = [ ('file', track.uri or ''), ('Time', track.length and (track.length // 1000) or 0), @@ -43,9 +43,9 @@ def track_to_mpd_format(track, position=None): if track.album is not None and track.album.artists: artists = artists_to_mpd_format(track.album.artists) result.append(('AlbumArtist', artists)) - if position is not None and cpid is not None: + if position is not None and tlid is not None: result.append(('Pos', position)) - result.append(('Id', cpid)) + result.append(('Id', tlid)) if track.album is not None and track.album.musicbrainz_id is not None: result.append(('MUSICBRAINZ_ALBUMID', track.album.musicbrainz_id)) # FIXME don't use first and best artist? @@ -106,7 +106,7 @@ def tracks_to_mpd_format(tracks, start=0, end=None): :param tracks: the tracks :type tracks: list of :class:`mopidy.models.Track` or - :class:`mopidy.models.CpTrack` + :class:`mopidy.models.TlTrack` :param start: position of first track to include in output :type start: int (positive or negative) :param end: position after last track to include in output diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 0f8426a8..235dd80a 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -84,10 +84,10 @@ class MprisObject(dbus.service.Object): logger.info('Connected to D-Bus') return bus_name - def _get_track_id(self, cp_track): - return '/com/mopidy/track/%d' % cp_track.cpid + def _get_track_id(self, tl_track): + return '/com/mopidy/track/%d' % tl_track.tlid - def _get_cpid(self, track_id): + def _get_tlid(self, track_id): assert track_id.startswith('/com/mopidy/track/') return track_id.split('/')[-1] @@ -234,14 +234,14 @@ class MprisObject(dbus.service.Object): logger.debug('%s.SetPosition not allowed', PLAYER_IFACE) return position = position // 1000 - current_cp_track = self.core.playback.current_cp_track.get() - if current_cp_track is None: + current_tl_track = self.core.playback.current_tl_track.get() + if current_tl_track is None: return - if track_id != self._get_track_id(current_cp_track): + if track_id != self._get_track_id(current_tl_track): return if position < 0: return - if current_cp_track.track.length < position: + if current_tl_track.track.length < position: return self.core.playback.seek(position) @@ -260,8 +260,8 @@ class MprisObject(dbus.service.Object): return track = self.core.library.lookup(uri).get() if track is not None: - cp_track = self.core.current_playlist.add(track).get() - self.core.playback.play(cp_track) + tl_track = self.core.tracklist.add(track).get() + self.core.playback.play(tl_track) else: logger.debug('Track with URI "%s" not found in library.', uri) @@ -330,12 +330,12 @@ class MprisObject(dbus.service.Object): self.core.playback.random = False def get_Metadata(self): - current_cp_track = self.core.playback.current_cp_track.get() - if current_cp_track is None: + current_tl_track = self.core.playback.current_tl_track.get() + if current_tl_track is None: return {'mpris:trackid': ''} else: - (_, track) = current_cp_track - metadata = {'mpris:trackid': self._get_track_id(current_cp_track)} + (_, track) = current_tl_track + metadata = {'mpris:trackid': self._get_track_id(current_tl_track)} if track.length: metadata['mpris:length'] = track.length * 1000 if track.uri: @@ -384,15 +384,15 @@ class MprisObject(dbus.service.Object): if not self.get_CanControl(): return False return ( - self.core.playback.cp_track_at_next.get() != - self.core.playback.current_cp_track.get()) + self.core.playback.tl_track_at_next.get() != + self.core.playback.current_tl_track.get()) def get_CanGoPrevious(self): if not self.get_CanControl(): return False return ( - self.core.playback.cp_track_at_previous.get() != - self.core.playback.current_cp_track.get()) + self.core.playback.tl_track_at_previous.get() != + self.core.playback.current_tl_track.get()) def get_CanPlay(self): if not self.get_CanControl(): diff --git a/mopidy/models.py b/mopidy/models.py index feb512f6..511ce847 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -151,7 +151,7 @@ class Album(ImmutableObject): super(Album, self).__init__(*args, **kwargs) -CpTrack = namedtuple('CpTrack', ['cpid', 'track']) +TlTrack = namedtuple('TlTrack', ['tlid', 'track']) class Track(ImmutableObject): diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index 34b18f2c..ec3ec1df 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals def populate_playlist(func): def wrapper(self): for track in self.tracks: - self.core.current_playlist.add(track) + self.core.tracklist.add(track) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index bd42a87b..21e377d9 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -22,7 +22,7 @@ class PlaybackControllerTest(object): self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) self.playback = self.core.playback - self.current_playlist = self.core.current_playlist + self.tracklist = self.core.tracklist assert len(self.tracks) >= 3, \ 'Need at least three tracks to run tests.' @@ -53,13 +53,13 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_track_state(self): self.assertEqual(self.playback.state, PlaybackState.STOPPED) - self.playback.play(self.current_playlist.cp_tracks[-1]) + self.playback.play(self.tracklist.tl_tracks[-1]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_track_return_value(self): self.assertEqual(self.playback.play( - self.current_playlist.cp_tracks[-1]), None) + self.tracklist.tl_tracks[-1]), None) @populate_playlist def test_play_when_playing(self): @@ -95,7 +95,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_track_sets_current_track(self): - self.playback.play(self.current_playlist.cp_tracks[-1]) + self.playback.play(self.tracklist.tl_tracks[-1]) self.assertEqual(self.playback.current_track, self.tracks[-1]) @populate_playlist @@ -108,12 +108,12 @@ class PlaybackControllerTest(object): @populate_playlist def test_current_track_after_completed_playlist(self): - self.playback.play(self.current_playlist.cp_tracks[-1]) + self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) - self.playback.play(self.current_playlist.cp_tracks[-1]) + self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.next() self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -162,7 +162,7 @@ class PlaybackControllerTest(object): def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. self.backend.playback.play = lambda track: track != self.tracks[1] - self.playback.play(self.current_playlist.cp_tracks[2]) + self.playback.play(self.tracklist.tl_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() self.assertNotEqual(self.playback.current_track, self.tracks[1]) @@ -172,13 +172,13 @@ class PlaybackControllerTest(object): def test_next(self): self.playback.play() - old_position = self.playback.current_playlist_position + old_position = self.playback.tracklist_position old_uri = self.playback.current_track.uri self.playback.next() self.assertEqual( - self.playback.current_playlist_position, old_position + 1) + self.playback.tracklist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -198,7 +198,7 @@ class PlaybackControllerTest(object): for i, track in enumerate(self.tracks): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.current_playlist_position, i) + self.assertEqual(self.playback.tracklist_position, i) self.playback.next() @@ -254,7 +254,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_track_at_end_of_playlist(self): self.playback.play() - for _ in self.current_playlist.cp_tracks[1:]: + for _ in self.tracklist.tl_tracks[1:]: self.playback.next() self.assertEqual(self.playback.track_at_next, None) @@ -277,7 +277,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assertIn(self.tracks[0], self.current_playlist.tracks) + self.assertIn(self.tracks[0], self.tracklist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -301,20 +301,20 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.current_playlist.append(self.tracks[:1]) + self.tracklist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist def test_end_of_track(self): self.playback.play() - old_position = self.playback.current_playlist_position + old_position = self.playback.tracklist_position old_uri = self.playback.current_track.uri self.playback.on_end_of_track() self.assertEqual( - self.playback.current_playlist_position, old_position + 1) + self.playback.tracklist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -334,7 +334,7 @@ class PlaybackControllerTest(object): for i, track in enumerate(self.tracks): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - self.assertEqual(self.playback.current_playlist_position, i) + self.assertEqual(self.playback.tracklist_position, i) self.playback.on_end_of_track() @@ -390,7 +390,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() - for _ in self.current_playlist.cp_tracks[1:]: + for _ in self.tracklist.tl_tracks[1:]: self.playback.on_end_of_track() self.assertEqual(self.playback.track_at_next, None) @@ -413,7 +413,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.tracklist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -429,7 +429,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.current_playlist.append(self.tracks[:1]) + self.tracklist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -490,36 +490,36 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist - def test_initial_current_playlist_position(self): - self.assertEqual(self.playback.current_playlist_position, None) + def test_initial_tracklist_position(self): + self.assertEqual(self.playback.tracklist_position, None) @populate_playlist - def test_current_playlist_position_during_play(self): + def test_tracklist_position_during_play(self): self.playback.play() - self.assertEqual(self.playback.current_playlist_position, 0) + self.assertEqual(self.playback.tracklist_position, 0) @populate_playlist - def test_current_playlist_position_after_next(self): + def test_tracklist_position_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.playback.current_playlist_position, 1) + self.assertEqual(self.playback.tracklist_position, 1) @populate_playlist - def test_current_playlist_position_at_end_of_playlist(self): - self.playback.play(self.current_playlist.cp_tracks[-1]) + def test_tracklist_position_at_end_of_playlist(self): + self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.current_playlist_position, None) + self.assertEqual(self.playback.tracklist_position, None) - def test_on_current_playlist_change_gets_called(self): - callback = self.playback.on_current_playlist_change + def test_on_tracklist_change_gets_called(self): + callback = self.playback.on_tracklist_change def wrapper(): wrapper.called = True return callback() wrapper.called = False - self.playback.on_current_playlist_change = wrapper - self.current_playlist.append([Track()]) + self.playback.on_tracklist_change = wrapper + self.tracklist.append([Track()]) self.assert_(wrapper.called) @@ -533,25 +533,25 @@ class PlaybackControllerTest(object): self.assertEqual('end_of_track', message['command']) @populate_playlist - def test_on_current_playlist_change_when_playing(self): + def test_on_tracklist_change_when_playing(self): self.playback.play() current_track = self.playback.current_track - self.current_playlist.append([self.tracks[2]]) + self.tracklist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_playlist - def test_on_current_playlist_change_when_stopped(self): - self.current_playlist.append([self.tracks[2]]) + def test_on_tracklist_change_when_stopped(self): + self.tracklist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist - def test_on_current_playlist_change_when_paused(self): + def test_on_tracklist_change_when_paused(self): self.playback.play() self.playback.pause() current_track = self.playback.current_track - self.current_playlist.append([self.tracks[2]]) + self.tracklist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @@ -642,7 +642,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_playing_updates_position(self): - length = self.current_playlist.tracks[0].length + length = self.tracklist.tracks[0].length self.playback.play() self.playback.seek(length - 1000) position = self.playback.time_position @@ -657,7 +657,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_paused_updates_position(self): - length = self.current_playlist.tracks[0].length + length = self.tracklist.tracks[0].length self.playback.play() self.playback.pause() self.playback.seek(length - 1000) @@ -687,8 +687,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_beyond_end_of_song_for_last_track(self): - self.playback.play(self.current_playlist.cp_tracks[-1]) - self.playback.seek(self.current_playlist.tracks[-1].length * 100) + self.playback.play(self.tracklist.tl_tracks[-1]) + self.playback.seek(self.tracklist.tracks[-1].length * 100) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @unittest.SkipTest @@ -774,9 +774,9 @@ class PlaybackControllerTest(object): def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.playback.consume = True self.playback.play() - for _ in range(len(self.current_playlist.tracks)): + for _ in range(len(self.tracklist.tracks)): self.playback.on_end_of_track() - self.assertEqual(len(self.current_playlist.tracks), 0) + self.assertEqual(len(self.tracklist.tracks), 0) @populate_playlist def test_play_with_random(self): @@ -811,7 +811,7 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_playlist_stops(self): - self.playback.play(self.current_playlist.cp_tracks[-1]) + self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/tracklist.py similarity index 88% rename from tests/backends/base/current_playlist.py rename to tests/backends/base/tracklist.py index 6446ffd6..64ab10d4 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/tracklist.py @@ -7,19 +7,19 @@ import pykka from mopidy import audio, core from mopidy.core import PlaybackState -from mopidy.models import CpTrack, Playlist, Track +from mopidy.models import TlTrack, Playlist, Track from tests.backends.base import populate_playlist -class CurrentPlaylistControllerTest(object): +class TracklistControllerTest(object): tracks = [] def setUp(self): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(audio=audio, backends=[self.backend]) - self.controller = self.core.current_playlist + self.controller = self.core.tracklist self.playback = self.core.playback assert len(self.tracks) == 3, 'Need three tracks to run tests.' @@ -28,25 +28,25 @@ class CurrentPlaylistControllerTest(object): pykka.ActorRegistry.stop_all() def test_length(self): - self.assertEqual(0, len(self.controller.cp_tracks)) + self.assertEqual(0, len(self.controller.tl_tracks)) self.assertEqual(0, self.controller.length) self.controller.append(self.tracks) - self.assertEqual(3, len(self.controller.cp_tracks)) + self.assertEqual(3, len(self.controller.tl_tracks)) self.assertEqual(3, self.controller.length) def test_add(self): for track in self.tracks: - cp_track = self.controller.add(track) + tl_track = self.controller.add(track) self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(cp_track, self.controller.cp_tracks[-1]) - self.assertEqual(track, cp_track.track) + self.assertEqual(tl_track, self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_track.track) def test_add_at_position(self): for track in self.tracks[:-1]: - cp_track = self.controller.add(track, 0) + tl_track = self.controller.add(track, 0) self.assertEqual(track, self.controller.tracks[0]) - self.assertEqual(cp_track, self.controller.cp_tracks[0]) - self.assertEqual(track, cp_track.track) + self.assertEqual(tl_track, self.controller.tl_tracks[0]) + self.assertEqual(track, tl_track.track) @populate_playlist def test_add_at_position_outside_of_playlist(self): @@ -55,14 +55,14 @@ class CurrentPlaylistControllerTest(object): self.assertRaises(AssertionError, test) @populate_playlist - def test_get_by_cpid(self): - cp_track = self.controller.cp_tracks[1] - self.assertEqual(cp_track, self.controller.get(cpid=cp_track.cpid)) + def test_get_by_tlid(self): + tl_track = self.controller.tl_tracks[1] + self.assertEqual(tl_track, self.controller.get(tlid=tl_track.tlid)) @populate_playlist def test_get_by_uri(self): - cp_track = self.controller.cp_tracks[1] - self.assertEqual(cp_track, self.controller.get(uri=cp_track.track.uri)) + tl_track = self.controller.tl_tracks[1] + self.assertEqual(tl_track, self.controller.get(uri=tl_track.track.uri)) @populate_playlist def test_get_by_uri_raises_error_for_invalid_uri(self): @@ -124,7 +124,7 @@ class CurrentPlaylistControllerTest(object): self.controller.append([track1, track2, track3]) self.assertEqual(track2, self.controller.get(uri='b')[1]) - def test_append_appends_to_the_current_playlist(self): + def test_append_appends_to_the_tracklist(self): self.controller.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.controller.tracks), 2) self.controller.append([Track(uri='c'), Track(uri='d')]) @@ -154,15 +154,15 @@ class CurrentPlaylistControllerTest(object): self.assertEqual(self.playback.current_track, None) def test_index_returns_index_of_track(self): - cp_tracks = [] + tl_tracks = [] for track in self.tracks: - cp_tracks.append(self.controller.add(track)) - self.assertEquals(0, self.controller.index(cp_tracks[0])) - self.assertEquals(1, self.controller.index(cp_tracks[1])) - self.assertEquals(2, self.controller.index(cp_tracks[2])) + tl_tracks.append(self.controller.add(track)) + self.assertEquals(0, self.controller.index(tl_tracks[0])) + self.assertEquals(1, self.controller.index(tl_tracks[1])) + self.assertEquals(2, self.controller.index(tl_tracks[2])) def test_index_raises_value_error_if_item_not_found(self): - test = lambda: self.controller.index(CpTrack(0, Track())) + test = lambda: self.controller.index(TlTrack(0, Track())) self.assertRaises(ValueError, test) @populate_playlist diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index eaf5863b..417c5251 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -21,14 +21,14 @@ class BackendEventsTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): - self.core.current_playlist.add(Track(uri='dummy:a')) + 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): - self.core.current_playlist.add(Track(uri='dummy:a')) + self.core.tracklist.add(Track(uri='dummy:a')) self.core.playback.play() self.core.playback.pause().get() send.reset_mock() @@ -36,20 +36,20 @@ class BackendEventsTest(unittest.TestCase): self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): - self.core.current_playlist.add(Track(uri='dummy:a')) + 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.current_playlist.add(Track(uri='dummy:a')) + 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.current_playlist.add(Track(uri='dummy:a', length=40000)) + self.core.tracklist.add(Track(uri='dummy:a', length=40000)) self.core.playback.play().get() send.reset_mock() self.core.playback.seek(1000).get() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index b669d5c0..285270ce 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -27,7 +27,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def add_track(self, path): uri = path_to_uri(path_to_data_dir(path)) track = Track(uri=uri, length=4464) - self.current_playlist.add(track) + self.tracklist.add(track) def test_uri_scheme(self): self.assertIn('file', self.core.uri_schemes) diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/tracklist_test.py similarity index 58% rename from tests/backends/local/current_playlist_test.py rename to tests/backends/local/tracklist_test.py index fa326501..f5330f52 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -5,21 +5,19 @@ from mopidy.backends.local import LocalBackend from mopidy.models import Track from tests import unittest -from tests.backends.base.current_playlist import CurrentPlaylistControllerTest +from tests.backends.base.tracklist import TracklistControllerTest from tests.backends.local import generate_song -class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, - unittest.TestCase): - +class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): backend_class = LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - super(LocalCurrentPlaylistControllerTest, self).setUp() + super(LocalTracklistControllerTest, self).setUp() def tearDown(self): - super(LocalCurrentPlaylistControllerTest, self).tearDown() + super(LocalTracklistControllerTest, self).tearDown() settings.runtime.clear() diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index 2dc9bf10..8e83f971 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -35,48 +35,48 @@ class CorePlaybackTest(unittest.TestCase): self.core = Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) - self.core.current_playlist.append(self.tracks) + self.core.tracklist.append(self.tracks) - self.cp_tracks = self.core.current_playlist.cp_tracks - self.unplayable_cp_track = self.cp_tracks[2] + self.tl_tracks = self.core.tracklist.tl_tracks + self.unplayable_tl_track = self.tl_tracks[2] def test_play_selects_dummy1_backend(self): - self.core.playback.play(self.cp_tracks[0]) + self.core.playback.play(self.tl_tracks[0]) self.playback1.play.assert_called_once_with(self.tracks[0]) self.assertFalse(self.playback2.play.called) def test_play_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[1]) + self.core.playback.play(self.tl_tracks[1]) self.assertFalse(self.playback1.play.called) self.playback2.play.assert_called_once_with(self.tracks[1]) def test_play_skips_to_next_on_unplayable_track(self): - self.core.playback.play(self.unplayable_cp_track) + self.core.playback.play(self.unplayable_tl_track) self.playback1.play.assert_called_once_with(self.tracks[3]) self.assertFalse(self.playback2.play.called) - self.assertEqual(self.core.playback.current_cp_track, - self.cp_tracks[3]) + self.assertEqual(self.core.playback.current_tl_track, + self.tl_tracks[3]) def test_pause_selects_dummy1_backend(self): - self.core.playback.play(self.cp_tracks[0]) + self.core.playback.play(self.tl_tracks[0]) self.core.playback.pause() self.playback1.pause.assert_called_once_with() self.assertFalse(self.playback2.pause.called) def test_pause_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[1]) + self.core.playback.play(self.tl_tracks[1]) self.core.playback.pause() self.assertFalse(self.playback1.pause.called) self.playback2.pause.assert_called_once_with() def test_pause_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.current_tl_track = self.unplayable_tl_track self.core.playback.pause() self.assertEqual(self.core.playback.state, PlaybackState.PAUSED) @@ -84,7 +84,7 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.pause.called) def test_resume_selects_dummy1_backend(self): - self.core.playback.play(self.cp_tracks[0]) + self.core.playback.play(self.tl_tracks[0]) self.core.playback.pause() self.core.playback.resume() @@ -92,7 +92,7 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.resume.called) def test_resume_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[1]) + self.core.playback.play(self.tl_tracks[1]) self.core.playback.pause() self.core.playback.resume() @@ -100,7 +100,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.resume.assert_called_once_with() def test_resume_does_nothing_if_track_is_unplayable(self): - self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.current_tl_track = self.unplayable_tl_track self.core.playback.state = PlaybackState.PAUSED self.core.playback.resume() @@ -109,21 +109,21 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.resume.called) def test_stop_selects_dummy1_backend(self): - self.core.playback.play(self.cp_tracks[0]) + self.core.playback.play(self.tl_tracks[0]) self.core.playback.stop() self.playback1.stop.assert_called_once_with() self.assertFalse(self.playback2.stop.called) def test_stop_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[1]) + self.core.playback.play(self.tl_tracks[1]) self.core.playback.stop() self.assertFalse(self.playback1.stop.called) self.playback2.stop.assert_called_once_with() def test_stop_changes_state_even_if_track_is_unplayable(self): - self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.current_tl_track = self.unplayable_tl_track self.core.playback.state = PlaybackState.PAUSED self.core.playback.stop() @@ -132,21 +132,21 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.stop.called) def test_seek_selects_dummy1_backend(self): - self.core.playback.play(self.cp_tracks[0]) + self.core.playback.play(self.tl_tracks[0]) self.core.playback.seek(10000) self.playback1.seek.assert_called_once_with(10000) self.assertFalse(self.playback2.seek.called) def test_seek_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[1]) + self.core.playback.play(self.tl_tracks[1]) self.core.playback.seek(10000) self.assertFalse(self.playback1.seek.called) self.playback2.seek.assert_called_once_with(10000) def test_seek_fails_for_unplayable_track(self): - self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.current_tl_track = self.unplayable_tl_track self.core.playback.state = PlaybackState.PLAYING success = self.core.playback.seek(1000) @@ -155,7 +155,7 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.seek.called) def test_time_position_selects_dummy1_backend(self): - self.core.playback.play(self.cp_tracks[0]) + self.core.playback.play(self.tl_tracks[0]) self.core.playback.seek(10000) self.core.playback.time_position @@ -163,7 +163,7 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) def test_time_position_selects_dummy2_backend(self): - self.core.playback.play(self.cp_tracks[1]) + self.core.playback.play(self.tl_tracks[1]) self.core.playback.seek(10000) self.core.playback.time_position @@ -171,7 +171,7 @@ class CorePlaybackTest(unittest.TestCase): self.playback2.get_time_position.assert_called_once_with() def test_time_position_returns_0_if_track_is_unplayable(self): - self.core.playback.current_cp_track = self.unplayable_cp_track + self.core.playback.current_tl_track = self.unplayable_tl_track result = self.core.playback.time_position diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 184f7a9c..2b6fdbd5 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -10,13 +10,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('add "dummy://foo"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) - self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) + self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertEqualResponse('OK') def test_add_with_uri_not_found_in_library_should_ack(self): @@ -33,15 +33,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('addid "dummy://foo"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) - self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) + self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertInResponse( - 'Id: %d' % self.core.current_playlist.cp_tracks.get()[5][0]) + 'Id: %d' % self.core.tracklist.tl_tracks.get()[5][0]) self.assertInResponse('OK') def test_addid_with_empty_uri_acks(self): @@ -52,24 +52,24 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('addid "dummy://foo" "3"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) - self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) + self.assertEqual(len(self.core.tracklist.tracks.get()), 6) + self.assertEqual(self.core.tracklist.tracks.get()[3], needle) self.assertInResponse( - 'Id: %d' % self.core.current_playlist.cp_tracks.get()[3][0]) + 'Id: %d' % self.core.tracklist.tl_tracks.get()[3][0]) self.assertInResponse('OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('addid "dummy://foo" "6"') self.assertEqualResponse('ACK [2@0] {addid} Bad song index') @@ -79,85 +79,85 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_clear(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('clear') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse('OK') def test_delete_songpos(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest( - 'delete "%d"' % self.core.current_playlist.cp_tracks.get()[2][0]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) + 'delete "%d"' % self.core.tracklist.tl_tracks.get()[2][0]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') def test_delete_songpos_out_of_bounds(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('delete "5"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('delete "1:"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') def test_delete_closed_range(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('delete "1:3"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 3) + self.assertEqual(len(self.core.tracklist.tracks.get()), 3) self.assertInResponse('OK') def test_delete_range_out_of_bounds(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest('delete "5:7"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.core.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + self.core.tracklist.append([Track(), Track()]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.sendRequest('deleteid "1"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.tracklist.tracks.get()), 1) self.assertInResponse('OK') def test_deleteid_does_not_exist(self): - self.core.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + self.core.tracklist.append([Track(), Track()]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.sendRequest('deleteid "12345"') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.assertEqualResponse('ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest('move "1" "0"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') self.assertEqual(tracks[2].name, 'c') @@ -167,13 +167,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_move_open_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest('move "2:" "0"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') self.assertEqual(tracks[2].name, 'e') @@ -183,13 +183,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_move_closed_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest('move "1:3" "0"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') self.assertEqual(tracks[2].name, 'a') @@ -199,13 +199,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_moveid(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest('moveid "4" "2"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'e') @@ -223,7 +223,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest('playlistfind "tag" "needle"') self.assertEqualResponse('ACK [0@0] {} Not implemented') - def test_playlistfind_by_filename_not_in_current_playlist(self): + def test_playlistfind_by_filename_not_in_tracklist(self): self.sendRequest('playlistfind "filename" "file:///dev/null"') self.assertEqualResponse('OK') @@ -231,8 +231,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest('playlistfind filename "file:///dev/null"') self.assertEqualResponse('OK') - def test_playlistfind_by_filename_in_current_playlist(self): - self.core.current_playlist.append([ + def test_playlistfind_by_filename_in_tracklist(self): + self.core.tracklist.append([ Track(uri='file:///exists')]) self.sendRequest('playlistfind filename "file:///exists"') @@ -242,7 +242,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_without_songid(self): - self.core.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.tracklist.append([Track(name='a'), Track(name='b')]) self.sendRequest('playlistid') self.assertInResponse('Title: a') @@ -250,7 +250,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_with_songid(self): - self.core.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.tracklist.append([Track(name='a'), Track(name='b')]) self.sendRequest('playlistid "1"') self.assertNotInResponse('Title: a') @@ -260,13 +260,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): - self.core.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.tracklist.append([Track(name='a'), Track(name='b')]) self.sendRequest('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -288,8 +288,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position - self.core.current_playlist.cp_id = 17 - self.core.current_playlist.append([ + self.core.tracklist.tlid = 17 + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -315,7 +315,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -336,7 +336,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_closed_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -367,7 +367,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest('plchanges "0"') @@ -377,10 +377,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_equal_version_returns_nothing(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.assertEqual(self.core.current_playlist.version.get(), 1) + self.assertEqual(self.core.tracklist.version.get(), 1) self.sendRequest('plchanges "1"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') @@ -388,10 +388,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_greater_version_returns_nothing(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) - self.assertEqual(self.core.current_playlist.version.get(), 1) + self.assertEqual(self.core.tracklist.version.get(), 1) self.sendRequest('plchanges "2"') self.assertNotInResponse('Title: a') self.assertNotInResponse('Title: b') @@ -399,7 +399,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest('plchanges "-1"') @@ -409,7 +409,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_without_quotes_works(self): - self.core.current_playlist.append( + self.core.tracklist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest('plchanges 0') @@ -419,39 +419,39 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchangesposid(self): - self.core.current_playlist.append([Track(), Track(), Track()]) + self.core.tracklist.append([Track(), Track(), Track()]) self.sendRequest('plchangesposid "0"') - cp_tracks = self.core.current_playlist.cp_tracks.get() + tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') - self.assertInResponse('Id: %d' % cp_tracks[0][0]) + self.assertInResponse('Id: %d' % tl_tracks[0][0]) self.assertInResponse('cpos: 2') - self.assertInResponse('Id: %d' % cp_tracks[1][0]) + self.assertInResponse('Id: %d' % tl_tracks[1][0]) self.assertInResponse('cpos: 2') - self.assertInResponse('Id: %d' % cp_tracks[2][0]) + self.assertInResponse('Id: %d' % tl_tracks[2][0]) self.assertInResponse('OK') def test_shuffle_without_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.core.current_playlist.version.get() + version = self.core.tracklist.version.get() self.sendRequest('shuffle') - self.assertLess(version, self.core.current_playlist.version.get()) + self.assertLess(version, self.core.tracklist.version.get()) self.assertInResponse('OK') def test_shuffle_with_open_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.core.current_playlist.version.get() + version = self.core.tracklist.version.get() self.sendRequest('shuffle "4:"') - self.assertLess(version, self.core.current_playlist.version.get()) - tracks = self.core.current_playlist.tracks.get() + self.assertLess(version, self.core.tracklist.version.get()) + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') @@ -459,15 +459,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_shuffle_with_closed_range(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.core.current_playlist.version.get() + version = self.core.tracklist.version.get() self.sendRequest('shuffle "1:3"') - self.assertLess(version, self.core.current_playlist.version.get()) - tracks = self.core.current_playlist.tracks.get() + self.assertLess(version, self.core.tracklist.version.get()) + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') @@ -475,13 +475,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_swap(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest('swap "1" "4"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') @@ -491,13 +491,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_swapid(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest('swapid "1" "4"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index b09ac481..51468390 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -168,7 +168,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_off(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.sendRequest('pause "1"') @@ -177,7 +177,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_on(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.sendRequest('pause "1"') @@ -185,7 +185,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_toggle(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -200,28 +200,28 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_without_pos(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): - self.core.current_playlist.append([]) + self.core.tracklist.append([]) self.sendRequest('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) @@ -229,7 +229,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), ]) @@ -241,7 +241,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), ]) @@ -258,7 +258,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): - self.core.current_playlist.clear() + self.core.tracklist.clear() self.sendRequest('play "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) @@ -266,7 +266,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( @@ -280,7 +280,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( @@ -296,14 +296,14 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -311,7 +311,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), ]) @@ -323,7 +323,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), ]) @@ -340,7 +340,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): - self.core.current_playlist.clear() + self.core.tracklist.clear() self.sendRequest('playid "-1"') self.assertEqual(STOPPED, self.core.playback.state.get()) @@ -348,7 +348,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -361,7 +361,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -376,7 +376,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_which_does_not_exist(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.sendRequest('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') @@ -386,7 +386,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seek(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest('seek "0"') self.sendRequest('seek "0" "30"') @@ -395,7 +395,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_seek_with_songpos(self): seek_track = Track(uri='dummy:b', length=40000) - self.core.current_playlist.append( + self.core.tracklist.append( [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest('seek "1" "30"') @@ -403,7 +403,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seek_without_quotes(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest('seek 0') self.sendRequest('seek 0 30') @@ -412,19 +412,19 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekid(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest('seekid "0" "30"') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) self.assertInResponse('OK') - def test_seekid_with_cpid(self): + def test_seekid_with_tlid(self): seek_track = Track(uri='dummy:b', length=40000) - self.core.current_playlist.append( + self.core.tracklist.append( [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest('seekid "1" "30"') - self.assertEqual(1, self.core.playback.current_cpid.get()) + self.assertEqual(1, self.core.playback.current_tlid.get()) self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse('OK') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index ede93d88..654987fc 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -18,7 +18,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Press next until you get to the unplayable track """ def test(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:error'), @@ -59,7 +59,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) @@ -71,14 +71,14 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): self.sendRequest('next') self.sendRequest('next') - cp_track_1 = self.core.playback.current_cp_track.get() + tl_track_1 = self.core.playback.current_tl_track.get() self.sendRequest('next') - cp_track_2 = self.core.playback.current_cp_track.get() + tl_track_2 = self.core.playback.current_tl_track.get() self.sendRequest('next') - cp_track_3 = self.core.playback.current_cp_track.get() + tl_track_3 = self.core.playback.current_tl_track.get() - self.assertNotEqual(cp_track_1, cp_track_2) - self.assertNotEqual(cp_track_2, cp_track_3) + self.assertNotEqual(tl_track_1, tl_track_2) + self.assertNotEqual(tl_track_2, tl_track_3) class IssueGH22RegressionTest(protocol.BaseTestCase): @@ -95,7 +95,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) @@ -124,7 +124,7 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): def test(self): self.core.stored_playlists.create('foo') - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index 6d406961..ef3cf7b2 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -12,7 +12,7 @@ class StatusHandlerTest(protocol.BaseTestCase): def test_currentsong(self): track = Track() - self.core.current_playlist.append([track]) + self.core.tracklist.append([track]) self.core.playback.play() self.sendRequest('currentsong') self.assertInResponse('file: ') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index e2eefbd4..c2201111 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -64,15 +64,15 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') self.assertInResponse('OK') - def test_load_known_playlist_appends_to_current_playlist(self): - self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + def test_load_known_playlist_appends_to_tracklist(self): + self.core.tracklist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.stored_playlists.playlists = [ Playlist(name='A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list"') - tracks = self.core.current_playlist.tracks.get() + tracks = self.core.tracklist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) self.assertEqual('b', tracks[1].uri) @@ -83,7 +83,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_unknown_playlist_acks(self): self.sendRequest('load "unknown playlist"') - self.assertEqual(0, len(self.core.current_playlist.tracks.get())) + self.assertEqual(0, len(self.core.tracklist.tracks.get())) self.assertEqualResponse('ACK [50@0] {load} No such playlist') def test_playlistadd(self): diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index b1f59076..711a069e 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -6,7 +6,7 @@ import os from mopidy import settings from mopidy.utils.path import mtime, uri_to_path from mopidy.frontends.mpd import translator, protocol -from mopidy.models import Album, Artist, CpTrack, Playlist, Track +from mopidy.models import Album, Artist, TlTrack, Playlist, Track from tests import unittest @@ -46,19 +46,19 @@ class TrackMpdFormatTest(unittest.TestCase): result = translator.track_to_mpd_format(Track(), position=1) self.assertNotIn(('Pos', 1), result) - def test_track_to_mpd_format_with_cpid(self): - result = translator.track_to_mpd_format(CpTrack(1, Track())) + def test_track_to_mpd_format_with_tlid(self): + result = translator.track_to_mpd_format(TlTrack(1, Track())) self.assertNotIn(('Id', 1), result) - def test_track_to_mpd_format_with_position_and_cpid(self): + def test_track_to_mpd_format_with_position_and_tlid(self): result = translator.track_to_mpd_format( - CpTrack(2, Track()), position=1) + TlTrack(2, Track()), position=1) self.assertIn(('Pos', 1), result) self.assertIn(('Id', 2), result) def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( - CpTrack(122, self.track), position=9) + TlTrack(122, self.track), position=9) self.assertIn(('file', 'a uri'), result) self.assertIn(('Time', 137), result) self.assertIn(('Artist', 'an artist'), result) diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 7d71b0bd..6afa5541 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -131,21 +131,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) - def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=None)]) + self.core.tracklist.append([Track(uri='dummy:a', length=None)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -155,7 +155,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=10000)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -165,7 +165,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=60000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=60000)]) self.core.playback.play() self.core.playback.pause() self.core.playback.seek(59123) @@ -174,7 +174,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=10000)]) self.core.playback.play() self.core.playback.pause() result = dict(status.status(self.context)) @@ -182,7 +182,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.core.current_playlist.append([Track(uri='dummy:a', bitrate=320)]) + self.core.tracklist.append([Track(uri='dummy:a', bitrate=320)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 6043551a..35fb0161 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -60,7 +60,7 @@ class PlayerInterfaceTest(unittest.TestCase): result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Track', result) - def test_get_loop_status_is_playlist_when_looping_current_playlist(self): + def test_get_loop_status_is_playlist_when_looping_tracklist(self): self.core.playback.repeat = True self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') @@ -101,7 +101,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -109,7 +109,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -149,38 +149,38 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertIn('mpris:trackid', result.keys()) self.assertEqual(result['mpris:trackid'], '') - def test_get_metadata_has_trackid_based_on_cpid(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + def test_get_metadata_has_trackid_based_on_tlid(self): + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.play() - (cpid, track) = self.core.playback.current_cp_track.get() + (tlid, track) = self.core.playback.current_tl_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) self.assertEqual( - result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) + result['mpris:trackid'], '/com/mopidy/track/%d' % tlid) def test_get_metadata_has_track_length(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) self.assertEqual(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) self.assertEqual(result['xesam:url'], 'dummy:a') def test_get_metadata_has_track_title(self): - self.core.current_playlist.append([Track(name='a')]) + self.core.tracklist.append([Track(name='a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) self.assertEqual(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): - self.core.current_playlist.append([Track(artists=[ + self.core.tracklist.append([Track(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)])]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -188,14 +188,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): - self.core.current_playlist.append([Track(album=Album(name='a'))]) + self.core.tracklist.append([Track(album=Album(name='a'))]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) self.assertEqual(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): - self.core.current_playlist.append([Track(album=Album(artists=[ + self.core.tracklist.append([Track(album=Album(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -203,7 +203,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): - self.core.current_playlist.append([Track(track_no=7)]) + self.core.tracklist.append([Track(track_no=7)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) @@ -246,7 +246,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) result_in_microseconds = self.mpris.Get( @@ -270,7 +270,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') @@ -278,7 +278,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') @@ -286,7 +286,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') @@ -294,7 +294,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -303,7 +303,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -311,7 +311,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -320,7 +320,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.tracklist.append([Track(uri='dummy:a')]) self.core.playback.play() self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') @@ -363,7 +363,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') @@ -371,7 +371,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_next_when_playing_skips_to_next_track_and_keep_playing(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') @@ -381,7 +381,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -391,7 +391,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() @@ -402,7 +402,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.stop() @@ -414,7 +414,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -423,7 +423,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -434,7 +434,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') @@ -443,7 +443,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_when_paused_skips_to_previous_track_and_pause(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -455,7 +455,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_previous_when_stopped_skips_to_previous_track_and_stops(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() @@ -468,7 +468,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -476,7 +476,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -484,7 +484,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() @@ -494,7 +494,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -502,7 +502,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -510,7 +510,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() @@ -526,7 +526,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() @@ -534,7 +534,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -542,7 +542,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -550,7 +550,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() @@ -560,21 +560,21 @@ class PlayerInterfaceTest(unittest.TestCase): def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() self.assertEqual(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_pause = self.core.playback.time_position.get() @@ -591,14 +591,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): - self.core.current_playlist.clear() + self.core.tracklist.clear() self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() self.assertEqual(self.core.playback.state.get(), STOPPED) def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -614,7 +614,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -631,7 +631,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -650,7 +650,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -670,7 +670,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_seek, 0) def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a', length=40000), Track(uri='dummy:b')]) self.core.playback.play() @@ -695,7 +695,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() @@ -713,7 +713,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() @@ -734,7 +734,7 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -757,7 +757,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_does_nothing_if_position_is_gt_track_length(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -780,7 +780,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_is_noop_if_track_id_isnt_current_track(self): - self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -807,7 +807,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) @@ -815,21 +815,21 @@ class PlayerInterfaceTest(unittest.TestCase): self.backend.library.dummy_library = [ Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') - self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.tracklist.tracks.get()), 0) - def test_open_uri_adds_uri_to_current_playlist(self): + def test_open_uri_adds_uri_to_tracklist(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') self.assertEqual( - self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') + self.core.tracklist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) @@ -843,7 +843,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() @@ -860,7 +860,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.core.current_playlist.append([ + self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) diff --git a/tests/models_test.py b/tests/models_test.py index b59ed0e4..4e3cdabf 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import datetime -from mopidy.models import Artist, Album, CpTrack, Track, Playlist +from mopidy.models import Artist, Album, TlTrack, Track, Playlist from tests import unittest @@ -314,19 +314,19 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) -class CpTrackTest(unittest.TestCase): +class TlTrackTest(unittest.TestCase): def setUp(self): - self.cpid = 123 + self.tlid = 123 self.track = Track() - self.cp_track = CpTrack(self.cpid, self.track) + self.tl_track = TlTrack(self.tlid, self.track) - def test_cp_track_can_be_accessed_as_a_tuple(self): - self.assertEqual(self.cpid, self.cp_track[0]) - self.assertEqual(self.track, self.cp_track[1]) + def test_tl_track_can_be_accessed_as_a_tuple(self): + self.assertEqual(self.tlid, self.tl_track[0]) + self.assertEqual(self.track, self.tl_track[1]) - def test_cp_track_can_be_accessed_by_attribute_names(self): - self.assertEqual(self.cpid, self.cp_track.cpid) - self.assertEqual(self.track, self.cp_track.track) + def test_tl_track_can_be_accessed_by_attribute_names(self): + self.assertEqual(self.tlid, self.tl_track.tlid) + self.assertEqual(self.track, self.tl_track.track) class TrackTest(unittest.TestCase): From 4f0a708411dd22a395bf6876b61064f7da479ec1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 11:34:48 +0100 Subject: [PATCH 213/323] mpd: Allow 'file' key to 'search' and 'find' --- docs/changes.rst | 3 +++ mopidy/frontends/mpd/protocol/music_db.py | 12 ++++++---- tests/frontends/mpd/protocol/music_db_test.py | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 81a27d33..cd8fd814 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -99,6 +99,9 @@ backends: - The settings validator will now allow any setting prefixed with ``CUSTOM_`` to exist in the settings file. +- The MPD commands ``search`` and ``find`` now allows the key ``file``, which + is used by ncmpcpp instead of ``filename``. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 49c52d34..d867de58 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -13,10 +13,10 @@ 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]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"') + 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]ilename|[Tt]itle|[Aa]ny))"? ' + r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ile[name]*|[Tt]itle|[Aa]ny))"? ' r'"(?P[^"]+)"') query = {} for query_part in query_parts: @@ -24,6 +24,8 @@ def _build_query(mpd_query): field = m.groupdict()['field'].lower() if field == 'title': field = 'track' + elif field == 'file': + field = 'filename' field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'].lower() if field in query: @@ -47,7 +49,7 @@ def count(context, tag, needle): @handle_request( - r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def find(context, mpd_query): """ @@ -72,6 +74,7 @@ def find(context, mpd_query): *ncmpcpp:* - also uses the search type "date". + - uses "file" instead of "filename". """ query = _build_query(mpd_query) return playlist_to_mpd_format( @@ -320,7 +323,7 @@ def rescan(context, uri=None): @handle_request( - r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def search(context, mpd_query): """ @@ -348,6 +351,7 @@ def search(context, mpd_query): *ncmpcpp:* - also uses the search type "date". + - uses "file" instead of "filename". """ query = _build_query(mpd_query) return playlist_to_mpd_format( diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index e1c571f5..7059c855 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -75,6 +75,22 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.sendRequest('find artist "what"') self.assertInResponse('OK') + def test_find_filename(self): + self.sendRequest('find "filename" "afilename"') + self.assertInResponse('OK') + + def test_find_filename_without_quotes(self): + self.sendRequest('find filename "afilename"') + self.assertInResponse('OK') + + def test_find_file(self): + self.sendRequest('find "file" "afilename"') + self.assertInResponse('OK') + + def test_find_file_without_quotes(self): + self.sendRequest('find file "afilename"') + self.assertInResponse('OK') + def test_find_title(self): self.sendRequest('find "title" "what"') self.assertInResponse('OK') @@ -313,6 +329,14 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search filename "afilename"') self.assertInResponse('OK') + def test_search_file(self): + self.sendRequest('search "file" "afilename"') + self.assertInResponse('OK') + + def test_search_file_without_quotes(self): + self.sendRequest('search file "afilename"') + self.assertInResponse('OK') + def test_search_title(self): self.sendRequest('search "title" "atitle"') self.assertInResponse('OK') From cc39853638757a4466ae118dc7ef0ea889a919c4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 11:39:27 +0100 Subject: [PATCH 214/323] mpd: Normalize file/filename filters to uri filters --- mopidy/backends/local/library.py | 4 ++-- mopidy/frontends/mpd/protocol/music_db.py | 4 ++-- tests/backends/base/library.py | 17 +++++------------ 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 9232225a..3454ca76 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -61,7 +61,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field in ('uri', 'filename'): + elif field == 'uri': result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) @@ -95,7 +95,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(album_filter, result_tracks) elif field == 'artist': result_tracks = filter(artist_filter, result_tracks) - elif field in ('uri', 'filename'): + elif field == 'uri': result_tracks = filter(uri_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index d867de58..a9464241 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -24,8 +24,8 @@ def _build_query(mpd_query): field = m.groupdict()['field'].lower() if field == 'title': field = 'track' - elif field == 'file': - field = 'filename' + elif field in ('file', 'filename'): + field = 'uri' field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'].lower() if field in query: diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index d2e44140..0b32186f 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -81,13 +81,13 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - def test_find_exact_filename(self): - track_1_filename = 'file://' + path_to_data_dir('uri1') - result = self.library.find_exact(filename=track_1_filename) + 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, Playlist(tracks=self.tracks[:1])) - track_2_filename = 'file://' + path_to_data_dir('uri2') - result = self.library.find_exact(filename=track_2_filename) + track_2_uri = 'file://' + path_to_data_dir('uri2') + result = self.library.find_exact(uri=track_2_uri) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) def test_find_exact_wrong_type(self): @@ -148,13 +148,6 @@ class LibraryControllerTest(object): result = self.library.search(uri=['RI2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - def test_search_filename(self): - result = self.library.search(filename=['RI1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) - - result = self.library.search(filename=['RI2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) - def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, Playlist(tracks=self.tracks[:1])) From 487503b51cb15260063b01361a16ae9d425d67fd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 11:47:49 +0100 Subject: [PATCH 215/323] mpd: Remove URI scheme check, as core handles that --- mopidy/frontends/mpd/protocol/current_playlist.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 57b06e1a..4c308468 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -22,12 +22,10 @@ def add(context, uri): """ if not uri: return - for uri_scheme in context.core.uri_schemes.get(): - if uri.startswith(uri_scheme): - track = context.core.library.lookup(uri).get() - if track is not None: - context.core.current_playlist.add(track) - return + track = context.core.library.lookup(uri).get() + if track: + context.core.current_playlist.add(track) + return raise MpdNoExistError('directory or file not found', command='add') From 7ec156e373e581e00bd3ee3094d02544681e3eec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 11:51:41 +0100 Subject: [PATCH 216/323] mpd: Don't lowercase search queries --- docs/changes.rst | 3 +++ mopidy/frontends/mpd/protocol/music_db.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index cd8fd814..b0887deb 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -110,6 +110,9 @@ backends: - The MPD command ``plchanges`` always returned the entire playlist. It now returns an empty response when the client has seen the latest version. +- MPD no longer lowercases search queries. This broke e.g. search by URI, where + casing may be essential. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index a9464241..00559e13 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -27,7 +27,7 @@ def _build_query(mpd_query): elif field in ('file', 'filename'): field = 'uri' field = str(field) # Needed for kwargs keys on OS X and Windows - what = m.groupdict()['what'].lower() + what = m.groupdict()['what'] if field in query: query[field].append(what) else: From 0dd09bce82c79fb29a538fb747ef32231605766d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 11:53:20 +0100 Subject: [PATCH 217/323] spotify: Support search by track URI (fixes #233) --- docs/changes.rst | 3 +++ mopidy/backends/spotify/library.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index b0887deb..35877808 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -102,6 +102,9 @@ backends: - The MPD commands ``search`` and ``find`` now allows the key ``file``, which is used by ncmpcpp instead of ``filename``. +- The Spotify backend now returns the track if you search for the Spotify track + URI. (Fixes: :issue:`233`) + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 18900d28..9be6a0c1 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -75,9 +75,16 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): return Playlist(tracks=tracks) spotify_query = [] for (field, values) in query.iteritems(): - if field == 'track': + if field == 'uri': + tracks = [] + for value in values: + track = self.lookup(value) + if track: + tracks.append(track) + return Playlist(tracks=tracks) + elif field == 'track': field = 'title' - if field == 'date': + elif field == 'date': field = 'year' if not hasattr(values, '__iter__'): values = [values] From e793be6e18a3874288c6795f42d85d32d5c8df44 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 20:01:57 +0100 Subject: [PATCH 218/323] distutils doesn't like unicode in its package lists --- setup.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index d559d0c9..6135df31 100644 --- a/setup.py +++ b/setup.py @@ -66,18 +66,17 @@ for scheme in INSTALL_SCHEMES.values(): # an easy way to do this. packages, data_files = [], [] root_dir = os.path.dirname(__file__) -if root_dir != '': +if root_dir != b'': os.chdir(root_dir) -project_dir = 'mopidy' - +project_dir = b'mopidy' for dirpath, dirnames, filenames in os.walk(project_dir): # Ignore dirnames that start with '.' for i, dirname in enumerate(dirnames): - if dirname.startswith('.'): + if dirname.startswith(b'.'): del dirnames[i] - if '__init__.py' in filenames: - packages.append('.'.join(fullsplit(dirpath))) + if b'__init__.py' in filenames: + packages.append(b'.'.join(fullsplit(dirpath))) elif filenames: data_files.append([ dirpath, [os.path.join(dirpath, f) for f in filenames]]) @@ -89,7 +88,7 @@ setup( author='Stein Magnus Jodal', author_email='stein.magnus@jodal.no', packages=packages, - package_data={'mopidy': ['backends/spotify/spotify_appkey.key']}, + package_data={b'mopidy': ['backends/spotify/spotify_appkey.key']}, cmdclass=cmdclasses, data_files=data_files, scripts=['bin/mopidy', 'bin/mopidy-scan'], From 1eef3f6c0ea890891fd374434bbbb9819e275d3c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 15:32:27 +0100 Subject: [PATCH 219/323] audio: Make software mixer volume be an int, not a float --- mopidy/audio/actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 633a9b00..a7b4e8d8 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -329,7 +329,7 @@ class Audio(pykka.ThreadingActor): :rtype: int in range [0..100] or :class:`None` """ if self._software_mixing: - return round(self._playbin.get_property('volume') * 100) + return int(round(self._playbin.get_property('volume') * 100)) if self._mixer is None: return None From 42fdaf3ff01440445f134d392f6014365f394968 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 14:02:57 +0100 Subject: [PATCH 220/323] audio: Move tests to make room for more audio tests --- tests/audio/__init__.py | 0 tests/{audio_test.py => audio/actor_test.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/audio/__init__.py rename tests/{audio_test.py => audio/actor_test.py} (100%) diff --git a/tests/audio/__init__.py b/tests/audio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/audio_test.py b/tests/audio/actor_test.py similarity index 100% rename from tests/audio_test.py rename to tests/audio/actor_test.py From 76b1fa8e1bc7efa89533d90f2fbf23a8619a512c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 13 Nov 2012 20:31:38 +0100 Subject: [PATCH 221/323] audio: Add state_changed(old_state, new_state) event --- mopidy/audio/listener.py | 15 +++++++++++++++ tests/audio/listener_test.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/audio/listener_test.py diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index 42c85e1e..da5f7b39 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -28,3 +28,18 @@ class AudioListener(object): *MAY* be implemented by actor. """ pass + + def state_changed(self, old_state, new_state): + """ + Called after the playback state have changed. + + Will be called for both immediate and async state changes in GStreamer. + + *MAY* be implemented by actor. + + :param old_state: the state before the change + :type old_state: string from :class:`mopidy.core.PlaybackState` field + :param new_state: the state after the change + :type new_state: string from :class:`mopidy.core.PlaybackState` field + """ + pass diff --git a/tests/audio/listener_test.py b/tests/audio/listener_test.py new file mode 100644 index 00000000..b3274721 --- /dev/null +++ b/tests/audio/listener_test.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +from mopidy import audio + +from tests import unittest + + +class AudioListenerTest(unittest.TestCase): + def setUp(self): + self.listener = audio.AudioListener() + + def test_listener_has_default_impl_for_reached_end_of_stream(self): + self.listener.reached_end_of_stream() + + def test_listener_has_default_impl_for_state_changed(self): + self.listener.state_changed(None, None) From f9bd0d00b3ae187dbe1e441a154508cb718f2c95 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 00:35:17 +0100 Subject: [PATCH 222/323] audio: Move PlaybackState from core to audio so audio can use it --- mopidy/audio/__init__.py | 1 + mopidy/audio/constants.py | 16 ++++++++++++++++ mopidy/core/playback.py | 17 ++--------------- 3 files changed, 19 insertions(+), 15 deletions(-) create mode 100644 mopidy/audio/constants.py diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index c3fbc0c9..7cf1dcee 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -3,3 +3,4 @@ from __future__ import unicode_literals # flake8: noqa from .actor import Audio from .listener import AudioListener +from .constants import PlaybackState diff --git a/mopidy/audio/constants.py b/mopidy/audio/constants.py new file mode 100644 index 00000000..08ad9768 --- /dev/null +++ b/mopidy/audio/constants.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + + +class PlaybackState(object): + """ + Enum of playback states. + """ + + #: Constant representing the paused state. + PAUSED = 'paused' + + #: Constant representing the playing state. + PLAYING = 'playing' + + #: Constant representing the stopped state. + STOPPED = 'stopped' diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 54364ec2..273eb68d 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -4,6 +4,8 @@ import logging import random import urlparse +from mopidy.audio import PlaybackState + from . import listener @@ -24,21 +26,6 @@ def option_wrapper(name, default): return property(get_option, set_option) -class PlaybackState(object): - """ - Enum of playback states. - """ - - #: Constant representing the paused state. - PAUSED = 'paused' - - #: Constant representing the playing state. - PLAYING = 'playing' - - #: Constant representing the stopped state. - STOPPED = 'stopped' - - class PlaybackController(object): # pylint: disable = R0902 # Too many instance attributes From 87ce7bbe115aabd107149e4730b7c0a1e83b6b78 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 01:49:05 +0100 Subject: [PATCH 223/323] audio: Maintain state and trigger events based on GStreamer state changes --- mopidy/audio/actor.py | 44 +++++++++++++++++++++++++++++++---- tests/audio/actor_test.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a7b4e8d8..49794c76 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -13,6 +13,7 @@ from mopidy import settings from mopidy.utils import process from . import mixers +from .constants import PlaybackState from .listener import AudioListener logger = logging.getLogger('mopidy.audio') @@ -29,9 +30,11 @@ class Audio(pykka.ThreadingActor): - :attr:`mopidy.settings.OUTPUT` - :attr:`mopidy.settings.MIXER` - :attr:`mopidy.settings.MIXER_TRACK` - """ + #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` + state = PlaybackState.STOPPED + def __init__(self): super(Audio, self).__init__() @@ -160,8 +163,12 @@ class Audio(pykka.ThreadingActor): bus.remove_signal_watch() def _on_message(self, bus, message): - if message.type == gst.MESSAGE_EOS: - self._trigger_reached_end_of_stream_event() + if (message.type == gst.MESSAGE_STATE_CHANGED + and message.src == self._playbin): + old_state, new_state, pending_state = message.parse_state_changed() + self._on_playbin_state_changed(old_state, new_state, pending_state) + elif message.type == gst.MESSAGE_EOS: + self._on_end_of_stream() elif message.type == gst.MESSAGE_ERROR: error, debug = message.parse_error() logger.error('%s %s', error, debug) @@ -170,8 +177,35 @@ class Audio(pykka.ThreadingActor): error, debug = message.parse_warning() logger.warning('%s %s', error, debug) - def _trigger_reached_end_of_stream_event(self): - logger.debug('Triggering reached end of stream event') + def _on_playbin_state_changed(self, old_state, new_state, pending_state): + if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: + # XXX: We're not called on the last state cheng when going down to + # NULL, so we rewrite the second to last call to get the expected + # behavior. + new_state = gst.STATE_NULL + pending_state = gst.STATE_VOID_PENDING + + if pending_state != gst.STATE_VOID_PENDING: + return # Ignore intermediate state changes + + if new_state == gst.STATE_READY: + return # Ignore READY state as it's GStreamer specific + + if new_state == gst.STATE_PLAYING: + new_state = PlaybackState.PLAYING + elif new_state == gst.STATE_PAUSED: + new_state = PlaybackState.PAUSED + elif new_state == gst.STATE_NULL: + new_state = PlaybackState.STOPPED + + old_state, self.state = self.state, new_state + + logger.debug('Triggering state_changed event') + AudioListener.send('state_changed', + old_state=old_state, new_state=new_state) + + def _on_end_of_stream(self): + logger.debug('Triggering reached_end_of_stream event') AudioListener.send('reached_end_of_stream') def set_uri(self, uri): diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index b8b65e83..64666d9d 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + from mopidy import audio, settings from mopidy.utils.path import path_to_uri @@ -63,3 +67,48 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_invalid_output_raises_error(self): pass # TODO + + +class AudioStateTest(unittest.TestCase): + def setUp(self): + self.audio = audio.Audio() + + def test_state_starts_as_stopped(self): + self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) + + def test_state_does_not_change_when_in_gst_ready_state(self): + self.audio._on_playbin_state_changed( + gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING) + + self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) + + def test_state_changes_from_stopped_to_playing_on_play(self): + self.audio._on_playbin_state_changed( + gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING) + self.audio._on_playbin_state_changed( + gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING) + self.audio._on_playbin_state_changed( + gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING) + + self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state) + + def test_state_changes_from_playing_to_paused_on_pause(self): + self.audio.state = audio.PlaybackState.PLAYING + + self.audio._on_playbin_state_changed( + gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING) + + self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state) + + def test_state_changes_from_playing_to_stopped_on_stop(self): + self.audio.state = audio.PlaybackState.PLAYING + + self.audio._on_playbin_state_changed( + gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL) + self.audio._on_playbin_state_changed( + gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL) + # We never get the following call, so the logic must work without it + #self.audio._on_playbin_state_changed( + # gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) + + self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) From 9168982a6173dd9da0c4f441a5a71d5859956f2b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 01:56:46 +0100 Subject: [PATCH 224/323] core: Pause playback if audio is paused and playback isn't (fixes #232) --- mopidy/core/actor.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 731e5309..858eeaf9 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -4,7 +4,7 @@ import itertools import pykka -from mopidy.audio import AudioListener +from mopidy.audio import AudioListener, PlaybackState from .library import LibraryController from .playback import PlaybackController @@ -55,6 +55,14 @@ class Core(pykka.ThreadingActor, AudioListener): def reached_end_of_stream(self): self.playback.on_end_of_track() + def state_changed(self, old_state, new_state): + # XXX: This is a temporary fix for issue #232 while we wait for a more + # permanent solution with the implementation of issue #234. + if (new_state == PlaybackState.PAUSED + and self.playback.state != PlaybackState.PAUSED): + self.playback.state = new_state + self.playback._trigger_track_playback_paused() + class Backends(list): def __init__(self, backends): From 3a24deaec3712f52da6ff28f825e1f98b6805df4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 09:23:19 +0100 Subject: [PATCH 225/323] Rename 'stored playlists' to 'playlists' --- docs/api/backends.rst | 6 +- docs/api/concepts.rst | 10 +-- docs/api/core.rst | 8 +-- mopidy/backends/base.py | 26 ++++---- mopidy/backends/dummy.py | 4 +- mopidy/backends/local/actor.py | 4 +- .../{stored_playlists.py => playlists.py} | 4 +- mopidy/backends/spotify/actor.py | 4 +- mopidy/backends/spotify/container_manager.py | 2 +- mopidy/backends/spotify/library.py | 4 +- mopidy/backends/spotify/playlist_manager.py | 8 +-- .../{stored_playlists.py => playlists.py} | 2 +- mopidy/backends/spotify/session_manager.py | 17 +++--- mopidy/core/__init__.py | 2 +- mopidy/core/actor.py | 20 +++--- .../{stored_playlists.py => playlists.py} | 44 ++++++------- .../mpd/protocol/stored_playlists.py | 8 +-- .../{stored_playlists.py => playlists.py} | 61 +++++++++---------- ...{stored_playlists_test.py => playlists.py} | 38 ++++++------ ...{stored_playlists_test.py => playlists.py} | 56 ++++++++--------- .../frontends/mpd/protocol/regression_test.py | 4 +- .../mpd/protocol/stored_playlists_test.py | 14 ++--- 22 files changed, 172 insertions(+), 174 deletions(-) rename mopidy/backends/local/{stored_playlists.py => playlists.py} (96%) rename mopidy/backends/spotify/{stored_playlists.py => playlists.py} (81%) rename mopidy/core/{stored_playlists.py => playlists.py} (77%) rename tests/backends/base/{stored_playlists.py => playlists.py} (60%) rename tests/backends/local/{stored_playlists_test.py => playlists.py} (74%) rename tests/core/{stored_playlists_test.py => playlists.py} (76%) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index c296fb78..0dc4900d 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -19,10 +19,10 @@ Playback provider :members: -Stored playlists provider -========================= +Playlists provider +================== -.. autoclass:: mopidy.backends.base.BaseStoredPlaylistsProvider +.. autoclass:: mopidy.backends.base.BasePlaylistsProvider :members: diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index 2fc4d9b2..68718935 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -53,7 +53,7 @@ See :ref:`core-api` for more details. Core -> "Tracklist\ncontroller" Core -> "Library\ncontroller" Core -> "Playback\ncontroller" - Core -> "Stored\nplaylists\ncontroller" + Core -> "Playlists\ncontroller" "Library\ncontroller" -> "Local backend" "Library\ncontroller" -> "Spotify backend" @@ -62,8 +62,8 @@ See :ref:`core-api` for more details. "Playback\ncontroller" -> "Spotify backend" "Playback\ncontroller" -> Audio - "Stored\nplaylists\ncontroller" -> "Local backend" - "Stored\nplaylists\ncontroller" -> "Spotify backend" + "Playlists\ncontroller" -> "Local backend" + "Playlists\ncontroller" -> "Spotify backend" Backends @@ -80,12 +80,12 @@ See :ref:`backend-api` for more details. "Local backend" -> "Local\nlibrary\nprovider" -> "Local disk" "Local backend" -> "Local\nplayback\nprovider" -> "Local disk" - "Local backend" -> "Local\nstored\nplaylists\nprovider" -> "Local disk" + "Local backend" -> "Local\nplaylists\nprovider" -> "Local disk" "Local\nplayback\nprovider" -> Audio "Spotify backend" -> "Spotify\nlibrary\nprovider" -> "Spotify service" "Spotify backend" -> "Spotify\nplayback\nprovider" -> "Spotify service" - "Spotify backend" -> "Spotify\nstored\nplaylists\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nplaylists\nprovider" -> "Spotify service" "Spotify\nplayback\nprovider" -> Audio diff --git a/docs/api/core.rst b/docs/api/core.rst index 9f5d43d2..de85557c 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -35,12 +35,12 @@ Manages everything related to the tracks we are currently playing. :members: -Stored playlists controller -=========================== +Playlists controller +==================== -Manages stored playlist. +Manages persistence of playlists. -.. autoclass:: mopidy.core.StoredPlaylistsController +.. autoclass:: mopidy.core.PlaylistsController :members: diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 765476f7..8250a24c 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -20,10 +20,10 @@ class Backend(object): #: the backend doesn't provide playback. playback = None - #: The stored playlists provider. An instance of - #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`, or - #: class:`None` if the backend doesn't provide stored playlists. - stored_playlists = None + #: The playlists provider. An instance of + #: :class:`mopidy.backends.base.BasePlaylistsProvider`, or class:`None` if + #: the backend doesn't provide playlists. + playlists = None #: List of URI schemes this backend can handle. uri_schemes = [] @@ -38,8 +38,8 @@ class Backend(object): def has_playback(self): return self.playback is not None - def has_stored_playlists(self): - return self.stored_playlists is not None + def has_playlists(self): + return self.playlists is not None class BaseLibraryProvider(object): @@ -167,7 +167,7 @@ class BasePlaybackProvider(object): return self.audio.get_position().get() -class BaseStoredPlaylistsProvider(object): +class BasePlaylistsProvider(object): """ :param backend: backend the controller is a part of :type backend: :class:`mopidy.backends.base.Backend` @@ -182,7 +182,7 @@ class BaseStoredPlaylistsProvider(object): @property def playlists(self): """ - Currently stored playlists. + Currently available playlists. Read/write. List of :class:`mopidy.models.Playlist`. """ @@ -194,7 +194,7 @@ class BaseStoredPlaylistsProvider(object): def create(self, name): """ - See :meth:`mopidy.core.StoredPlaylistsController.create`. + See :meth:`mopidy.core.PlaylistsController.create`. *MUST be implemented by subclass.* """ @@ -202,7 +202,7 @@ class BaseStoredPlaylistsProvider(object): def delete(self, uri): """ - See :meth:`mopidy.core.StoredPlaylistsController.delete`. + See :meth:`mopidy.core.PlaylistsController.delete`. *MUST be implemented by subclass.* """ @@ -210,7 +210,7 @@ class BaseStoredPlaylistsProvider(object): def lookup(self, uri): """ - See :meth:`mopidy.core.StoredPlaylistsController.lookup`. + See :meth:`mopidy.core.PlaylistsController.lookup`. *MUST be implemented by subclass.* """ @@ -218,7 +218,7 @@ class BaseStoredPlaylistsProvider(object): def refresh(self): """ - See :meth:`mopidy.core.StoredPlaylistsController.refresh`. + See :meth:`mopidy.core.PlaylistsController.refresh`. *MUST be implemented by subclass.* """ @@ -226,7 +226,7 @@ class BaseStoredPlaylistsProvider(object): def save(self, playlist): """ - See :meth:`mopidy.core.StoredPlaylistsController.save`. + See :meth:`mopidy.core.PlaylistsController.save`. *MUST be implemented by subclass.* """ diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 34a176e5..af8f7487 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -28,7 +28,7 @@ class DummyBackend(pykka.ThreadingActor, base.Backend): self.library = DummyLibraryProvider(backend=self) self.playback = DummyPlaybackProvider(audio=audio, backend=self) - self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) + self.playlists = DummyPlaylistsProvider(backend=self) self.uri_schemes = ['dummy'] @@ -80,7 +80,7 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): return self._time_position -class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): +class DummyPlaylistsProvider(base.BasePlaylistsProvider): def create(self, name): playlist = Playlist(name=name) self._playlists.append(playlist) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index fb287468..75baeab2 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -7,7 +7,7 @@ import pykka from mopidy.backends import base from .library import LocalLibraryProvider -from .stored_playlists import LocalStoredPlaylistsProvider +from .playlists import LocalPlaylistsProvider logger = logging.getLogger('mopidy.backends.local') @@ -18,6 +18,6 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.library = LocalLibraryProvider(backend=self) self.playback = base.BasePlaybackProvider(audio=audio, backend=self) - self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) + self.playlists = LocalPlaylistsProvider(backend=self) self.uri_schemes = ['file'] diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/playlists.py similarity index 96% rename from mopidy/backends/local/stored_playlists.py rename to mopidy/backends/local/playlists.py index f521fc2e..05873a98 100644 --- a/mopidy/backends/local/stored_playlists.py +++ b/mopidy/backends/local/playlists.py @@ -16,9 +16,9 @@ from .translator import parse_m3u logger = logging.getLogger('mopidy.backends.local') -class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): +class LocalPlaylistsProvider(base.BasePlaylistsProvider): def __init__(self, *args, **kwargs): - super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) + super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) self._path = settings.LOCAL_PLAYLIST_PATH self.refresh() diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index a5b23071..5fc5cc4f 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -20,11 +20,11 @@ class SpotifyBackend(pykka.ThreadingActor, base.Backend): from .library import SpotifyLibraryProvider from .playback import SpotifyPlaybackProvider from .session_manager import SpotifySessionManager - from .stored_playlists import SpotifyStoredPlaylistsProvider + from .playlists import SpotifyPlaylistsProvider self.library = SpotifyLibraryProvider(backend=self) self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) - self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) + self.playlists = SpotifyPlaylistsProvider(backend=self) self.uri_schemes = ['spotify'] diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index dc498a02..e8d1ed0b 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -17,7 +17,7 @@ class SpotifyContainerManager(PyspotifyContainerManager): """Callback used by pyspotify""" logger.debug('Callback called: playlist container loaded') - self.session_manager.refresh_stored_playlists() + self.session_manager.refresh_playlists() count = 0 for playlist in self.session_manager.session.playlist_container(): diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 9be6a0c1..67c390fc 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -68,9 +68,9 @@ 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 stored playlists when the query is empty. + # all tracks in the playlists when the query is empty. tracks = [] - for playlist in self.backend.stored_playlists.playlists: + for playlist in self.backend.playlists.playlists: tracks += playlist.tracks return Playlist(tracks=tracks) spotify_query = [] diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index a3deff7e..6cd6d4ed 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -19,7 +19,7 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): 'Callback called: ' '%d track(s) added to position %d in playlist "%s"', len(tracks), position, playlist.name()) - self.session_manager.refresh_stored_playlists() + self.session_manager.refresh_playlists() def tracks_moved(self, playlist, tracks, new_position, userdata): """Callback used by pyspotify""" @@ -27,7 +27,7 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): 'Callback called: ' '%d track(s) moved to position %d in playlist "%s"', len(tracks), new_position, playlist.name()) - self.session_manager.refresh_stored_playlists() + self.session_manager.refresh_playlists() def tracks_removed(self, playlist, tracks, userdata): """Callback used by pyspotify""" @@ -35,13 +35,13 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): 'Callback called: ' '%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) - self.session_manager.refresh_stored_playlists() + self.session_manager.refresh_playlists() def playlist_renamed(self, playlist, userdata): """Callback used by pyspotify""" logger.debug( 'Callback called: Playlist renamed to "%s"', playlist.name()) - self.session_manager.refresh_stored_playlists() + self.session_manager.refresh_playlists() def playlist_state_changed(self, playlist, userdata): """Callback used by pyspotify""" diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/playlists.py similarity index 81% rename from mopidy/backends/spotify/stored_playlists.py rename to mopidy/backends/spotify/playlists.py index 559ffd99..2c31caa8 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/playlists.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from mopidy.backends import base -class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): +class SpotifyPlaylistsProvider(base.BasePlaylistsProvider): def create(self, name): pass # TODO diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 62eecde3..cd3d97db 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -122,30 +122,29 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): if 'offline-mgr' in data and 'files unlocked' in data: # XXX This is a very very fragile and ugly hack, but we get no # proper event when libspotify is done with initial data loading. - # We delay the expensive refresh of Mopidy's stored playlists until - # this message arrives. This way, we avoid doing the refresh once - # for every playlist or other change. This reduces the time from + # We delay the expensive refresh of Mopidy's playlists until this + # message arrives. This way, we avoid doing the refresh once for + # every playlist or other change. This reduces the time from # startup until the Spotify backend is ready from 35s to 12s in one # test with clean Spotify cache. In cases with an outdated cache - # the time improvements should be a lot better. + # the time improvements should be a lot greater. self._initial_data_receive_completed = True - self.refresh_stored_playlists() + self.refresh_playlists() def end_of_track(self, session): """Callback used by pyspotify""" logger.debug('End of data stream reached') self.audio.emit_end_of_stream() - def refresh_stored_playlists(self): - """Refresh the stored playlists in the backend with fresh meta data - from Spotify""" + def refresh_playlists(self): + """Refresh the playlists in the backend with data from Spotify""" if not self._initial_data_receive_completed: logger.debug('Still getting data; skipped refresh of playlists') return playlists = map( translator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) - self.backend.stored_playlists.playlists = playlists + self.backend.playlists.playlists = playlists logger.info('Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index eaa50ec6..f49bbbe7 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -5,5 +5,5 @@ from .actor import Core from .library import LibraryController from .listener import CoreListener from .playback import PlaybackController, PlaybackState -from .stored_playlists import StoredPlaylistsController +from .playlists import PlaylistsController from .tracklist import TracklistController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 731e5309..4307ffb1 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -8,7 +8,7 @@ from mopidy.audio import AudioListener from .library import LibraryController from .playback import PlaybackController -from .stored_playlists import StoredPlaylistsController +from .playlists import PlaylistsController from .tracklist import TracklistController @@ -21,9 +21,9 @@ class Core(pykka.ThreadingActor, AudioListener): #: :class:`mopidy.core.PlaybackController`. playback = None - #: The stored playlists controller. An instance of - #: :class:`mopidy.core.StoredPlaylistsController`. - stored_playlists = None + #: The playlists controller. An instance of + #: :class:`mopidy.core.PlaylistsController`. + playlists = None #: The tracklist controller. An instance of #: :class:`mopidy.core.TracklistController`. @@ -39,7 +39,7 @@ class Core(pykka.ThreadingActor, AudioListener): self.playback = PlaybackController( audio=audio, backends=self.backends, core=self) - self.stored_playlists = StoredPlaylistsController( + self.playlists = PlaylistsController( backends=self.backends, core=self) self.tracklist = TracklistController(core=self) @@ -66,8 +66,8 @@ class Backends(list): # the X_by_uri_scheme dicts below. self.with_library = [b for b in backends if b.has_library().get()] self.with_playback = [b for b in backends if b.has_playback().get()] - self.with_stored_playlists = [b for b in backends - if b.has_stored_playlists().get()] + self.with_playlists = [b for b in backends + if b.has_playlists().get()] self.by_uri_scheme = {} for backend in backends: @@ -82,12 +82,12 @@ class Backends(list): self.with_library_by_uri_scheme = {} self.with_playback_by_uri_scheme = {} - self.with_stored_playlists_by_uri_scheme = {} + self.with_playlists_by_uri_scheme = {} for uri_scheme, backend in self.by_uri_scheme.items(): if backend.has_library().get(): self.with_library_by_uri_scheme[uri_scheme] = backend if backend.has_playback().get(): self.with_playback_by_uri_scheme[uri_scheme] = backend - if backend.has_stored_playlists().get(): - self.with_stored_playlists_by_uri_scheme[uri_scheme] = backend + if backend.has_playlists().get(): + self.with_playlists_by_uri_scheme[uri_scheme] = backend diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/playlists.py similarity index 77% rename from mopidy/core/stored_playlists.py rename to mopidy/core/playlists.py index cae39ca9..069150e5 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/playlists.py @@ -6,7 +6,7 @@ import urlparse import pykka -class StoredPlaylistsController(object): +class PlaylistsController(object): pykka_traversable = True def __init__(self, backends, core): @@ -16,12 +16,12 @@ class StoredPlaylistsController(object): @property def playlists(self): """ - Currently stored playlists. + The available playlists. Read-only. List of :class:`mopidy.models.Playlist`. """ - futures = [b.stored_playlists.playlists - for b in self.backends.with_stored_playlists] + futures = [b.playlists.playlists + for b in self.backends.with_playlists] results = pykka.get_all(futures) return list(itertools.chain(*results)) @@ -43,11 +43,11 @@ class StoredPlaylistsController(object): :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - if uri_scheme in self.backends.with_stored_playlists_by_uri_scheme: + if uri_scheme in self.backends.with_playlists_by_uri_scheme: backend = self.backends.by_uri_scheme[uri_scheme] else: - backend = self.backends.with_stored_playlists[0] - return backend.stored_playlists.create(name).get() + backend = self.backends.with_playlists[0] + return backend.playlists.create(name).get() def delete(self, uri): """ @@ -60,14 +60,14 @@ class StoredPlaylistsController(object): :type uri: string """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.with_stored_playlists_by_uri_scheme.get( + backend = self.backends.with_playlists_by_uri_scheme.get( uri_scheme, None) if backend: - backend.stored_playlists.delete(uri).get() + backend.playlists.delete(uri).get() def get(self, **criteria): """ - Get playlist by given criterias from the set of stored playlists. + Get playlist by given criterias from the set of playlists. Raises :exc:`LookupError` if a unique match is not found. @@ -97,24 +97,24 @@ class StoredPlaylistsController(object): def lookup(self, uri): """ - Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. Returns :class:`None` if not found. + Lookup playlist with given URI in both the set of playlists and in any + other playlist sources. Returns :class:`None` if not found. :param uri: playlist URI :type uri: string :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ uri_scheme = urlparse.urlparse(uri).scheme - backend = self.backends.with_stored_playlists_by_uri_scheme.get( + backend = self.backends.with_playlists_by_uri_scheme.get( uri_scheme, None) if backend: - return backend.stored_playlists.lookup(uri).get() + return backend.playlists.lookup(uri).get() else: return None def refresh(self, uri_scheme=None): """ - Refresh the stored playlists in :attr:`playlists`. + Refresh the playlists in :attr:`playlists`. If ``uri_scheme`` is :class:`None`, all backends are asked to refresh. If ``uri_scheme`` is an URI scheme handled by a backend, only that @@ -125,18 +125,18 @@ class StoredPlaylistsController(object): :type uri_scheme: string """ if uri_scheme is None: - futures = [b.stored_playlists.refresh() - for b in self.backends.with_stored_playlists] + futures = [b.playlists.refresh() + for b in self.backends.with_playlists] pykka.get_all(futures) else: - backend = self.backends.with_stored_playlists_by_uri_scheme.get( + backend = self.backends.with_playlists_by_uri_scheme.get( uri_scheme, None) if backend: - backend.stored_playlists.refresh().get() + backend.playlists.refresh().get() def save(self, playlist): """ - Save the playlist to the set of stored playlists. + Save the playlist. For a playlist to be saveable, it must have the ``uri`` attribute set. You should not set the ``uri`` atribute yourself, but use playlist @@ -159,7 +159,7 @@ class StoredPlaylistsController(object): if playlist.uri is None: return uri_scheme = urlparse.urlparse(playlist.uri).scheme - backend = self.backends.with_stored_playlists_by_uri_scheme.get( + backend = self.backends.with_playlists_by_uri_scheme.get( uri_scheme, None) if backend: - return backend.stored_playlists.save(playlist).get() + return backend.playlists.save(playlist).get() diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index e81b3ab0..b8ac8c4c 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -24,7 +24,7 @@ def listplaylist(context, name): file: relative/path/to/file3.mp3 """ try: - playlist = context.core.stored_playlists.get(name=name).get() + playlist = context.core.playlists.get(name=name).get() return ['file: %s' % t.uri for t in playlist.tracks] except LookupError: raise MpdNoExistError('No such playlist', command='listplaylist') @@ -46,7 +46,7 @@ def listplaylistinfo(context, name): Album, Artist, Track """ try: - playlist = context.core.stored_playlists.get(name=name).get() + playlist = context.core.playlists.get(name=name).get() return playlist_to_mpd_format(playlist) except LookupError: raise MpdNoExistError('No such playlist', command='listplaylistinfo') @@ -74,7 +74,7 @@ def listplaylists(context): Last-Modified: 2010-02-06T02:11:08Z """ result = [] - for playlist in context.core.stored_playlists.playlists.get(): + for playlist in context.core.playlists.playlists.get(): result.append(('playlist', playlist.name)) last_modified = ( playlist.last_modified or dt.datetime.now()).isoformat() @@ -101,7 +101,7 @@ def load(context, name): - ``load`` appends the given playlist to the current playlist. """ try: - playlist = context.core.stored_playlists.get(name=name).get() + playlist = context.core.playlists.get(name=name).get() context.core.tracklist.append(playlist.tracks) except LookupError: raise MpdNoExistError('No such playlist', command='load') diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/playlists.py similarity index 60% rename from tests/backends/base/stored_playlists.py rename to tests/backends/base/playlists.py index 42c7baa7..473caf8c 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/playlists.py @@ -13,7 +13,7 @@ from mopidy.models import Playlist from tests import unittest, path_to_data_dir -class StoredPlaylistsControllerTest(object): +class PlaylistsControllerTest(object): def setUp(self): settings.LOCAL_PLAYLIST_PATH = tempfile.mkdtemp() settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') @@ -22,7 +22,6 @@ class StoredPlaylistsControllerTest(object): self.audio = mock.Mock(spec=audio.Audio) self.backend = self.backend_class.start(audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) - self.stored = self.core.stored_playlists def tearDown(self): pykka.ActorRegistry.stop_all() @@ -33,74 +32,74 @@ class StoredPlaylistsControllerTest(object): settings.runtime.clear() def test_create_returns_playlist_with_name_set(self): - playlist = self.stored.create('test') + playlist = self.core.playlists.create('test') self.assertEqual(playlist.name, 'test') def test_create_returns_playlist_with_uri_set(self): - playlist = self.stored.create('test') + playlist = self.core.playlists.create('test') self.assert_(playlist.uri) def test_create_adds_playlist_to_playlists_collection(self): - playlist = self.stored.create('test') - self.assert_(self.stored.playlists) - self.assertIn(playlist, self.stored.playlists) + playlist = self.core.playlists.create('test') + self.assert_(self.core.playlists.playlists) + self.assertIn(playlist, self.core.playlists.playlists) def test_playlists_empty_to_start_with(self): - self.assert_(not self.stored.playlists) + self.assert_(not self.core.playlists.playlists) def test_delete_non_existant_playlist(self): - self.stored.delete('file:///unknown/playlist') + self.core.playlists.delete('file:///unknown/playlist') def test_delete_playlist_removes_it_from_the_collection(self): - playlist = self.stored.create('test') - self.assertIn(playlist, self.stored.playlists) + playlist = self.core.playlists.create('test') + self.assertIn(playlist, self.core.playlists.playlists) - self.stored.delete(playlist.uri) + self.core.playlists.delete(playlist.uri) - self.assertNotIn(playlist, self.stored.playlists) + self.assertNotIn(playlist, self.core.playlists.playlists) def test_get_without_criteria(self): - test = self.stored.get + test = self.core.playlists.get self.assertRaises(LookupError, test) def test_get_with_wrong_cirteria(self): - test = lambda: self.stored.get(name='foo') + test = lambda: self.core.playlists.get(name='foo') self.assertRaises(LookupError, test) def test_get_with_right_criteria(self): - playlist1 = self.stored.create('test') - playlist2 = self.stored.get(name='test') + playlist1 = self.core.playlists.create('test') + playlist2 = self.core.playlists.get(name='test') self.assertEqual(playlist1, playlist2) def test_get_by_name_returns_unique_match(self): playlist = Playlist(name='b') - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='a'), playlist] - self.assertEqual(playlist, self.stored.get(name='b')) + self.assertEqual(playlist, self.core.playlists.get(name='b')) def test_get_by_name_returns_first_of_multiple_matches(self): playlist = Playlist(name='b') - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ playlist, Playlist(name='a'), Playlist(name='b')] try: - self.stored.get(name='b') + self.core.playlists.get(name='b') self.fail('Should raise LookupError if multiple matches') except LookupError as e: self.assertEqual('"name=b" match multiple playlists', e[0]) def test_get_by_name_raises_keyerror_if_no_match(self): - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='a'), Playlist(name='b')] try: - self.stored.get(name='c') + self.core.playlists.get(name='c') self.fail('Should raise LookupError if no match') except LookupError as e: self.assertEqual('"name=c" match no playlists', e[0]) def test_lookup_finds_playlist_by_uri(self): - original_playlist = self.stored.create('test') + original_playlist = self.core.playlists.create('test') - looked_up_playlist = self.stored.lookup(original_playlist.uri) + looked_up_playlist = self.core.playlists.lookup(original_playlist.uri) self.assertEqual(original_playlist, looked_up_playlist) @@ -108,14 +107,14 @@ class StoredPlaylistsControllerTest(object): def test_refresh(self): pass - def test_save_replaces_stored_playlist_with_updated_playlist(self): - playlist1 = self.stored.create('test1') - self.assertIn(playlist1, self.stored.playlists) + def test_save_replaces_existing_playlist_with_updated_playlist(self): + playlist1 = self.core.playlists.create('test1') + self.assertIn(playlist1, self.core.playlists.playlists) playlist2 = playlist1.copy(name='test2') - playlist2 = self.stored.save(playlist2) - self.assertNotIn(playlist1, self.stored.playlists) - self.assertIn(playlist2, self.stored.playlists) + playlist2 = self.core.playlists.save(playlist2) + self.assertNotIn(playlist1, self.core.playlists.playlists) + self.assertIn(playlist2, self.core.playlists.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/playlists.py similarity index 74% rename from tests/backends/local/stored_playlists_test.py rename to tests/backends/local/playlists.py index a99b8c23..fcc39132 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/playlists.py @@ -8,13 +8,13 @@ from mopidy.models import Track from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir -from tests.backends.base.stored_playlists import ( - StoredPlaylistsControllerTest) +from tests.backends.base.playlists import ( + PlaylistsControllerTest) from tests.backends.local import generate_song -class LocalStoredPlaylistsControllerTest( - StoredPlaylistsControllerTest, unittest.TestCase): +class LocalPlaylistsControllerTest( + PlaylistsControllerTest, unittest.TestCase): backend_class = LocalBackend @@ -22,14 +22,14 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) - self.stored.create('test') + self.core.playlists.create('test') self.assertTrue(os.path.exists(path)) def test_create_slugifies_playlist_name(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create('test FOO baR') + playlist = self.core.playlists.create('test FOO baR') self.assertEqual('test-foo-bar', playlist.name) self.assertTrue(os.path.exists(path)) @@ -37,7 +37,7 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create('../../test FOO baR') + playlist = self.core.playlists.create('../../test FOO baR') self.assertEqual('test-foo-bar', playlist.name) self.assertTrue(os.path.exists(path)) @@ -45,13 +45,13 @@ class LocalStoredPlaylistsControllerTest( path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2-foo-bar.m3u') - playlist = self.stored.create('test1') + playlist = self.core.playlists.create('test1') self.assertTrue(os.path.exists(path1)) self.assertFalse(os.path.exists(path2)) playlist = playlist.copy(name='test2 FOO baR') - playlist = self.stored.save(playlist) + playlist = self.core.playlists.save(playlist) self.assertEqual('test2-foo-bar', playlist.name) self.assertFalse(os.path.exists(path1)) @@ -61,19 +61,19 @@ class LocalStoredPlaylistsControllerTest( path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) - playlist = self.stored.create('test') + playlist = self.core.playlists.create('test') self.assertTrue(os.path.exists(path)) - self.stored.delete(playlist.uri) + self.core.playlists.delete(playlist.uri) self.assertFalse(os.path.exists(path)) def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) track_path = track.uri[len('file://'):] - playlist = self.stored.create('test') + playlist = self.core.playlists.create('test') playlist_path = playlist.uri[len('file://'):] playlist = playlist.copy(tracks=[track]) - playlist = self.stored.save(playlist) + playlist = self.core.playlists.save(playlist) with open(playlist_path) as playlist_file: contents = playlist_file.read() @@ -84,20 +84,20 @@ class LocalStoredPlaylistsControllerTest( playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = self.stored.create('test') + playlist = self.core.playlists.create('test') playlist = playlist.copy(tracks=[track]) - playlist = self.stored.save(playlist) + playlist = self.core.playlists.save(playlist) backend = self.backend_class(audio=self.audio) - self.assert_(backend.stored_playlists.playlists) + self.assert_(backend.playlists.playlists) self.assertEqual( path_to_uri(playlist_path), - backend.stored_playlists.playlists[0].uri) + backend.playlists.playlists[0].uri) self.assertEqual( - playlist.name, backend.stored_playlists.playlists[0].name) + playlist.name, backend.playlists.playlists[0].name) self.assertEqual( - track.uri, backend.stored_playlists.playlists[0].tracks[0].uri) + track.uri, backend.playlists.playlists[0].tracks[0].uri) @unittest.SkipTest def test_santitising_of_playlist_filenames(self): diff --git a/tests/core/stored_playlists_test.py b/tests/core/playlists.py similarity index 76% rename from tests/core/stored_playlists_test.py rename to tests/core/playlists.py index 79b7d012..949625fe 100644 --- a/tests/core/stored_playlists_test.py +++ b/tests/core/playlists.py @@ -9,23 +9,23 @@ from mopidy.models import Playlist, Track from tests import unittest -class StoredPlaylistsTest(unittest.TestCase): +class PlaylistsTest(unittest.TestCase): def setUp(self): self.backend1 = mock.Mock() self.backend1.uri_schemes.get.return_value = ['dummy1'] - self.sp1 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) - self.backend1.stored_playlists = self.sp1 + self.sp1 = mock.Mock(spec=base.BasePlaylistsProvider) + self.backend1.playlists = self.sp1 self.backend2 = mock.Mock() self.backend2.uri_schemes.get.return_value = ['dummy2'] - self.sp2 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) - self.backend2.stored_playlists = self.sp2 + self.sp2 = mock.Mock(spec=base.BasePlaylistsProvider) + self.backend2.playlists = self.sp2 - # A backend without the optional stored playlists provider + # A backend without the optional playlists provider self.backend3 = mock.Mock() self.backend3.uri_schemes.get.return_value = ['dummy3'] - self.backend3.has_stored_playlists().get.return_value = False - self.backend3.stored_playlists = None + 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')]) @@ -39,7 +39,7 @@ class StoredPlaylistsTest(unittest.TestCase): self.backend3, self.backend1, self.backend2]) def test_get_playlists_combines_result_from_backends(self): - result = self.core.stored_playlists.playlists + result = self.core.playlists.playlists self.assertIn(self.pl1a, result) self.assertIn(self.pl1b, result) @@ -51,7 +51,7 @@ class StoredPlaylistsTest(unittest.TestCase): self.sp1.create().get.return_value = playlist self.sp1.reset_mock() - result = self.core.stored_playlists.create('foo') + result = self.core.playlists.create('foo') self.assertEqual(playlist, result) self.sp1.create.assert_called_once_with('foo') @@ -62,7 +62,7 @@ class StoredPlaylistsTest(unittest.TestCase): self.sp2.create().get.return_value = playlist self.sp2.reset_mock() - result = self.core.stored_playlists.create('foo', uri_scheme='dummy2') + result = self.core.playlists.create('foo', uri_scheme='dummy2') self.assertEqual(playlist, result) self.assertFalse(self.sp1.create.called) @@ -73,75 +73,75 @@ class StoredPlaylistsTest(unittest.TestCase): self.sp1.create().get.return_value = playlist self.sp1.reset_mock() - result = self.core.stored_playlists.create('foo', uri_scheme='dummy3') + result = self.core.playlists.create('foo', uri_scheme='dummy3') self.assertEqual(playlist, result) self.sp1.create.assert_called_once_with('foo') self.assertFalse(self.sp2.create.called) def test_delete_selects_the_dummy1_backend(self): - self.core.stored_playlists.delete('dummy1:a') + self.core.playlists.delete('dummy1:a') self.sp1.delete.assert_called_once_with('dummy1:a') self.assertFalse(self.sp2.delete.called) def test_delete_selects_the_dummy2_backend(self): - self.core.stored_playlists.delete('dummy2:a') + self.core.playlists.delete('dummy2:a') self.assertFalse(self.sp1.delete.called) self.sp2.delete.assert_called_once_with('dummy2:a') def test_delete_with_unknown_uri_scheme_does_nothing(self): - self.core.stored_playlists.delete('unknown:a') + self.core.playlists.delete('unknown:a') self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) def test_delete_ignores_backend_without_playlist_support(self): - self.core.stored_playlists.delete('dummy3:a') + self.core.playlists.delete('dummy3:a') self.assertFalse(self.sp1.delete.called) self.assertFalse(self.sp2.delete.called) def test_lookup_selects_the_dummy1_backend(self): - self.core.stored_playlists.lookup('dummy1:a') + self.core.playlists.lookup('dummy1:a') self.sp1.lookup.assert_called_once_with('dummy1:a') self.assertFalse(self.sp2.lookup.called) def test_lookup_selects_the_dummy2_backend(self): - self.core.stored_playlists.lookup('dummy2:a') + self.core.playlists.lookup('dummy2:a') self.assertFalse(self.sp1.lookup.called) self.sp2.lookup.assert_called_once_with('dummy2:a') def test_lookup_track_in_backend_without_playlists_fails(self): - result = self.core.stored_playlists.lookup('dummy3:a') + result = self.core.playlists.lookup('dummy3:a') self.assertIsNone(result) self.assertFalse(self.sp1.lookup.called) self.assertFalse(self.sp2.lookup.called) def test_refresh_without_uri_scheme_refreshes_all_backends(self): - self.core.stored_playlists.refresh() + self.core.playlists.refresh() self.sp1.refresh.assert_called_once_with() self.sp2.refresh.assert_called_once_with() def test_refresh_with_uri_scheme_refreshes_matching_backend(self): - self.core.stored_playlists.refresh(uri_scheme='dummy2') + self.core.playlists.refresh(uri_scheme='dummy2') self.assertFalse(self.sp1.refresh.called) self.sp2.refresh.assert_called_once_with() def test_refresh_with_unknown_uri_scheme_refreshes_nothing(self): - self.core.stored_playlists.refresh(uri_scheme='foobar') + self.core.playlists.refresh(uri_scheme='foobar') self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) def test_refresh_ignores_backend_without_playlist_support(self): - self.core.stored_playlists.refresh(uri_scheme='dummy3') + self.core.playlists.refresh(uri_scheme='dummy3') self.assertFalse(self.sp1.refresh.called) self.assertFalse(self.sp2.refresh.called) @@ -151,7 +151,7 @@ class StoredPlaylistsTest(unittest.TestCase): self.sp1.save().get.return_value = playlist self.sp1.reset_mock() - result = self.core.stored_playlists.save(playlist) + result = self.core.playlists.save(playlist) self.assertEqual(playlist, result) self.sp1.save.assert_called_once_with(playlist) @@ -162,28 +162,28 @@ class StoredPlaylistsTest(unittest.TestCase): self.sp2.save().get.return_value = playlist self.sp2.reset_mock() - result = self.core.stored_playlists.save(playlist) + result = self.core.playlists.save(playlist) self.assertEqual(playlist, result) self.assertFalse(self.sp1.save.called) self.sp2.save.assert_called_once_with(playlist) def test_save_does_nothing_if_playlist_uri_is_unset(self): - result = self.core.stored_playlists.save(Playlist()) + result = self.core.playlists.save(Playlist()) self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) def test_save_does_nothing_if_playlist_uri_has_unknown_scheme(self): - result = self.core.stored_playlists.save(Playlist(uri='foobar:a')) + result = self.core.playlists.save(Playlist(uri='foobar:a')) self.assertIsNone(result) self.assertFalse(self.sp1.save.called) self.assertFalse(self.sp2.save.called) def test_save_ignores_backend_without_playlist_support(self): - result = self.core.stored_playlists.save(Playlist(uri='dummy3:a')) + result = self.core.playlists.save(Playlist(uri='dummy3:a')) self.assertIsNone(result) self.assertFalse(self.sp1.save.called) diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 654987fc..68230c6a 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -123,7 +123,7 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.stored_playlists.create('foo') + self.core.playlists.create('foo') self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) @@ -148,7 +148,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.stored_playlists.create( + self.core.playlists.create( u'all lart spotify:track:\w\{22\} pastes') self.sendRequest('lsinfo "/"') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index c2201111..6bac95e5 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -7,9 +7,9 @@ from mopidy.models import Track, Playlist from tests.frontends.mpd import protocol -class StoredPlaylistsHandlerTest(protocol.BaseTestCase): +class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest('listplaylist "name"') @@ -17,7 +17,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylist_without_quotes(self): - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest('listplaylist name') @@ -29,7 +29,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') def test_listplaylistinfo(self): - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest('listplaylistinfo "name"') @@ -39,7 +39,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] self.sendRequest('listplaylistinfo name') @@ -55,7 +55,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='a', last_modified=last_modified)] self.sendRequest('listplaylists') @@ -67,7 +67,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_known_playlist_appends_to_tracklist(self): self.core.tracklist.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) - self.backend.stored_playlists.playlists = [ + self.backend.playlists.playlists = [ Playlist(name='A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] From cee6894ff65a95da3dc8fce1fe96fc3b1e0ceaf9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 09:27:03 +0100 Subject: [PATCH 226/323] Update changelog --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 80ba5ebe..42478178 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -108,6 +108,9 @@ backends: - Renamed "current playlist" to "tracklist" everywhere, including the core API used by frontends. +- Renamed "stored playlists" to "playlists" everywhere, including the core API + used by frontends. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now From 6b7256a9556cc719173591be3371d4b8fdc3d140 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 10:51:52 +0100 Subject: [PATCH 227/323] audio: Explicitly disconnect signal handles on teardown --- mopidy/audio/actor.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a7b4e8d8..c2448e1f 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -40,7 +40,8 @@ class Audio(pykka.ThreadingActor): self._mixer_track = None self._software_mixing = False - self._message_processor_set_up = False + self._notify_source_signal_id = None + self._message_signal_id = None def on_start(self): try: @@ -63,7 +64,8 @@ class Audio(pykka.ThreadingActor): fakesink = gst.element_factory_make('fakesink') self._playbin.set_property('video-sink', fakesink) - self._playbin.connect('notify::source', self._on_new_source) + self._notify_source_signal_id = self._playbin.connect( + 'notify::source', self._on_new_source) def _on_new_source(self, element, pad): uri = element.get_property('uri') @@ -79,6 +81,8 @@ class Audio(pykka.ThreadingActor): source.set_property('caps', default_caps) def _teardown_playbin(self): + if self._notify_source_signal_id: + self._playbin.disconnect(self._notify_source_signal_id) self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): @@ -151,12 +155,12 @@ class Audio(pykka.ThreadingActor): def _setup_message_processor(self): bus = self._playbin.get_bus() bus.add_signal_watch() - bus.connect('message', self._on_message) - self._message_processor_set_up = True + self._message_signal_id = bus.connect('message', self._on_message) def _teardown_message_processor(self): - if self._message_processor_set_up: + if self._message_signal_id: bus = self._playbin.get_bus() + bus.disconnect(self._message_signal_id) bus.remove_signal_watch() def _on_message(self, bus, message): From 75f7fc273aea55645143a767e63e190c612bde96 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 10:58:02 +0100 Subject: [PATCH 228/323] spotify: Fix GStreamer warning on seek (fixes #227) Don't call audio.{prepare_change,start_playback}() before/after seek. This does not seem to have any effect on functionality, and avoids Gstreamer failing to disconnect the "notify::source" signal handler, which again causes a warning. --- mopidy/backends/spotify/playback.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index de82464a..d3585021 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -52,12 +52,8 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): return self.seek(time_position) def seek(self, time_position): - self.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.audio.start_playback() - self._timer.seek(time_position) - return True def stop(self): From 8a440fff3f0abbb778ae4bc8ba064f6a8247c9d4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 18:12:44 +0100 Subject: [PATCH 229/323] network: Server send buffer should be bytes, not unicode (#241) --- mopidy/utils/network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 91831871..3ddfe2ee 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -140,7 +140,7 @@ class Connection(object): self.timeout = timeout self.send_lock = threading.Lock() - self.send_buffer = '' + self.send_buffer = b'' self.stopping = False @@ -193,7 +193,7 @@ class Connection(object): if e.errno in (errno.EWOULDBLOCK, errno.EINTR): return data self.stop('Unexpected client error: %s' % e) - return '' + return b'' def enable_timeout(self): """Reactivate timeout mechanism.""" From 8a292bce58f5c95c42a2c09e59dc4002b7abf830 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 20:45:24 +0100 Subject: [PATCH 230/323] scanner: Move main() function from bin/ to mopidy.scanner --- bin/mopidy-scan | 51 ++++------------------------------------------- mopidy/scanner.py | 43 ++++++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/bin/mopidy-scan b/bin/mopidy-scan index 001ea372..00f51809 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -1,48 +1,5 @@ -#!/usr/bin/env python +#! /usr/bin/env python -from __future__ import unicode_literals - -import sys -import logging - -from mopidy import settings -from mopidy.utils.log import setup_console_logging, setup_root_logger -from mopidy.scanner import Scanner, translator -from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format - - -setup_root_logger() -setup_console_logging(2) - - -tracks = [] - - -def store(data): - track = translator(data) - tracks.append(track) - logging.debug('Added %s', track.uri) - - -def debug(uri, error, debug): - logging.error('Failed %s: %s - %s', uri, error, debug) - - -logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) - - -scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) -try: - scanner.start() -except KeyboardInterrupt: - scanner.stop() - - -logging.info('Done') - - -for a in tracks_to_tag_cache_format(tracks): - if len(a) == 1: - print ('%s' % a).encode('utf-8') - else: - print ('%s: %s' % a).encode('utf-8') +if __name__ == '__main__': + from mopidy.scanner import main + main() diff --git a/mopidy/scanner.py b/mopidy/scanner.py index e5e484e5..c20ef4fb 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -1,5 +1,8 @@ from __future__ import unicode_literals +import logging +import datetime + import gobject gobject.threads_init() @@ -7,10 +10,40 @@ import pygst pygst.require('0.10') import gst -import datetime - -from mopidy.utils.path import path_to_uri, find_files +from mopidy import settings +from mopidy.frontends.mpd import translator as mpd_translator from mopidy.models import Track, Artist, Album +from mopidy.utils import log, path + + +def main(): + log.setup_root_logger() + log.setup_console_logging(2) + + tracks = [] + + def store(data): + track = translator(data) + tracks.append(track) + logging.debug('Added %s', track.uri) + + def debug(uri, error, debug): + logging.error('Failed %s: %s - %s', uri, error, debug) + + logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) + scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) + try: + scanner.start() + except KeyboardInterrupt: + scanner.stop() + + logging.info('Done') + + for row in mpd_translator.tracks_to_tag_cache_format(tracks): + if len(row) == 1: + print ('%s' % row).encode('utf-8') + else: + print ('%s: %s' % row).encode('utf-8') def translator(data): @@ -56,7 +89,7 @@ def translator(data): class Scanner(object): def __init__(self, folder, data_callback, error_callback=None): - self.files = find_files(folder) + self.files = path.find_files(folder) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() @@ -119,7 +152,7 @@ class Scanner(object): def next_uri(self): try: - uri = path_to_uri(self.files.next()) + uri = path.path_to_uri(self.files.next()) except StopIteration: self.stop() return False From bcaeca7acc89324dea4d09f7d986db74d2ac7d04 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 22:51:12 +0100 Subject: [PATCH 231/323] scanner: Support multiple tag sets per track (fixes #236) --- docs/changes.rst | 4 ++++ mopidy/scanner.py | 58 +++++++++++++++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 42478178..c05cda1c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -122,6 +122,10 @@ backends: - MPD no longer lowercases search queries. This broke e.g. search by URI, where casing may be essential. +- :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC + files (Apple lossless) because it didn't support multiple tag messages from + GStreamer per track it scanned. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/scanner.py b/mopidy/scanner.py index c20ef4fb..d84c262c 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -89,51 +89,70 @@ def translator(data): class Scanner(object): def __init__(self, folder, data_callback, error_callback=None): + self.data = {} self.files = path.find_files(folder) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() - fakesink = gst.element_factory_make('fakesink') + self.fakesink = gst.element_factory_make('fakesink') + self.fakesink.set_property('signal-handoffs', True) + self.fakesink.connect('handoff', self.process_handoff) self.uribin = gst.element_factory_make('uridecodebin') self.uribin.set_property('caps', gst.Caps(b'audio/x-raw-int')) - self.uribin.connect( - 'pad-added', self.process_new_pad, fakesink.get_pad('sink')) + self.uribin.connect('pad-added', self.process_new_pad) self.pipe = gst.element_factory_make('pipeline') self.pipe.add(self.uribin) - self.pipe.add(fakesink) + self.pipe.add(self.fakesink) bus = self.pipe.get_bus() bus.add_signal_watch() + bus.connect('message::application', self.process_application) bus.connect('message::tag', self.process_tags) bus.connect('message::error', self.process_error) - def process_new_pad(self, source, pad, target_pad): - pad.link(target_pad) + def process_handoff(self, fakesink, buffer_, pad): + # When this function is called the first buffer has reached the end of + # the pipeline, and we can continue with the next track. Since we're + # in another thread, we send a message back to the main thread using + # the bus. + structure = gst.Structure('handoff') + message = gst.message_new_application(fakesink, structure) + bus = self.pipe.get_bus() + bus.post(message) + + def process_new_pad(self, source, pad): + pad.link(self.fakesink.get_pad('sink')) + + def process_application(self, bus, message): + if message.src != self.fakesink: + return + + if message.structure.get_name() != 'handoff': + return + + self.data['uri'] = unicode(self.uribin.get_property('uri')) + self.data[gst.TAG_DURATION] = self.get_duration() + + try: + self.data_callback(self.data) + self.next_uri() + except KeyboardInterrupt: + self.stop() def process_tags(self, bus, message): taglist = message.parse_tag() - data = { - 'uri': unicode(self.uribin.get_property('uri')), - gst.TAG_DURATION: self.get_duration(), - } for key in taglist.keys(): # XXX: For some crazy reason some wma files spit out lists here, # not sure if this is due to better data in headers or wma being # stupid. So ugly hack for now :/ if type(taglist[key]) is list: - data[key] = taglist[key][0] + self.data[key] = taglist[key][0] else: - data[key] = taglist[key] - - try: - self.data_callback(data) - self.next_uri() - except KeyboardInterrupt: - self.stop() + self.data[key] = taglist[key] def process_error(self, bus, message): if self.error_callback: @@ -151,6 +170,7 @@ class Scanner(object): return None def next_uri(self): + self.data = {} try: uri = path.path_to_uri(self.files.next()) except StopIteration: @@ -158,7 +178,7 @@ class Scanner(object): return False self.pipe.set_state(gst.STATE_NULL) self.uribin.set_property('uri', uri) - self.pipe.set_state(gst.STATE_PAUSED) + self.pipe.set_state(gst.STATE_PLAYING) return True def start(self): From f0c8c2287fe0741d60cb8291ae6f21ce8d4160a8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 23:37:27 +0100 Subject: [PATCH 232/323] audio: Fix typo --- mopidy/audio/actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 49794c76..1355cfef 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -179,7 +179,7 @@ class Audio(pykka.ThreadingActor): def _on_playbin_state_changed(self, old_state, new_state, pending_state): if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: - # XXX: We're not called on the last state cheng when going down to + # XXX: We're not called on the last state change when going down to # NULL, so we rewrite the second to last call to get the expected # behavior. new_state = gst.STATE_NULL From 36a14bd8d60786bb7b392261ad0f2a7a59fe30d6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 23:40:09 +0100 Subject: [PATCH 233/323] audio: Make log message more useful --- mopidy/audio/actor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 1355cfef..f2208a0a 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -200,7 +200,9 @@ class Audio(pykka.ThreadingActor): old_state, self.state = self.state, new_state - logger.debug('Triggering state_changed event') + logger.debug( + 'Triggering event: state_changed(old_state=%s, new_state=%s)', + old_state, new_state) AudioListener.send('state_changed', old_state=old_state, new_state=new_state) From 326970e5adcb815e3249c0c249bdbfdc1943990d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 14 Nov 2012 23:42:33 +0100 Subject: [PATCH 234/323] core: Explain reason behind temporary state syncing hack --- mopidy/core/actor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 858eeaf9..3ebb785b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -57,7 +57,11 @@ class Core(pykka.ThreadingActor, AudioListener): def state_changed(self, old_state, new_state): # XXX: This is a temporary fix for issue #232 while we wait for a more - # permanent solution with the implementation of issue #234. + # permanent solution with the implementation of issue #234. When the + # Spotify play token is lost, the Spotify backend pauses audio + # playback, but mopidy.core doesn't know this, so we need to update + # mopidy.core's state to match the actual state in mopidy.audio. If we + # don't do this, clients will think that we're still playing. if (new_state == PlaybackState.PAUSED and self.playback.state != PlaybackState.PAUSED): self.playback.state = new_state From b3fd9d8b4071f6483566579fb1d9e0857aa84557 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 15 Nov 2012 08:48:43 +0100 Subject: [PATCH 235/323] deps: Pykka version check doesn't need to work with < 1.0 --- mopidy/utils/deps.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 41fd513d..3c177036 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -135,15 +135,9 @@ def _gstreamer_check_elements(): def pykka_info(): - if hasattr(pykka, '__version__'): - # Pykka >= 0.14 - version = pykka.__version__ - else: - # Pykka < 0.14 - version = pykka.get_version() return { 'name': 'Pykka', - 'version': version, + 'version': pykka.__version__, 'path': pykka.__file__, } From 684586dd184cda31a02fb473a0962eb3f405d3e4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 15 Nov 2012 09:04:21 +0100 Subject: [PATCH 236/323] mpris: Update for MPRIS 2.2 compliance --- mopidy/frontends/mpris/objects.py | 4 +++- tests/frontends/mpris/root_interface_test.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 235dd80a..cf7f71ce 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -30,7 +30,7 @@ PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' class MprisObject(dbus.service.Object): - """Implements http://www.mpris.org/2.1/spec/""" + """Implements http://www.mpris.org/2.2/spec/""" properties = None @@ -46,6 +46,8 @@ class MprisObject(dbus.service.Object): def _get_root_iface_properties(self): return { 'CanQuit': (True, None), + 'Fullscreen': (False, None), + 'CanSetFullscreen': (False, None), 'CanRaise': (False, None), # NOTE Change if adding optional track list support 'HasTrackList': (False, None), diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 9e16c6bb..722fd2cd 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -31,6 +31,18 @@ class RootInterfaceTest(unittest.TestCase): def test_constructor_connects_to_dbus(self): self.assert_(self.mpris._connect_to_dbus.called) + def test_fullscreen_returns_false(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'Fullscreen') + self.assertFalse(result) + + def test_setting_fullscreen_fails_and_returns_none(self): + result = self.mpris.Set(objects.ROOT_IFACE, 'Fullscreen', 'True') + self.assertIsNone(result) + + def test_can_set_fullscreen_returns_false(self): + result = self.mpris.Get(objects.ROOT_IFACE, 'CanSetFullscreen') + self.assertFalse(result) + def test_can_raise_returns_false(self): result = self.mpris.Get(objects.ROOT_IFACE, 'CanRaise') self.assertFalse(result) @@ -64,7 +76,7 @@ class RootInterfaceTest(unittest.TestCase): self.assertEquals(result, 'foo') settings.runtime.clear() - def test_supported_uri_schemes_is_empty(self): + def test_supported_uri_schemes_includes_backend_uri_schemes(self): result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes') self.assertEquals(len(result), 1) self.assertEquals(result[0], 'dummy') From 4aa23e3306a534e8222c5ede2f8eaabf863441d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 15 Nov 2012 09:16:01 +0100 Subject: [PATCH 237/323] docs: A small changelog cleanup --- docs/changes.rst | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index c05cda1c..d2e7d7b5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -56,23 +56,29 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. -- The stored playlists part of the core API has been revised to be more focused - around the playlist URI, and some redundant functionality has been removed: +- Renamed "current playlist" to "tracklist" everywhere, including the core API + used by frontends. - - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports +- Renamed "stored playlists" to "playlists" everywhere, including the core API + used by frontends. + +- The playlists part of the core API has been revised to be more focused around + the playlist URI, and some redundant functionality has been removed: + + - :attr:`mopidy.core.PlaylistsController.playlists` no longer supports assignment to it. The `playlists` property on the backend layer still does, and all functionality is maintained by assigning to the playlists collections at the backend level. - - :meth:`mopidy.core.StoredPlaylistsController.delete` now accepts an URI, - and not a playlist object. + - :meth:`mopidy.core.PlaylistsController.delete` now accepts an URI, and not + a playlist object. - - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved + - :meth:`mopidy.core.PlaylistsController.save` now returns the saved playlist. The returned playlist may differ from the saved playlist, and should thus be used instead of the playlist passed to ``save()``. - - :meth:`mopidy.core.StoredPlaylistsController.rename` has been removed, - since renaming can be done with ``save()``. + - :meth:`mopidy.core.PlaylistsController.rename` has been removed, since + renaming can be done with ``save()``. **Changes** @@ -105,12 +111,6 @@ backends: - The Spotify backend now returns the track if you search for the Spotify track URI. (Fixes: :issue:`233`) -- Renamed "current playlist" to "tracklist" everywhere, including the core API - used by frontends. - -- Renamed "stored playlists" to "playlists" everywhere, including the core API - used by frontends. - **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now From 0a96e5dccb3f7177c239f6efefd176ea87850833 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 15 Nov 2012 22:34:20 +0100 Subject: [PATCH 238/323] Update emit_data to take buffers. Simplify emit data method to take Gstreamer buffers. This allows us to more concisely give it buffers with duration, timestamp and other relevant data set. --- mopidy/audio/actor.py | 12 +++--------- mopidy/backends/spotify/session_manager.py | 9 ++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 162e2a05..b422bc67 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -225,22 +225,16 @@ class Audio(pykka.ThreadingActor): """ self._playbin.set_property('uri', uri) - def emit_data(self, capabilities, data): + def emit_data(self, buffer_): """ Call this to deliver raw audio data to be played. Note that the uri must be set to ``appsrc://`` for this to work. - :param capabilities: a GStreamer capabilities string - :type capabilities: string - :param data: raw audio data to be played + :param buffer_: buffer to pass to appsrc + :type buffer_: :class:`gst.Buffer` """ - caps = gst.caps_from_string(capabilities) - buffer_ = gst.Buffer(buffer(data)) - buffer_.set_caps(caps) - source = self._playbin.get_property('source') - source.set_property('caps', caps) source.emit('push-buffer', buffer_) def emit_end_of_stream(self): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index cd3d97db..8032a289 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import os import threading @@ -108,7 +112,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.audio.emit_data(capabilites, bytes(frames)) + buffer_ = gst.Buffer(bytes(frames)) + buffer_.set_caps(gst.caps_from_string(capabilites)) + + self.audio.emit_data(buffer_) return num_frames def play_token_lost(self, session): From f2b975cc37c93cf5633f85497d6e4c5ffa7f572a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 15 Nov 2012 22:38:27 +0100 Subject: [PATCH 239/323] Update emit_data to return true if data was delivered. This is probably not needed, but for the sake of correctnes it doesn't hurt. --- mopidy/audio/actor.py | 5 ++++- mopidy/backends/spotify/session_manager.py | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index b422bc67..a17033ed 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -231,11 +231,14 @@ class Audio(pykka.ThreadingActor): Note that the uri must be set to ``appsrc://`` for this to work. + Returns true if data was delivered. + :param buffer_: buffer to pass to appsrc :type buffer_: :class:`gst.Buffer` + :rtype: boolean """ source = self._playbin.get_property('source') - source.emit('push-buffer', buffer_) + return source.emit('push-buffer', buffer_) == gst.FLOW_OK def emit_end_of_stream(self): """ diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 8032a289..998e4d5e 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -115,8 +115,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - self.audio.emit_data(buffer_) - return num_frames + if self.audio.emit_data(buffer_).get(): + return num_frames + else: + return 0 def play_token_lost(self, session): """Callback used by pyspotify""" From d516e9023ab8ee0894341c70c8c6a75a33e9959e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 15 Nov 2012 22:49:44 +0100 Subject: [PATCH 240/323] Store active appsrc and refuse data when it is not set. We use the new source flag and the about to finish flags to set and unset the current appsrc. In emit data we now return false if the appsrc is not set. Also note that we need to use b'' for Gstreamer properties as it can't convert unicode to the correct type. I also added the signal disconnect code for about to finish. --- mopidy/audio/actor.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a17033ed..a23c4c43 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -42,8 +42,10 @@ class Audio(pykka.ThreadingActor): self._mixer = None self._mixer_track = None self._software_mixing = False + self._appsrc = None self._notify_source_signal_id = None + self._about_to_finish_id = None self._message_signal_id = None def on_start(self): @@ -67,9 +69,14 @@ class Audio(pykka.ThreadingActor): fakesink = gst.element_factory_make('fakesink') self._playbin.set_property('video-sink', fakesink) + self._about_to_finish_id = self._playbin.connect( + 'about-to-finish', self._on_about_to_finish) self._notify_source_signal_id = self._playbin.connect( 'notify::source', self._on_new_source) + def _on_about_to_finish(self, element): + self._appsrc = None + def _on_new_source(self, element, pad): uri = element.get_property('uri') if not uri or not uri.startswith('appsrc://'): @@ -82,8 +89,13 @@ class Audio(pykka.ThreadingActor): b'rate=(int)44100') source = element.get_property('source') source.set_property('caps', default_caps) + source.set_property('format', b'time') # Gstreamer does not like unicode + + self._appsrc = source def _teardown_playbin(self): + if self._about_to_finish_id: + self._playbin.disconnect(self._about_to_finish_id) if self._notify_source_signal_id: self._playbin.disconnect(self._notify_source_signal_id) self._playbin.set_state(gst.STATE_NULL) @@ -237,8 +249,9 @@ class Audio(pykka.ThreadingActor): :type buffer_: :class:`gst.Buffer` :rtype: boolean """ - source = self._playbin.get_property('source') - return source.emit('push-buffer', buffer_) == gst.FLOW_OK + if not self._appsrc: + return False + return self._appsrc.emit('push-buffer', buffer_) == gst.FLOW_OK def emit_end_of_stream(self): """ From 02b225eb6e8a32140dfac2f0b9de47780e79160f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 15 Nov 2012 10:20:58 +0100 Subject: [PATCH 241/323] tests: Update dummy backend's playlists provider implementation --- mopidy/backends/dummy.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index af8f7487..d3239b34 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -82,22 +82,30 @@ class DummyPlaybackProvider(base.BasePlaybackProvider): class DummyPlaylistsProvider(base.BasePlaylistsProvider): def create(self, name): - playlist = Playlist(name=name) + playlist = Playlist(name=name, uri='dummy:%s' % name) self._playlists.append(playlist) return playlist - def delete(self, playlist): - self._playlists.remove(playlist) + def delete(self, uri): + playlist = self.lookup(uri) + if playlist: + self._playlists.remove(playlist) def lookup(self, uri): - return filter(lambda p: p.uri == uri, self._playlists) + for playlist in self._playlists: + if playlist.uri == uri: + return playlist def refresh(self): pass - def rename(self, playlist, new_name): - self._playlists[self._playlists.index(playlist)] = \ - playlist.copy(name=new_name) - def save(self, playlist): - self._playlists.append(playlist) + old_playlist = self.lookup(playlist.uri) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist From fff70c46a65855899e252a6e43d427f51e43764b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 10:24:12 +0100 Subject: [PATCH 242/323] core: Make tracklist.append() return the appended TlTracks --- docs/changes.rst | 5 +++++ mopidy/core/tracklist.py | 6 +++++- tests/backends/base/tracklist.py | 9 ++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index d2e7d7b5..bf5dd8cb 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -111,6 +111,11 @@ backends: - The Spotify backend now returns the track if you search for the Spotify track URI. (Fixes: :issue:`233`) +- :meth:`mopidy.core.TracklistController.append` now returns a list of the + :class:`mopidy.models.TlTrack` instances that was added to the tracklist. + This makes it easier to start playing one of the tracks that was just + appended to the tracklist. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 529d2a7a..5458ee3e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -92,13 +92,17 @@ class TracklistController(object): :param tracks: tracks to append :type tracks: list of :class:`mopidy.models.Track` + :rtype: list of class:`mopidy.models.TlTrack` """ + tl_tracks = [] for track in tracks: - self.add(track, increase_version=False) + tl_tracks.append(self.add(track, increase_version=False)) if tracks: self.version += 1 + return tl_tracks + def clear(self): """Clear the current playlist.""" self._tl_tracks = [] diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 64ab10d4..e67a40e4 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -153,10 +153,13 @@ class TracklistControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) + @populate_playlist + def test_append_returns_the_tl_tracks_that_was_added(self): + tl_tracks = self.controller.append(self.controller.tracks[1:2]) + self.assertEqual(tl_tracks[0][1], self.controller.tracks[1]) + def test_index_returns_index_of_track(self): - tl_tracks = [] - for track in self.tracks: - tl_tracks.append(self.controller.add(track)) + tl_tracks = self.controller.append(self.tracks) self.assertEquals(0, self.controller.index(tl_tracks[0])) self.assertEquals(1, self.controller.index(tl_tracks[1])) self.assertEquals(2, self.controller.index(tl_tracks[2])) From cbc08f398c8de7d2fd0ad316c057ee7ebd5d69ab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 11:40:09 +0100 Subject: [PATCH 243/323] core: Update tracklist docstrings --- mopidy/core/tracklist.py | 44 +++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 5458ee3e..f8cf819e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -33,7 +33,7 @@ class TracklistController(object): @property def tracks(self): """ - List of :class:`mopidy.models.Track` in the current playlist. + List of :class:`mopidy.models.Track` in the tracklist. Read-only. """ @@ -42,15 +42,15 @@ class TracklistController(object): @property def length(self): """ - Length of the current playlist. + Length of the tracklist. """ return len(self._tl_tracks) @property def version(self): """ - The current playlist version. Integer which is increased every time the - current playlist is changed. Is not reset before Mopidy is restarted. + The tracklist version. Integer which is increased every time the + tracklist is changed. Is not reset before Mopidy is restarted. """ return self._version @@ -62,20 +62,19 @@ class TracklistController(object): def add(self, track, at_position=None, increase_version=True): """ - Add the track to the end of, or at the given position in the current - playlist. + Add the track to the end of, or at the given position in the tracklist. :param track: track to add :type track: :class:`mopidy.models.Track` - :param at_position: position in current playlist to add track + :param at_position: position in tracklist to add track :type at_position: int or :class:`None` - :param increase_version: if the playlist version should be increased + :param increase_version: if the tracklist version should be increased :type increase_version: :class:`True` or :class:`False` :rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) that - was added to the current playlist playlist + was added to the tracklist """ assert at_position <= len(self._tl_tracks), \ - 'at_position can not be greater than playlist length' + 'at_position can not be greater than tracklist length' tl_track = TlTrack(self.tlid, track) if at_position is not None: self._tl_tracks.insert(at_position, tl_track) @@ -88,7 +87,7 @@ class TracklistController(object): def append(self, tracks): """ - Append the given tracks to the current playlist. + Append the given tracks to the tracklist. :param tracks: tracks to append :type tracks: list of :class:`mopidy.models.Track` @@ -104,20 +103,19 @@ class TracklistController(object): return tl_tracks def clear(self): - """Clear the current playlist.""" + """Clear the tracklist.""" self._tl_tracks = [] self.version += 1 def get(self, **criteria): """ - Get track by given criterias from current playlist. + Get track by given criterias from tracklist. Raises :exc:`LookupError` if a unique match is not found. Examples:: - get(tlid=7) # Returns track with TLID 7 - # (current playlist ID) + get(tlid=7) # Returns track with TLID 7 (tracklist ID) get(id=1) # Returns track with ID 1 get(uri='xyz') # Returns track with URI 'xyz' get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' @@ -145,7 +143,7 @@ class TracklistController(object): def index(self, tl_track): """ Get index of the given (TLID integer, :class:`mopidy.models.Track`) - two-tuple in the current playlist. + two-tuple in the tracklist. Raises :exc:`ValueError` if not found. @@ -174,10 +172,10 @@ class TracklistController(object): assert start < end, 'start must be smaller than end' assert start >= 0, 'start must be at least zero' assert end <= len(tl_tracks), \ - 'end can not be larger than playlist length' + 'end can not be larger than tracklist length' assert to_position >= 0, 'to_position must be at least zero' assert to_position <= len(tl_tracks), \ - 'to_position can not be larger than playlist length' + 'to_position can not be larger than tracklist length' new_tl_tracks = tl_tracks[:start] + tl_tracks[end:] for tl_track in tl_tracks[start:end]: @@ -188,7 +186,7 @@ class TracklistController(object): def remove(self, **criteria): """ - Remove the track from the current playlist. + Remove the track from the tracklist. Uses :meth:`get()` to lookup the track to remove. @@ -202,7 +200,7 @@ class TracklistController(object): def shuffle(self, start=None, end=None): """ - Shuffles the entire playlist. If ``start`` and ``end`` is given only + Shuffles the entire tracklist. If ``start`` and ``end`` is given only shuffles the slice ``[start:end]``. :param start: position of first track to shuffle @@ -220,7 +218,7 @@ class TracklistController(object): if end is not None: assert end <= len(tl_tracks), 'end can not be larger than ' + \ - 'playlist length' + 'tracklist length' before = tl_tracks[:start or 0] shuffled = tl_tracks[start:end] @@ -231,8 +229,8 @@ class TracklistController(object): def slice(self, start, end): """ - Returns a slice of the current playlist, limited by the given - start and end positions. + Returns a slice of the tracklist, limited by the given start and end + positions. :param start: position of first track to include in slice :type start: int From 476df7a14debcc9a2c3f867e9fe3716aaf986a75 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 11:40:39 +0100 Subject: [PATCH 244/323] core: Add tracklist_changed() event --- mopidy/core/listener.py | 8 ++++++++ tests/core/listener_test.py | 3 +++ 2 files changed, 11 insertions(+) diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 9c8bf4bc..2cf49490 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -84,6 +84,14 @@ class CoreListener(object): """ pass + def tracklist_changed(self): + """ + Called whenever the tracklist is changed. + + *MAY* be implemented by actor. + """ + pass + def playlist_changed(self): """ Called whenever a playlist is changed. diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 0bc3f8fd..54713916 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -26,6 +26,9 @@ class CoreListenerTest(unittest.TestCase): self.listener.playback_state_changed( PlaybackState.STOPPED, PlaybackState.PLAYING) + def test_listener_has_default_impl_for_tracklist_changed(self): + self.listener.tracklist_changed() + def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed() From 6ffc61e9c9b890d31e79ba18c76cfd4d6bbc030d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 11:43:07 +0100 Subject: [PATCH 245/323] core,mpd: Trigger tracklist_changed() instead of playlist_changed() on tracklist change --- docs/changes.rst | 5 +++++ mopidy/core/tracklist.py | 8 ++++---- mopidy/frontends/mpd/actor.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index bf5dd8cb..ce1538d8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -116,6 +116,11 @@ backends: This makes it easier to start playing one of the tracks that was just appended to the tracklist. +- When the tracklist is changed, we now trigger the new + :meth:`mopidy.core.CoreListener.tracklist_changed` event. Previously we + triggered :meth:`mopidy.core.CoreListener.playlist_changed`, which is + intended for stored playlists, not the tracklist. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index f8cf819e..4e01ed46 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -58,7 +58,7 @@ class TracklistController(object): def version(self, version): self._version = version self.core.playback.on_tracklist_change() - self._trigger_playlist_changed() + self._trigger_tracklist_changed() def add(self, track, at_position=None, increase_version=True): """ @@ -240,6 +240,6 @@ class TracklistController(object): """ return [copy(tl_track) for tl_track in self._tl_tracks[start:end]] - def _trigger_playlist_changed(self): - logger.debug('Triggering playlist changed event') - listener.CoreListener.send('playlist_changed') + def _trigger_tracklist_changed(self): + logger.debug('Triggering event: tracklist_changed()') + listener.CoreListener.send('tracklist_changed') diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 3ba6378c..925b15b7 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -43,7 +43,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def playback_state_changed(self, old_state, new_state): self.send_idle('player') - def playlist_changed(self): + def tracklist_changed(self): self.send_idle('playlist') def options_changed(self): From d378fd7160f5f715b03dca5560051c0edf8049d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 11:53:48 +0100 Subject: [PATCH 246/323] tests: Move events tests from tests/backends/ to tests/core --- tests/{backends => core}/events_test.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{backends => core}/events_test.py (100%) diff --git a/tests/backends/events_test.py b/tests/core/events_test.py similarity index 100% rename from tests/backends/events_test.py rename to tests/core/events_test.py From 0e7f867d6793726c3c3e663ca56fe7ea05ce7abb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 13:54:55 +0100 Subject: [PATCH 247/323] core: Test tracklist event trigging --- tests/core/events_test.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 417c5251..2612187c 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -54,3 +54,39 @@ class BackendEventsTest(unittest.TestCase): 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_append_sends_tracklist_changed_event(self, send): + send.reset_mock() + self.core.tracklist.append([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.append([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.append( + [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.append([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.append( + [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') From 533b46987d49500d13d0843a9bad4f04cfccc017 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 13:56:30 +0100 Subject: [PATCH 248/323] core: Document which methods triggers tracklist_changed() --- mopidy/core/tracklist.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 4e01ed46..f1025e2d 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -64,6 +64,9 @@ class TracklistController(object): """ Add the track to the end of, or at the given position in the tracklist. + Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` + event. + :param track: track to add :type track: :class:`mopidy.models.Track` :param at_position: position in tracklist to add track @@ -89,6 +92,9 @@ class TracklistController(object): """ Append the given tracks to the tracklist. + Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` + event. + :param tracks: tracks to append :type tracks: list of :class:`mopidy.models.Track` :rtype: list of class:`mopidy.models.TlTrack` @@ -103,7 +109,12 @@ class TracklistController(object): return tl_tracks def clear(self): - """Clear the tracklist.""" + """ + Clear the tracklist. + + Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` + event. + """ self._tl_tracks = [] self.version += 1 @@ -157,6 +168,9 @@ class TracklistController(object): """ Move the tracks in the slice ``[start:end]`` to ``to_position``. + Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` + event. + :param start: position of first track to move :type start: int :param end: position after last track to move @@ -190,6 +204,9 @@ class TracklistController(object): Uses :meth:`get()` to lookup the track to remove. + Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` + event. + :param criteria: on or more criteria to match by :type criteria: dict """ @@ -203,6 +220,9 @@ class TracklistController(object): Shuffles the entire tracklist. If ``start`` and ``end`` is given only shuffles the slice ``[start:end]``. + Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` + event. + :param start: position of first track to shuffle :type start: int or :class:`None` :param end: position after last track to shuffle From cfac728defdcb46bc9fd7c4c0c97dff613bcc547 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 14:57:25 +0100 Subject: [PATCH 249/323] tests: Don't use indexes into TlTracks --- tests/backends/base/tracklist.py | 12 ++++++------ .../frontends/mpd/protocol/current_playlist_test.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index e67a40e4..65328f60 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -88,7 +88,7 @@ class TracklistControllerTest(object): def test_get_by_uri_returns_unique_match(self): track = Track(uri='a') self.controller.append([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.get(uri='a')[1]) + self.assertEqual(track, self.controller.get(uri='a').track) def test_get_by_uri_raises_error_if_multiple_matches(self): track = Track(uri='a') @@ -113,16 +113,16 @@ class TracklistControllerTest(object): track2 = Track(uri='b', name='x') track3 = Track(uri='b', name='y') self.controller.append([track1, track2, track3]) - self.assertEqual(track1, self.controller.get(uri='a', name='x')[1]) - self.assertEqual(track2, self.controller.get(uri='b', name='x')[1]) - self.assertEqual(track3, self.controller.get(uri='b', name='y')[1]) + self.assertEqual(track1, self.controller.get(uri='a', name='x').track) + self.assertEqual(track2, self.controller.get(uri='b', name='x').track) + self.assertEqual(track3, self.controller.get(uri='b', name='y').track) def test_get_by_criteria_that_is_not_present_in_all_elements(self): track1 = Track() track2 = Track(uri='b') track3 = Track() self.controller.append([track1, track2, track3]) - self.assertEqual(track2, self.controller.get(uri='b')[1]) + self.assertEqual(track2, self.controller.get(uri='b').track) def test_append_appends_to_the_tracklist(self): self.controller.append([Track(uri='a'), Track(uri='b')]) @@ -156,7 +156,7 @@ class TracklistControllerTest(object): @populate_playlist def test_append_returns_the_tl_tracks_that_was_added(self): tl_tracks = self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(tl_tracks[0][1], self.controller.tracks[1]) + self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) def test_index_returns_index_of_track(self): tl_tracks = self.controller.append(self.tracks) diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 2b6fdbd5..f5f15f81 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -41,7 +41,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[5], needle) self.assertInResponse( - 'Id: %d' % self.core.tracklist.tl_tracks.get()[5][0]) + 'Id: %d' % self.core.tracklist.tl_tracks.get()[5].tlid) self.assertInResponse('OK') def test_addid_with_empty_uri_acks(self): @@ -60,7 +60,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(len(self.core.tracklist.tracks.get()), 6) self.assertEqual(self.core.tracklist.tracks.get()[3], needle) self.assertInResponse( - 'Id: %d' % self.core.tracklist.tl_tracks.get()[3][0]) + 'Id: %d' % self.core.tracklist.tl_tracks.get()[3].tlid) self.assertInResponse('OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): @@ -94,7 +94,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(len(self.core.tracklist.tracks.get()), 5) self.sendRequest( - 'delete "%d"' % self.core.tracklist.tl_tracks.get()[2][0]) + 'delete "%d"' % self.core.tracklist.tl_tracks.get()[2].tlid) self.assertEqual(len(self.core.tracklist.tracks.get()), 4) self.assertInResponse('OK') @@ -424,11 +424,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.sendRequest('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() self.assertInResponse('cpos: 0') - self.assertInResponse('Id: %d' % tl_tracks[0][0]) + self.assertInResponse('Id: %d' % tl_tracks[0].tlid) self.assertInResponse('cpos: 2') - self.assertInResponse('Id: %d' % tl_tracks[1][0]) + self.assertInResponse('Id: %d' % tl_tracks[1].tlid) self.assertInResponse('cpos: 2') - self.assertInResponse('Id: %d' % tl_tracks[2][0]) + self.assertInResponse('Id: %d' % tl_tracks[2].tlid) self.assertInResponse('OK') def test_shuffle_without_range(self): From 87ba412942340dfc46701b460612a77687193eeb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 15:01:37 +0100 Subject: [PATCH 250/323] models: Make TlTrack an ImmutableObject --- mopidy/models.py | 43 ++++++++++++++++++++--- tests/models_test.py | 84 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 511ce847..17616f9d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -from collections import namedtuple - class ImmutableObject(object): """ @@ -151,9 +149,6 @@ class Album(ImmutableObject): super(Album, self).__init__(*args, **kwargs) -TlTrack = namedtuple('TlTrack', ['tlid', 'track']) - - class Track(ImmutableObject): """ :param uri: track URI @@ -208,6 +203,44 @@ class Track(ImmutableObject): super(Track, self).__init__(*args, **kwargs) +class TlTrack(ImmutableObject): + """ + A tracklist track. Wraps a regular track and it's tracklist ID. + + The use of :class:`TlTrack` allows the same track to appear multiple times + in the tracklist. + + This class also accepts it's parameters as positional arguments. Both + arguments must be provided, and they must appear in the order they are + listed here. + + This class also supports iteration, so your extract its values like this:: + + (tlid, track) = tl_track + + :param tlid: tracklist ID + :type tlid: int + :param track: the track + :type track: :class:`Track` + """ + + #: The tracklist ID. Read-only. + tlid = None + + #: The track. Read-only. + track = None + + def __init__(self, *args, **kwargs): + if len(args) == 2 and len(kwargs) == 0: + kwargs['tlid'] = args[0] + kwargs['track'] = args[1] + args = [] + super(TlTrack, self).__init__(*args, **kwargs) + + def __iter__(self): + return iter([self.tlid, self.track]) + + class Playlist(ImmutableObject): """ :param uri: playlist URI diff --git a/tests/models_test.py b/tests/models_test.py index 4e3cdabf..d5d58ace 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -314,21 +314,6 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) -class TlTrackTest(unittest.TestCase): - def setUp(self): - self.tlid = 123 - self.track = Track() - self.tl_track = TlTrack(self.tlid, self.track) - - def test_tl_track_can_be_accessed_as_a_tuple(self): - self.assertEqual(self.tlid, self.tl_track[0]) - self.assertEqual(self.track, self.tl_track[1]) - - def test_tl_track_can_be_accessed_by_attribute_names(self): - self.assertEqual(self.tlid, self.tl_track.tlid) - self.assertEqual(self.track, self.tl_track.track) - - class TrackTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' @@ -567,6 +552,75 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) +class TlTrackTest(unittest.TestCase): + def test_tlid(self): + tlid = 123 + tl_track = TlTrack(tlid=tlid) + self.assertEqual(tl_track.tlid, tlid) + self.assertRaises(AttributeError, setattr, tl_track, 'tlid', None) + + def test_track(self): + track = Track() + tl_track = TlTrack(track=track) + self.assertEqual(tl_track.track, track) + self.assertRaises(AttributeError, setattr, tl_track, 'track', None) + + def test_invalid_kwarg(self): + test = lambda: TlTrack(foo='baz') + self.assertRaises(TypeError, test) + + def test_positional_args(self): + tlid = 123 + track = Track() + tl_track = TlTrack(tlid, track) + self.assertEqual(tl_track.tlid, tlid) + self.assertEqual(tl_track.track, track) + + def test_iteration(self): + tlid = 123 + track = Track() + tl_track = TlTrack(tlid, track) + (tlid2, track2) = tl_track + self.assertEqual(tlid2, tlid) + self.assertEqual(track2, track) + + def test_repr(self): + self.assertEquals( + "TlTrack(tlid=123, track=Track(artists=[], uri=u'uri'))", + repr(TlTrack(tlid=123, track=Track(uri='uri')))) + + def test_serialize(self): + self.assertDictEqual( + {'tlid': 123, 'track': {'uri': 'uri', 'name': 'name'}}, + TlTrack(tlid=123, track=Track(uri='uri', name='name')).serialize()) + + def test_eq(self): + tlid = 123 + track = Track() + tl_track1 = TlTrack(tlid=tlid, track=track) + tl_track2 = TlTrack(tlid=tlid, track=track) + self.assertEqual(tl_track1, tl_track2) + self.assertEqual(hash(tl_track1), hash(tl_track2)) + + def test_eq_none(self): + self.assertNotEqual(TlTrack(), None) + + def test_eq_other(self): + self.assertNotEqual(TlTrack(), 'other') + + def test_ne_tlid(self): + tl_track1 = TlTrack(tlid=123) + tl_track2 = TlTrack(tlid=321) + self.assertNotEqual(tl_track1, tl_track2) + self.assertNotEqual(hash(tl_track1), hash(tl_track2)) + + def test_ne_track(self): + tl_track1 = TlTrack(track=Track(uri='a')) + tl_track2 = TlTrack(track=Track(uri='b')) + self.assertNotEqual(tl_track1, tl_track2) + self.assertNotEqual(hash(tl_track1), hash(tl_track2)) + + class PlaylistTest(unittest.TestCase): def test_uri(self): uri = 'an_uri' From 811c508c8029c0fbc6d2850c94dde9765be77be6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 15:03:05 +0100 Subject: [PATCH 251/323] core: No need to copy immutable TlTrack objects --- mopidy/core/tracklist.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index f1025e2d..4c6dd715 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -from copy import copy import logging import random @@ -28,7 +27,7 @@ class TracklistController(object): Read-only. """ - return [copy(tl_track) for tl_track in self._tl_tracks] + return self._tl_tracks[:] @property def tracks(self): @@ -258,7 +257,7 @@ class TracklistController(object): :type end: int :rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) """ - return [copy(tl_track) for tl_track in self._tl_tracks[start:end]] + return self._tl_tracks[start:end] def _trigger_tracklist_changed(self): logger.debug('Triggering event: tracklist_changed()') From f4cddc0ce58d42c5e461572cdadf043f13cf0725 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 15:46:56 +0100 Subject: [PATCH 252/323] core: Hide internal variables in tracklist --- mopidy/core/tracklist.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 4c6dd715..4a628d81 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -15,8 +15,8 @@ class TracklistController(object): pykka_traversable = True def __init__(self, core): - self.core = core - self.tlid = 0 + self._core = core + self._next_tlid = 0 self._tl_tracks = [] self._version = 0 @@ -56,7 +56,7 @@ class TracklistController(object): @version.setter # noqa def version(self, version): self._version = version - self.core.playback.on_tracklist_change() + self._core.playback.on_tracklist_change() self._trigger_tracklist_changed() def add(self, track, at_position=None, increase_version=True): @@ -77,14 +77,14 @@ class TracklistController(object): """ assert at_position <= len(self._tl_tracks), \ 'at_position can not be greater than tracklist length' - tl_track = TlTrack(self.tlid, track) + tl_track = TlTrack(self._next_tlid, track) if at_position is not None: self._tl_tracks.insert(at_position, tl_track) else: self._tl_tracks.append(tl_track) if increase_version: self.version += 1 - self.tlid += 1 + self._next_tlid += 1 return tl_track def append(self, tracks): From 1d9a2a23b17e7a0460d6deb81a341cef2f6bd1c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 23:48:59 +0100 Subject: [PATCH 253/323] core: Flake8 1.5 formatting fixes --- mopidy/core/playlists.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 069150e5..63797477 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -20,8 +20,8 @@ class PlaylistsController(object): Read-only. List of :class:`mopidy.models.Playlist`. """ - futures = [b.playlists.playlists - for b in self.backends.with_playlists] + futures = [ + b.playlists.playlists for b in self.backends.with_playlists] results = pykka.get_all(futures) return list(itertools.chain(*results)) @@ -125,8 +125,8 @@ class PlaylistsController(object): :type uri_scheme: string """ if uri_scheme is None: - futures = [b.playlists.refresh() - for b in self.backends.with_playlists] + futures = [ + b.playlists.refresh() for b in self.backends.with_playlists] pykka.get_all(futures) else: backend = self.backends.with_playlists_by_uri_scheme.get( From fd86b7173c2bcf8bfccbe3b3bd06f927ad9efcba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 23:24:11 +0100 Subject: [PATCH 254/323] core: Add playlist to playlist_changed() event --- docs/changes.rst | 3 +++ mopidy/core/listener.py | 5 ++++- tests/core/listener_test.py | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ce1538d8..b0ca8989 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -121,6 +121,9 @@ backends: triggered :meth:`mopidy.core.CoreListener.playlist_changed`, which is intended for stored playlists, not the tracklist. +- The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed + to include the playlist that was changed. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 2cf49490..df726b77 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -92,11 +92,14 @@ class CoreListener(object): """ pass - def playlist_changed(self): + def playlist_changed(self, playlist): """ Called whenever a playlist is changed. *MAY* be implemented by actor. + + :param playlist: the changed playlist + :type playlist: :class:`mopidy.models.Playlist` """ pass diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 54713916..dc3b8964 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy.core import CoreListener, PlaybackState -from mopidy.models import Track +from mopidy.models import Playlist, Track from tests import unittest @@ -30,7 +30,7 @@ class CoreListenerTest(unittest.TestCase): self.listener.tracklist_changed() def test_listener_has_default_impl_for_playlist_changed(self): - self.listener.playlist_changed() + self.listener.playlist_changed(Playlist()) def test_listener_has_default_impl_for_options_changed(self): self.listener.options_changed() From 4efff4a5a312833fd1e3fca6a4f4a2c0d1bfdad9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 23:49:28 +0100 Subject: [PATCH 255/323] core: Trigger playlist_changed() event on create() and save() --- mopidy/core/playlists.py | 10 ++++++++-- tests/core/events_test.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 63797477..878b2b1a 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -5,6 +5,8 @@ import urlparse import pykka +from . import listener + class PlaylistsController(object): pykka_traversable = True @@ -47,7 +49,9 @@ class PlaylistsController(object): backend = self.backends.by_uri_scheme[uri_scheme] else: backend = self.backends.with_playlists[0] - return backend.playlists.create(name).get() + playlist = backend.playlists.create(name).get() + listener.CoreListener.send('playlist_changed', playlist=playlist) + return playlist def delete(self, uri): """ @@ -162,4 +166,6 @@ class PlaylistsController(object): backend = self.backends.with_playlists_by_uri_scheme.get( uri_scheme, None) if backend: - return backend.playlists.save(playlist).get() + playlist = backend.playlists.save(playlist).get() + listener.CoreListener.send('playlist_changed', playlist=playlist) + return playlist diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 2612187c..be991e66 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -90,3 +90,26 @@ class BackendEventsTest(unittest.TestCase): send.reset_mock() self.core.tracklist.shuffle().get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') + + @unittest.SkipTest + def test_playlists_load_sends_playlists_loaded_event(self, send): + # TODO Figure out what type of event and how to send events when + # the backend finished loading playlists + pass + + 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 + def test_playlists_delete_sends_playlist_deleted_event(self, send): + # TODO We should probably add a playlist_deleted event + pass + + 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') + self.core.playlists.save(playlist).get() + self.assertEqual(send.call_args[0][0], 'playlist_changed') From 5526ee5a957936ae2cd6bf390cff95ead926b607 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 00:39:19 +0100 Subject: [PATCH 256/323] core: Add CoreListener.playlists_loaded() event --- mopidy/core/listener.py | 8 ++++++++ tests/core/listener_test.py | 3 +++ 2 files changed, 11 insertions(+) diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index df726b77..dc8bf1d7 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -92,6 +92,14 @@ class CoreListener(object): """ pass + def playlists_loaded(self): + """ + Called when playlists are loaded or refreshed. + + *MAY* be implemented by actor. + """ + pass + def playlist_changed(self, playlist): """ Called whenever a playlist is changed. diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index dc3b8964..2e121796 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -29,6 +29,9 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_tracklist_changed(self): self.listener.tracklist_changed() + def test_listener_has_default_impl_for_playlists_loaded(self): + self.listener.playlists_loaded() + def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed(Playlist()) From 426d5aea16de5d0cd3d6e1a0ea8b0cb479dfbce0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 00:40:49 +0100 Subject: [PATCH 257/323] core: Trigger playlists_loaded() after playlist refresh --- mopidy/core/playlists.py | 2 ++ tests/core/events_test.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 878b2b1a..25ae2bdf 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -132,11 +132,13 @@ class PlaylistsController(object): futures = [ b.playlists.refresh() for b in self.backends.with_playlists] pykka.get_all(futures) + listener.CoreListener.send('playlists_loaded') else: backend = self.backends.with_playlists_by_uri_scheme.get( uri_scheme, None) if backend: backend.playlists.refresh().get() + listener.CoreListener.send('playlists_loaded') def save(self, playlist): """ diff --git a/tests/core/events_test.py b/tests/core/events_test.py index be991e66..212f3b5d 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -91,11 +91,15 @@ class BackendEventsTest(unittest.TestCase): self.core.tracklist.shuffle().get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') - @unittest.SkipTest - def test_playlists_load_sends_playlists_loaded_event(self, send): - # TODO Figure out what type of event and how to send events when - # the backend finished loading playlists - pass + 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() From 9fbb79760760b73675d7c7e83d2f8fd3b41bb6a3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 00:52:57 +0100 Subject: [PATCH 258/323] core: flake8 1.5 style fix --- mopidy/core/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index fe7cf94b..231fe523 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -78,8 +78,8 @@ class Backends(list): # the X_by_uri_scheme dicts below. self.with_library = [b for b in backends if b.has_library().get()] self.with_playback = [b for b in backends if b.has_playback().get()] - self.with_playlists = [b for b in backends - if b.has_playlists().get()] + self.with_playlists = [ + b for b in backends if b.has_playlists().get()] self.by_uri_scheme = {} for backend in backends: From 0f6c9a1673738b72db21f3ca4b8e856700aa35cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 00:53:45 +0100 Subject: [PATCH 259/323] backends: Add BackendListener interface with playlists_loaded() event --- docs/api/backends.rst | 7 +++++++ mopidy/backends/listener.py | 32 ++++++++++++++++++++++++++++++++ tests/backends/listener_test.py | 13 +++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 mopidy/backends/listener.py create mode 100644 tests/backends/listener_test.py diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 0dc4900d..f0aadd53 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -33,6 +33,13 @@ Library provider :members: +Backend listener +================ + +.. autoclass:: mopidy.backends.listener.BackendListener + :members: + + .. _backend-implementations: Backend implementations diff --git a/mopidy/backends/listener.py b/mopidy/backends/listener.py new file mode 100644 index 00000000..30b3291d --- /dev/null +++ b/mopidy/backends/listener.py @@ -0,0 +1,32 @@ +from __future__ import unicode_literals + +import pykka + + +class BackendListener(object): + """ + Marker interface for recipients of events sent by the backend actors. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the core actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + + Normally, only the Core actor should mix in this class. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of backend listener events""" + listeners = pykka.ActorRegistry.get_by_class(BackendListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) + + def playlists_loaded(self): + """ + Called when playlists are loaded or refreshed. + + *MAY* be implemented by actor. + """ + pass diff --git a/tests/backends/listener_test.py b/tests/backends/listener_test.py new file mode 100644 index 00000000..a4df513c --- /dev/null +++ b/tests/backends/listener_test.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals + +from mopidy.backends.listener import BackendListener + +from tests import unittest + + +class CoreListenerTest(unittest.TestCase): + def setUp(self): + self.listener = BackendListener() + + def test_listener_has_default_impl_for_playlists_loaded(self): + self.listener.playlists_loaded() From 330731a2475333565fae03af8520175a623acf17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 00:56:38 +0100 Subject: [PATCH 260/323] core: Forward playlists_loaded() event from backends to frontends --- mopidy/core/actor.py | 8 +++++++- tests/core/events_test.py | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 231fe523..a4f184bf 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,14 +5,16 @@ import itertools import pykka from mopidy.audio import AudioListener, PlaybackState +from mopidy.backends.listener import BackendListener from .library import LibraryController +from .listener import CoreListener from .playback import PlaybackController from .playlists import PlaylistsController from .tracklist import TracklistController -class Core(pykka.ThreadingActor, AudioListener): +class Core(pykka.ThreadingActor, AudioListener, BackendListener): #: The library controller. An instance of # :class:`mopidy.core.LibraryController`. library = None @@ -67,6 +69,10 @@ class Core(pykka.ThreadingActor, AudioListener): self.playback.state = new_state self.playback._trigger_track_playback_paused() + def playlists_loaded(self): + # Forward event from backend to frontends + CoreListener.send('playlists_loaded') + class Backends(list): def __init__(self, backends): diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 212f3b5d..8f969b0d 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -20,6 +20,11 @@ class BackendEventsTest(unittest.TestCase): def tearDown(self): 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() From 4e4c887fb1cb3016944dfd712995386905ca512b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 00:58:31 +0100 Subject: [PATCH 261/323] spotify: Trigger BackendListener.playlists_loaded() when playlists loads --- mopidy/backends/spotify/session_manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 998e4d5e..b46fd659 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -11,6 +11,7 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager from mopidy import settings +from mopidy.backends.listener import BackendListener from mopidy.models import Playlist from mopidy.utils import process, versioning @@ -155,6 +156,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): playlists = filter(None, playlists) self.backend.playlists.playlists = playlists logger.info('Loaded %d Spotify playlist(s)', len(playlists)) + BackendListener.send('playlists_loaded') def search(self, query, queue): """Search method used by Mopidy backend""" From a7be82463a099ca235300ce9139258e385e47086 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 01:40:40 +0100 Subject: [PATCH 262/323] spotify: Add playlists.lookup(uri) for looking up loaded playlists --- mopidy/backends/spotify/playlists.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/playlists.py b/mopidy/backends/spotify/playlists.py index 2c31caa8..bd201179 100644 --- a/mopidy/backends/spotify/playlists.py +++ b/mopidy/backends/spotify/playlists.py @@ -11,7 +11,9 @@ class SpotifyPlaylistsProvider(base.BasePlaylistsProvider): pass # TODO def lookup(self, uri): - pass # TODO + for playlist in self._playlists: + if playlist.uri == uri: + return playlist def refresh(self): pass # TODO From b8c7703c79834d5523b5c3ad3b748fca86157539 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 15 Nov 2012 22:54:41 +0100 Subject: [PATCH 263/323] mpris: Implement the playlists interface (fixes #229) --- docs/changes.rst | 4 + docs/clients/mpris.rst | 4 +- mopidy/frontends/mpris/actor.py | 39 ++-- mopidy/frontends/mpris/objects.py | 85 ++++++++- tests/frontends/mpris/events_test.py | 18 +- .../mpris/playlists_interface_test.py | 171 ++++++++++++++++++ 6 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 tests/frontends/mpris/playlists_interface_test.py diff --git a/docs/changes.rst b/docs/changes.rst index b0ca8989..4317e4ef 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -124,6 +124,10 @@ backends: - The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed to include the playlist that was changed. +- The MPRIS playlists interface is now supported by our MPRIS frontend. This + means that you now can select playlists to queue and play from the Ubuntu + Sound Menu. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 95866089..c782fa26 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -9,8 +9,8 @@ Specification. It's a spec that describes a standard D-Bus interface for making media players available to other applications on the same system. Mopidy's :ref:`MPRIS frontend ` currently implements all -required parts of the MPRIS spec, but not the optional playlist interface. For -tracking the development of the playlist interface, see :issue:`229`. +required parts of the MPRIS spec, plus the optional playlist interface. It does +not implement the optional tracklist interface. .. _ubuntu-sound-menu: diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 81a44fbb..795b2694 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -57,35 +57,48 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.indicate_server.show() logger.debug('Startup notification sent') - def _emit_properties_changed(self, *changed_properties): + def _emit_properties_changed(self, interface, changed_properties): if self.mpris_object is None: return props_with_new_values = [ - (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + (p, self.mpris_object.Get(interface, p)) for p in changed_properties] self.mpris_object.PropertiesChanged( - objects.PLAYER_IFACE, dict(props_with_new_values), []) + interface, dict(props_with_new_values), []) def track_playback_paused(self, track, time_position): - logger.debug('Received track playback paused event') - self._emit_properties_changed('PlaybackStatus') + logger.debug('Received track_playback_paused event') + self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) def track_playback_resumed(self, track, time_position): - logger.debug('Received track playback resumed event') - self._emit_properties_changed('PlaybackStatus') + logger.debug('Received track_playback_resumed event') + self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) def track_playback_started(self, track): - logger.debug('Received track playback started event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') + logger.debug('Received track_playback_started event') + self._emit_properties_changed( + objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) def track_playback_ended(self, track, time_position): - logger.debug('Received track playback ended event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') + logger.debug('Received track_playback_ended event') + self._emit_properties_changed( + objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) def volume_changed(self): - logger.debug('Received volume changed event') - self._emit_properties_changed('Volume') + logger.debug('Received volume_changed event') + self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) def seeked(self, time_position_in_ms): logger.debug('Received seeked event') self.mpris_object.Seeked(time_position_in_ms * 1000) + + def playlists_loaded(self): + logger.debug('Received playlists_loaded event') + self._emit_properties_changed( + objects.PLAYLISTS_IFACE, ['PlaylistCount']) + + def playlist_changed(self, playlist): + logger.debug('Received playlist_changed event') + playlist_id = self.mpris_object.get_playlist_id(playlist.uri) + playlist = (playlist_id, playlist.name, '') + self.mpris_object.PlaylistChanged(playlist) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index cf7f71ce..e7a9243e 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import base64 import logging import os @@ -27,6 +28,7 @@ BUS_NAME = 'org.mpris.MediaPlayer2.mopidy' OBJECT_PATH = '/org/mpris/MediaPlayer2' ROOT_IFACE = 'org.mpris.MediaPlayer2' PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' +PLAYLISTS_IFACE = 'org.mpris.MediaPlayer2.Playlists' class MprisObject(dbus.service.Object): @@ -39,6 +41,7 @@ class MprisObject(dbus.service.Object): self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), + PLAYLISTS_IFACE: self._get_playlists_iface_properties(), } bus_name = self._connect_to_dbus() dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) @@ -78,6 +81,13 @@ class MprisObject(dbus.service.Object): 'CanControl': (self.get_CanControl, None), } + def _get_playlists_iface_properties(self): + return { + 'PlaylistCount': (self.get_PlaylistCount, None), + 'Orderings': (self.get_Orderings, None), + 'ActivePlaylist': (self.get_ActivePlaylist, None), + } + def _connect_to_dbus(self): logger.debug('Connecting to D-Bus...') mainloop = dbus.mainloop.glib.DBusGMainLoop() @@ -86,10 +96,22 @@ class MprisObject(dbus.service.Object): logger.info('Connected to D-Bus') return bus_name - def _get_track_id(self, tl_track): + def get_playlist_id(self, playlist_uri): + # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use + # base64. Luckily, D-Bus does not limit the length of object paths. + # Since base32 pads trailing bytes with = chars, we need to replace + # them with the allow _ char. + encoded_uri = base64.b32encode(playlist_uri).replace('=', '_') + return '/com/mopidy/playlist/%s' % encoded_uri + + def get_playlist_uri(self, playlist_id): + encoded_uri = playlist_id.split('/')[-1].replace('_', '=') + return base64.b32decode(encoded_uri) + + def get_track_id(self, tl_track): return '/com/mopidy/track/%d' % tl_track.tlid - def _get_tlid(self, track_id): + def get_track_tlid(self, track_id): assert track_id.startswith('/com/mopidy/track/') return track_id.split('/')[-1] @@ -239,7 +261,7 @@ class MprisObject(dbus.service.Object): current_tl_track = self.core.playback.current_tl_track.get() if current_tl_track is None: return - if track_id != self._get_track_id(current_tl_track): + if track_id != self.get_track_id(current_tl_track): return if position < 0: return @@ -337,7 +359,7 @@ class MprisObject(dbus.service.Object): return {'mpris:trackid': ''} else: (_, track) = current_tl_track - metadata = {'mpris:trackid': self._get_track_id(current_tl_track)} + metadata = {'mpris:trackid': self.get_track_id(current_tl_track)} if track.length: metadata['mpris:length'] = track.length * 1000 if track.uri: @@ -420,3 +442,58 @@ class MprisObject(dbus.service.Object): def get_CanControl(self): # NOTE This could be a setting for the end user to change. return True + + ### Playlists interface methods + + @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) + def ActivatePlaylist(self, playlist_id): + logger.debug( + '%s.ActivatePlaylist(%r) called', PLAYLISTS_IFACE, playlist_id) + playlist_uri = self.get_playlist_uri(playlist_id) + playlist = self.core.playlists.lookup(playlist_uri).get() + if playlist and playlist.tracks: + tl_tracks = self.core.tracklist.append(playlist.tracks).get() + self.core.playback.play(tl_tracks[0]) + + @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) + def GetPlaylists(self, index, max_count, order, reverse): + logger.debug( + '%s.GetPlaylists(%r, %r, %r, %r) called', + PLAYLISTS_IFACE, index, max_count, order, reverse) + playlists = self.core.playlists.playlists.get() + if order == 'Alphabetical': + playlists.sort(key=lambda p: p.name, reverse=reverse) + elif order == 'Modified': + playlists.sort(key=lambda p: p.last_modified, reverse=reverse) + elif order == 'User' and reverse: + playlists.reverse() + slice_end = index + max_count + playlists = playlists[index:slice_end] + results = [ + (self.get_playlist_id(p.uri), p.name, '') + for p in playlists] + return dbus.Array(results, signature='(oss)') + + ### Playlists interface signals + + @dbus.service.signal(dbus_interface=PLAYLISTS_IFACE, signature='(oss)') + def PlaylistChanged(self, playlist): + logger.debug('%s.PlaylistChanged signaled', PLAYLISTS_IFACE) + # Do nothing, as just calling the method is enough to emit the signal. + + ### Playlists interface properties + + def get_PlaylistCount(self): + return len(self.core.playlists.playlists.get()) + + def get_Orderings(self): + return [ + 'Alphabetical', # Order by playlist.name + 'Modified', # Order by playlist.last_modified + 'User', # Don't change order + ] + + def get_ActivePlaylist(self): + playlist_is_valid = False + playlist = ('/', 'None', '') + return (playlist_is_valid, playlist) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 94f48115..18a9de6f 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 Track +from mopidy.models import Playlist, Track try: from mopidy.frontends.mpris import MprisFrontend, objects @@ -75,3 +75,19 @@ class BackendEventsTest(unittest.TestCase): def test_seeked_event_causes_mpris_seeked_event(self): self.mpris_frontend.seeked(31000) self.mpris_object.Seeked.assert_called_with(31000000) + + def test_playlists_loaded_event_changes_playlist_count(self): + self.mpris_object.Get.return_value = 17 + self.mpris_frontend.playlists_loaded() + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYLISTS_IFACE, 'PlaylistCount'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYLISTS_IFACE, {'PlaylistCount': 17}, []) + + def test_playlist_changed_event_causes_mpris_playlist_changed_event(self): + self.mpris_object.get_playlist_id.return_value = 'id-for-dummy:foo' + playlist = Playlist(uri='dummy:foo', name='foo') + self.mpris_frontend.playlist_changed(playlist) + self.mpris_object.PlaylistChanged.assert_called_with( + ('id-for-dummy:foo', 'foo', '')) diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py new file mode 100644 index 00000000..21038d4b --- /dev/null +++ b/tests/frontends/mpris/playlists_interface_test.py @@ -0,0 +1,171 @@ +from __future__ import unicode_literals + +import datetime +import sys + +import mock +import pykka + +from mopidy import core, exceptions +from mopidy.audio import PlaybackState +from mopidy.backends import dummy +from mopidy.models import Track + +try: + from mopidy.frontends.mpris import objects +except exceptions.OptionalDependencyError: + pass + +from tests import unittest + + +@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') +class PlayerInterfaceTest(unittest.TestCase): + def setUp(self): + objects.MprisObject._connect_to_dbus = mock.Mock() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + self.mpris = objects.MprisObject(core=self.core) + + foo = self.core.playlists.create('foo').get() + foo = foo.copy(last_modified=datetime.datetime(2012, 3, 1, 6, 0, 0)) + foo = self.core.playlists.save(foo).get() + + bar = self.core.playlists.create('bar').get() + bar = bar.copy(last_modified=datetime.datetime(2012, 2, 1, 6, 0, 0)) + bar = self.core.playlists.save(bar).get() + + baz = self.core.playlists.create('baz').get() + baz = baz.copy(last_modified=datetime.datetime(2012, 1, 1, 6, 0, 0)) + baz = self.core.playlists.save(baz).get() + self.playlist = baz + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_activate_playlist_appends_tracks_to_tracklist(self): + self.core.tracklist.append([ + Track(uri='dummy:old-a'), + Track(uri='dummy:old-b'), + ]) + self.playlist = self.playlist.copy(tracks=[ + Track(uri='dummy:baz-a'), + Track(uri='dummy:baz-b'), + Track(uri='dummy:baz-c'), + ]) + self.playlist = self.core.playlists.save(self.playlist).get() + + self.assertEqual(2, self.core.tracklist.length.get()) + + playlists = self.mpris.GetPlaylists(0, 100, 'User', False) + playlist_id = playlists[2][0] + self.mpris.ActivatePlaylist(playlist_id) + + self.assertEqual(5, self.core.tracklist.length.get()) + self.assertEqual( + PlaybackState.PLAYING, self.core.playback.state.get()) + self.assertEqual( + self.playlist.tracks[0], self.core.playback.current_track.get()) + + def test_activate_empty_playlist_is_harmless(self): + self.assertEqual(0, self.core.tracklist.length.get()) + + playlists = self.mpris.GetPlaylists(0, 100, 'User', False) + playlist_id = playlists[2][0] + self.mpris.ActivatePlaylist(playlist_id) + + self.assertEqual(0, self.core.tracklist.length.get()) + self.assertEqual( + PlaybackState.STOPPED, self.core.playback.state.get()) + self.assertIsNone(self.core.playback.current_track.get()) + + def test_get_playlists_in_alphabetical_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', False) + + self.assertEqual(3, len(result)) + + self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC4Q_', result[0][0]) + self.assertEqual('bar', result[0][1]) + + self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC6Q_', result[1][0]) + self.assertEqual('baz', result[1][1]) + + self.assertEqual('/com/mopidy/playlist/MR2W23LZHJTG63Y_', result[2][0]) + self.assertEqual('foo', result[2][1]) + + def test_get_playlists_in_reverse_alphabetical_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', True) + + self.assertEqual(3, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('baz', result[1][1]) + self.assertEqual('bar', result[2][1]) + + def test_get_playlists_in_modified_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Modified', False) + + self.assertEqual(3, len(result)) + self.assertEqual('baz', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('foo', result[2][1]) + + def test_get_playlists_in_reverse_modified_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Modified', True) + + self.assertEqual(3, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('baz', result[2][1]) + + def test_get_playlists_in_user_order(self): + result = self.mpris.GetPlaylists(0, 100, 'User', False) + + self.assertEqual(3, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('baz', result[2][1]) + + def test_get_playlists_in_reverse_user_order(self): + result = self.mpris.GetPlaylists(0, 100, 'User', True) + + self.assertEqual(3, len(result)) + self.assertEqual('baz', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('foo', result[2][1]) + + def test_get_playlists_slice_on_start_of_list(self): + result = self.mpris.GetPlaylists(0, 2, 'User', False) + + self.assertEqual(2, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('bar', result[1][1]) + + def test_get_playlists_slice_later_in_list(self): + result = self.mpris.GetPlaylists(2, 2, 'User', False) + + self.assertEqual(1, len(result)) + self.assertEqual('baz', result[0][1]) + + def test_get_playlist_count_returns_number_of_playlists(self): + result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'PlaylistCount') + + self.assertEqual(3, result) + + def test_get_orderings_includes_alpha_modified_and_user(self): + result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'Orderings') + + self.assertIn('Alphabetical', result) + self.assertNotIn('Created', result) + self.assertIn('Modified', result) + self.assertNotIn('Played', result) + self.assertIn('User', result) + + def test_get_active_playlist_does_not_return_a_playlist(self): + result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'ActivePlaylist') + valid, playlist = result + playlist_id, playlist_name, playlist_icon_uri = playlist + + self.assertEqual(False, valid) + self.assertEqual('/', playlist_id) + self.assertEqual('None', playlist_name) + self.assertEqual('', playlist_icon_uri) From 5368c5fade66ff38879f155bb3eb42212fdaae0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 16:49:23 +0100 Subject: [PATCH 264/323] mpd: Docstring formatting --- mopidy/frontends/mpd/protocol/music_db.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 00559e13..bea57198 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -112,9 +112,7 @@ def list_(context, field, mpd_query=None): ``artist``, ``date``, or ``genre``. ``ARTIST`` is an optional parameter when type is ``album``, - ``date``, or ``genre``. - - This filters the result list by an artist. + ``date``, or ``genre``. This filters the result list by an artist. *Clarifications:* From 5efce8ac76fba238deedadd93cda5092a0ba7d11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 17:09:27 +0100 Subject: [PATCH 265/323] local: Trigger playlists_loaded() event on playlist load/refresh --- mopidy/backends/local/playlists.py | 3 ++- tests/backends/base/events.py | 23 +++++++++++++++++++++++ tests/backends/local/events_test.py | 8 ++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/backends/base/events.py create mode 100644 tests/backends/local/events_test.py diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 05873a98..ea45bcbb 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -6,7 +6,7 @@ import os import shutil from mopidy import settings -from mopidy.backends import base +from mopidy.backends import base, listener from mopidy.models import Playlist from mopidy.utils import formatting, path @@ -63,6 +63,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): playlists.append(playlist) self.playlists = playlists + listener.BackendListener.send('playlists_loaded') def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' diff --git a/tests/backends/base/events.py b/tests/backends/base/events.py new file mode 100644 index 00000000..0a2e6722 --- /dev/null +++ b/tests/backends/base/events.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals + +import mock +import pykka + +from mopidy import core, audio +from mopidy.backends import listener + + +@mock.patch.object(listener.BackendListener, 'send') +class BackendEventsTest(object): + def setUp(self): + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=audio).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + 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') diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py new file mode 100644 index 00000000..ba61f97a --- /dev/null +++ b/tests/backends/local/events_test.py @@ -0,0 +1,8 @@ +from mopidy.backends.local import LocalBackend + +from tests import unittest +from tests.backends.base import events + + +class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): + backend_class = LocalBackend From 15cb291316b40621f18247cb9e71d56ca86c9dfa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 17:10:27 +0100 Subject: [PATCH 266/323] tests: Rename test file so that it's executed --- tests/backends/local/{playlists.py => playlists_test.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/backends/local/{playlists.py => playlists_test.py} (100%) diff --git a/tests/backends/local/playlists.py b/tests/backends/local/playlists_test.py similarity index 100% rename from tests/backends/local/playlists.py rename to tests/backends/local/playlists_test.py From 2ff362a7f0cf26baa1ac99ef5b9b04bf72e2287d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 17 Nov 2012 17:14:40 +0100 Subject: [PATCH 267/323] mpris: Fix typo --- mopidy/frontends/mpris/objects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index e7a9243e..a66abdb5 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -99,8 +99,8 @@ class MprisObject(dbus.service.Object): def get_playlist_id(self, playlist_uri): # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use # base64. Luckily, D-Bus does not limit the length of object paths. - # Since base32 pads trailing bytes with = chars, we need to replace - # them with the allow _ char. + # Since base32 pads trailing bytes with "=" chars, we need to replace + # them with an allowed character such as "_". encoded_uri = base64.b32encode(playlist_uri).replace('=', '_') return '/com/mopidy/playlist/%s' % encoded_uri From 74a65896687059ab057e8c5c11cb18512ac92422 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 16:11:18 +0100 Subject: [PATCH 268/323] tests: Fix typo in class name --- tests/models_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/models_test.py b/tests/models_test.py index d5d58ace..6c1520cb 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -7,7 +7,7 @@ from mopidy.models import Artist, Album, TlTrack, Track, Playlist from tests import unittest -class GenericCopyTets(unittest.TestCase): +class GenericCopyTest(unittest.TestCase): def compare(self, orig, other): self.assertEqual(orig, other) self.assertNotEqual(id(orig), id(other)) From f237736f877031a36b1126425b5e937749b1f0fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 16:27:04 +0100 Subject: [PATCH 269/323] models: Add '__type__' attribute to serialized models --- mopidy/models.py | 1 + tests/models_test.py | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 17616f9d..6a2938ad 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -78,6 +78,7 @@ class ImmutableObject(object): def serialize(self): data = {} + data['__type__'] = self.__class__.__name__ for key in self.__dict__.keys(): public_key = key.lstrip('_') value = self.__dict__[key] diff --git a/tests/models_test.py b/tests/models_test.py index 6c1520cb..5fbd4dd3 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -83,7 +83,7 @@ class ArtistTest(unittest.TestCase): def test_serialize(self): self.assertDictEqual( - {'uri': 'uri', 'name': 'name'}, + {'__type__': 'Artist', 'uri': 'uri', 'name': 'name'}, Artist(uri='uri', name='name').serialize()) def test_eq_name(self): @@ -195,13 +195,14 @@ class AlbumTest(unittest.TestCase): def test_serialize_without_artists(self): self.assertDictEqual( - {'uri': 'uri', 'name': 'name'}, + {'__type__': 'Album', 'uri': 'uri', 'name': 'name'}, Album(uri='uri', name='name').serialize()) def test_serialize_with_artists(self): artist = Artist(name='foo') self.assertDictEqual( - {'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, + {'__type__': 'Album', 'uri': 'uri', 'name': 'name', 'artists': + [artist.serialize()]}, Album(uri='uri', name='name', artists=[artist]).serialize()) def test_eq_name(self): @@ -386,19 +387,21 @@ class TrackTest(unittest.TestCase): def test_serialize_without_artists(self): self.assertDictEqual( - {'uri': 'uri', 'name': 'name'}, + {'__type__': 'Track', 'uri': 'uri', 'name': 'name'}, Track(uri='uri', name='name').serialize()) def test_serialize_with_artists(self): artist = Artist(name='foo') self.assertDictEqual( - {'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, + {'__type__': 'Track', 'uri': 'uri', 'name': 'name', + 'artists': [artist.serialize()]}, Track(uri='uri', name='name', artists=[artist]).serialize()) def test_serialize_with_album(self): album = Album(name='foo') self.assertDictEqual( - {'uri': 'uri', 'name': 'name', 'album': album.serialize()}, + {'__type__': 'Track', 'uri': 'uri', 'name': 'name', + 'album': album.serialize()}, Track(uri='uri', name='name', album=album).serialize()) def test_eq_uri(self): @@ -590,9 +593,10 @@ class TlTrackTest(unittest.TestCase): repr(TlTrack(tlid=123, track=Track(uri='uri')))) def test_serialize(self): + track = Track(uri='uri', name='name') self.assertDictEqual( - {'tlid': 123, 'track': {'uri': 'uri', 'name': 'name'}}, - TlTrack(tlid=123, track=Track(uri='uri', name='name')).serialize()) + {'__type__': 'TlTrack', 'tlid': 123, 'track': track.serialize()}, + TlTrack(tlid=123, track=track).serialize()) def test_eq(self): tlid = 123 @@ -719,13 +723,14 @@ class PlaylistTest(unittest.TestCase): def test_serialize_without_tracks(self): self.assertDictEqual( - {'uri': 'uri', 'name': 'name'}, + {'__type__': 'Playlist', 'uri': 'uri', 'name': 'name'}, Playlist(uri='uri', name='name').serialize()) def test_serialize_with_tracks(self): track = Track(name='foo') self.assertDictEqual( - {'uri': 'uri', 'name': 'name', 'tracks': [track.serialize()]}, + {'__type__': 'Playlist', 'uri': 'uri', 'name': 'name', + 'tracks': [track.serialize()]}, Playlist(uri='uri', name='name', tracks=[track]).serialize()) def test_eq_name(self): From 68e4b207cb99be65df7d096691ce2f07b5aba8b3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 16:50:17 +0100 Subject: [PATCH 270/323] models: Support automatic serialization to and deserialization from JSON --- mopidy/models.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ tests/models_test.py | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/mopidy/models.py b/mopidy/models.py index 6a2938ad..9eadb314 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import json + class ImmutableObject(object): """ @@ -91,6 +93,49 @@ class ImmutableObject(object): return data +class ModelJSONEncoder(json.JSONEncoder): + """ + Automatically serialize Mopidy models to JSON. + + Usage:: + + >>> import json + >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) + '{"a_track": {"__type__": "Track", "name": "name"}}' + + """ + def default(self, obj): + if isinstance(obj, ImmutableObject): + return obj.serialize() + return json.JSONEncoder.default(self, obj) + + +def model_json_decoder(dct): + """ + Automatically deserialize Mopidy models from JSON. + + Usage:: + + >>> import json + >>> json.loads( + ... '{"a_track": {"__type__": "Track", "name": "name"}}', + ... object_hook=model_json_decoder) + {u'a_track': Track(artists=[], name=u'name')} + + """ + if '__type__' in dct: + obj_type = dct.pop('__type__') + if obj_type == 'Album': + return Album(**dct) + if obj_type == 'Artist': + return Artist(**dct) + if obj_type == 'Playlist': + return Playlist(**dct) + if obj_type == 'Track': + return Track(**dct) + return dct + + class Artist(ImmutableObject): """ :param uri: artist URI diff --git a/tests/models_test.py b/tests/models_test.py index 5fbd4dd3..c49142a8 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -1,8 +1,11 @@ from __future__ import unicode_literals import datetime +import json -from mopidy.models import Artist, Album, TlTrack, Track, Playlist +from mopidy.models import ( + Artist, Album, TlTrack, Track, Playlist, + ModelJSONEncoder, model_json_decoder) from tests import unittest @@ -86,6 +89,12 @@ class ArtistTest(unittest.TestCase): {'__type__': 'Artist', 'uri': 'uri', 'name': 'name'}, Artist(uri='uri', name='name').serialize()) + def test_to_json_and_Back(self): + artist1 = Artist(uri='uri', name='name') + serialized = json.dumps(artist1, cls=ModelJSONEncoder) + artist2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(artist1, artist2) + def test_eq_name(self): artist1 = Artist(name='name') artist2 = Artist(name='name') @@ -205,6 +214,12 @@ class AlbumTest(unittest.TestCase): [artist.serialize()]}, Album(uri='uri', name='name', artists=[artist]).serialize()) + def test_to_json_and_back(self): + album1 = Album(uri='uri', name='name', artists=[Artist(name='foo')]) + serialized = json.dumps(album1, cls=ModelJSONEncoder) + album2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(album1, album2) + def test_eq_name(self): album1 = Album(name='name') album2 = Album(name='name') @@ -404,6 +419,14 @@ class TrackTest(unittest.TestCase): 'album': album.serialize()}, Track(uri='uri', name='name', album=album).serialize()) + def test_to_json_and_back(self): + track1 = Track( + uri='uri', name='name', album=Album(name='foo'), + artists=[Artist(name='foo')]) + serialized = json.dumps(track1, cls=ModelJSONEncoder) + track2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(track1, track2) + def test_eq_uri(self): track1 = Track(uri='uri1') track2 = Track(uri='uri1') @@ -598,6 +621,12 @@ class TlTrackTest(unittest.TestCase): {'__type__': 'TlTrack', 'tlid': 123, 'track': track.serialize()}, TlTrack(tlid=123, track=track).serialize()) + def test_to_json_and_back(self): + track1 = Track(uri='uri', name='name') + serialized = json.dumps(track1, cls=ModelJSONEncoder) + track2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(track1, track2) + def test_eq(self): tlid = 123 track = Track() @@ -733,6 +762,12 @@ class PlaylistTest(unittest.TestCase): 'tracks': [track.serialize()]}, Playlist(uri='uri', name='name', tracks=[track]).serialize()) + def test_to_json_and_back(self): + playlist1 = Playlist(uri='uri', name='name') + serialized = json.dumps(playlist1, cls=ModelJSONEncoder) + playlist2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(playlist1, playlist2) + def test_eq_name(self): playlist1 = Playlist(name='name') playlist2 = Playlist(name='name') From 3bc4126b45f952c254de82263fbc33b906679ea5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 17:28:37 +0100 Subject: [PATCH 271/323] models: Fix TlTrack deserialization --- mopidy/models.py | 2 ++ tests/models_test.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 9eadb314..4861ef0d 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -131,6 +131,8 @@ def model_json_decoder(dct): return Artist(**dct) if obj_type == 'Playlist': return Playlist(**dct) + if obj_type == 'TlTrack': + return TlTrack(**dct) if obj_type == 'Track': return Track(**dct) return dct diff --git a/tests/models_test.py b/tests/models_test.py index c49142a8..21ad7ead 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -622,10 +622,10 @@ class TlTrackTest(unittest.TestCase): TlTrack(tlid=123, track=track).serialize()) def test_to_json_and_back(self): - track1 = Track(uri='uri', name='name') - serialized = json.dumps(track1, cls=ModelJSONEncoder) - track2 = json.loads(serialized, object_hook=model_json_decoder) - self.assertEqual(track1, track2) + tl_track1 = TlTrack(tlid=123, track=Track(uri='uri', name='name')) + serialized = json.dumps(tl_track1, cls=ModelJSONEncoder) + tl_track2 = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(tl_track1, tl_track2) def test_eq(self): tlid = 123 From f17852c98f3d5993f5dd801fa7b40bd16d86a073 Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 19 Nov 2012 14:18:42 +0100 Subject: [PATCH 272/323] Add proxy support --- mopidy/backends/spotify/actor.py | 7 +++- mopidy/backends/spotify/session_manager.py | 8 +++-- mopidy/settings.py | 42 ++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 5fc5cc4f..5e90205b 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -31,9 +31,14 @@ class SpotifyBackend(pykka.ThreadingActor, base.Backend): # Fail early if settings are not present username = settings.SPOTIFY_USERNAME password = settings.SPOTIFY_PASSWORD + proxy = settings.SPOTIFY_PROXY_HOST + proxy_username = settings.SPOTIFY_PROXY_USERNAME + proxy_password = settings.SPOTIFY_PROXY_PASSWORD self.spotify = SpotifySessionManager( - username, password, audio=audio, backend_ref=self.actor_ref) + username, password, audio=audio, backend_ref=self.actor_ref, + proxy=proxy, proxy_username=proxy_username, + proxy_password=proxy_password) def on_start(self): logger.info('Mopidy uses SPOTIFY(R) CORE') diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index b46fd659..09df3102 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -33,8 +33,12 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % versioning.get_version() - def __init__(self, username, password, audio, backend_ref): - PyspotifySessionManager.__init__(self, username, password) + def __init__(self, username, password, audio, backend_ref, proxy=None, + proxy_username=None, proxy_password=None): + PyspotifySessionManager.__init__( + self, username, password, proxy=proxy, + proxy_username=proxy_username, + proxy_password=proxy_password) process.BaseThread.__init__(self) self.name = 'SpotifyThread' diff --git a/mopidy/settings.py b/mopidy/settings.py index 897745d7..22df5d2d 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -218,3 +218,45 @@ SPOTIFY_PASSWORD = '' #: #: SPOTIFY_BITRATE = 160 SPOTIFY_BITRATE = 160 + +#: Spotify proxy host +#: +#: Example:: +#: +#: SPOTIFY_PROXY_HOST = u'protocol://host:port' +#: +#: Used by :mod:`mopidy.backends.spotify` +#: +#: Default :: +#: +#: SPOTIFY_PROXY_HOST = None +#: +SPOTIFY_PROXY_HOST = None + +#: Spotify proxy username +#: +#: Example:: +#: +#: SPOTIFY_PROXY_HOST = u'username' +#: +#: Used by :mod:`mopidy.backends.spotify` +#: +#: Default :: +#: +#: SPOTIFY_PROXY_USERNAME = None +#: +SPOTIFY_PROXY_USERNAME = None + +#: Spotify proxy password +#: +#: Example:: +#: +#: SPOTIFY_PROXY_HOST = u'password' +#: +#: Used by :mod:`mopidy.backends.spotify` +#: +#: Default :: +#: +#: SPOTIFY_PROXY_PASSWORD = None +#: +SPOTIFY_PROXY_PASSWORD = None From 6f0919bda8fbe0a6be799f938de32739439ef92d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 17:59:31 +0100 Subject: [PATCH 273/323] style: Fix flake8 warnings --- mopidy/audio/actor.py | 7 ++++--- mopidy/core/library.py | 12 +++++------ mopidy/utils/process.py | 6 ++++-- tests/core/playback_test.py | 4 ++-- tests/frontends/mpd/protocol/playback_test.py | 16 +++++++-------- .../frontends/mpd/protocol/regression_test.py | 20 +++++++++---------- 6 files changed, 34 insertions(+), 31 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index a23c4c43..7de98075 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -89,7 +89,8 @@ class Audio(pykka.ThreadingActor): b'rate=(int)44100') source = element.get_property('source') source.set_property('caps', default_caps) - source.set_property('format', b'time') # Gstreamer does not like unicode + # GStreamer does not like unicode + source.set_property('format', b'time') self._appsrc = source @@ -219,8 +220,8 @@ class Audio(pykka.ThreadingActor): logger.debug( 'Triggering event: state_changed(old_state=%s, new_state=%s)', old_state, new_state) - AudioListener.send('state_changed', - old_state=old_state, new_state=new_state) + AudioListener.send( + 'state_changed', old_state=old_state, new_state=new_state) def _on_end_of_stream(self): logger.debug('Triggering reached_end_of_stream event') diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 58263fd1..6e421595 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -36,8 +36,8 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [b.library.find_exact(**query) - for b in self.backends.with_library] + futures = [ + b.library.find_exact(**query) for b in self.backends.with_library] results = pykka.get_all(futures) return Playlist(tracks=[ track for playlist in results for track in playlist.tracks]) @@ -68,8 +68,8 @@ class LibraryController(object): if backend: backend.library.refresh(uri).get() else: - futures = [b.library.refresh(uri) - for b in self.backends.with_library] + futures = [ + b.library.refresh(uri) for b in self.backends.with_library] pykka.get_all(futures) def search(self, **query): @@ -89,8 +89,8 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - futures = [b.library.search(**query) - for b in self.backends.with_library] + futures = [ + b.library.search(**query) for b in self.backends.with_library] results = pykka.get_all(futures) track_lists = [playlist.tracks for playlist in results] tracks = list(itertools.chain(*track_lists)) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 27e312de..5edf287e 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -16,7 +16,8 @@ from mopidy import exceptions logger = logging.getLogger('mopidy.utils.process') -SIGNALS = dict((k, v) for v, k in signal.__dict__.iteritems() +SIGNALS = dict( + (k, v) for v, k in signal.__dict__.iteritems() if v.startswith('SIG') and not v.startswith('SIG_')) @@ -98,7 +99,8 @@ class DebugThread(threading.Thread): for ident, frame in sys._current_frames().items(): if self.ident != ident: stack = ''.join(traceback.format_stack(frame)) - logger.debug('Current state of %s (%s):\n%s', + logger.debug( + 'Current state of %s (%s):\n%s', threads[ident], ident, stack) del frame diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index 8e83f971..bb3d359f 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -58,8 +58,8 @@ class CorePlaybackTest(unittest.TestCase): self.playback1.play.assert_called_once_with(self.tracks[3]) self.assertFalse(self.playback2.play.called) - self.assertEqual(self.core.playback.current_tl_track, - self.tl_tracks[3]) + self.assertEqual( + self.core.playback.current_tl_track, self.tl_tracks[3]) def test_pause_selects_dummy1_backend(self): self.core.playback.play(self.tl_tracks[0]) diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 51468390..f81be241 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -236,8 +236,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('dummy:a', - self.core.playback.current_track.get().uri) + self.assertEqual( + 'dummy:a', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): @@ -253,8 +253,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('dummy:b', - self.core.playback.current_track.get().uri) + self.assertEqual( + 'dummy:b', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): @@ -318,8 +318,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('dummy:a', - self.core.playback.current_track.get().uri) + self.assertEqual( + 'dummy:a', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): @@ -335,8 +335,8 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) - self.assertEqual('dummy:b', - self.core.playback.current_track.get().uri) + self.assertEqual( + 'dummy:b', self.core.playback.current_track.get().uri) self.assertInResponse('OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 68230c6a..6b8832e4 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -29,22 +29,22 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): random.seed(1) # Playlist order: abcfde self.sendRequest('play') - self.assertEquals('dummy:a', - self.core.playback.current_track.get().uri) + self.assertEquals( + 'dummy:a', self.core.playback.current_track.get().uri) self.sendRequest('random "1"') self.sendRequest('next') - self.assertEquals('dummy:b', - self.core.playback.current_track.get().uri) + self.assertEquals( + 'dummy:b', self.core.playback.current_track.get().uri) self.sendRequest('next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('dummy:f', - self.core.playback.current_track.get().uri) + self.assertEquals( + 'dummy:f', self.core.playback.current_track.get().uri) self.sendRequest('next') - self.assertEquals('dummy:d', - self.core.playback.current_track.get().uri) + self.assertEquals( + 'dummy:d', self.core.playback.current_track.get().uri) self.sendRequest('next') - self.assertEquals('dummy:e', - self.core.playback.current_track.get().uri) + self.assertEquals( + 'dummy:e', self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): From 7df556c9b30160d7747c5d9deb9861994855a803 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 18:13:14 +0100 Subject: [PATCH 274/323] Return lists of tracks from search() and find_exact() --- docs/changes.rst | 4 ++ mopidy/backends/dummy.py | 4 +- mopidy/backends/local/library.py | 6 +-- mopidy/backends/spotify/library.py | 8 ++-- mopidy/core/library.py | 13 ++--- mopidy/frontends/mpd/protocol/music_db.py | 18 +++---- tests/backends/base/library.py | 58 +++++++++++------------ tests/core/library_test.py | 18 +++---- 8 files changed, 64 insertions(+), 65 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4317e4ef..6e9a8f66 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -128,6 +128,10 @@ backends: means that you now can select playlists to queue and play from the Ubuntu Sound Menu. +- :meth:`mopidy.core.LibraryController.find_exact` and + :meth:`mopidy.core.LibraryController.search` now returns plain lists of + tracks instead of playlist objects. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index d3239b34..1d561bda 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -39,7 +39,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): self.dummy_library = [] def find_exact(self, **query): - return Playlist() + return [] def lookup(self, uri): matches = filter(lambda t: uri == t.uri, self.dummy_library) @@ -50,7 +50,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): pass def search(self, **query): - return Playlist() + return [] class DummyPlaybackProvider(base.BasePlaybackProvider): diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 3454ca76..1b406f84 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 Playlist, Album +from mopidy.models import Album from .translator import parse_mpd_tag_cache @@ -67,7 +67,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) + return result_tracks def search(self, **query): self._validate_query(query) @@ -101,7 +101,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(any_filter, result_tracks) else: raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) + return 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 67c390fc..5d80bc8d 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -6,7 +6,7 @@ import Queue from spotify import Link, SpotifyError from mopidy.backends import base -from mopidy.models import Track, Playlist +from mopidy.models import Track from . import translator @@ -72,7 +72,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): tracks = [] for playlist in self.backend.playlists.playlists: tracks += playlist.tracks - return Playlist(tracks=tracks) + return tracks spotify_query = [] for (field, values) in query.iteritems(): if field == 'uri': @@ -81,7 +81,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): track = self.lookup(value) if track: tracks.append(track) - return Playlist(tracks=tracks) + return tracks elif field == 'track': field = 'title' elif field == 'date': @@ -103,4 +103,4 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): try: return queue.get(timeout=3) # XXX What is an reasonable timeout? except Queue.Empty: - return Playlist(tracks=[]) + return [] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 6e421595..ccd4e92b 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -5,8 +5,6 @@ import urlparse import pykka -from mopidy.models import Playlist - class LibraryController(object): pykka_traversable = True @@ -34,13 +32,12 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: :class:`mopidy.models.Playlist` + :rtype: list of :class:`mopidy.models.Track` """ futures = [ b.library.find_exact(**query) for b in self.backends.with_library] results = pykka.get_all(futures) - return Playlist(tracks=[ - track for playlist in results for track in playlist.tracks]) + return list(itertools.chain(*results)) def lookup(self, uri): """ @@ -87,11 +84,9 @@ class LibraryController(object): :param query: one or more queries to search for :type query: dict - :rtype: :class:`mopidy.models.Playlist` + :rtype: list of :class:`mopidy.models.Track` """ futures = [ b.library.search(**query) for b in self.backends.with_library] results = pykka.get_all(futures) - track_lists = [playlist.tracks for playlist in results] - tracks = list(itertools.chain(*track_lists)) - return Playlist(tracks=tracks) + return list(itertools.chain(*results)) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index bea57198..08ba8b19 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,7 +5,7 @@ import shlex from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists -from mopidy.frontends.mpd.translator import playlist_to_mpd_format +from mopidy.frontends.mpd.translator import tracks_to_mpd_format def _build_query(mpd_query): @@ -77,7 +77,7 @@ def find(context, mpd_query): - uses "file" instead of "filename". """ query = _build_query(mpd_query) - return playlist_to_mpd_format( + return tracks_to_mpd_format( context.core.library.find_exact(**query).get()) @@ -235,8 +235,8 @@ def _list_build_query(field, mpd_query): def _list_artist(context, query): artists = set() - playlist = context.core.library.find_exact(**query).get() - for track in playlist.tracks: + tracks = context.core.library.find_exact(**query).get() + for track in tracks: for artist in track.artists: artists.add(('Artist', artist.name)) return artists @@ -244,8 +244,8 @@ def _list_artist(context, query): def _list_album(context, query): albums = set() - playlist = context.core.library.find_exact(**query).get() - for track in playlist.tracks: + tracks = context.core.library.find_exact(**query).get() + for track in tracks: if track.album is not None: albums.add(('Album', track.album.name)) return albums @@ -253,8 +253,8 @@ def _list_album(context, query): def _list_date(context, query): dates = set() - playlist = context.core.library.find_exact(**query).get() - for track in playlist.tracks: + tracks = context.core.library.find_exact(**query).get() + for track in tracks: if track.date is not None: dates.add(('Date', track.date)) return dates @@ -352,7 +352,7 @@ def search(context, mpd_query): - uses "file" instead of "filename". """ query = _build_query(mpd_query) - return playlist_to_mpd_format( + return tracks_to_mpd_format( context.core.library.search(**query).get()) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 0b32186f..50b73040 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import pykka from mopidy import core -from mopidy.models import Playlist, Track, Album, Artist +from mopidy.models import Track, Album, Artist from tests import unittest, path_to_data_dir @@ -52,43 +52,43 @@ class LibraryControllerTest(object): def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) result = self.library.find_exact(artist=['unknown artist']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) result = self.library.find_exact(album=['unknown artist']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) def test_find_exact_artist(self): result = self.library.find_exact(artist=['artist1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.find_exact(artist=['artist2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_find_exact_track(self): result = self.library.find_exact(track=['track1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.find_exact(track=['track2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_find_exact_album(self): result = self.library.find_exact(album=['album1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.find_exact(album=['album2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + 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, Playlist(tracks=self.tracks[:1])) + 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, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) @@ -106,57 +106,57 @@ class LibraryControllerTest(object): def test_search_no_hits(self): result = self.library.search(track=['unknown track']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) result = self.library.search(artist=['unknown artist']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) result = self.library.search(album=['unknown artist']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) result = self.library.search(uri=['unknown']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) result = self.library.search(any=['unknown']) - self.assertEqual(result, Playlist()) + self.assertEqual(result, []) def test_search_artist(self): result = self.library.search(artist=['Tist1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(artist=['Tist2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_search_track(self): result = self.library.search(track=['Rack1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(track=['Rack2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_search_album(self): result = self.library.search(album=['Bum1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(album=['Bum2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_search_uri(self): result = self.library.search(uri=['RI1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(uri=['RI2']) - self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + self.assertEqual(result, self.tracks[1:2]) def test_search_any(self): result = self.library.search(any=['Tist1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(any=['Rack1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(any=['Bum1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, self.tracks[:1]) result = self.library.search(any=['RI1']) - self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + self.assertEqual(result, 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 7886b85c..40b928d8 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 Playlist, Track +from mopidy.models import Track from tests import unittest @@ -75,29 +75,29 @@ 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 = Playlist(tracks=[track1]) + self.library1.find_exact().get.return_value = [track1] self.library1.find_exact.reset_mock() - self.library2.find_exact().get.return_value = Playlist(tracks=[track2]) + self.library2.find_exact().get.return_value = [track2] self.library2.find_exact.reset_mock() result = self.core.library.find_exact(any=['a']) - self.assertIn(track1, result.tracks) - self.assertIn(track2, result.tracks) + 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') - self.library1.search().get.return_value = Playlist(tracks=[track1]) + self.library1.search().get.return_value = [track1] self.library1.search.reset_mock() - self.library2.search().get.return_value = Playlist(tracks=[track2]) + self.library2.search().get.return_value = [track2] self.library2.search.reset_mock() result = self.core.library.search(any=['a']) - self.assertIn(track1, result.tracks) - self.assertIn(track2, result.tracks) + 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']) From 9159491d850a1370e47dddc01117bea9ec7e5ce8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 19:19:02 +0100 Subject: [PATCH 275/323] spotify: Search should return list of tracks --- mopidy/backends/spotify/session_manager.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index b46fd659..821bd27c 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -12,7 +12,6 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from mopidy import settings from mopidy.backends.listener import BackendListener -from mopidy.models import Playlist from mopidy.utils import process, versioning from . import translator @@ -164,9 +163,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): # TODO Include results from results.albums(), etc. too # TODO Consider launching a second search if results.total_tracks() # is larger than len(results.tracks()) - playlist = Playlist(tracks=[ - translator.to_mopidy_track(t) for t in results.tracks()]) - queue.put(playlist) + 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) From 62bfb9376a2d1bf7e588e9b7d17a501b08fdcd2c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 19:54:51 +0100 Subject: [PATCH 276/323] core: Refer to TlTrack instead of two-tuples in docstrings --- mopidy/core/playback.py | 17 ++++++----------- mopidy/core/tracklist.py | 14 ++++++-------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 273eb68d..94b4af9c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -38,9 +38,7 @@ class PlaybackController(object): #: Tracks are not removed from the playlist. consume = option_wrapper('_consume', False) - #: The currently playing or selected track. - #: - #: A two-tuple of (TLID integer, :class:`mopidy.models.Track`) or + #: The currently playing or selected :class:`mopidy.models.TlTrack`, or #: :class:`None`. current_tl_track = None @@ -139,7 +137,7 @@ class PlaybackController(object): """ The track that will be played at the end of the current track. - Read-only. A two-tuple of (TLID integer, :class:`mopidy.models.Track`). + Read-only. A :class:`mopidy.models.TlTrack`. Not necessarily the same track as :attr:`tl_track_at_next`. """ @@ -190,7 +188,7 @@ class PlaybackController(object): """ The track that will be played if calling :meth:`next()`. - Read-only. A two-tuple of (TLID integer, :class:`mopidy.models.Track`). + Read-only. A :class:`mopidy.models.TlTrack`. For normal playback this is the next track in the playlist. If repeat is enabled the next track can loop around the playlist. When random is @@ -238,7 +236,7 @@ class PlaybackController(object): """ The track that will be played if calling :meth:`previous()`. - A two-tuple of (TLID integer, :class:`mopidy.models.Track`). + A :class:`mopidy.models.TlTrack`. For normal playback this is the previous track in the playlist. If random and/or consume is enabled it should return the current track @@ -310,12 +308,10 @@ class PlaybackController(object): Change to the given track, keeping the current playback state. :param tl_track: track to change to - :type tl_track: two-tuple (TLID integer, :class:`mopidy.models.Track`) - or :class:`None` + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param on_error_step: direction to step at play error, 1 for next track (default), -1 for previous track :type on_error_step: int, -1 or 1 - """ old_state = self.state self.stop() @@ -383,8 +379,7 @@ class PlaybackController(object): currently active track. :param tl_track: track to play - :type tl_track: two-tuple (TLID integer, :class:`mopidy.models.Track`) - or :class:`None` + :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None` :param on_error_step: direction to step at play error, 1 for next track (default), -1 for previous track :type on_error_step: int, -1 or 1 diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 4a628d81..b352e06e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -23,7 +23,7 @@ class TracklistController(object): @property def tl_tracks(self): """ - List of two-tuples of (TLID integer, :class:`mopidy.models.Track`). + List of :class:`mopidy.models.TlTrack`. Read-only. """ @@ -72,8 +72,7 @@ class TracklistController(object): :type at_position: int or :class:`None` :param increase_version: if the tracklist version should be increased :type increase_version: :class:`True` or :class:`False` - :rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) that - was added to the tracklist + :rtype: :class:`mopidy.models.TlTrack` that was added to the tracklist """ assert at_position <= len(self._tl_tracks), \ 'at_position can not be greater than tracklist length' @@ -132,7 +131,7 @@ class TracklistController(object): :param criteria: on or more criteria to match by :type criteria: dict - :rtype: two-tuple (TLID integer, :class:`mopidy.models.Track`) + :rtype: :class:`mopidy.models.TlTrack` """ matches = self._tl_tracks for (key, value) in criteria.iteritems(): @@ -152,13 +151,12 @@ class TracklistController(object): def index(self, tl_track): """ - Get index of the given (TLID integer, :class:`mopidy.models.Track`) - two-tuple in the tracklist. + Get index of the given :class:`mopidy.models.TlTrack` in the tracklist. Raises :exc:`ValueError` if not found. :param tl_track: track to find the index of - :type tl_track: two-tuple (TLID integer, :class:`mopidy.models.Track`) + :type tl_track: :class:`mopidy.models.TlTrack` :rtype: int """ return self._tl_tracks.index(tl_track) @@ -255,7 +253,7 @@ class TracklistController(object): :type start: int :param end: position after last track to include in slice :type end: int - :rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) + :rtype: :class:`mopidy.models.TlTrack` """ return self._tl_tracks[start:end] From 3bd9d2096fed5dd2bbd24cf0efa008fbff7672c9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 21:01:42 +0100 Subject: [PATCH 277/323] tests: Formatting --- tests/frontends/mpris/player_interface_test.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 35fb0161..39b77093 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -812,23 +812,20 @@ class PlayerInterfaceTest(unittest.TestCase): def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [ - Track(uri='notdummy:/test/uri')] + self.backend.library.dummy_library = [Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') self.assertEqual(len(self.core.tracklist.tracks.get()), 0) def test_open_uri_adds_uri_to_tracklist(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [ - Track(uri='dummy:/test/uri')] + self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') self.assertEqual( self.core.tracklist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [ - Track(uri='dummy:/test/uri')] + self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) @@ -841,8 +838,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [ - Track(uri='dummy:/test/uri')] + self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() @@ -858,8 +854,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.dummy_library = [ - Track(uri='dummy:/test/uri')] + self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] self.core.tracklist.append([ Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() From 32639ea8de0488577c5ac7801bcc6663ebefbeac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 20:45:41 +0100 Subject: [PATCH 278/323] Replace {tracklist,playlists}.get() with .filter() which always returns a list --- docs/changes.rst | 9 ++ mopidy/core/playlists.py | 26 ++--- mopidy/core/tracklist.py | 38 +++---- .../mpd/protocol/current_playlist.py | 47 ++++---- mopidy/frontends/mpd/protocol/playback.py | 7 +- .../mpd/protocol/stored_playlists.py | 21 ++-- tests/backends/base/playlists.py | 44 ++++---- tests/backends/base/tracklist.py | 100 +++++++++--------- .../mpd/protocol/current_playlist_test.py | 17 +++ 9 files changed, 152 insertions(+), 157 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6e9a8f66..f024bc30 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -132,6 +132,15 @@ backends: :meth:`mopidy.core.LibraryController.search` now returns plain lists of tracks instead of playlist objects. +- :meth:`mopidy.core.TracklistController.get` has been replaced by + :meth:`mopidy.core.TracklistController.filter`. + +- :meth:`mopidy.core.PlaylistsController.get` has been replaced by + :meth:`mopidy.core.PlaylistsController.filter`. + +- :meth:`mopidy.core.TracklistController.remove` can now remove multiple + tracks, and returns the tracks it removed. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 25ae2bdf..dcdc665f 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -69,35 +69,25 @@ class PlaylistsController(object): if backend: backend.playlists.delete(uri).get() - def get(self, **criteria): + def filter(self, **criteria): """ - Get playlist by given criterias from the set of playlists. - - Raises :exc:`LookupError` if a unique match is not found. + Filter playlists by the given criterias. Examples:: - get(name='a') # Returns track with name 'a' - get(uri='xyz') # Returns track with URI 'xyz' - get(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' + 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' :param criteria: one or more criteria to match by :type criteria: dict - :rtype: :class:`mopidy.models.Playlist` + :rtype: list of :class:`mopidy.models.Playlist` """ matches = self.playlists for (key, value) in criteria.iteritems(): matches = filter(lambda p: getattr(p, key) == value, matches) - if len(matches) == 1: - return matches[0] - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - if len(matches) == 0: - raise LookupError('"%s" match no playlists' % criteria_string) - else: - raise LookupError( - '"%s" match multiple playlists' % criteria_string) + return matches def lookup(self, uri): """ diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index b352e06e..e00a42f9 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -116,22 +116,20 @@ class TracklistController(object): self._tl_tracks = [] self.version += 1 - def get(self, **criteria): + def filter(self, **criteria): """ - Get track by given criterias from tracklist. - - Raises :exc:`LookupError` if a unique match is not found. + Filter the tracklist by the given criterias. Examples:: - get(tlid=7) # Returns track with TLID 7 (tracklist ID) - get(id=1) # Returns track with ID 1 - get(uri='xyz') # Returns track with URI 'xyz' - get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' + 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' :param criteria: on or more criteria to match by :type criteria: dict - :rtype: :class:`mopidy.models.TlTrack` + :rtype: list of :class:`mopidy.models.TlTrack` """ matches = self._tl_tracks for (key, value) in criteria.iteritems(): @@ -140,14 +138,7 @@ class TracklistController(object): else: matches = filter( lambda ct: getattr(ct.track, key) == value, matches) - if len(matches) == 1: - return matches[0] - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - if len(matches) == 0: - raise LookupError('"%s" match no tracks' % criteria_string) - else: - raise LookupError('"%s" match multiple tracks' % criteria_string) + return matches def index(self, tl_track): """ @@ -197,20 +188,23 @@ class TracklistController(object): def remove(self, **criteria): """ - Remove the track from the tracklist. + Remove the matching tracks from the tracklist. - Uses :meth:`get()` to lookup the track to remove. + Uses :meth:`filter()` to lookup the tracks to remove. Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` event. :param criteria: on or more criteria to match by :type criteria: dict + :rtype: list of :class:`mopidy.models.TlTrack` that was removed """ - tl_track = self.get(**criteria) - position = self._tl_tracks.index(tl_track) - del self._tl_tracks[position] + tl_tracks = self.filter(**criteria) + for tl_track in tl_tracks: + position = self._tl_tracks.index(tl_track) + del self._tl_tracks[position] self.version += 1 + return tl_tracks def shuffle(self, start=None, end=None): """ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 1a3f4f7b..a1af4549 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -103,12 +103,11 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ - try: - tlid = int(tlid) - if context.core.playback.current_tlid.get() == tlid: - context.core.playback.next() - return context.core.tracklist.remove(tlid=tlid).get() - except LookupError: + tlid = int(tlid) + if context.core.playback.current_tlid.get() == tlid: + context.core.playback.next() + tl_tracks = context.core.tracklist.remove(tlid=tlid).get() + if not tl_tracks: raise MpdNoExistError('No such song', command='deleteid') @@ -163,8 +162,10 @@ def moveid(context, tlid, to): """ tlid = int(tlid) to = int(to) - tl_track = context.core.tracklist.get(tlid=tlid).get() - position = context.core.tracklist.index(tl_track).get() + tl_tracks = context.core.tracklist.filter(tlid=tlid).get() + if not tl_tracks: + raise MpdNoExistError('No such song', command='moveid') + position = context.core.tracklist.index(tl_tracks[0]).get() context.core.tracklist.move(position, position + 1, to) @@ -199,12 +200,11 @@ def playlistfind(context, tag, needle): - does not add quotes around the tag. """ if tag == 'filename': - try: - tl_track = context.core.tracklist.get(uri=needle).get() - position = context.core.tracklist.index(tl_track).get() - return translator.track_to_mpd_format(tl_track, position=position) - except LookupError: + tl_tracks = context.core.tracklist.filter(uri=needle).get() + if not tl_tracks: return None + position = context.core.tracklist.index(tl_tracks[0]).get() + return translator.track_to_mpd_format(tl_tracks[0], position=position) raise MpdNotImplemented # TODO @@ -219,13 +219,12 @@ def playlistid(context, tlid=None): and specifies a single song to display info for. """ if tlid is not None: - try: - tlid = int(tlid) - tl_track = context.core.tracklist.get(tlid=tlid).get() - position = context.core.tracklist.index(tl_track).get() - return translator.track_to_mpd_format(tl_track, position=position) - except LookupError: + tlid = int(tlid) + tl_tracks = context.core.tracklist.filter(tlid=tlid).get() + if not tl_tracks: raise MpdNoExistError('No such song', command='playlistid') + position = context.core.tracklist.index(tl_tracks[0]).get() + return translator.track_to_mpd_format(tl_tracks[0], position=position) else: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) @@ -385,8 +384,10 @@ def swapid(context, tlid1, tlid2): """ tlid1 = int(tlid1) tlid2 = int(tlid2) - tl_track1 = context.core.tracklist.get(tlid=tlid1).get() - tl_track2 = context.core.tracklist.get(tlid=tlid2).get() - position1 = context.core.tracklist.index(tl_track1).get() - position2 = context.core.tracklist.index(tl_track2).get() + tl_tracks1 = context.core.tracklist.filter(tlid=tlid1).get() + tl_tracks2 = context.core.tracklist.filter(tlid=tlid2).get() + if not tl_tracks1 or not tl_tracks2: + raise MpdNoExistError('No such song', command='swapid') + position1 = context.core.tracklist.index(tl_tracks1[0]).get() + position2 = context.core.tracklist.index(tl_tracks2[0]).get() swap(context, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 74ecfb1c..d166f982 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -151,11 +151,10 @@ def playid(context, tlid): tlid = int(tlid) if tlid == -1: return _play_minus_one(context) - try: - tl_track = context.core.tracklist.get(tlid=tlid).get() - return context.core.playback.play(tl_track).get() - except LookupError: + tl_tracks = context.core.tracklist.filter(tlid=tlid).get() + if not tl_tracks: raise MpdNoExistError('No such song', command='playid') + return context.core.playback.play(tl_tracks[0]).get() @handle_request(r'^play (?P-?\d+)$') diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index b8ac8c4c..d5d6b2a6 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -23,11 +23,10 @@ def listplaylist(context, name): file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ - try: - playlist = context.core.playlists.get(name=name).get() - return ['file: %s' % t.uri for t in playlist.tracks] - except LookupError: + playlists = context.core.playlists.filter(name=name).get() + if not playlists: raise MpdNoExistError('No such playlist', command='listplaylist') + return ['file: %s' % t.uri for t in playlists[0].tracks] @handle_request(r'^listplaylistinfo (?P\S+)$') @@ -45,11 +44,10 @@ def listplaylistinfo(context, name): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - try: - playlist = context.core.playlists.get(name=name).get() - return playlist_to_mpd_format(playlist) - except LookupError: + playlists = context.core.playlists.filter(name=name).get() + if not playlists: raise MpdNoExistError('No such playlist', command='listplaylistinfo') + return playlist_to_mpd_format(playlists[0]) @handle_request(r'^listplaylists$') @@ -100,11 +98,10 @@ def load(context, name): - ``load`` appends the given playlist to the current playlist. """ - try: - playlist = context.core.playlists.get(name=name).get() - context.core.tracklist.append(playlist.tracks) - except LookupError: + playlists = context.core.playlists.filter(name=name).get() + if not playlists: raise MpdNoExistError('No such playlist', command='load') + context.core.tracklist.append(playlists[0].tracks) @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') diff --git a/tests/backends/base/playlists.py b/tests/backends/base/playlists.py index 473caf8c..c162e500 100644 --- a/tests/backends/base/playlists.py +++ b/tests/backends/base/playlists.py @@ -58,43 +58,35 @@ class PlaylistsControllerTest(object): self.assertNotIn(playlist, self.core.playlists.playlists) - def test_get_without_criteria(self): - test = self.core.playlists.get - self.assertRaises(LookupError, test) + def test_filter_without_criteria(self): + self.assertEqual( + self.core.playlists.playlists, self.core.playlists.filter()) - def test_get_with_wrong_cirteria(self): - test = lambda: self.core.playlists.get(name='foo') - self.assertRaises(LookupError, test) + def test_filter_with_wrong_criteria(self): + self.assertEqual([], self.core.playlists.filter(name='foo')) - def test_get_with_right_criteria(self): - playlist1 = self.core.playlists.create('test') - playlist2 = self.core.playlists.get(name='test') - self.assertEqual(playlist1, playlist2) + def test_filter_with_right_criteria(self): + playlist = self.core.playlists.create('test') + playlists = self.core.playlists.filter(name='test') + self.assertEqual([playlist], playlists) - def test_get_by_name_returns_unique_match(self): + def test_filter_by_name_returns_single_match(self): playlist = Playlist(name='b') - self.backend.playlists.playlists = [ - Playlist(name='a'), playlist] - self.assertEqual(playlist, self.core.playlists.get(name='b')) + self.backend.playlists.playlists = [Playlist(name='a'), playlist] + self.assertEqual([playlist], self.core.playlists.filter(name='b')) - def test_get_by_name_returns_first_of_multiple_matches(self): + def test_filter_by_name_returns_multiple_matches(self): playlist = Playlist(name='b') self.backend.playlists.playlists = [ playlist, Playlist(name='a'), Playlist(name='b')] - try: - self.core.playlists.get(name='b') - self.fail('Should raise LookupError if multiple matches') - except LookupError as e: - self.assertEqual('"name=b" match multiple playlists', e[0]) + playlists = self.core.playlists.filter(name='b') + self.assertIn(playlist, playlists) + self.assertEqual(2, len(playlists)) - def test_get_by_name_raises_keyerror_if_no_match(self): + def test_filter_by_name_returns_no_matches(self): self.backend.playlists.playlists = [ Playlist(name='a'), Playlist(name='b')] - try: - self.core.playlists.get(name='c') - self.fail('Should raise LookupError if no match') - except LookupError as e: - self.assertEqual('"name=c" match no playlists', e[0]) + self.assertEqual([], self.core.playlists.filter(name='c')) def test_lookup_finds_playlist_by_uri(self): original_playlist = self.core.playlists.create('test') diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 65328f60..a5fbbcb5 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -55,19 +55,56 @@ class TracklistControllerTest(object): self.assertRaises(AssertionError, test) @populate_playlist - def test_get_by_tlid(self): + def test_filter_by_tlid(self): tl_track = self.controller.tl_tracks[1] - self.assertEqual(tl_track, self.controller.get(tlid=tl_track.tlid)) + self.assertEqual( + [tl_track], self.controller.filter(tlid=tl_track.tlid)) @populate_playlist - def test_get_by_uri(self): + def test_filter_by_uri(self): tl_track = self.controller.tl_tracks[1] - self.assertEqual(tl_track, self.controller.get(uri=tl_track.track.uri)) + self.assertEqual( + [tl_track], self.controller.filter(uri=tl_track.track.uri)) @populate_playlist - def test_get_by_uri_raises_error_for_invalid_uri(self): - test = lambda: self.controller.get(uri='foobar') - self.assertRaises(LookupError, test) + def test_filter_by_uri_returns_nothing_for_invalid_uri(self): + self.assertEqual([], self.controller.filter(uri='foobar')) + + def test_filter_by_uri_returns_single_match(self): + track = Track(uri='a') + self.controller.append([Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.controller.filter(uri='a')[0].track) + + def test_filter_by_uri_returns_multiple_matches(self): + track = Track(uri='a') + self.controller.append([Track(uri='z'), track, track]) + tl_tracks = self.controller.filter(uri='a') + self.assertEqual(track, tl_tracks[0].track) + self.assertEqual(track, tl_tracks[1].track) + + def test_filter_by_uri_returns_nothing_if_no_match(self): + self.controller.playlist = Playlist( + tracks=[Track(uri='z'), Track(uri='y')]) + self.assertEqual([], self.controller.filter(uri='a')) + + def test_filter_by_multiple_criteria_returns_elements_matching_all(self): + track1 = Track(uri='a', name='x') + track2 = Track(uri='b', name='x') + track3 = Track(uri='b', name='y') + self.controller.append([track1, track2, track3]) + self.assertEqual( + track1, self.controller.filter(uri='a', name='x')[0].track) + self.assertEqual( + track2, self.controller.filter(uri='b', name='x')[0].track) + self.assertEqual( + track3, self.controller.filter(uri='b', name='y')[0].track) + + def test_filter_by_criteria_that_is_not_present_in_all_elements(self): + track1 = Track() + track2 = Track(uri='b') + track3 = Track() + self.controller.append([track1, track2, track3]) + self.assertEqual(track2, self.controller.filter(uri='b')[0].track) @populate_playlist def test_clear(self): @@ -85,45 +122,6 @@ class TracklistControllerTest(object): self.controller.clear() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - def test_get_by_uri_returns_unique_match(self): - track = Track(uri='a') - self.controller.append([Track(uri='z'), track, Track(uri='y')]) - self.assertEqual(track, self.controller.get(uri='a').track) - - def test_get_by_uri_raises_error_if_multiple_matches(self): - track = Track(uri='a') - self.controller.append([Track(uri='z'), track, track]) - try: - self.controller.get(uri='a') - self.fail('Should raise LookupError if multiple matches') - except LookupError as e: - self.assertEqual('"uri=a" match multiple tracks', e[0]) - - def test_get_by_uri_raises_error_if_no_match(self): - self.controller.playlist = Playlist( - tracks=[Track(uri='z'), Track(uri='y')]) - try: - self.controller.get(uri='a') - self.fail('Should raise LookupError if no match') - except LookupError as e: - self.assertEqual('"uri=a" match no tracks', e[0]) - - def test_get_by_multiple_criteria_returns_elements_matching_all(self): - track1 = Track(uri='a', name='x') - track2 = Track(uri='b', name='x') - track3 = Track(uri='b', name='y') - self.controller.append([track1, track2, track3]) - self.assertEqual(track1, self.controller.get(uri='a', name='x').track) - self.assertEqual(track2, self.controller.get(uri='b', name='x').track) - self.assertEqual(track3, self.controller.get(uri='b', name='y').track) - - def test_get_by_criteria_that_is_not_present_in_all_elements(self): - track1 = Track() - track2 = Track(uri='b') - track3 = Track() - self.controller.append([track1, track2, track3]) - self.assertEqual(track2, self.controller.get(uri='b').track) - def test_append_appends_to_the_tracklist(self): self.controller.append([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.controller.tracks), 2) @@ -222,13 +220,11 @@ class TracklistControllerTest(object): self.assertEqual(track2, self.controller.tracks[1]) @populate_playlist - def test_removing_track_that_does_not_exist(self): - test = lambda: self.controller.remove(uri='/nonexistant') - self.assertRaises(LookupError, test) + def test_removing_track_that_does_not_exist_does_nothing(self): + self.controller.remove(uri='/nonexistant') - def test_removing_from_empty_playlist(self): - test = lambda: self.controller.remove(uri='/nonexistant') - self.assertRaises(LookupError, test) + def test_removing_from_empty_playlist_does_nothing(self): + self.controller.remove(uri='/nonexistant') @populate_playlist def test_shuffle(self): diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index f5f15f81..dd1ba57e 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -214,6 +214,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[5].name, 'f') self.assertInResponse('OK') + def test_moveid_with_tlid_not_found_in_tracklist_should_ack(self): + self.sendRequest('moveid "9" "0"') + self.assertEqualResponse( + 'ACK [50@0] {moveid} No such song') + def test_playlist_returns_same_as_playlistinfo(self): playlist_response = self.sendRequest('playlist') playlistinfo_response = self.sendRequest('playlistinfo') @@ -505,3 +510,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(tracks[4].name, 'b') self.assertEqual(tracks[5].name, 'f') self.assertInResponse('OK') + + def test_swapid_with_first_id_unknown_should_ack(self): + self.core.tracklist.append([Track()]) + self.sendRequest('swapid "0" "4"') + self.assertEqualResponse( + 'ACK [50@0] {swapid} No such song') + + def test_swapid_with_second_id_unknown_should_ack(self): + self.core.tracklist.append([Track()]) + self.sendRequest('swapid "4" "0"') + self.assertEqualResponse( + 'ACK [50@0] {swapid} No such song') From eab399357f73047e21bc270d24e120d77699a9b6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 21:08:41 +0100 Subject: [PATCH 279/323] Make library.lookup() return a list of tracks --- docs/changes.rst | 4 ++++ mopidy/backends/dummy.py | 4 +--- mopidy/backends/local/library.py | 4 ++-- mopidy/backends/local/playlists.py | 2 +- mopidy/backends/spotify/library.py | 4 ++-- mopidy/core/library.py | 9 ++++++--- .../mpd/protocol/current_playlist.py | 20 ++++++++++++------- mopidy/frontends/mpris/objects.py | 11 ++++------ tests/backends/base/library.py | 8 ++++---- tests/core/library_test.py | 4 ++-- 10 files changed, 39 insertions(+), 31 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index f024bc30..b45ae7c6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -141,6 +141,10 @@ backends: - :meth:`mopidy.core.TracklistController.remove` can now remove multiple tracks, and returns the tracks it removed. +- :meth:`mopidy.core.LibraryController.lookup` now returns a list of tracks. + This makes it possible to support lookup of artist or album URIs which then + can expand to a list of tracks. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 1d561bda..62ac8e8f 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -42,9 +42,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): return [] def lookup(self, uri): - matches = filter(lambda t: uri == t.uri, self.dummy_library) - if matches: - return matches[0] + return filter(lambda t: uri == t.uri, self.dummy_library) def refresh(self, uri=None): pass diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 1b406f84..e0e6f423 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -30,10 +30,10 @@ class LocalLibraryProvider(base.BaseLibraryProvider): def lookup(self, uri): try: - return self._uri_mapping[uri] + return [self._uri_mapping[uri]] except KeyError: logger.debug('Failed to lookup %r', uri) - return None + return [] def find_exact(self, **query): self._validate_query(query) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index ea45bcbb..666532c5 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -55,7 +55,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): try: # TODO We must use core.library.lookup() to support tracks # from other backends - tracks.append(self.backend.library.lookup(track_uri)) + tracks += self.backend.library.lookup(track_uri) except LookupError as ex: logger.error('Playlist item could not be added: %s', ex) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 5d80bc8d..df04058b 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -57,10 +57,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): def lookup(self, uri): try: - return SpotifyTrack(uri) + return [SpotifyTrack(uri)] except SpotifyError as e: logger.debug('Failed to lookup "%s": %s', uri, e) - return None + return [] def refresh(self, uri=None): pass # TODO diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ccd4e92b..c1a89222 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -41,17 +41,20 @@ class LibraryController(object): def lookup(self, uri): """ - Lookup track with given URI. Returns :class:`None` if not found. + Lookup the given URI. + + If the URI expands to multiple tracks, the returned list will contain + them all. :param uri: track URI :type uri: string - :rtype: :class:`mopidy.models.Track` or :class:`None` + :rtype: list of :class:`mopidy.models.Track` """ backend = self._get_backend(uri) if backend: return backend.library.lookup(uri).get() else: - return None + return [] def refresh(self, uri=None): """ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index a1af4549..da950078 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -22,9 +22,9 @@ def add(context, uri): """ if not uri: return - track = context.core.library.lookup(uri).get() - if track: - context.core.tracklist.add(track) + tracks = context.core.library.lookup(uri).get() + if tracks: + context.core.tracklist.append(tracks) return raise MpdNoExistError('directory or file not found', command='add') @@ -52,13 +52,19 @@ def addid(context, uri, songpos=None): raise MpdNoExistError('No such song', command='addid') if songpos is not None: songpos = int(songpos) - track = context.core.library.lookup(uri).get() - if track is None: + tracks = context.core.library.lookup(uri).get() + if not tracks: raise MpdNoExistError('No such song', command='addid') if songpos and songpos > context.core.tracklist.length.get(): raise MpdArgError('Bad song index', command='addid') - tl_track = context.core.tracklist.add(track, at_position=songpos).get() - return ('Id', tl_track.tlid) + first_tl_track = None + for track in tracks: + tl_track = context.core.tracklist.add(track, at_position=songpos).get() + if songpos is not None: + songpos += 1 + if first_tl_track is None: + first_tl_track = tl_track + return ('Id', first_tl_track.tlid) @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index a66abdb5..51b0d7e8 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -279,13 +279,10 @@ class MprisObject(dbus.service.Object): return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. - uri_schemes = self.core.uri_schemes.get() - if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]): - return - track = self.core.library.lookup(uri).get() - if track is not None: - tl_track = self.core.tracklist.add(track).get() - self.core.playback.play(tl_track) + tracks = self.core.library.lookup(uri).get() + if tracks: + tl_tracks = self.core.tracklist.append(tracks).get() + self.core.playback.play(tl_tracks[0]) else: logger.debug('Track with URI "%s" not found in library.', uri) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 50b73040..4e9232e5 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -43,12 +43,12 @@ class LibraryControllerTest(object): pass def test_lookup(self): - track = self.library.lookup(self.tracks[0].uri) - self.assertEqual(track, self.tracks[0]) + tracks = self.library.lookup(self.tracks[0].uri) + self.assertEqual(tracks, self.tracks[0:1]) def test_lookup_unknown_track(self): - track = self.library.lookup('fake uri') - self.assertEquals(track, None) + tracks = self.library.lookup('fake uri') + self.assertEqual(tracks, []) def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 40b928d8..1bd481de 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -41,10 +41,10 @@ class CoreLibraryTest(unittest.TestCase): self.assertFalse(self.library1.lookup.called) self.library2.lookup.assert_called_once_with('dummy2:a') - def test_lookup_fails_for_dummy3_track(self): + def test_lookup_returns_nothing_for_dummy3_track(self): result = self.core.library.lookup('dummy3:a') - self.assertIsNone(result) + self.assertEqual(result, []) self.assertFalse(self.library1.lookup.called) self.assertFalse(self.library2.lookup.called) From 6aa1ee7f5ce0ed92462de02b223d19782b314d02 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 22:18:51 +0100 Subject: [PATCH 280/323] network: The recieve buffer should be a bytestring --- mopidy/utils/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 3ddfe2ee..604350d1 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -317,7 +317,7 @@ class LineProtocol(pykka.ThreadingActor): super(LineProtocol, self).__init__() self.connection = connection self.prevent_timeout = False - self.recv_buffer = '' + self.recv_buffer = b'' if self.delimiter: self.delimiter = re.compile(self.delimiter) From 78dec9717d738697567475f395df93bb2c6725c6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 22:19:41 +0100 Subject: [PATCH 281/323] mpd: Ignore search/find/list with empty filter values (fixes #246) --- docs/changes.rst | 4 ++++ mopidy/frontends/mpd/protocol/music_db.py | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index b45ae7c6..ffb7fbf6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -160,6 +160,10 @@ backends: files (Apple lossless) because it didn't support multiple tag messages from GStreamer per track it scanned. +- :issue:`246`: The MPD command ``list album artist ""`` and similar + ``search``, ``find``, and ``list`` commands with empty filter values caused a + :exc:`LookupError`, but should have been ignored by the MPD server. + v0.8.1 (2012-10-30) =================== diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 08ba8b19..4d6433f1 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -28,6 +28,8 @@ def _build_query(mpd_query): 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: @@ -76,7 +78,10 @@ def find(context, mpd_query): - also uses the search type "date". - uses "file" instead of "filename". """ - query = _build_query(mpd_query) + try: + query = _build_query(mpd_query) + except ValueError: + return return tracks_to_mpd_format( context.core.library.find_exact(**query).get()) @@ -185,7 +190,10 @@ def list_(context, field, mpd_query=None): - capitalizes the field argument. """ field = field.lower() - query = _list_build_query(field, mpd_query) + try: + query = _list_build_query(field, mpd_query) + except ValueError: + return if field == 'artist': return _list_artist(context, query) elif field == 'album': @@ -211,6 +219,8 @@ def _list_build_query(field, mpd_query): 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( @@ -224,6 +234,8 @@ def _list_build_query(field, mpd_query): 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: @@ -351,7 +363,10 @@ def search(context, mpd_query): - also uses the search type "date". - uses "file" instead of "filename". """ - query = _build_query(mpd_query) + try: + query = _build_query(mpd_query) + except ValueError: + return return tracks_to_mpd_format( context.core.library.search(**query).get()) From f83c595e3aa5bdeb71723ddc662132edd0cc9062 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 23:02:08 +0100 Subject: [PATCH 282/323] models: Support deserialization of any ImmutableObject --- mopidy/models.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 4861ef0d..b8f8b8b2 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -125,16 +125,9 @@ def model_json_decoder(dct): """ if '__type__' in dct: obj_type = dct.pop('__type__') - if obj_type == 'Album': - return Album(**dct) - if obj_type == 'Artist': - return Artist(**dct) - if obj_type == 'Playlist': - return Playlist(**dct) - if obj_type == 'TlTrack': - return TlTrack(**dct) - if obj_type == 'Track': - return Track(**dct) + cls = globals().get(obj_type, None) + if issubclass(cls, ImmutableObject): + return cls(**dct) return dct From 34d444e56372e0231a99504397408aeb77d73fd2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 23:35:05 +0100 Subject: [PATCH 283/323] models: Don't allow model deserialization to override methods --- mopidy/models.py | 2 +- tests/models_test.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index b8f8b8b2..901d637b 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -14,7 +14,7 @@ class ImmutableObject(object): def __init__(self, *args, **kwargs): for key, value in kwargs.items(): - if not hasattr(self, key): + if not hasattr(self, key) or callable(getattr(self, key)): raise TypeError( '__init__() got an unexpected keyword argument "%s"' % key) diff --git a/tests/models_test.py b/tests/models_test.py index 21ad7ead..ed17cef3 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -89,12 +89,33 @@ class ArtistTest(unittest.TestCase): {'__type__': 'Artist', 'uri': 'uri', 'name': 'name'}, Artist(uri='uri', name='name').serialize()) - def test_to_json_and_Back(self): + def test_to_json_and_back(self): artist1 = Artist(uri='uri', name='name') serialized = json.dumps(artist1, cls=ModelJSONEncoder) artist2 = json.loads(serialized, object_hook=model_json_decoder) self.assertEqual(artist1, artist2) + def test_to_json_and_back_with_unknown_field(self): + artist = Artist(uri='uri', name='name').serialize() + artist['foo'] = 'foo' + serialized = json.dumps(artist) + test = lambda: json.loads(serialized, object_hook=model_json_decoder) + self.assertRaises(TypeError, test) + + def test_to_json_and_back_with_field_matching_method(self): + artist = Artist(uri='uri', name='name').serialize() + artist['copy'] = 'foo' + serialized = json.dumps(artist) + test = lambda: json.loads(serialized, object_hook=model_json_decoder) + self.assertRaises(TypeError, test) + + def test_to_json_and_back_with_field_matching_internal_field(self): + artist = Artist(uri='uri', name='name').serialize() + artist['__mro__'] = 'foo' + serialized = json.dumps(artist) + test = lambda: json.loads(serialized, object_hook=model_json_decoder) + self.assertRaises(TypeError, test) + def test_eq_name(self): artist1 = Artist(name='name') artist2 = Artist(name='name') From 693a3d3ec63bcf3c0dc4831e68c9800e75019376 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 19 Nov 2012 23:58:09 +0100 Subject: [PATCH 284/323] models: Model creation with kwarg matching method name should fail --- tests/models_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/models_test.py b/tests/models_test.py index ed17cef3..218804e7 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -79,6 +79,13 @@ class ArtistTest(unittest.TestCase): test = lambda: Artist(foo='baz') self.assertRaises(TypeError, test) + def test_invalid_kwarg_with_name_matching_method(self): + test = lambda: Artist(copy='baz') + self.assertRaises(TypeError, test) + + test = lambda: Artist(serialize='baz') + self.assertRaises(TypeError, test) + def test_repr(self): self.assertEquals( "Artist(name=u'name', uri=u'uri')", From 8c6f04a408739957c8c818302fb67886208b24d3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 00:21:26 +0100 Subject: [PATCH 285/323] models: Change serialized type marker from '__type__' to '__model__' --- mopidy/models.py | 12 ++++++------ tests/models_test.py | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/mopidy/models.py b/mopidy/models.py index 901d637b..a4ed1b4f 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -80,7 +80,7 @@ class ImmutableObject(object): def serialize(self): data = {} - data['__type__'] = self.__class__.__name__ + data['__model__'] = self.__class__.__name__ for key in self.__dict__.keys(): public_key = key.lstrip('_') value = self.__dict__[key] @@ -101,7 +101,7 @@ class ModelJSONEncoder(json.JSONEncoder): >>> import json >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) - '{"a_track": {"__type__": "Track", "name": "name"}}' + '{"a_track": {"__model__": "Track", "name": "name"}}' """ def default(self, obj): @@ -118,14 +118,14 @@ def model_json_decoder(dct): >>> import json >>> json.loads( - ... '{"a_track": {"__type__": "Track", "name": "name"}}', + ... '{"a_track": {"__model__": "Track", "name": "name"}}', ... object_hook=model_json_decoder) {u'a_track': Track(artists=[], name=u'name')} """ - if '__type__' in dct: - obj_type = dct.pop('__type__') - cls = globals().get(obj_type, None) + if '__model__' in dct: + model_name = dct.pop('__model__') + cls = globals().get(model_name, None) if issubclass(cls, ImmutableObject): return cls(**dct) return dct diff --git a/tests/models_test.py b/tests/models_test.py index 218804e7..9a3062fc 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -93,7 +93,7 @@ class ArtistTest(unittest.TestCase): def test_serialize(self): self.assertDictEqual( - {'__type__': 'Artist', 'uri': 'uri', 'name': 'name'}, + {'__model__': 'Artist', 'uri': 'uri', 'name': 'name'}, Artist(uri='uri', name='name').serialize()) def test_to_json_and_back(self): @@ -232,14 +232,14 @@ class AlbumTest(unittest.TestCase): def test_serialize_without_artists(self): self.assertDictEqual( - {'__type__': 'Album', 'uri': 'uri', 'name': 'name'}, + {'__model__': 'Album', 'uri': 'uri', 'name': 'name'}, Album(uri='uri', name='name').serialize()) def test_serialize_with_artists(self): artist = Artist(name='foo') self.assertDictEqual( - {'__type__': 'Album', 'uri': 'uri', 'name': 'name', 'artists': - [artist.serialize()]}, + {'__model__': 'Album', 'uri': 'uri', 'name': 'name', + 'artists': [artist.serialize()]}, Album(uri='uri', name='name', artists=[artist]).serialize()) def test_to_json_and_back(self): @@ -430,20 +430,20 @@ class TrackTest(unittest.TestCase): def test_serialize_without_artists(self): self.assertDictEqual( - {'__type__': 'Track', 'uri': 'uri', 'name': 'name'}, + {'__model__': 'Track', 'uri': 'uri', 'name': 'name'}, Track(uri='uri', name='name').serialize()) def test_serialize_with_artists(self): artist = Artist(name='foo') self.assertDictEqual( - {'__type__': 'Track', 'uri': 'uri', 'name': 'name', + {'__model__': 'Track', 'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, Track(uri='uri', name='name', artists=[artist]).serialize()) def test_serialize_with_album(self): album = Album(name='foo') self.assertDictEqual( - {'__type__': 'Track', 'uri': 'uri', 'name': 'name', + {'__model__': 'Track', 'uri': 'uri', 'name': 'name', 'album': album.serialize()}, Track(uri='uri', name='name', album=album).serialize()) @@ -646,7 +646,7 @@ class TlTrackTest(unittest.TestCase): def test_serialize(self): track = Track(uri='uri', name='name') self.assertDictEqual( - {'__type__': 'TlTrack', 'tlid': 123, 'track': track.serialize()}, + {'__model__': 'TlTrack', 'tlid': 123, 'track': track.serialize()}, TlTrack(tlid=123, track=track).serialize()) def test_to_json_and_back(self): @@ -780,13 +780,13 @@ class PlaylistTest(unittest.TestCase): def test_serialize_without_tracks(self): self.assertDictEqual( - {'__type__': 'Playlist', 'uri': 'uri', 'name': 'name'}, + {'__model__': 'Playlist', 'uri': 'uri', 'name': 'name'}, Playlist(uri='uri', name='name').serialize()) def test_serialize_with_tracks(self): track = Track(name='foo') self.assertDictEqual( - {'__type__': 'Playlist', 'uri': 'uri', 'name': 'name', + {'__model__': 'Playlist', 'uri': 'uri', 'name': 'name', 'tracks': [track.serialize()]}, Playlist(uri='uri', name='name', tracks=[track]).serialize()) From 7da2058b656b98b012b7bf3daa315cc3359ec553 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 00:31:41 +0100 Subject: [PATCH 286/323] mpd: Test response for bad 'list' requests --- tests/frontends/mpd/protocol/music_db_test.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 7059c855..08b36332 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -173,6 +173,10 @@ class MusicDatabaseListTest(protocol.BaseTestCase): 'list "artist" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') + def test_list_artist_without_filter_value(self): + self.sendRequest('list "artist" "artist" ""') + self.assertInResponse('OK') + ### Album def test_list_album_with_quotes(self): @@ -191,6 +195,10 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.sendRequest('list "album" "anartist"') self.assertInResponse('OK') + def test_list_album_with_artist_name_without_filter_value(self): + self.sendRequest('list "album" ""') + self.assertInResponse('OK') + def test_list_album_by_artist(self): self.sendRequest('list "album" "artist" "anartist"') self.assertInResponse('OK') @@ -216,6 +224,10 @@ class MusicDatabaseListTest(protocol.BaseTestCase): 'list "album" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') + def test_list_album_without_filter_value(self): + self.sendRequest('list "album" "artist" ""') + self.assertInResponse('OK') + ### Date def test_list_date_with_quotes(self): @@ -259,6 +271,10 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.sendRequest('list "date" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') + def test_list_date_without_filter_value(self): + self.sendRequest('list "date" "artist" ""') + self.assertInResponse('OK') + ### Genre def test_list_genre_with_quotes(self): @@ -303,6 +319,10 @@ class MusicDatabaseListTest(protocol.BaseTestCase): 'list "genre" "artist" "anartist" "album" "analbum"') self.assertInResponse('OK') + def test_list_genre_without_filter_value(self): + self.sendRequest('list "genre" "artist" ""') + self.assertInResponse('OK') + class MusicDatabaseSearchTest(protocol.BaseTestCase): def test_search_album(self): From bec91284be7c2554c238d2d25cd35986fdac3a6a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 00:38:53 +0100 Subject: [PATCH 287/323] mpd: Allow bad 'find' requests --- mopidy/frontends/mpd/protocol/music_db.py | 2 +- tests/frontends/mpd/protocol/music_db_test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 4d6433f1..26371364 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -52,7 +52,7 @@ def count(context, tag, needle): @handle_request( r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def find(context, mpd_query): """ *musicpd.org, music database section:* diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 08b36332..9a233e40 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -119,6 +119,10 @@ class MusicDatabaseFindTest(protocol.BaseTestCase): self.sendRequest('find album "album_what" artist "artist_what"') self.assertInResponse('OK') + def test_find_without_filter_value(self): + self.sendRequest('find "album" ""') + self.assertInResponse('OK') + class MusicDatabaseListTest(protocol.BaseTestCase): def test_list_foo_returns_ack(self): From d226db90397d5a021eaefe9fba07268356b54112 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 08:49:55 +0100 Subject: [PATCH 288/323] docs: Fix docstring errors --- mopidy/core/playback.py | 4 +++- mopidy/core/tracklist.py | 20 +++++++------------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 94b4af9c..0cd2b3e8 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -324,6 +324,8 @@ class PlaybackController(object): def on_end_of_track(self): """ Tell the playback controller that end of track is reached. + + Used by event handler in :class:`mopidy.core.Core`. """ if self.state == PlaybackState.STOPPED: return @@ -343,7 +345,7 @@ class PlaybackController(object): """ Tell the playback controller that the current playlist has changed. - Used by :class:`mopidy.core.CurrentPlaylistController`. + Used by :class:`mopidy.core.TracklistController`. """ self._first_shuffle = True self._shuffled = [] diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index e00a42f9..fb84c112 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -63,8 +63,7 @@ class TracklistController(object): """ Add the track to the end of, or at the given position in the tracklist. - Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` - event. + Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param track: track to add :type track: :class:`mopidy.models.Track` @@ -90,12 +89,11 @@ class TracklistController(object): """ Append the given tracks to the tracklist. - Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` - event. + Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param tracks: tracks to append :type tracks: list of :class:`mopidy.models.Track` - :rtype: list of class:`mopidy.models.TlTrack` + :rtype: list of :class:`mopidy.models.TlTrack` """ tl_tracks = [] for track in tracks: @@ -110,8 +108,7 @@ class TracklistController(object): """ Clear the tracklist. - Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` - event. + Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. """ self._tl_tracks = [] self.version += 1 @@ -156,8 +153,7 @@ class TracklistController(object): """ Move the tracks in the slice ``[start:end]`` to ``to_position``. - Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` - event. + Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param start: position of first track to move :type start: int @@ -192,8 +188,7 @@ class TracklistController(object): Uses :meth:`filter()` to lookup the tracks to remove. - Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` - event. + Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param criteria: on or more criteria to match by :type criteria: dict @@ -211,8 +206,7 @@ class TracklistController(object): Shuffles the entire tracklist. If ``start`` and ``end`` is given only shuffles the slice ``[start:end]``. - Triggers the :method:`mopidy.core.CoreListener.tracklist_changed` - event. + Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. :param start: position of first track to shuffle :type start: int or :class:`None` From e9658453b002daa5d0e59fac8c74f1b62e955d02 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 08:51:19 +0100 Subject: [PATCH 289/323] core: Make tracklist.version read-only --- mopidy/core/tracklist.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index fb84c112..7e26709e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -53,9 +53,8 @@ class TracklistController(object): """ return self._version - @version.setter # noqa - def version(self, version): - self._version = version + def _increase_version(self): + self._version += 1 self._core.playback.on_tracklist_change() self._trigger_tracklist_changed() @@ -81,7 +80,7 @@ class TracklistController(object): else: self._tl_tracks.append(tl_track) if increase_version: - self.version += 1 + self._increase_version() self._next_tlid += 1 return tl_track @@ -100,7 +99,7 @@ class TracklistController(object): tl_tracks.append(self.add(track, increase_version=False)) if tracks: - self.version += 1 + self._increase_version() return tl_tracks @@ -111,7 +110,7 @@ class TracklistController(object): Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. """ self._tl_tracks = [] - self.version += 1 + self._increase_version() def filter(self, **criteria): """ @@ -180,7 +179,7 @@ class TracklistController(object): new_tl_tracks.insert(to_position, tl_track) to_position += 1 self._tl_tracks = new_tl_tracks - self.version += 1 + self._increase_version() def remove(self, **criteria): """ @@ -198,7 +197,7 @@ class TracklistController(object): for tl_track in tl_tracks: position = self._tl_tracks.index(tl_track) del self._tl_tracks[position] - self.version += 1 + self._increase_version() return tl_tracks def shuffle(self, start=None, end=None): @@ -230,7 +229,7 @@ class TracklistController(object): after = tl_tracks[end or len(tl_tracks):] random.shuffle(shuffled) self._tl_tracks = before + shuffled + after - self.version += 1 + self._increase_version() def slice(self, start, end): """ From f588787ac3d1ce5cbf8a7caa6629881b521b40d7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 09:13:34 +0100 Subject: [PATCH 290/323] core: Expose getters/setters for all properties This will be useful when exposing the core API over various protocols, e.g. JSON-RPC. --- mopidy/core/actor.py | 7 +- mopidy/core/playback.py | 210 +++++++++++++++++++-------------------- mopidy/core/playlists.py | 15 +-- mopidy/core/tracklist.py | 52 +++++----- 4 files changed, 145 insertions(+), 139 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index a4f184bf..cd4ba180 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -46,14 +46,15 @@ class Core(pykka.ThreadingActor, AudioListener, BackendListener): self.tracklist = TracklistController(core=self) - @property - def uri_schemes(self): - """List of URI schemes we can handle""" + def get_uri_schemes(self): futures = [b.uri_schemes for b in self.backends] results = pykka.get_all(futures) uri_schemes = itertools.chain(*results) return sorted(uri_schemes) + uri_schemes = property(get_uri_schemes) + """List of URI schemes we can handle""" + def reached_end_of_stream(self): self.playback.on_end_of_track() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0cd2b3e8..e562a9b1 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -79,42 +79,28 @@ class PlaybackController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) - def _get_tlid(self, tl_track): - if tl_track is None: - return None - return tl_track.tlid + def get_current_tlid(self): + return self.current_tl_track and self.current_tl_track.tlid - def _get_track(self, tl_track): - if tl_track is None: - return None - return tl_track.track + current_tlid = property(get_current_tlid) + """ + The TLID (tracklist ID) of the currently playing or selected + track. - @property - def current_tlid(self): - """ - The TLID (tracklist ID) of the currently playing or selected - track. + Read-only. Extracted from :attr:`current_tl_track` for convenience. + """ - Read-only. Extracted from :attr:`current_tl_track` for convenience. - """ - return self._get_tlid(self.current_tl_track) + def get_current_track(self): + return self.current_tl_track and self.current_tl_track.track - @property - def current_track(self): - """ - The currently playing or selected :class:`mopidy.models.Track`. + current_track = property(get_current_track) + """ + The currently playing or selected :class:`mopidy.models.Track`. - Read-only. Extracted from :attr:`current_tl_track` for convenience. - """ - return self._get_track(self.current_tl_track) + Read-only. Extracted from :attr:`current_tl_track` for convenience. + """ - @property - def tracklist_position(self): - """ - The position of the current track in the tracklist. - - Read-only. - """ + def get_tracklist_position(self): if self.current_tl_track is None: return None try: @@ -122,25 +108,25 @@ class PlaybackController(object): except ValueError: return None - @property - def track_at_eot(self): - """ - The track that will be played at the end of the current track. + tracklist_position = property(get_tracklist_position) + """ + The position of the current track in the tracklist. - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`tl_track_at_eot` for convenience. - """ - return self._get_track(self.tl_track_at_eot) + Read-only. + """ - @property - def tl_track_at_eot(self): - """ - The track that will be played at the end of the current track. + def get_track_at_eot(self): + return self.tl_track_at_eot and self.tl_track_at_eot.track - Read-only. A :class:`mopidy.models.TlTrack`. + track_at_eot = property(get_track_at_eot) + """ + The track that will be played at the end of the current track. - Not necessarily the same track as :attr:`tl_track_at_next`. - """ + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`tl_track_at_eot` for convenience. + """ + + def get_tl_track_at_eot(self): # pylint: disable = R0911 # Too many return statements @@ -173,28 +159,27 @@ class PlaybackController(object): except IndexError: return None - @property - def track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. + tl_track_at_eot = property(get_tl_track_at_eot) + """ + The track that will be played at the end of the current track. - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`tl_track_at_next` for convenience. - """ - return self._get_track(self.tl_track_at_next) + Read-only. A :class:`mopidy.models.TlTrack`. - @property - def tl_track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. + Not necessarily the same track as :attr:`tl_track_at_next`. + """ - Read-only. A :class:`mopidy.models.TlTrack`. + def get_track_at_next(self): + return self.tl_track_at_next and self.tl_track_at_next.track - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ + track_at_next = property(get_track_at_next) + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`tl_track_at_next` for convenience. + """ + + def get_tl_track_at_next(self): tl_tracks = self.core.tracklist.tl_tracks if not tl_tracks: @@ -221,27 +206,30 @@ class PlaybackController(object): except IndexError: return None - @property - def track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. + tl_track_at_next = property(get_tl_track_at_next) + """ + The track that will be played if calling :meth:`next()`. - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`tl_track_at_previous` for convenience. - """ - return self._get_track(self.tl_track_at_previous) + Read-only. A :class:`mopidy.models.TlTrack`. - @property - def tl_track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ - A :class:`mopidy.models.TlTrack`. + def get_track_at_previous(self): + return self.tl_track_at_previous and self.tl_track_at_previous.track - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ + track_at_previous = property(get_track_at_previous) + """ + The track that will be played if calling :meth:`previous()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`tl_track_at_previous` for convenience. + """ + + def get_tl_track_at_previous(self): if self.repeat or self.consume or self.random: return self.current_tl_track @@ -250,59 +238,71 @@ class PlaybackController(object): return self.core.tracklist.tl_tracks[self.tracklist_position - 1] - @property - def state(self): - """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. + tl_track_at_previous = property(get_tl_track_at_previous) + """ + The track that will be played if calling :meth:`previous()`. - Possible states and transitions: + A :class:`mopidy.models.TlTrack`. - .. digraph:: state_transitions + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] - """ + def get_state(self): return self._state - @state.setter # noqa - def state(self, new_state): + def set_state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug('Changing state: %s -> %s', old_state, new_state) self._trigger_playback_state_changed(old_state, new_state) - @property - def time_position(self): - """Time position in milliseconds.""" + state = property(get_state, set_state) + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + + def get_time_position(self): backend = self._get_backend() if backend: return backend.playback.get_time_position().get() else: return 0 - @property - def volume(self): - """Volume as int in range [0..100] or :class:`None`""" + time_position = property(get_time_position) + """Time position in milliseconds.""" + + def get_volume(self): if self.audio: return self.audio.get_volume().get() else: # For testing return self._volume - @volume.setter # noqa - def volume(self, volume): + def set_volume(self, volume): if self.audio: self.audio.set_volume(volume) else: # For testing self._volume = volume + volume = property(get_volume, set_volume) + """Volume as int in range [0..100] or :class:`None`""" + def change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index dcdc665f..6a368ac6 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -15,18 +15,19 @@ class PlaylistsController(object): self.backends = backends self.core = core - @property - def playlists(self): - """ - The available playlists. - - Read-only. List of :class:`mopidy.models.Playlist`. - """ + def get_playlists(self): futures = [ b.playlists.playlists for b in self.backends.with_playlists] results = pykka.get_all(futures) return list(itertools.chain(*results)) + playlists = property(get_playlists) + """ + The available playlists. + + Read-only. List of :class:`mopidy.models.Playlist`. + """ + def create(self, name, uri_scheme=None): """ Create a new playlist. diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 7e26709e..05d551fe 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -20,37 +20,33 @@ class TracklistController(object): self._tl_tracks = [] self._version = 0 - @property - def tl_tracks(self): - """ - List of :class:`mopidy.models.TlTrack`. - - Read-only. - """ + def get_tl_tracks(self): return self._tl_tracks[:] - @property - def tracks(self): - """ - List of :class:`mopidy.models.Track` in the tracklist. + tl_tracks = property(get_tl_tracks) + """ + List of :class:`mopidy.models.TlTrack`. - Read-only. - """ + Read-only. + """ + + def get_tracks(self): return [tl_track.track for tl_track in self._tl_tracks] - @property - def length(self): - """ - Length of the tracklist. - """ + tracks = property(get_tracks) + """ + List of :class:`mopidy.models.Track` in the tracklist. + + Read-only. + """ + + def get_length(self): return len(self._tl_tracks) - @property - def version(self): - """ - The tracklist version. Integer which is increased every time the - tracklist is changed. Is not reset before Mopidy is restarted. - """ + length = property(get_length) + """Length of the tracklist.""" + + def get_version(self): return self._version def _increase_version(self): @@ -58,6 +54,14 @@ class TracklistController(object): self._core.playback.on_tracklist_change() self._trigger_tracklist_changed() + version = property(get_version) + """ + The tracklist version. + + Read-only. Integer which is increased every time the tracklist is changed. + Is not reset before Mopidy is restarted. + """ + def add(self, track, at_position=None, increase_version=True): """ Add the track to the end of, or at the given position in the tracklist. From ee8c2ca58911e92f0dfd867fe5be8a112793d21e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 09:24:37 +0100 Subject: [PATCH 291/323] tests: Rename populate_playlist() to populate_tracklist() --- tests/backends/base/__init__.py | 2 +- tests/backends/base/playback.py | 212 +++++++++++++++---------------- tests/backends/base/tracklist.py | 50 ++++---- 3 files changed, 132 insertions(+), 132 deletions(-) diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index ec3ec1df..c415ef23 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -def populate_playlist(func): +def populate_tracklist(func): def wrapper(self): for track in self.tracks: self.core.tracklist.add(track) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 21e377d9..94fc7759 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -9,7 +9,7 @@ from mopidy.core import PlaybackState from mopidy.models import Track from tests import unittest -from tests.backends.base import populate_playlist +from tests.backends.base import populate_tracklist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 @@ -40,35 +40,35 @@ class PlaybackControllerTest(object): def test_play_with_empty_playlist_return_value(self): self.assertEqual(self.playback.play(), None) - @populate_playlist + @populate_tracklist def test_play_state(self): self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_play_return_value(self): self.assertEqual(self.playback.play(), None) - @populate_playlist + @populate_tracklist def test_play_track_state(self): self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play(self.tracklist.tl_tracks[-1]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_play_track_return_value(self): self.assertEqual(self.playback.play( self.tracklist.tl_tracks[-1]), None) - @populate_playlist + @populate_tracklist def test_play_when_playing(self): self.playback.play() track = self.playback.current_track self.playback.play() self.assertEqual(track, self.playback.current_track) - @populate_playlist + @populate_tracklist def test_play_when_paused(self): self.playback.play() track = self.playback.current_track @@ -77,7 +77,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) - @populate_playlist + @populate_tracklist def test_play_when_pause_after_next(self): self.playback.play() self.playback.next() @@ -88,17 +88,17 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) - @populate_playlist + @populate_tracklist def test_play_sets_current_track(self): self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_play_track_sets_current_track(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.assertEqual(self.playback.current_track, self.tracks[-1]) - @populate_playlist + @populate_tracklist def test_play_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. self.backend.playback.play = lambda track: track != self.tracks[0] @@ -106,7 +106,7 @@ class PlaybackControllerTest(object): self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_current_track_after_completed_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() @@ -118,14 +118,14 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) - @populate_playlist + @populate_tracklist def test_previous(self): self.playback.play() self.playback.next() self.playback.previous() self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_previous_more(self): self.playback.play() # At track 0 self.playback.next() # At track 1 @@ -133,13 +133,13 @@ class PlaybackControllerTest(object): self.playback.previous() # At track 1 self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_previous_return_value(self): self.playback.play() self.playback.next() self.assertEqual(self.playback.previous(), None) - @populate_playlist + @populate_tracklist def test_previous_does_not_trigger_playback(self): self.playback.play() self.playback.next() @@ -147,7 +147,7 @@ class PlaybackControllerTest(object): self.playback.previous() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_previous_at_start_of_playlist(self): self.playback.previous() self.assertEqual(self.playback.state, PlaybackState.STOPPED) @@ -158,7 +158,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) - @populate_playlist + @populate_tracklist def test_previous_skips_to_previous_track_on_failure(self): # If backend's play() returns False, it is a failure. self.backend.playback.play = lambda track: track != self.tracks[1] @@ -168,7 +168,7 @@ class PlaybackControllerTest(object): self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_next(self): self.playback.play() @@ -181,17 +181,17 @@ class PlaybackControllerTest(object): self.playback.tracklist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) - @populate_playlist + @populate_tracklist def test_next_return_value(self): self.playback.play() self.assertEqual(self.playback.next(), None) - @populate_playlist + @populate_tracklist def test_next_does_not_trigger_playback(self): self.playback.next() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_next_at_end_of_playlist(self): self.playback.play() @@ -204,7 +204,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_next_until_end_of_playlist_and_play_from_start(self): self.playback.play() @@ -222,7 +222,7 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_next_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. self.backend.playback.play = lambda track: track != self.tracks[1] @@ -232,16 +232,16 @@ class PlaybackControllerTest(object): self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) - @populate_playlist + @populate_tracklist def test_next_track_before_play(self): self.assertEqual(self.playback.track_at_next, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_next_track_during_play(self): self.playback.play() self.assertEqual(self.playback.track_at_next, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_next_track_after_previous(self): self.playback.play() self.playback.next() @@ -251,14 +251,14 @@ class PlaybackControllerTest(object): def test_next_track_empty_playlist(self): self.assertEqual(self.playback.track_at_next, None) - @populate_playlist + @populate_tracklist def test_next_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.next() self.assertEqual(self.playback.track_at_next, None) - @populate_playlist + @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): self.playback.repeat = True self.playback.play() @@ -266,20 +266,20 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.track_at_next, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_next_track_with_random(self): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - @populate_playlist + @populate_tracklist def test_next_with_consume(self): self.playback.consume = True self.playback.play() self.playback.next() self.assertIn(self.tracks[0], self.tracklist.tracks) - @populate_playlist + @populate_tracklist def test_next_with_single_and_repeat(self): self.playback.single = True self.playback.repeat = True @@ -287,7 +287,7 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_next_with_random(self): # FIXME feels very fragile random.seed(1) @@ -296,7 +296,7 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_next_track_with_random_after_append_playlist(self): random.seed(1) self.playback.random = True @@ -304,7 +304,7 @@ class PlaybackControllerTest(object): self.tracklist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_end_of_track(self): self.playback.play() @@ -317,17 +317,17 @@ class PlaybackControllerTest(object): self.playback.tracklist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) - @populate_playlist + @populate_tracklist def test_end_of_track_return_value(self): self.playback.play() self.assertEqual(self.playback.on_end_of_track(), None) - @populate_playlist + @populate_tracklist def test_end_of_track_does_not_trigger_playback(self): self.playback.on_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_end_of_track_at_end_of_playlist(self): self.playback.play() @@ -340,7 +340,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): self.playback.play() @@ -358,7 +358,7 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_end_of_track_skips_to_next_track_on_failure(self): # If backend's play() returns False, it is a failure. self.backend.playback.play = lambda track: track != self.tracks[1] @@ -368,16 +368,16 @@ class PlaybackControllerTest(object): self.assertNotEqual(self.playback.current_track, self.tracks[1]) self.assertEqual(self.playback.current_track, self.tracks[2]) - @populate_playlist + @populate_tracklist def test_end_of_track_track_before_play(self): self.assertEqual(self.playback.track_at_next, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() self.assertEqual(self.playback.track_at_next, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() self.playback.on_end_of_track() @@ -387,14 +387,14 @@ class PlaybackControllerTest(object): def test_end_of_track_track_empty_playlist(self): self.assertEqual(self.playback.track_at_next, None) - @populate_playlist + @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.on_end_of_track() self.assertEqual(self.playback.track_at_next, None) - @populate_playlist + @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): self.playback.repeat = True self.playback.play() @@ -402,20 +402,20 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.track_at_next, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - @populate_playlist + @populate_tracklist def test_end_of_track_with_consume(self): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() self.assertNotIn(self.tracks[0], self.tracklist.tracks) - @populate_playlist + @populate_tracklist def test_end_of_track_with_random(self): # FIXME feels very fragile random.seed(1) @@ -424,7 +424,7 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) self.playback.random = True @@ -432,22 +432,22 @@ class PlaybackControllerTest(object): self.tracklist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_previous_track_before_play(self): self.assertEqual(self.playback.track_at_previous, None) - @populate_playlist + @populate_tracklist def test_previous_track_after_play(self): self.playback.play() self.assertEqual(self.playback.track_at_previous, None) - @populate_playlist + @populate_tracklist def test_previous_track_after_next(self): self.playback.play() self.playback.next() self.assertEqual(self.playback.track_at_previous, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_previous_track_after_previous(self): self.playback.play() # At track 0 self.playback.next() # At track 1 @@ -458,7 +458,7 @@ class PlaybackControllerTest(object): def test_previous_track_empty_playlist(self): self.assertEqual(self.playback.track_at_previous, None) - @populate_playlist + @populate_tracklist def test_previous_track_with_consume(self): self.playback.consume = True for _ in self.tracks: @@ -466,7 +466,7 @@ class PlaybackControllerTest(object): self.assertEqual( self.playback.track_at_previous, self.playback.current_track) - @populate_playlist + @populate_tracklist def test_previous_track_with_random(self): self.playback.random = True for _ in self.tracks: @@ -474,37 +474,37 @@ class PlaybackControllerTest(object): self.assertEqual( self.playback.track_at_previous, self.playback.current_track) - @populate_playlist + @populate_tracklist def test_initial_current_track(self): self.assertEqual(self.playback.current_track, None) - @populate_playlist + @populate_tracklist def test_current_track_during_play(self): self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_current_track_after_next(self): self.playback.play() self.playback.next() self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_initial_tracklist_position(self): self.assertEqual(self.playback.tracklist_position, None) - @populate_playlist + @populate_tracklist def test_tracklist_position_during_play(self): self.playback.play() self.assertEqual(self.playback.tracklist_position, 0) - @populate_playlist + @populate_tracklist def test_tracklist_position_after_next(self): self.playback.play() self.playback.next() self.assertEqual(self.playback.tracklist_position, 1) - @populate_playlist + @populate_tracklist def test_tracklist_position_at_end_of_playlist(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() @@ -524,7 +524,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) @unittest.SkipTest # Blocks for 10ms - @populate_playlist + @populate_tracklist def test_end_of_track_callback_gets_called(self): self.playback.play() result = self.playback.seek(self.tracks[0].length - 10) @@ -532,7 +532,7 @@ class PlaybackControllerTest(object): message = self.core_queue.get(True, 1) self.assertEqual('end_of_track', message['command']) - @populate_playlist + @populate_tracklist def test_on_tracklist_change_when_playing(self): self.playback.play() current_track = self.playback.current_track @@ -540,13 +540,13 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) - @populate_playlist + @populate_tracklist def test_on_tracklist_change_when_stopped(self): self.tracklist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) - @populate_playlist + @populate_tracklist def test_on_tracklist_change_when_paused(self): self.playback.play() self.playback.pause() @@ -555,55 +555,55 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) - @populate_playlist + @populate_tracklist def test_pause_when_stopped(self): self.playback.pause() self.assertEqual(self.playback.state, PlaybackState.PAUSED) - @populate_playlist + @populate_tracklist def test_pause_when_playing(self): self.playback.play() self.playback.pause() self.assertEqual(self.playback.state, PlaybackState.PAUSED) - @populate_playlist + @populate_tracklist def test_pause_when_paused(self): self.playback.play() self.playback.pause() self.playback.pause() self.assertEqual(self.playback.state, PlaybackState.PAUSED) - @populate_playlist + @populate_tracklist def test_pause_return_value(self): self.playback.play() self.assertEqual(self.playback.pause(), None) - @populate_playlist + @populate_tracklist def test_resume_when_stopped(self): self.playback.resume() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_resume_when_playing(self): self.playback.play() self.playback.resume() self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_resume_when_paused(self): self.playback.play() self.playback.pause() self.playback.resume() self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_resume_return_value(self): self.playback.play() self.playback.pause() self.assertEqual(self.playback.resume(), None) @unittest.SkipTest # Uses sleep and might not work with LocalBackend - @populate_playlist + @populate_tracklist def test_resume_continues_from_right_position(self): self.playback.play() time.sleep(0.2) @@ -611,12 +611,12 @@ class PlaybackControllerTest(object): self.playback.resume() self.assertNotEqual(self.playback.time_position, 0) - @populate_playlist + @populate_tracklist def test_seek_when_stopped(self): result = self.playback.seek(1000) self.assert_(result, 'Seek return value was %s' % result) - @populate_playlist + @populate_tracklist def test_seek_when_stopped_updates_position(self): self.playback.seek(1000) position = self.playback.time_position @@ -629,18 +629,18 @@ class PlaybackControllerTest(object): self.playback.seek(0) self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_seek_when_stopped_triggers_play(self): self.playback.seek(0) self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_seek_when_playing(self): self.playback.play() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) - @populate_playlist + @populate_tracklist def test_seek_when_playing_updates_position(self): length = self.tracklist.tracks[0].length self.playback.play() @@ -648,14 +648,14 @@ class PlaybackControllerTest(object): position = self.playback.time_position self.assertGreaterEqual(position, length - 1010) - @populate_playlist + @populate_tracklist def test_seek_when_paused(self): self.playback.play() self.playback.pause() result = self.playback.seek(self.tracks[0].length - 1000) self.assert_(result, 'Seek return value was %s' % result) - @populate_playlist + @populate_tracklist def test_seek_when_paused_updates_position(self): length = self.tracklist.tracks[0].length self.playback.play() @@ -664,7 +664,7 @@ class PlaybackControllerTest(object): position = self.playback.time_position self.assertGreaterEqual(position, length - 1010) - @populate_playlist + @populate_tracklist def test_seek_when_paused_triggers_play(self): self.playback.play() self.playback.pause() @@ -672,34 +672,34 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.PLAYING) @unittest.SkipTest - @populate_playlist + @populate_tracklist def test_seek_beyond_end_of_song(self): # FIXME need to decide return value self.playback.play() result = self.playback.seek(self.tracks[0].length * 100) self.assert_(not result, 'Seek return value was %s' % result) - @populate_playlist + @populate_tracklist def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play() self.playback.seek(self.tracks[0].length * 100) self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_seek_beyond_end_of_song_for_last_track(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.seek(self.tracklist.tracks[-1].length * 100) self.assertEqual(self.playback.state, PlaybackState.STOPPED) @unittest.SkipTest - @populate_playlist + @populate_tracklist def test_seek_beyond_start_of_song(self): # FIXME need to decide return value self.playback.play() result = self.playback.seek(-1000) self.assert_(not result, 'Seek return value was %s' % result) - @populate_playlist + @populate_tracklist def test_seek_beyond_start_of_song_update_postion(self): self.playback.play() self.playback.seek(-1000) @@ -707,18 +707,18 @@ class PlaybackControllerTest(object): self.assertGreaterEqual(position, 0) self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_stop_when_stopped(self): self.playback.stop() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_stop_when_playing(self): self.playback.play() self.playback.stop() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - @populate_playlist + @populate_tracklist def test_stop_when_paused(self): self.playback.play() self.playback.pause() @@ -736,7 +736,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.time_position, 0) - @populate_playlist + @populate_tracklist def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) @@ -745,7 +745,7 @@ class PlaybackControllerTest(object): self.assertEqual(self.playback.time_position, 0) @unittest.SkipTest # Uses sleep and does might not work with LocalBackend - @populate_playlist + @populate_tracklist def test_time_position_when_playing(self): self.playback.play() first = self.playback.time_position @@ -754,7 +754,7 @@ class PlaybackControllerTest(object): self.assertGreater(second, first) @unittest.SkipTest # Uses sleep - @populate_playlist + @populate_tracklist def test_time_position_when_paused(self): self.playback.play() time.sleep(0.2) @@ -764,13 +764,13 @@ class PlaybackControllerTest(object): second = self.playback.time_position self.assertEqual(first, second) - @populate_playlist + @populate_tracklist def test_play_with_consume(self): self.playback.consume = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.playback.consume = True self.playback.play() @@ -778,14 +778,14 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(len(self.tracklist.tracks), 0) - @populate_playlist + @populate_tracklist def test_play_with_random(self): random.seed(1) self.playback.random = True self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[2]) - @populate_playlist + @populate_tracklist def test_previous_with_random(self): random.seed(1) self.playback.random = True @@ -795,13 +795,13 @@ class PlaybackControllerTest(object): self.playback.previous() self.assertEqual(self.playback.current_track, current_track) - @populate_playlist + @populate_tracklist def test_end_of_song_starts_next_track(self): self.playback.play() self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[1]) - @populate_playlist + @populate_tracklist def test_end_of_song_with_single_and_repeat_starts_same(self): self.playback.single = True self.playback.repeat = True @@ -809,7 +809,7 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, self.tracks[0]) - @populate_playlist + @populate_tracklist def test_end_of_playlist_stops(self): self.playback.play(self.tracklist.tl_tracks[-1]) self.playback.on_end_of_track() @@ -824,7 +824,7 @@ class PlaybackControllerTest(object): def test_consume_off_by_default(self): self.assertEqual(self.playback.consume, False) - @populate_playlist + @populate_tracklist def test_random_until_end_of_playlist(self): self.playback.random = True self.playback.play() @@ -832,7 +832,7 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.track_at_next, None) - @populate_playlist + @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): self.playback.repeat = True for _ in self.tracks: @@ -842,7 +842,7 @@ class PlaybackControllerTest(object): self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) - @populate_playlist + @populate_tracklist def test_random_until_end_of_playlist_with_repeat(self): self.playback.repeat = True self.playback.random = True @@ -851,7 +851,7 @@ class PlaybackControllerTest(object): self.playback.next() self.assertNotEqual(self.playback.track_at_next, None) - @populate_playlist + @populate_tracklist def test_played_track_during_random_not_played_again(self): self.playback.random = True self.playback.play() @@ -861,7 +861,7 @@ class PlaybackControllerTest(object): played.append(self.playback.current_track) self.playback.next() - @populate_playlist + @populate_tracklist def test_playing_track_that_isnt_in_playlist(self): test = lambda: self.playback.play((17, Track())) self.assertRaises(AssertionError, test) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index a5fbbcb5..52ddfa46 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -9,7 +9,7 @@ from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import TlTrack, Playlist, Track -from tests.backends.base import populate_playlist +from tests.backends.base import populate_tracklist class TracklistControllerTest(object): @@ -48,25 +48,25 @@ class TracklistControllerTest(object): self.assertEqual(tl_track, self.controller.tl_tracks[0]) self.assertEqual(track, tl_track.track) - @populate_playlist + @populate_tracklist def test_add_at_position_outside_of_playlist(self): test = lambda: self.controller.add( self.tracks[0], len(self.tracks) + 2) self.assertRaises(AssertionError, test) - @populate_playlist + @populate_tracklist def test_filter_by_tlid(self): tl_track = self.controller.tl_tracks[1] self.assertEqual( [tl_track], self.controller.filter(tlid=tl_track.tlid)) - @populate_playlist + @populate_tracklist def test_filter_by_uri(self): tl_track = self.controller.tl_tracks[1] self.assertEqual( [tl_track], self.controller.filter(uri=tl_track.track.uri)) - @populate_playlist + @populate_tracklist def test_filter_by_uri_returns_nothing_for_invalid_uri(self): self.assertEqual([], self.controller.filter(uri='foobar')) @@ -106,7 +106,7 @@ class TracklistControllerTest(object): self.controller.append([track1, track2, track3]) self.assertEqual(track2, self.controller.filter(uri='b')[0].track) - @populate_playlist + @populate_tracklist def test_clear(self): self.controller.clear() self.assertEqual(len(self.controller.tracks), 0) @@ -115,7 +115,7 @@ class TracklistControllerTest(object): self.controller.clear() self.assertEqual(len(self.controller.tracks), 0) - @populate_playlist + @populate_tracklist def test_clear_when_playing(self): self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) @@ -137,7 +137,7 @@ class TracklistControllerTest(object): self.controller.append([]) self.assertEqual(self.controller.version, version) - @populate_playlist + @populate_tracklist def test_append_preserves_playing_state(self): self.playback.play() track = self.playback.current_track @@ -145,13 +145,13 @@ class TracklistControllerTest(object): self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) - @populate_playlist + @populate_tracklist def test_append_preserves_stopped_state(self): self.controller.append(self.controller.tracks[1:2]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) - @populate_playlist + @populate_tracklist def test_append_returns_the_tl_tracks_that_was_added(self): tl_tracks = self.controller.append(self.controller.tracks[1:2]) self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) @@ -166,14 +166,14 @@ class TracklistControllerTest(object): test = lambda: self.controller.index(TlTrack(0, Track())) self.assertRaises(ValueError, test) - @populate_playlist + @populate_tracklist def test_move_single(self): self.controller.move(0, 0, 2) tracks = self.controller.tracks self.assertEqual(tracks[2], self.tracks[0]) - @populate_playlist + @populate_tracklist def test_move_group(self): self.controller.move(0, 2, 1) @@ -181,25 +181,25 @@ class TracklistControllerTest(object): self.assertEqual(tracks[1], self.tracks[0]) self.assertEqual(tracks[2], self.tracks[1]) - @populate_playlist + @populate_tracklist def test_moving_track_outside_of_playlist(self): tracks = len(self.controller.tracks) test = lambda: self.controller.move(0, 0, tracks + 5) self.assertRaises(AssertionError, test) - @populate_playlist + @populate_tracklist def test_move_group_outside_of_playlist(self): tracks = len(self.controller.tracks) test = lambda: self.controller.move(0, 2, tracks + 5) self.assertRaises(AssertionError, test) - @populate_playlist + @populate_tracklist def test_move_group_out_of_range(self): tracks = len(self.controller.tracks) test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) self.assertRaises(AssertionError, test) - @populate_playlist + @populate_tracklist def test_move_group_invalid_group(self): test = lambda: self.controller.move(2, 1, 0) self.assertRaises(AssertionError, test) @@ -209,7 +209,7 @@ class TracklistControllerTest(object): tracks2 = self.controller.tracks self.assertNotEqual(id(tracks1), id(tracks2)) - @populate_playlist + @populate_tracklist def test_remove(self): track1 = self.controller.tracks[1] track2 = self.controller.tracks[2] @@ -219,14 +219,14 @@ class TracklistControllerTest(object): self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) - @populate_playlist + @populate_tracklist def test_removing_track_that_does_not_exist_does_nothing(self): self.controller.remove(uri='/nonexistant') def test_removing_from_empty_playlist_does_nothing(self): self.controller.remove(uri='/nonexistant') - @populate_playlist + @populate_tracklist def test_shuffle(self): random.seed(1) self.controller.shuffle() @@ -236,7 +236,7 @@ class TracklistControllerTest(object): self.assertNotEqual(self.tracks, shuffled_tracks) self.assertEqual(set(self.tracks), set(shuffled_tracks)) - @populate_playlist + @populate_tracklist def test_shuffle_subset(self): random.seed(1) self.controller.shuffle(1, 3) @@ -247,18 +247,18 @@ class TracklistControllerTest(object): self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(set(self.tracks), set(shuffled_tracks)) - @populate_playlist + @populate_tracklist def test_shuffle_invalid_subset(self): test = lambda: self.controller.shuffle(3, 1) self.assertRaises(AssertionError, test) - @populate_playlist + @populate_tracklist def test_shuffle_superset(self): tracks = len(self.controller.tracks) test = lambda: self.controller.shuffle(1, tracks + 5) self.assertRaises(AssertionError, test) - @populate_playlist + @populate_tracklist def test_shuffle_open_subset(self): random.seed(1) self.controller.shuffle(1) @@ -269,14 +269,14 @@ class TracklistControllerTest(object): self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(set(self.tracks), set(shuffled_tracks)) - @populate_playlist + @populate_tracklist def test_slice_returns_a_subset_of_tracks(self): track_slice = self.controller.slice(1, 3) self.assertEqual(2, len(track_slice)) self.assertEqual(self.tracks[1], track_slice[0].track) self.assertEqual(self.tracks[2], track_slice[1].track) - @populate_playlist + @populate_tracklist def test_slice_returns_empty_list_if_indexes_outside_tracks_list(self): self.assertEqual(0, len(self.controller.slice(7, 8))) self.assertEqual(0, len(self.controller.slice(-1, 1))) From f8bd291d5f6d868bfac6ed8a7e4656ea80f0e067 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 14:26:06 +0100 Subject: [PATCH 292/323] spotify: Require pyspotify 1.9 --- docs/changes.rst | 4 ++++ mopidy/backends/spotify/__init__.py | 2 +- requirements/spotify.txt | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 requirements/spotify.txt diff --git a/docs/changes.rst b/docs/changes.rst index ffb7fbf6..ba4718df 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,6 +8,10 @@ This change log is used to track all major changes to Mopidy. v0.9.0 (in development) ======================= +**Dependencies** + +- pyspotify >= 1.9, < 1.10 is now required for Spotify support. + **Multiple backends support** Support for using the local and Spotify backends simultaneously have for a very diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index fa6feb99..141656cc 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -21,7 +21,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** - libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) -- pyspotify >= 1.8, < 1.9 (python-spotify package from apt.mopidy.com) +- pyspotify >= 1.9, < 1.10 (python-spotify package from apt.mopidy.com) **Settings:** diff --git a/requirements/spotify.txt b/requirements/spotify.txt new file mode 100644 index 00000000..c37d4674 --- /dev/null +++ b/requirements/spotify.txt @@ -0,0 +1 @@ +pyspotify >= 1.9, < 1.10 From 4b6272037180a1f12511d7e1567ee2cb84aa31a6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 14:30:25 +0100 Subject: [PATCH 293/323] settings: Tweak docstrings --- mopidy/settings.py | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 22df5d2d..2e022bc2 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -219,44 +219,33 @@ SPOTIFY_PASSWORD = '' #: SPOTIFY_BITRATE = 160 SPOTIFY_BITRATE = 160 -#: Spotify proxy host +#: Spotify proxy host. +#: +#: Used by :mod:`mopidy.backends.spotify`. #: #: Example:: #: #: SPOTIFY_PROXY_HOST = u'protocol://host:port' #: -#: Used by :mod:`mopidy.backends.spotify` -#: -#: Default :: +#: Default:: #: #: SPOTIFY_PROXY_HOST = None -#: SPOTIFY_PROXY_HOST = None -#: Spotify proxy username +#: Spotify proxy username. #: -#: Example:: +#: Used by :mod:`mopidy.backends.spotify`. #: -#: SPOTIFY_PROXY_HOST = u'username' -#: -#: Used by :mod:`mopidy.backends.spotify` -#: -#: Default :: +#: Default:: #: #: SPOTIFY_PROXY_USERNAME = None -#: SPOTIFY_PROXY_USERNAME = None -#: Spotify proxy password -#: -#: Example:: -#: -#: SPOTIFY_PROXY_HOST = u'password' +#: Spotify proxy password. #: #: Used by :mod:`mopidy.backends.spotify` #: -#: Default :: +#: Default:: #: #: SPOTIFY_PROXY_PASSWORD = None -#: SPOTIFY_PROXY_PASSWORD = None From e87f6f70b17acde2a93476e82c7c0ffb80593401 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 14:30:41 +0100 Subject: [PATCH 294/323] docs: Add Spotify proxy support to changelog --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index ba4718df..5ed689a3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -149,6 +149,9 @@ backends: This makes it possible to support lookup of artist or album URIs which then can expand to a list of tracks. +- Added support for connecting to the Spotify service through an HTTP or SOCKS + proxy, which is supported by pyspotify >= 1.9. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now From 16518697c8cecbd17e1928493576357d12da19c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 15:08:17 +0100 Subject: [PATCH 295/323] spotify: Only pause on connection error if playing Spotify has availability issues today, which makes this easy to reproduce and improve. Before this patch, the following was logged on a Spotify connection error when not playing: ERROR Spotify connection error: Can not connect to Spotify WARNING Setting GStreamer state to GST_STATE_PAUSED failed ERROR Resource not found. gstplaybin2.c(3824): setup_next_source (): /GstPlayBin2:playbin20 With this patch, only the first and relevant error message is logged. --- mopidy/backends/spotify/session_manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 00d45e19..cfe4e433 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -10,7 +10,7 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from mopidy import settings +from mopidy import audio, settings from mopidy.backends.listener import BackendListener from mopidy.utils import process, versioning @@ -92,7 +92,8 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.info('Spotify connection OK') else: logger.error('Spotify connection error: %s', error) - self.backend.playback.pause() + if self.audio.state.get() == audio.PlaybackState.PLAYING: + self.backend.playback.pause() def message_to_user(self, session, message): """Callback used by pyspotify""" From fd49faeed3ef10c4f909dd073bd1d2b8a46f641b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 15:11:29 +0100 Subject: [PATCH 296/323] spotify: Fix resume which was broken by fix for #227 --- mopidy/backends/spotify/playback.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d3585021..e4534172 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -46,10 +46,11 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def resume(self): time_position = self.get_time_position() - self._timer.resume() - - return self.seek(time_position) + self.audio.prepare_change() + result = self.seek(time_position) + self.audio.start_playback() + return result def seek(self, time_position): self.backend.spotify.session.seek(time_position) From 70d4dba7aac8ebc654cfe06ced9c2e2ffe06496d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 09:40:39 +0100 Subject: [PATCH 297/323] core: Remove playback.track_at_next --- docs/changes.rst | 3 +++ mopidy/core/playback.py | 11 -------- mopidy/frontends/mpris/objects.py | 4 +-- tests/backends/base/__init__.py | 3 ++- tests/backends/base/playback.py | 42 +++++++++++++++---------------- 5 files changed, 28 insertions(+), 35 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 5ed689a3..4bdacd78 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -149,6 +149,9 @@ backends: This makes it possible to support lookup of artist or album URIs which then can expand to a list of tracks. +- Remove :attr:`mopidy.core.PlaybackController.track_at_next`. Use + :attr:`mopidy.core.PlaybackController.tl_track_at_next` instead. + - Added support for connecting to the Spotify service through an HTTP or SOCKS proxy, which is supported by pyspotify >= 1.9. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e562a9b1..613f21ba 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -168,17 +168,6 @@ class PlaybackController(object): Not necessarily the same track as :attr:`tl_track_at_next`. """ - def get_track_at_next(self): - return self.tl_track_at_next and self.tl_track_at_next.track - - track_at_next = property(get_track_at_next) - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`tl_track_at_next` for convenience. - """ - def get_tl_track_at_next(self): tl_tracks = self.core.tracklist.tl_tracks diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 51b0d7e8..93f14a2d 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -419,8 +419,8 @@ class MprisObject(dbus.service.Object): if not self.get_CanControl(): return False return ( - self.core.playback.current_track.get() is not None or - self.core.playback.track_at_next.get() is not None) + self.core.playback.current_tl_track.get() is not None or + self.core.playback.tl_track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index c415ef23..477f8cc1 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -3,8 +3,9 @@ from __future__ import unicode_literals def populate_tracklist(func): def wrapper(self): + self.tl_tracks = [] for track in self.tracks: - self.core.tracklist.add(track) + self.tl_tracks.append(self.core.tracklist.add(track)) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 94fc7759..619efbb2 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -234,29 +234,29 @@ class PlaybackControllerTest(object): @populate_tracklist def test_next_track_before_play(self): - self.assertEqual(self.playback.track_at_next, self.tracks[0]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_next_track_during_play(self): self.playback.play() - self.assertEqual(self.playback.track_at_next, self.tracks[1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_next_track_after_previous(self): self.playback.play() self.playback.next() self.playback.previous() - self.assertEqual(self.playback.track_at_next, self.tracks[1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) def test_next_track_empty_playlist(self): - self.assertEqual(self.playback.track_at_next, None) + self.assertEqual(self.playback.tl_track_at_next, None) @populate_tracklist def test_next_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.next() - self.assertEqual(self.playback.track_at_next, None) + self.assertEqual(self.playback.tl_track_at_next, None) @populate_tracklist def test_next_track_at_end_of_playlist_with_repeat(self): @@ -264,13 +264,13 @@ class PlaybackControllerTest(object): self.playback.play() for _ in self.tracks[1:]: self.playback.next() - self.assertEqual(self.playback.track_at_next, self.tracks[0]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_next_track_with_random(self): random.seed(1) self.playback.random = True - self.assertEqual(self.playback.track_at_next, self.tracks[2]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) @populate_tracklist def test_next_with_consume(self): @@ -300,9 +300,9 @@ class PlaybackControllerTest(object): def test_next_track_with_random_after_append_playlist(self): random.seed(1) self.playback.random = True - self.assertEqual(self.playback.track_at_next, self.tracks[2]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) self.tracklist.append(self.tracks[:1]) - self.assertEqual(self.playback.track_at_next, self.tracks[1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_end_of_track(self): @@ -370,29 +370,29 @@ class PlaybackControllerTest(object): @populate_tracklist def test_end_of_track_track_before_play(self): - self.assertEqual(self.playback.track_at_next, self.tracks[0]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_during_play(self): self.playback.play() - self.assertEqual(self.playback.track_at_next, self.tracks[1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_end_of_track_track_after_previous(self): self.playback.play() self.playback.on_end_of_track() self.playback.previous() - self.assertEqual(self.playback.track_at_next, self.tracks[1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) def test_end_of_track_track_empty_playlist(self): - self.assertEqual(self.playback.track_at_next, None) + self.assertEqual(self.playback.tl_track_at_next, None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist(self): self.playback.play() for _ in self.tracklist.tl_tracks[1:]: self.playback.on_end_of_track() - self.assertEqual(self.playback.track_at_next, None) + self.assertEqual(self.playback.tl_track_at_next, None) @populate_tracklist def test_end_of_track_track_at_end_of_playlist_with_repeat(self): @@ -400,13 +400,13 @@ class PlaybackControllerTest(object): self.playback.play() for _ in self.tracks[1:]: self.playback.on_end_of_track() - self.assertEqual(self.playback.track_at_next, self.tracks[0]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[0]) @populate_tracklist def test_end_of_track_track_with_random(self): random.seed(1) self.playback.random = True - self.assertEqual(self.playback.track_at_next, self.tracks[2]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) @populate_tracklist def test_end_of_track_with_consume(self): @@ -428,9 +428,9 @@ class PlaybackControllerTest(object): def test_end_of_track_track_with_random_after_append_playlist(self): random.seed(1) self.playback.random = True - self.assertEqual(self.playback.track_at_next, self.tracks[2]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) self.tracklist.append(self.tracks[:1]) - self.assertEqual(self.playback.track_at_next, self.tracks[1]) + self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist def test_previous_track_before_play(self): @@ -830,14 +830,14 @@ class PlaybackControllerTest(object): self.playback.play() for _ in self.tracks[1:]: self.playback.next() - self.assertEqual(self.playback.track_at_next, None) + self.assertEqual(self.playback.tl_track_at_next, None) @populate_tracklist def test_random_until_end_of_playlist_and_play_from_start(self): self.playback.repeat = True for _ in self.tracks: self.playback.next() - self.assertNotEqual(self.playback.track_at_next, None) + self.assertNotEqual(self.playback.tl_track_at_next, None) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() self.assertEqual(self.playback.state, PlaybackState.PLAYING) @@ -849,7 +849,7 @@ class PlaybackControllerTest(object): self.playback.play() for _ in self.tracks: self.playback.next() - self.assertNotEqual(self.playback.track_at_next, None) + self.assertNotEqual(self.playback.tl_track_at_next, None) @populate_tracklist def test_played_track_during_random_not_played_again(self): From 4c19321500bebb67eba3dec9639482345b90c732 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 09:41:46 +0100 Subject: [PATCH 298/323] core: Remove playback.track_at_eot --- docs/changes.rst | 3 +++ mopidy/core/playback.py | 11 ----------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 4bdacd78..a3891424 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -152,6 +152,9 @@ backends: - Remove :attr:`mopidy.core.PlaybackController.track_at_next`. Use :attr:`mopidy.core.PlaybackController.tl_track_at_next` instead. +- Remove :attr:`mopidy.core.PlaybackController.track_at_eot`. Use + :attr:`mopidy.core.PlaybackController.tl_track_at_eot` instead. + - Added support for connecting to the Spotify service through an HTTP or SOCKS proxy, which is supported by pyspotify >= 1.9. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 613f21ba..cab2c392 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -115,17 +115,6 @@ class PlaybackController(object): Read-only. """ - def get_track_at_eot(self): - return self.tl_track_at_eot and self.tl_track_at_eot.track - - track_at_eot = property(get_track_at_eot) - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`tl_track_at_eot` for convenience. - """ - def get_tl_track_at_eot(self): # pylint: disable = R0911 # Too many return statements From 2f2716767791cd21e51570b1d5a3d19115180419 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 09:44:09 +0100 Subject: [PATCH 299/323] core: Remove playback.track_at_previous --- docs/changes.rst | 3 +++ mopidy/core/playback.py | 11 ----------- tests/backends/base/playback.py | 16 +++++++++------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a3891424..77c418bf 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -149,6 +149,9 @@ backends: This makes it possible to support lookup of artist or album URIs which then can expand to a list of tracks. +- Remove :attr:`mopidy.core.PlaybackController.track_at_previous`. Use + :attr:`mopidy.core.PlaybackController.tl_track_at_previous` instead. + - Remove :attr:`mopidy.core.PlaybackController.track_at_next`. Use :attr:`mopidy.core.PlaybackController.tl_track_at_next` instead. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index cab2c392..901c7e34 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -196,17 +196,6 @@ class PlaybackController(object): before the list repeats. """ - def get_track_at_previous(self): - return self.tl_track_at_previous and self.tl_track_at_previous.track - - track_at_previous = property(get_track_at_previous) - """ - The track that will be played if calling :meth:`previous()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`tl_track_at_previous` for convenience. - """ - def get_tl_track_at_previous(self): if self.repeat or self.consume or self.random: return self.current_tl_track diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 619efbb2..fffe09da 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -434,18 +434,18 @@ class PlaybackControllerTest(object): @populate_tracklist def test_previous_track_before_play(self): - self.assertEqual(self.playback.track_at_previous, None) + self.assertEqual(self.playback.tl_track_at_previous, None) @populate_tracklist def test_previous_track_after_play(self): self.playback.play() - self.assertEqual(self.playback.track_at_previous, None) + self.assertEqual(self.playback.tl_track_at_previous, None) @populate_tracklist def test_previous_track_after_next(self): self.playback.play() self.playback.next() - self.assertEqual(self.playback.track_at_previous, self.tracks[0]) + self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) @populate_tracklist def test_previous_track_after_previous(self): @@ -453,10 +453,10 @@ class PlaybackControllerTest(object): self.playback.next() # At track 1 self.playback.next() # At track 2 self.playback.previous() # At track 1 - self.assertEqual(self.playback.track_at_previous, self.tracks[0]) + self.assertEqual(self.playback.tl_track_at_previous, self.tl_tracks[0]) def test_previous_track_empty_playlist(self): - self.assertEqual(self.playback.track_at_previous, None) + self.assertEqual(self.playback.tl_track_at_previous, None) @populate_tracklist def test_previous_track_with_consume(self): @@ -464,7 +464,8 @@ class PlaybackControllerTest(object): for _ in self.tracks: self.playback.next() self.assertEqual( - self.playback.track_at_previous, self.playback.current_track) + self.playback.tl_track_at_previous, + self.playback.current_tl_track) @populate_tracklist def test_previous_track_with_random(self): @@ -472,7 +473,8 @@ class PlaybackControllerTest(object): for _ in self.tracks: self.playback.next() self.assertEqual( - self.playback.track_at_previous, self.playback.current_track) + self.playback.tl_track_at_previous, + self.playback.current_tl_track) @populate_tracklist def test_initial_current_track(self): From d107b13fcbb16b2fb7fc0210f1607d6a1abad222 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 10:09:46 +0100 Subject: [PATCH 300/323] core: Remove playback.current_tlid --- docs/changes.rst | 3 +++ mopidy/core/playback.py | 11 ----------- mopidy/frontends/mpd/protocol/current_playlist.py | 3 ++- mopidy/frontends/mpd/protocol/playback.py | 9 +++++---- tests/frontends/mpd/protocol/playback_test.py | 2 +- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 77c418bf..d664872b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -158,6 +158,9 @@ backends: - Remove :attr:`mopidy.core.PlaybackController.track_at_eot`. Use :attr:`mopidy.core.PlaybackController.tl_track_at_eot` instead. +- Remove :attr:`mopidy.core.PlaybackController.current_tlid`. Use + :attr:`mopidy.core.PlaybackController.current_tl_track` instead. + - Added support for connecting to the Spotify service through an HTTP or SOCKS proxy, which is supported by pyspotify >= 1.9. diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 901c7e34..94fd7d4e 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -79,17 +79,6 @@ class PlaybackController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) - def get_current_tlid(self): - return self.current_tl_track and self.current_tl_track.tlid - - current_tlid = property(get_current_tlid) - """ - The TLID (tracklist ID) of the currently playing or selected - track. - - Read-only. Extracted from :attr:`current_tl_track` for convenience. - """ - def get_current_track(self): return self.current_tl_track and self.current_tl_track.track diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index da950078..69e04d4b 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -110,7 +110,8 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ tlid = int(tlid) - if context.core.playback.current_tlid.get() == tlid: + tl_track = context.core.playback.current_tl_track.get() + if tl_track and tl_track.tlid == tlid: context.core.playback.next() tl_tracks = context.core.tracklist.remove(tlid=tlid).get() if not tl_tracks: diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index d166f982..5a4569e1 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -329,9 +329,9 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.core.playback.tracklist_position != songpos: + if context.core.playback.tracklist_position.get() != songpos: playpos(context, songpos) - context.core.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000).get() @handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') @@ -343,9 +343,10 @@ def seekid(context, tlid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - if context.core.playback.current_tlid != tlid: + tl_track = context.core.playback.current_tl_track.get() + if not tl_track or tl_track.tlid != tlid: playid(context, tlid) - context.core.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000).get() @handle_request(r'^setvol (?P[-+]*\d+)$') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index f81be241..9bf467f5 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -424,7 +424,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest('seekid "1" "30"') - self.assertEqual(1, self.core.playback.current_tlid.get()) + 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 efe7247407ce25ebff0b180f889a422f275373ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 11:51:59 +0100 Subject: [PATCH 301/323] core: Merge functionality of tracklist.append into tracklist.add --- mopidy/core/tracklist.py | 63 ++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 05d551fe..57c9de63 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import logging import random -from mopidy.models import TlTrack +from mopidy.models import TlTrack, Track from . import listener @@ -62,9 +62,17 @@ class TracklistController(object): Is not reset before Mopidy is restarted. """ - def add(self, track, at_position=None, increase_version=True): + def add(self, tracks, at_position=None): """ - Add the track to the end of, or at the given position in the tracklist. + Add the track or list of tracks to the tracklist. + + If ``at_position`` is given, the tracks placed at the given position in + the tracklist. If ``at_position`` is not given, the tracks are appended + to the end of the tracklist. + + If ``tracks`` is a track object, a single + :class:`mopidy.models.TlTrack` object is returned. If ``tracks`` is a + list, a list of :class:`mopidy.models.TlTrack` is returned. Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. @@ -72,40 +80,39 @@ class TracklistController(object): :type track: :class:`mopidy.models.Track` :param at_position: position in tracklist to add track :type at_position: int or :class:`None` - :param increase_version: if the tracklist version should be increased - :type increase_version: :class:`True` or :class:`False` - :rtype: :class:`mopidy.models.TlTrack` that was added to the tracklist + :rtype: a single or a list of :class:`mopidy.models.TlTrack` """ - assert at_position <= len(self._tl_tracks), \ + assert at_position is None or at_position <= len(self._tl_tracks), \ 'at_position can not be greater than tracklist length' - tl_track = TlTrack(self._next_tlid, track) - if at_position is not None: - self._tl_tracks.insert(at_position, tl_track) - else: - self._tl_tracks.append(tl_track) - if increase_version: - self._increase_version() - self._next_tlid += 1 - return tl_track - def append(self, tracks): - """ - Append the given tracks to the tracklist. + single_add = False + if isinstance(tracks, Track): + tracks = [tracks] + single_add = True - Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. - - :param tracks: tracks to append - :type tracks: list of :class:`mopidy.models.Track` - :rtype: list of :class:`mopidy.models.TlTrack` - """ tl_tracks = [] for track in tracks: - tl_tracks.append(self.add(track, increase_version=False)) + tl_track = TlTrack(self._next_tlid, track) + self._next_tlid += 1 + if at_position is not None: + self._tl_tracks.insert(at_position, tl_track) + at_position += 1 + else: + self._tl_tracks.append(tl_track) + tl_tracks.append(tl_track) - if tracks: + if tl_tracks: self._increase_version() - return tl_tracks + if single_add: + return tl_tracks[0] + else: + return tl_tracks + + append = add + """ + Alias for :meth:`add`. + """ def clear(self): """ From 70dbf81191ee4a1f8c9136f1d5f1ed64f048855e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 11:53:08 +0100 Subject: [PATCH 302/323] mpd: Simplify 'addid' implementation using improved tracklist.add() --- mopidy/frontends/mpd/protocol/current_playlist.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 69e04d4b..81df2827 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -57,14 +57,8 @@ def addid(context, uri, songpos=None): raise MpdNoExistError('No such song', command='addid') if songpos and songpos > context.core.tracklist.length.get(): raise MpdArgError('Bad song index', command='addid') - first_tl_track = None - for track in tracks: - tl_track = context.core.tracklist.add(track, at_position=songpos).get() - if songpos is not None: - songpos += 1 - if first_tl_track is None: - first_tl_track = tl_track - return ('Id', first_tl_track.tlid) + tl_tracks = context.core.tracklist.add(tracks, at_position=songpos).get() + return ('Id', tl_tracks[0].tlid) @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') From 1ed56c9ed71f52db267c97979194eaee5889cb43 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 12:14:47 +0100 Subject: [PATCH 303/323] Use tracklist.add() instead of tracklist.append() --- .../mpd/protocol/current_playlist.py | 4 +- .../mpd/protocol/stored_playlists.py | 2 +- mopidy/frontends/mpris/objects.py | 4 +- tests/backends/base/playback.py | 12 +- tests/backends/base/tracklist.py | 42 +++--- tests/core/events_test.py | 13 +- tests/core/playback_test.py | 2 +- .../mpd/protocol/current_playlist_test.py | 75 +++++----- tests/frontends/mpd/protocol/playback_test.py | 60 +++----- .../frontends/mpd/protocol/regression_test.py | 8 +- tests/frontends/mpd/protocol/status_test.py | 2 +- .../mpd/protocol/stored_playlists_test.py | 2 +- tests/frontends/mpd/status_test.py | 14 +- .../frontends/mpris/player_interface_test.py | 139 +++++++----------- .../mpris/playlists_interface_test.py | 2 +- 15 files changed, 165 insertions(+), 216 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 81df2827..fbc92b46 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -24,7 +24,7 @@ def add(context, uri): return tracks = context.core.library.lookup(uri).get() if tracks: - context.core.tracklist.append(tracks) + context.core.tracklist.add(tracks) return raise MpdNoExistError('directory or file not found', command='add') @@ -371,7 +371,7 @@ def swap(context, songpos1, songpos2): del tracks[songpos2] tracks.insert(songpos2, song1) context.core.tracklist.clear() - context.core.tracklist.append(tracks) + context.core.tracklist.add(tracks) @handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index d5d6b2a6..de2b267e 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -101,7 +101,7 @@ def load(context, name): playlists = context.core.playlists.filter(name=name).get() if not playlists: raise MpdNoExistError('No such playlist', command='load') - context.core.tracklist.append(playlists[0].tracks) + context.core.tracklist.add(playlists[0].tracks) @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 93f14a2d..15ef9383 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -281,7 +281,7 @@ class MprisObject(dbus.service.Object): # is added to the backend. tracks = self.core.library.lookup(uri).get() if tracks: - tl_tracks = self.core.tracklist.append(tracks).get() + tl_tracks = self.core.tracklist.add(tracks).get() self.core.playback.play(tl_tracks[0]) else: logger.debug('Track with URI "%s" not found in library.', uri) @@ -449,7 +449,7 @@ class MprisObject(dbus.service.Object): playlist_uri = self.get_playlist_uri(playlist_id) playlist = self.core.playlists.lookup(playlist_uri).get() if playlist and playlist.tracks: - tl_tracks = self.core.tracklist.append(playlist.tracks).get() + tl_tracks = self.core.tracklist.add(playlist.tracks).get() self.core.playback.play(tl_tracks[0]) @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index fffe09da..09dffbab 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -301,7 +301,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - self.tracklist.append(self.tracks[:1]) + self.tracklist.add(self.tracks[:1]) self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist @@ -429,7 +429,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[2]) - self.tracklist.append(self.tracks[:1]) + self.tracklist.add(self.tracks[:1]) self.assertEqual(self.playback.tl_track_at_next, self.tl_tracks[1]) @populate_tracklist @@ -521,7 +521,7 @@ class PlaybackControllerTest(object): wrapper.called = False self.playback.on_tracklist_change = wrapper - self.tracklist.append([Track()]) + self.tracklist.add([Track()]) self.assert_(wrapper.called) @@ -538,13 +538,13 @@ class PlaybackControllerTest(object): def test_on_tracklist_change_when_playing(self): self.playback.play() current_track = self.playback.current_track - self.tracklist.append([self.tracks[2]]) + self.tracklist.add([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_tracklist def test_on_tracklist_change_when_stopped(self): - self.tracklist.append([self.tracks[2]]) + self.tracklist.add([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -553,7 +553,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() current_track = self.playback.current_track - self.tracklist.append([self.tracks[2]]) + self.tracklist.add([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 52ddfa46..53b3288a 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -30,7 +30,7 @@ class TracklistControllerTest(object): def test_length(self): self.assertEqual(0, len(self.controller.tl_tracks)) self.assertEqual(0, self.controller.length) - self.controller.append(self.tracks) + self.controller.add(self.tracks) self.assertEqual(3, len(self.controller.tl_tracks)) self.assertEqual(3, self.controller.length) @@ -72,12 +72,12 @@ class TracklistControllerTest(object): def test_filter_by_uri_returns_single_match(self): track = Track(uri='a') - self.controller.append([Track(uri='z'), track, Track(uri='y')]) + self.controller.add([Track(uri='z'), track, Track(uri='y')]) self.assertEqual(track, self.controller.filter(uri='a')[0].track) def test_filter_by_uri_returns_multiple_matches(self): track = Track(uri='a') - self.controller.append([Track(uri='z'), track, track]) + self.controller.add([Track(uri='z'), track, track]) tl_tracks = self.controller.filter(uri='a') self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[1].track) @@ -91,7 +91,7 @@ class TracklistControllerTest(object): track1 = Track(uri='a', name='x') track2 = Track(uri='b', name='x') track3 = Track(uri='b', name='y') - self.controller.append([track1, track2, track3]) + self.controller.add([track1, track2, track3]) self.assertEqual( track1, self.controller.filter(uri='a', name='x')[0].track) self.assertEqual( @@ -103,7 +103,7 @@ class TracklistControllerTest(object): track1 = Track() track2 = Track(uri='b') track3 = Track() - self.controller.append([track1, track2, track3]) + self.controller.add([track1, track2, track3]) self.assertEqual(track2, self.controller.filter(uri='b')[0].track) @populate_tracklist @@ -122,42 +122,42 @@ class TracklistControllerTest(object): self.controller.clear() self.assertEqual(self.playback.state, PlaybackState.STOPPED) - def test_append_appends_to_the_tracklist(self): - self.controller.append([Track(uri='a'), Track(uri='b')]) + def test_add_appends_to_the_tracklist(self): + self.controller.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.controller.tracks), 2) - self.controller.append([Track(uri='c'), Track(uri='d')]) + self.controller.add([Track(uri='c'), Track(uri='d')]) self.assertEqual(len(self.controller.tracks), 4) self.assertEqual(self.controller.tracks[0].uri, 'a') self.assertEqual(self.controller.tracks[1].uri, 'b') self.assertEqual(self.controller.tracks[2].uri, 'c') self.assertEqual(self.controller.tracks[3].uri, 'd') - def test_append_does_not_reset_version(self): + def test_add_does_not_reset_version(self): version = self.controller.version - self.controller.append([]) + self.controller.add([]) self.assertEqual(self.controller.version, version) @populate_tracklist - def test_append_preserves_playing_state(self): + def test_add_preserves_playing_state(self): self.playback.play() track = self.playback.current_track - self.controller.append(self.controller.tracks[1:2]) + self.controller.add(self.controller.tracks[1:2]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) @populate_tracklist - def test_append_preserves_stopped_state(self): - self.controller.append(self.controller.tracks[1:2]) + def test_add_preserves_stopped_state(self): + self.controller.add(self.controller.tracks[1:2]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_tracklist - def test_append_returns_the_tl_tracks_that_was_added(self): - tl_tracks = self.controller.append(self.controller.tracks[1:2]) + def test_add_returns_the_tl_tracks_that_was_added(self): + tl_tracks = self.controller.add(self.controller.tracks[1:2]) self.assertEqual(tl_tracks[0].track, self.controller.tracks[1]) def test_index_returns_index_of_track(self): - tl_tracks = self.controller.append(self.tracks) + tl_tracks = self.controller.add(self.tracks) self.assertEquals(0, self.controller.index(tl_tracks[0])) self.assertEquals(1, self.controller.index(tl_tracks[1])) self.assertEquals(2, self.controller.index(tl_tracks[2])) @@ -281,12 +281,12 @@ class TracklistControllerTest(object): self.assertEqual(0, len(self.controller.slice(7, 8))) self.assertEqual(0, len(self.controller.slice(-1, 1))) - def test_version_does_not_change_when_appending_nothing(self): + def test_version_does_not_change_when_adding_nothing(self): version = self.controller.version - self.controller.append([]) + self.controller.add([]) self.assertEquals(version, self.controller.version) - def test_version_increases_when_appending_something(self): + def test_version_increases_when_adding_something(self): version = self.controller.version - self.controller.append([Track()]) + self.controller.add([Track()]) self.assertLess(version, self.controller.version) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 8f969b0d..b0ae2081 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -65,32 +65,27 @@ class BackendEventsTest(unittest.TestCase): self.core.tracklist.add(Track(uri='dummy:a')).get() self.assertEqual(send.call_args[0][0], 'tracklist_changed') - def test_tracklist_append_sends_tracklist_changed_event(self, send): - send.reset_mock() - self.core.tracklist.append([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.append([Track(uri='dummy:a')]).get() + 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.append( + 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.append([Track(uri='dummy:a')]).get() + 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.append( + self.core.tracklist.add( [Track(uri='dummy:a'), Track(uri='dummy:b')]).get() send.reset_mock() self.core.tracklist.shuffle().get() diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py index bb3d359f..ffbca506 100644 --- a/tests/core/playback_test.py +++ b/tests/core/playback_test.py @@ -35,7 +35,7 @@ class CorePlaybackTest(unittest.TestCase): self.core = Core(audio=None, backends=[ self.backend1, self.backend2, self.backend3]) - self.core.tracklist.append(self.tracks) + self.core.tracklist.add(self.tracks) self.tl_tracks = self.core.tracklist.tl_tracks self.unplayable_tl_track = self.tl_tracks[2] diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index dd1ba57e..fc4640b1 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -10,7 +10,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -33,7 +33,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -52,7 +52,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -67,7 +67,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): needle = Track(uri='dummy://foo') self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -79,7 +79,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {addid} No such song') def test_clear(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -89,7 +89,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_delete_songpos(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -99,7 +99,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_delete_songpos_out_of_bounds(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -108,7 +108,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -117,7 +117,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_delete_closed_range(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -126,7 +126,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_delete_range_out_of_bounds(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 5) @@ -135,7 +135,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.core.tracklist.append([Track(), Track()]) + self.core.tracklist.add([Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.sendRequest('deleteid "1"') @@ -143,7 +143,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_deleteid_does_not_exist(self): - self.core.tracklist.append([Track(), Track()]) + self.core.tracklist.add([Track(), Track()]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.sendRequest('deleteid "12345"') @@ -151,7 +151,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -167,7 +167,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_move_open_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -183,7 +183,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_move_closed_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -199,7 +199,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_moveid(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -237,8 +237,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('OK') def test_playlistfind_by_filename_in_tracklist(self): - self.core.tracklist.append([ - Track(uri='file:///exists')]) + self.core.tracklist.add([Track(uri='file:///exists')]) self.sendRequest('playlistfind filename "file:///exists"') self.assertInResponse('file: file:///exists') @@ -247,7 +246,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_without_songid(self): - self.core.tracklist.append([Track(name='a'), Track(name='b')]) + self.core.tracklist.add([Track(name='a'), Track(name='b')]) self.sendRequest('playlistid') self.assertInResponse('Title: a') @@ -255,7 +254,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_with_songid(self): - self.core.tracklist.append([Track(name='a'), Track(name='b')]) + self.core.tracklist.add([Track(name='a'), Track(name='b')]) self.sendRequest('playlistid "1"') self.assertNotInResponse('Title: a') @@ -265,13 +264,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistid_with_not_existing_songid_fails(self): - self.core.tracklist.append([Track(name='a'), Track(name='b')]) + self.core.tracklist.add([Track(name='a'), Track(name='b')]) self.sendRequest('playlistid "25"') self.assertEqualResponse('ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -294,7 +293,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position self.core.tracklist.tlid = 17 - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -320,7 +319,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -341,7 +340,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playlistinfo_with_closed_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -372,7 +371,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_plchanges_with_lower_version_returns_changes(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest('plchanges "0"') @@ -382,7 +381,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_equal_version_returns_nothing(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.tracklist.version.get(), 1) @@ -393,7 +392,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_greater_version_returns_nothing(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) self.assertEqual(self.core.tracklist.version.get(), 1) @@ -404,7 +403,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest('plchanges "-1"') @@ -414,7 +413,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchanges_without_quotes_works(self): - self.core.tracklist.append( + self.core.tracklist.add( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest('plchanges 0') @@ -424,7 +423,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_plchangesposid(self): - self.core.tracklist.append([Track(), Track(), Track()]) + self.core.tracklist.add([Track(), Track(), Track()]) self.sendRequest('plchangesposid "0"') tl_tracks = self.core.tracklist.tl_tracks.get() @@ -437,7 +436,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_shuffle_without_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -448,7 +447,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_shuffle_with_open_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -464,7 +463,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_shuffle_with_closed_range(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -480,7 +479,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_swap(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -496,7 +495,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_swapid(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -512,13 +511,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_swapid_with_first_id_unknown_should_ack(self): - self.core.tracklist.append([Track()]) + self.core.tracklist.add([Track()]) self.sendRequest('swapid "0" "4"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') def test_swapid_with_second_id_unknown_should_ack(self): - self.core.tracklist.append([Track()]) + self.core.tracklist.add([Track()]) self.sendRequest('swapid "4" "0"') self.assertEqualResponse( 'ACK [50@0] {swapid} No such song') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 9bf467f5..14168a35 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -168,7 +168,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_off(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.sendRequest('pause "1"') @@ -177,7 +177,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_on(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.sendRequest('pause "1"') @@ -185,7 +185,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_pause_toggle(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -200,28 +200,28 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_without_pos(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('play') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('play "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_without_quotes(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('play 0') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_play_with_pos_out_of_bounds(self): - self.core.tracklist.append([]) + self.core.tracklist.add([]) self.sendRequest('play "0"') self.assertEqual(STOPPED, self.core.playback.state.get()) @@ -229,10 +229,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.tracklist.append([ - Track(uri='dummy:a'), - Track(uri='dummy:b'), - ]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.sendRequest('play "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -241,10 +238,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), - Track(uri='dummy:b'), - ]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -266,8 +260,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_is_ignored_if_playing(self): - self.core.tracklist.append([ - Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -280,8 +273,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_play_minus_one_resumes_if_paused(self): - self.core.tracklist.append([ - Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -296,14 +288,14 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('playid "0"') self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse('OK') def test_playid_without_quotes(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('playid 0') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -311,10 +303,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.core.playback.current_track.get(), None) - self.core.tracklist.append([ - Track(uri='dummy:a'), - Track(uri='dummy:b'), - ]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.sendRequest('playid "-1"') self.assertEqual(PLAYING, self.core.playback.state.get()) @@ -323,10 +312,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), - Track(uri='dummy:b'), - ]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.current_track.get(), None) self.core.playback.play() self.core.playback.next() @@ -348,7 +334,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_is_ignored_if_playing(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -361,7 +347,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_minus_one_resumes_if_paused(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.seek(30000) self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -376,7 +362,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_playid_which_does_not_exist(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.sendRequest('playid "12345"') self.assertInResponse('ACK [50@0] {playid} No such song') @@ -386,7 +372,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seek(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.sendRequest('seek "0"') self.sendRequest('seek "0" "30"') @@ -395,7 +381,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_seek_with_songpos(self): seek_track = Track(uri='dummy:b', length=40000) - self.core.tracklist.append( + self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest('seek "1" "30"') @@ -403,7 +389,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seek_without_quotes(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.sendRequest('seek 0') self.sendRequest('seek 0 30') @@ -412,7 +398,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_seekid(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.sendRequest('seekid "0" "30"') self.assertGreaterEqual( self.core.playback.time_position.get(), 30000) @@ -420,7 +406,7 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): def test_seekid_with_tlid(self): seek_track = Track(uri='dummy:b', length=40000) - self.core.tracklist.append( + self.core.tracklist.add( [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest('seekid "1" "30"') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 6b8832e4..0bc488fd 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -18,7 +18,7 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Press next until you get to the unplayable track """ def test(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:error'), @@ -59,7 +59,7 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) @@ -95,7 +95,7 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) @@ -124,7 +124,7 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): def test(self): self.core.playlists.create('foo') - self.core.tracklist.append([ + self.core.tracklist.add([ Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index ef3cf7b2..24f24ab2 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -12,7 +12,7 @@ class StatusHandlerTest(protocol.BaseTestCase): def test_currentsong(self): track = Track() - self.core.tracklist.append([track]) + self.core.tracklist.add([track]) self.core.playback.play() self.sendRequest('currentsong') self.assertInResponse('file: ') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 6bac95e5..cf1b8cd0 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -65,7 +65,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') def test_load_known_playlist_appends_to_tracklist(self): - self.core.tracklist.append([Track(uri='a'), Track(uri='b')]) + 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=[ diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 6afa5541..d508cbf0 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -131,21 +131,21 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.core.tracklist.append([Track(uri='dummy:a', length=None)]) + self.core.tracklist.add([Track(uri='dummy:a', length=None)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -155,7 +155,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.core.tracklist.append([Track(uri='dummy:a', length=10000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=10000)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -165,7 +165,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.core.tracklist.append([Track(uri='dummy:a', length=60000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=60000)]) self.core.playback.play() self.core.playback.pause() self.core.playback.seek(59123) @@ -174,7 +174,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.core.tracklist.append([Track(uri='dummy:a', length=10000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=10000)]) self.core.playback.play() self.core.playback.pause() result = dict(status.status(self.context)) @@ -182,7 +182,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.core.tracklist.append([Track(uri='dummy:a', bitrate=320)]) + self.core.tracklist.add([Track(uri='dummy:a', bitrate=320)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 39b77093..c48ffa98 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -101,16 +101,14 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) self.assertEqual(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) @@ -150,7 +148,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_tlid(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play() (tlid, track) = self.core.playback.current_tl_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -159,28 +157,28 @@ class PlayerInterfaceTest(unittest.TestCase): result['mpris:trackid'], '/com/mopidy/track/%d' % tlid) def test_get_metadata_has_track_length(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) self.assertEqual(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) self.assertEqual(result['xesam:url'], 'dummy:a') def test_get_metadata_has_track_title(self): - self.core.tracklist.append([Track(name='a')]) + self.core.tracklist.add([Track(name='a')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) self.assertEqual(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): - self.core.tracklist.append([Track(artists=[ + self.core.tracklist.add([Track(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)])]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -188,14 +186,14 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): - self.core.tracklist.append([Track(album=Album(name='a'))]) + self.core.tracklist.add([Track(album=Album(name='a'))]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) self.assertEqual(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): - self.core.tracklist.append([Track(album=Album(artists=[ + self.core.tracklist.add([Track(album=Album(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') @@ -203,7 +201,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): - self.core.tracklist.append([Track(track_no=7)]) + self.core.tracklist.add([Track(track_no=7)]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) @@ -246,7 +244,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(10000) result_in_microseconds = self.mpris.Get( @@ -270,15 +268,14 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertTrue(result) def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') @@ -286,16 +283,14 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -303,7 +298,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.repeat = True self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -311,8 +306,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') @@ -320,7 +314,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.core.tracklist.append([Track(uri='dummy:a')]) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play() self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') @@ -363,16 +357,14 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.Next() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_next_when_playing_skips_to_next_track_and_keep_playing(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -381,8 +373,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') @@ -391,8 +382,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') @@ -402,8 +392,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.stop() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') @@ -414,8 +403,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') @@ -423,8 +411,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') @@ -434,8 +421,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.assertEqual(self.core.playback.state.get(), PLAYING) @@ -443,8 +429,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_when_paused_skips_to_previous_track_and_pause(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.core.playback.pause() @@ -455,8 +440,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.state.get(), PAUSED) def test_previous_when_stopped_skips_to_previous_track_and_stops(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.next() self.core.playback.stop() @@ -468,24 +452,21 @@ class PlayerInterfaceTest(unittest.TestCase): def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() self.assertEqual(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() self.assertEqual(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() self.assertEqual(self.core.playback.state.get(), PAUSED) @@ -494,24 +475,21 @@ class PlayerInterfaceTest(unittest.TestCase): def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() self.assertEqual(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() @@ -526,32 +504,28 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() self.assertEqual(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() self.assertEqual(self.core.playback.state.get(), PAUSED) @@ -560,21 +534,19 @@ class PlayerInterfaceTest(unittest.TestCase): def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() self.assertEqual(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_pause = self.core.playback.time_position.get() @@ -598,7 +570,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -614,7 +586,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_seek = self.core.playback.time_position.get() @@ -631,7 +603,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -650,7 +622,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -670,7 +642,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertGreaterEqual(after_seek, 0) def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(uri='dummy:a', length=40000), Track(uri='dummy:b')]) self.core.playback.play() @@ -695,7 +667,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() @@ -713,7 +685,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() before_set_position = self.core.playback.time_position.get() @@ -734,7 +706,7 @@ class PlayerInterfaceTest(unittest.TestCase): after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -757,7 +729,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_does_nothing_if_position_is_gt_track_length(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -780,7 +752,7 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_set_position_is_noop_if_track_id_isnt_current_track(self): - self.core.tracklist.append([Track(uri='dummy:a', length=40000)]) + self.core.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play() self.core.playback.seek(20000) @@ -826,8 +798,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.OpenUri('dummy:/test/uri') @@ -839,8 +810,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.core.playback.pause() self.assertEqual(self.core.playback.state.get(), PAUSED) @@ -855,8 +825,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True self.backend.library.dummy_library = [Track(uri='dummy:/test/uri')] - self.core.tracklist.append([ - Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.tracklist.add([Track(uri='dummy:a'), Track(uri='dummy:b')]) self.core.playback.play() self.assertEqual(self.core.playback.state.get(), PLAYING) self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py index 21038d4b..2adffaf3 100644 --- a/tests/frontends/mpris/playlists_interface_test.py +++ b/tests/frontends/mpris/playlists_interface_test.py @@ -44,7 +44,7 @@ class PlayerInterfaceTest(unittest.TestCase): pykka.ActorRegistry.stop_all() def test_activate_playlist_appends_tracks_to_tracklist(self): - self.core.tracklist.append([ + self.core.tracklist.add([ Track(uri='dummy:old-a'), Track(uri='dummy:old-b'), ]) From 3dc15862130c9c568a807d369af916e97d967380 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 12:21:43 +0100 Subject: [PATCH 304/323] core: Remove tracklist.append() --- docs/changes.rst | 3 +++ mopidy/core/tracklist.py | 5 ----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index d664872b..f1eebad9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -161,6 +161,9 @@ backends: - Remove :attr:`mopidy.core.PlaybackController.current_tlid`. Use :attr:`mopidy.core.PlaybackController.current_tl_track` instead. +- Remove :meth:`mopidy.core.TracklistController.append`. Use + :meth:`mopidy.core.TracklistController.add` instead. + - Added support for connecting to the Spotify service through an HTTP or SOCKS proxy, which is supported by pyspotify >= 1.9. diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 57c9de63..a5ea6c11 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -109,11 +109,6 @@ class TracklistController(object): else: return tl_tracks - append = add - """ - Alias for :meth:`add`. - """ - def clear(self): """ Clear the tracklist. From ae9a25709173ba9512ff25569ece74f41de61f7b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 22:12:09 +0100 Subject: [PATCH 305/323] Make tracklist.add() only take and return lists --- mopidy/core/tracklist.py | 22 +++++----------------- tests/backends/base/__init__.py | 4 +--- tests/backends/base/tracklist.py | 12 ++++++------ tests/backends/local/playback_test.py | 2 +- tests/core/events_test.py | 12 ++++++------ 5 files changed, 19 insertions(+), 33 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index a5ea6c11..0337828c 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import logging import random -from mopidy.models import TlTrack, Track +from mopidy.models import TlTrack from . import listener @@ -70,26 +70,17 @@ class TracklistController(object): the tracklist. If ``at_position`` is not given, the tracks are appended to the end of the tracklist. - If ``tracks`` is a track object, a single - :class:`mopidy.models.TlTrack` object is returned. If ``tracks`` is a - list, a list of :class:`mopidy.models.TlTrack` is returned. - Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event. - :param track: track to add - :type track: :class:`mopidy.models.Track` + :param tracks: tracks to add + :type tracks: list of :class:`mopidy.models.Track` :param at_position: position in tracklist to add track :type at_position: int or :class:`None` - :rtype: a single or a list of :class:`mopidy.models.TlTrack` + :rtype: list of :class:`mopidy.models.TlTrack` """ assert at_position is None or at_position <= len(self._tl_tracks), \ 'at_position can not be greater than tracklist length' - single_add = False - if isinstance(tracks, Track): - tracks = [tracks] - single_add = True - tl_tracks = [] for track in tracks: tl_track = TlTrack(self._next_tlid, track) @@ -104,10 +95,7 @@ class TracklistController(object): if tl_tracks: self._increase_version() - if single_add: - return tl_tracks[0] - else: - return tl_tracks + return tl_tracks def clear(self): """ diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index 477f8cc1..7dc4bcf6 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -3,9 +3,7 @@ from __future__ import unicode_literals def populate_tracklist(func): def wrapper(self): - self.tl_tracks = [] - for track in self.tracks: - self.tl_tracks.append(self.core.tracklist.add(track)) + self.tl_tracks = self.core.tracklist.add(self.tracks) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 53b3288a..09b2b6a6 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -36,17 +36,17 @@ class TracklistControllerTest(object): def test_add(self): for track in self.tracks: - tl_track = self.controller.add(track) + tl_tracks = self.controller.add([track]) self.assertEqual(track, self.controller.tracks[-1]) - self.assertEqual(tl_track, self.controller.tl_tracks[-1]) - self.assertEqual(track, tl_track.track) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_tracks[0].track) def test_add_at_position(self): for track in self.tracks[:-1]: - tl_track = self.controller.add(track, 0) + tl_tracks = self.controller.add([track], 0) self.assertEqual(track, self.controller.tracks[0]) - self.assertEqual(tl_track, self.controller.tl_tracks[0]) - self.assertEqual(track, tl_track.track) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[0]) + self.assertEqual(track, tl_tracks[0].track) @populate_tracklist def test_add_at_position_outside_of_playlist(self): diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 285270ce..9731f70d 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -27,7 +27,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def add_track(self, path): uri = path_to_uri(path_to_data_dir(path)) track = Track(uri=uri, length=4464) - self.tracklist.add(track) + self.tracklist.add([track]) def test_uri_scheme(self): self.assertIn('file', self.core.uri_schemes) diff --git a/tests/core/events_test.py b/tests/core/events_test.py index b0ae2081..88f07de6 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -26,14 +26,14 @@ 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')) + 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): - self.core.tracklist.add(Track(uri='dummy:a')) + self.core.tracklist.add([Track(uri='dummy:a')]) self.core.playback.play() self.core.playback.pause().get() send.reset_mock() @@ -41,20 +41,20 @@ class BackendEventsTest(unittest.TestCase): 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')) + 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.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.tracklist.add([Track(uri='dummy:a', length=40000)]) self.core.playback.play().get() send.reset_mock() self.core.playback.seek(1000).get() @@ -62,7 +62,7 @@ class BackendEventsTest(unittest.TestCase): def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() - self.core.tracklist.add(Track(uri='dummy:a')).get() + 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): From acbde530c2b7d414ebc6b4cc9623930254db7c24 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 22:23:48 +0100 Subject: [PATCH 306/323] core: Add getters/setters for consume/random/repeat/single Also, the properties and methods was sorted alphabetically. The `state` and `time_position` properties were out of order. --- mopidy/core/playback.py | 194 +++++++++++++++++++++++----------------- 1 file changed, 113 insertions(+), 81 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 94fd7d4e..e50de2e7 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -12,56 +12,12 @@ from . import listener logger = logging.getLogger('mopidy.core') -def option_wrapper(name, default): - def get_option(self): - return getattr(self, name, default) - - def set_option(self, value): - if getattr(self, name, default) != value: - # pylint: disable = W0212 - self._trigger_options_changed() - # pylint: enable = W0212 - return setattr(self, name, value) - - return property(get_option, set_option) - - class PlaybackController(object): # pylint: disable = R0902 # Too many instance attributes pykka_traversable = True - #: :class:`True` - #: Tracks are removed from the playlist when they have been played. - #: :class:`False` - #: Tracks are not removed from the playlist. - consume = option_wrapper('_consume', False) - - #: The currently playing or selected :class:`mopidy.models.TlTrack`, or - #: :class:`None`. - current_tl_track = None - - #: :class:`True` - #: Tracks are selected at random from the playlist. - #: :class:`False` - #: Tracks are played in the order of the playlist. - random = option_wrapper('_random', False) - - #: :class:`True` - #: The current playlist is played repeatedly. To repeat a single track, - #: select both :attr:`repeat` and :attr:`single`. - #: :class:`False` - #: The current playlist is played once. - repeat = option_wrapper('_repeat', False) - - #: :class:`True` - #: Playback is stopped after current song, unless in :attr:`repeat` - #: mode. - #: :class:`False` - #: Playback continues after current song. - single = option_wrapper('_single', False) - def __init__(self, audio, backends, core): self.audio = audio self.backends = backends @@ -79,6 +35,30 @@ class PlaybackController(object): uri_scheme = urlparse.urlparse(uri).scheme return self.backends.with_playback_by_uri_scheme.get(uri_scheme, None) + ### Properties + + def get_consume(self): + return getattr(self, '_consume', False) + + def set_consume(self, value): + if self.get_consume() != value: + self._trigger_options_changed() + return setattr(self, '_consume', value) + + consume = property(get_consume, set_consume) + """ + :class:`True` + Tracks are removed from the playlist when they have been played. + :class:`False` + Tracks are not removed from the playlist. + """ + + current_tl_track = None + """ + The currently playing or selected :class:`mopidy.models.TlTrack`, or + :class:`None`. + """ + def get_current_track(self): return self.current_tl_track and self.current_tl_track.track @@ -89,6 +69,93 @@ class PlaybackController(object): Read-only. Extracted from :attr:`current_tl_track` for convenience. """ + def get_random(self): + return getattr(self, '_random', False) + + def set_random(self, value): + if self.get_random() != value: + self._trigger_options_changed() + return setattr(self, '_random', value) + + random = property(get_random, set_random) + """ + :class:`True` + Tracks are selected at random from the playlist. + :class:`False` + Tracks are played in the order of the playlist. + """ + + def get_repeat(self): + return getattr(self, '_repeat', False) + + def set_repeat(self, value): + if self.get_repeat() != value: + self._trigger_options_changed() + return setattr(self, '_repeat', value) + + repeat = property(get_repeat, set_repeat) + """ + :class:`True` + The current playlist is played repeatedly. To repeat a single track, + select both :attr:`repeat` and :attr:`single`. + :class:`False` + The current playlist is played once. + """ + + def get_single(self): + return getattr(self, '_single', False) + + def set_single(self, value): + if self.get_single() != value: + self._trigger_options_changed() + return setattr(self, '_single', value) + + single = property(get_single, set_single) + """ + :class:`True` + Playback is stopped after current song, unless in :attr:`repeat` + mode. + :class:`False` + Playback continues after current song. + """ + + def get_state(self): + return self._state + + def set_state(self, new_state): + (old_state, self._state) = (self.state, new_state) + logger.debug('Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed(old_state, new_state) + + state = property(get_state, set_state) + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + + def get_time_position(self): + backend = self._get_backend() + if backend: + return backend.playback.get_time_position().get() + else: + return 0 + + time_position = property(get_time_position) + """Time position in milliseconds.""" + def get_tracklist_position(self): if self.current_tl_track is None: return None @@ -205,43 +272,6 @@ class PlaybackController(object): instead. """ - def get_state(self): - return self._state - - def set_state(self, new_state): - (old_state, self._state) = (self.state, new_state) - logger.debug('Changing state: %s -> %s', old_state, new_state) - - self._trigger_playback_state_changed(old_state, new_state) - - state = property(get_state, set_state) - """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] - """ - - def get_time_position(self): - backend = self._get_backend() - if backend: - return backend.playback.get_time_position().get() - else: - return 0 - - time_position = property(get_time_position) - """Time position in milliseconds.""" - def get_volume(self): if self.audio: return self.audio.get_volume().get() @@ -259,6 +289,8 @@ class PlaybackController(object): volume = property(get_volume, set_volume) """Volume as int in range [0..100] or :class:`None`""" + ### Methods + def change_track(self, tl_track, on_error_step=1): """ Change to the given track, keeping the current playback state. From 8f1b98b30652ee1a764d14473f159a07d052e139 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 22:36:39 +0100 Subject: [PATCH 307/323] core: Don't fail when adding tracks after end of tracklist --- mopidy/core/tracklist.py | 3 --- tests/backends/base/tracklist.py | 8 +++++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 0337828c..656e15b1 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -78,9 +78,6 @@ class TracklistController(object): :type at_position: int or :class:`None` :rtype: list of :class:`mopidy.models.TlTrack` """ - assert at_position is None or at_position <= len(self._tl_tracks), \ - 'at_position can not be greater than tracklist length' - tl_tracks = [] for track in tracks: tl_track = TlTrack(self._next_tlid, track) diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 09b2b6a6..71f44018 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -50,9 +50,11 @@ class TracklistControllerTest(object): @populate_tracklist def test_add_at_position_outside_of_playlist(self): - test = lambda: self.controller.add( - self.tracks[0], len(self.tracks) + 2) - self.assertRaises(AssertionError, test) + for track in self.tracks: + tl_tracks = self.controller.add([track], len(self.tracks) + 2) + self.assertEqual(track, self.controller.tracks[-1]) + self.assertEqual(tl_tracks[0], self.controller.tl_tracks[-1]) + self.assertEqual(track, tl_tracks[0].track) @populate_tracklist def test_filter_by_tlid(self): From 174d38b790bb5a1eb05f2ee122c958d9a4a32424 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 23:34:21 +0100 Subject: [PATCH 308/323] docs: Clean changelog for v0.9 --- docs/changes.rst | 262 ++++++++++++++++++++++++++--------------------- 1 file changed, 146 insertions(+), 116 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index f1eebad9..4cfe2972 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,15 +8,15 @@ This change log is used to track all major changes to Mopidy. v0.9.0 (in development) ======================= +Support for using the local and Spotify backends simultaneously have for a very +long time been our most requested feature. Finally, it's here! + **Dependencies** - pyspotify >= 1.9, < 1.10 is now required for Spotify support. **Multiple backends support** -Support for using the local and Spotify backends simultaneously have for a very -long time been our most requested feature. Finally, it's here! - - Both the local backend and the Spotify backend are now turned on by default. The local backend is listed first in the :attr:`mopidy.settings.BACKENDS` setting, and are thus given the highest priority in e.g. search results, @@ -28,8 +28,69 @@ long time been our most requested feature. Finally, it's here! As always, see :mod:`mopidy.settings` for the full list of available settings. +**Spotify backend** + +- The Spotify backend now includes release year and artist on albums. + +- :issue:`233`: The Spotify backend now returns the track if you search for the + Spotify track URI. + +- Added support for connecting to the Spotify service through an HTTP or SOCKS + proxy, which is supported by pyspotify >= 1.9. + +**Local backend** + +- :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC + files (Apple lossless) because it didn't support multiple tag messages from + GStreamer per track it scanned. + +- Added support for search by filename to local backend. + +**MPD frontend** + +- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now + accepts unquoted playlist names if they don't contain spaces. + +- :issue:`246`: The MPD command ``list album artist ""`` and similar + ``search``, ``find``, and ``list`` commands with empty filter values caused a + :exc:`LookupError`, but should have been ignored by the MPD server. + +- The MPD frontend no longer lowercases search queries. This broke e.g. search + by URI, where casing may be essential. + +- The MPD command ``plchanges`` always returned the entire playlist. It now + returns an empty response when the client has seen the latest version. + +- The MPD commands ``search`` and ``find`` now allows the key ``file``, which + is used by ncmpcpp instead of ``filename``. + +**MPRIS frontend** + +- The MPRIS playlists interface is now supported by our MPRIS frontend. This + means that you now can select playlists to queue and play from the Ubuntu + Sound Menu. + +**Audio mixers** + +- Made the :mod:`NAD mixer ` responsive to interrupts + during amplifier calibration. It will now quit immediately, while previously + it completed the calibration first, and then quit, which could take more than + 15 seconds. + +**Developer support** + +- Added optional background thread for debugging deadlocks. When the feature is + enabled via the ``--debug-thread`` option or + :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump + the traceback for all running threads. + +- The settings validator will now allow any setting prefixed with ``CUSTOM_`` + to exist in the settings file. + +**Internal changes** + Internally, Mopidy have seen a lot of changes to pave the way for multiple -backends: +backends and the future HTTP frontend. - A new layer and actor, "core", has been added to our stack, inbetween the frontends and the backends. The responsibility of the core layer and actor is @@ -40,12 +101,6 @@ backends: Frontends no longer know anything about the backends. They just use the :ref:`core-api`. -- The base playback provider has been updated with sane default behavior - instead of empty functions. By default, the playback provider now lets - GStreamer keep track of the current track's time position. The local backend - simply uses the base playback provider without any changes. The same applies - to any future backend that just needs GStreamer to play an URI for it. - - The dependency graph between the core controllers and the backend providers have been straightened out, so that we don't have any circular dependencies. The frontend, core, backend, and audio layers are now strictly separate. The @@ -60,131 +115,106 @@ backends: dummy/mocked lower layers easier than with the old variant, where dependencies where looked up in Pykka's actor registry. -- Renamed "current playlist" to "tracklist" everywhere, including the core API - used by frontends. +- All properties in the core API now got getters, and setters if setting them + is allowed. They are not explictly listed in the docs as they have the same + behavior as the documented properties, but they are available and may be + used. This is useful for the future HTTP frontend. -- Renamed "stored playlists" to "playlists" everywhere, including the core API - used by frontends. - -- The playlists part of the core API has been revised to be more focused around - the playlist URI, and some redundant functionality has been removed: - - - :attr:`mopidy.core.PlaylistsController.playlists` no longer supports - assignment to it. The `playlists` property on the backend layer still does, - and all functionality is maintained by assigning to the playlists - collections at the backend level. - - - :meth:`mopidy.core.PlaylistsController.delete` now accepts an URI, and not - a playlist object. - - - :meth:`mopidy.core.PlaylistsController.save` now returns the saved - playlist. The returned playlist may differ from the saved playlist, and - should thus be used instead of the playlist passed to ``save()``. - - - :meth:`mopidy.core.PlaylistsController.rename` has been removed, since - renaming can be done with ``save()``. - -**Changes** - -- Made the :mod:`NAD mixer ` responsive to interrupts - during amplifier calibration. It will now quit immediately, while previously - it completed the calibration first, and then quit, which could take more than - 15 seconds. +*Models:* - Added :attr:`mopidy.models.Album.date` attribute. It has the same format as the existing :attr:`mopidy.models.Track.date`. -- The Spotify backend now includes release year and artist on albums. +- Added :class:`mopidy.models.ModelJSONEncoder` and + :func:`mopidy.models.model_json_decoder` for automatic JSON serialization and + deserialization of data structures which contains Mopidy models. This is + useful for the future HTTP frontend. -- Added support for search by filename to local backend. +*Library:* -- Added optional background thread for debugging deadlocks. When the feature is - enabled via the ``--debug-thread`` option or - :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump - the traceback for all running threads. +- :meth:`mopidy.core.LibraryController.find_exact` and + :meth:`mopidy.core.LibraryController.search` now returns plain lists of + tracks instead of playlist objects. -- Make the entire code base use unicode strings by default, and only fall back - to bytestrings where it is required. Another step closer to Python 3. +- :meth:`mopidy.core.LibraryController.lookup` now returns a list of tracks + instead of a single track. This makes it possible to support lookup of + artist or album URIs which then can expand to a list of tracks. -- The settings validator will now allow any setting prefixed with ``CUSTOM_`` - to exist in the settings file. +*Playback:* -- The MPD commands ``search`` and ``find`` now allows the key ``file``, which - is used by ncmpcpp instead of ``filename``. +- The base playback provider has been updated with sane default behavior + instead of empty functions. By default, the playback provider now lets + GStreamer keep track of the current track's time position. The local backend + simply uses the base playback provider without any changes. Any future + backend that just feeds URIs to GStreamer to play can also use the base + playback provider without any changes. -- The Spotify backend now returns the track if you search for the Spotify track - URI. (Fixes: :issue:`233`) +- Removed :attr:`mopidy.core.PlaybackController.track_at_previous`. Use + :attr:`mopidy.core.PlaybackController.tl_track_at_previous` instead. -- :meth:`mopidy.core.TracklistController.append` now returns a list of the - :class:`mopidy.models.TlTrack` instances that was added to the tracklist. - This makes it easier to start playing one of the tracks that was just - appended to the tracklist. +- Removed :attr:`mopidy.core.PlaybackController.track_at_next`. Use + :attr:`mopidy.core.PlaybackController.tl_track_at_next` instead. + +- Removed :attr:`mopidy.core.PlaybackController.track_at_eot`. Use + :attr:`mopidy.core.PlaybackController.tl_track_at_eot` instead. + +- Removed :attr:`mopidy.core.PlaybackController.current_tlid`. Use + :attr:`mopidy.core.PlaybackController.current_tl_track` instead. + +*Playlists:* + +The playlists part of the core API has been revised to be more focused around +the playlist URI, and some redundant functionality has been removed: + +- Renamed "stored playlists" to "playlists" everywhere, including the core API + used by frontends. + +- :attr:`mopidy.core.PlaylistsController.playlists` no longer supports + assignment to it. The `playlists` property on the backend layer still does, + and all functionality is maintained by assigning to the playlists collections + at the backend level. + +- :meth:`mopidy.core.PlaylistsController.delete` now accepts an URI, and not a + playlist object. + +- :meth:`mopidy.core.PlaylistsController.save` now returns the saved playlist. + The returned playlist may differ from the saved playlist, and should thus be + used instead of the playlist passed to + :meth:`mopidy.core.PlaylistsController.save`. + +- :meth:`mopidy.core.PlaylistsController.rename` has been removed, since + renaming can be done with :meth:`mopidy.core.PlaylistsController.save`. + +- :meth:`mopidy.core.PlaylistsController.get` has been replaced by + :meth:`mopidy.core.PlaylistsController.filter`. + +- The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed + to include the playlist that was changed. + +*Tracklist:* + +- Renamed "current playlist" to "tracklist" everywhere, including the core API + used by frontends. + +- Removed :meth:`mopidy.core.TracklistController.append`. Use + :meth:`mopidy.core.TracklistController.add` instead, which is now capable of + adding multiple tracks. + +- :meth:`mopidy.core.TracklistController.get` has been replaced by + :meth:`mopidy.core.TracklistController.filter`. + +- :meth:`mopidy.core.TracklistController.remove` can now remove multiple + tracks, and returns the tracks it removed. - When the tracklist is changed, we now trigger the new :meth:`mopidy.core.CoreListener.tracklist_changed` event. Previously we triggered :meth:`mopidy.core.CoreListener.playlist_changed`, which is intended for stored playlists, not the tracklist. -- The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed - to include the playlist that was changed. +*Towards Python 3 support:* -- The MPRIS playlists interface is now supported by our MPRIS frontend. This - means that you now can select playlists to queue and play from the Ubuntu - Sound Menu. - -- :meth:`mopidy.core.LibraryController.find_exact` and - :meth:`mopidy.core.LibraryController.search` now returns plain lists of - tracks instead of playlist objects. - -- :meth:`mopidy.core.TracklistController.get` has been replaced by - :meth:`mopidy.core.TracklistController.filter`. - -- :meth:`mopidy.core.PlaylistsController.get` has been replaced by - :meth:`mopidy.core.PlaylistsController.filter`. - -- :meth:`mopidy.core.TracklistController.remove` can now remove multiple - tracks, and returns the tracks it removed. - -- :meth:`mopidy.core.LibraryController.lookup` now returns a list of tracks. - This makes it possible to support lookup of artist or album URIs which then - can expand to a list of tracks. - -- Remove :attr:`mopidy.core.PlaybackController.track_at_previous`. Use - :attr:`mopidy.core.PlaybackController.tl_track_at_previous` instead. - -- Remove :attr:`mopidy.core.PlaybackController.track_at_next`. Use - :attr:`mopidy.core.PlaybackController.tl_track_at_next` instead. - -- Remove :attr:`mopidy.core.PlaybackController.track_at_eot`. Use - :attr:`mopidy.core.PlaybackController.tl_track_at_eot` instead. - -- Remove :attr:`mopidy.core.PlaybackController.current_tlid`. Use - :attr:`mopidy.core.PlaybackController.current_tl_track` instead. - -- Remove :meth:`mopidy.core.TracklistController.append`. Use - :meth:`mopidy.core.TracklistController.add` instead. - -- Added support for connecting to the Spotify service through an HTTP or SOCKS - proxy, which is supported by pyspotify >= 1.9. - -**Bug fixes** - -- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now - accepts unquotes playlist names if they don't contain spaces. - -- The MPD command ``plchanges`` always returned the entire playlist. It now - returns an empty response when the client has seen the latest version. - -- MPD no longer lowercases search queries. This broke e.g. search by URI, where - casing may be essential. - -- :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC - files (Apple lossless) because it didn't support multiple tag messages from - GStreamer per track it scanned. - -- :issue:`246`: The MPD command ``list album artist ""`` and similar - ``search``, ``find``, and ``list`` commands with empty filter values caused a - :exc:`LookupError`, but should have been ignored by the MPD server. +- Make the entire code base use unicode strings by default, and only fall back + to bytestrings where it is required. Another step closer to Python 3. v0.8.1 (2012-10-30) From 02345beb0e805c3ae17f186eb9dece9a7e69b83e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 23:57:06 +0100 Subject: [PATCH 309/323] docs: Add major docs changes to the changelog --- docs/changes.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 4cfe2972..47d1ea2d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -15,6 +15,18 @@ long time been our most requested feature. Finally, it's here! - pyspotify >= 1.9, < 1.10 is now required for Spotify support. +**Documentation** + +- New :ref:`installation` guides, organized by OS and distribution so that you + can follow one concise list of instructions instead of jumping around the + docs to look for instructions for each dependency. + +- Moved :ref:`raspberrypi-installation` howto from the wiki to the docs. + +- Updated :ref:`mpd-clients` overview. + +- Added :ref:`mpris-clients` and :ref:`upnp-clients` overview. + **Multiple backends support** - Both the local backend and the Spotify backend are now turned on by default. @@ -110,6 +122,8 @@ backends and the future HTTP frontend. broadcasting of events to listeners, through e.g. :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. + See :ref:`concepts` for more details and illustrations of all the relations. + - All dependencies are now explicitly passed to the constructors of the frontends, core, and the backends. This makes testing each layer with dummy/mocked lower layers easier than with the old variant, where From f313d9d44681e0795b237edfc8cd89af09408068 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 00:23:10 +0100 Subject: [PATCH 310/323] spotify: Ignore playlists without a name --- docs/changes.rst | 3 +++ mopidy/backends/spotify/translator.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 47d1ea2d..eef62a1f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -50,6 +50,9 @@ long time been our most requested feature. Finally, it's here! - Added support for connecting to the Spotify service through an HTTP or SOCKS proxy, which is supported by pyspotify >= 1.9. +- Subscriptions to other Spotify user's "starred" playlists are ignored, as + they currently isn't fully supported by pyspotify. + **Local backend** - :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 834b34d8..92b4514e 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -56,6 +56,10 @@ def to_mopidy_playlist(spotify_playlist): uri = str(Link.from_playlist(spotify_playlist)) if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') + if not spotify_playlist.name(): + # Other user's "starred" playlists isn't handled properly by pyspotify + # See https://github.com/mopidy/pyspotify/issues/81 + return return Playlist( uri=uri, name=spotify_playlist.name(), From 72574c1ae0c57e77a97eb585a252e3a62a0e553d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 00:32:46 +0100 Subject: [PATCH 311/323] mpd: listplaylists should not return playlists without a name --- docs/changes.rst | 3 +++ mopidy/frontends/mpd/protocol/stored_playlists.py | 7 +++++++ tests/frontends/mpd/protocol/stored_playlists_test.py | 9 +++++++++ 3 files changed, 19 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index eef62a1f..295bf8fd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -79,6 +79,9 @@ long time been our most requested feature. Finally, it's here! - The MPD commands ``search`` and ``find`` now allows the key ``file``, which is used by ncmpcpp instead of ``filename``. +- The MPD command ``listplaylists`` will no longer return playlists without a + name. This could crash ncmpcpp. + **MPRIS frontend** - The MPRIS playlists interface is now supported by our MPRIS frontend. This diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index de2b267e..a7be2399 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -70,9 +70,16 @@ def listplaylists(context): Last-Modified: 2010-02-06T02:10:25Z playlist: b Last-Modified: 2010-02-06T02:11:08Z + + *Clarifications:* + + - ncmpcpp 0.5.10 segfaults if we return 'playlist: ' on a line, so we must + ignore playlists without names, which isn't very useful anyway. """ result = [] for playlist in context.core.playlists.playlists.get(): + if not playlist.name: + continue result.append(('playlist', playlist.name)) last_modified = ( playlist.last_modified or dt.datetime.now()).isoformat() diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index cf1b8cd0..414f0b25 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -64,6 +64,15 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') self.assertInResponse('OK') + def test_listplaylists_ignores_playlists_without_name(self): + last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) + self.backend.playlists.playlists = [ + Playlist(name='', last_modified=last_modified)] + + self.sendRequest('listplaylists') + self.assertNotInResponse('playlist: ') + self.assertInResponse('OK') + def test_load_known_playlist_appends_to_tracklist(self): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) From 5fbb6328d64c8a540dc3670c7b5e115f585c9467 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 00:48:08 +0100 Subject: [PATCH 312/323] mpd: list shouldn't return blank artist names, album names, or dates --- docs/changes.rst | 3 +++ mopidy/backends/dummy.py | 6 +++-- mopidy/frontends/mpd/protocol/music_db.py | 7 +++--- tests/frontends/mpd/protocol/music_db_test.py | 25 +++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 295bf8fd..485ac0fd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -82,6 +82,9 @@ long time been our most requested feature. Finally, it's here! - The MPD command ``listplaylists`` will no longer return playlists without a name. This could crash ncmpcpp. +- The MPD command ``list`` will no longer return artist names, album names, or + dates that are blank. + **MPRIS frontend** - The MPRIS playlists interface is now supported by our MPRIS frontend. This diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 62ac8e8f..39180bbb 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -37,9 +37,11 @@ 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 = [] def find_exact(self, **query): - return [] + return self.dummy_find_exact_result def lookup(self, uri): return filter(lambda t: uri == t.uri, self.dummy_library) @@ -48,7 +50,7 @@ class DummyLibraryProvider(base.BaseLibraryProvider): pass def search(self, **query): - return [] + return self.dummy_search_result class DummyPlaybackProvider(base.BasePlaybackProvider): diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 26371364..8f41b199 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -250,7 +250,8 @@ def _list_artist(context, query): tracks = context.core.library.find_exact(**query).get() for track in tracks: for artist in track.artists: - artists.add(('Artist', artist.name)) + if artist.name: + artists.add(('Artist', artist.name)) return artists @@ -258,7 +259,7 @@ def _list_album(context, query): albums = set() tracks = context.core.library.find_exact(**query).get() for track in tracks: - if track.album is not None: + if track.album and track.album.name: albums.add(('Album', track.album.name)) return albums @@ -267,7 +268,7 @@ def _list_date(context, query): dates = set() tracks = context.core.library.find_exact(**query).get() for track in tracks: - if track.date is not None: + if track.date: dates.add(('Date', track.date)) return dates diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 9a233e40..44999a4f 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +from mopidy.models import Album, Artist, Track + from tests.frontends.mpd import protocol @@ -181,6 +183,14 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.sendRequest('list "artist" "artist" ""') 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.sendRequest('list "artist"') + self.assertNotInResponse('Artist: ') + self.assertInResponse('OK') + ### Album def test_list_album_with_quotes(self): @@ -232,6 +242,14 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.sendRequest('list "album" "artist" ""') 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.sendRequest('list "album"') + self.assertNotInResponse('Album: ') + self.assertInResponse('OK') + ### Date def test_list_date_with_quotes(self): @@ -279,6 +297,13 @@ class MusicDatabaseListTest(protocol.BaseTestCase): self.sendRequest('list "date" "artist" ""') self.assertInResponse('OK') + def test_list_date_should_not_return_blank_dates(self): + self.backend.library.dummy_find_exact_result = [Track(date='')] + + self.sendRequest('list "date"') + self.assertNotInResponse('Date: ') + self.assertInResponse('OK') + ### Genre def test_list_genre_with_quotes(self): From dc24876f66dcf824d0a96b378764ff902f059b68 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 20 Nov 2012 00:44:15 +0100 Subject: [PATCH 313/323] mpd: Allow bad 'search' requests --- mopidy/frontends/mpd/protocol/music_db.py | 2 +- tests/frontends/mpd/protocol/music_db_test.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 8f41b199..00b9ec00 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -335,7 +335,7 @@ def rescan(context, uri=None): @handle_request( r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$') def search(context, mpd_query): """ *musicpd.org, music database section:* diff --git a/tests/frontends/mpd/protocol/music_db_test.py b/tests/frontends/mpd/protocol/music_db_test.py index 44999a4f..4539eb4c 100644 --- a/tests/frontends/mpd/protocol/music_db_test.py +++ b/tests/frontends/mpd/protocol/music_db_test.py @@ -362,6 +362,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search album "analbum"') self.assertInResponse('OK') + def test_search_album_without_filter_value(self): + self.sendRequest('search "album" ""') + self.assertInResponse('OK') + def test_search_artist(self): self.sendRequest('search "artist" "anartist"') self.assertInResponse('OK') @@ -370,6 +374,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search artist "anartist"') self.assertInResponse('OK') + def test_search_artist_without_filter_value(self): + self.sendRequest('search "artist" ""') + self.assertInResponse('OK') + def test_search_filename(self): self.sendRequest('search "filename" "afilename"') self.assertInResponse('OK') @@ -378,6 +386,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search filename "afilename"') self.assertInResponse('OK') + def test_search_filename_without_filter_value(self): + self.sendRequest('search "filename" ""') + self.assertInResponse('OK') + def test_search_file(self): self.sendRequest('search "file" "afilename"') self.assertInResponse('OK') @@ -386,6 +398,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search file "afilename"') self.assertInResponse('OK') + def test_search_file_without_filter_value(self): + self.sendRequest('search "file" ""') + self.assertInResponse('OK') + def test_search_title(self): self.sendRequest('search "title" "atitle"') self.assertInResponse('OK') @@ -394,6 +410,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search title "atitle"') self.assertInResponse('OK') + def test_search_title_without_filter_value(self): + self.sendRequest('search "title" ""') + self.assertInResponse('OK') + def test_search_any(self): self.sendRequest('search "any" "anything"') self.assertInResponse('OK') @@ -402,6 +422,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search any "anything"') self.assertInResponse('OK') + def test_search_any_without_filter_value(self): + self.sendRequest('search "any" ""') + self.assertInResponse('OK') + def test_search_date(self): self.sendRequest('search "date" "2002-01-01"') self.assertInResponse('OK') @@ -414,6 +438,10 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase): self.sendRequest('search Date "2005"') self.assertInResponse('OK') + def test_search_date_without_filter_value(self): + self.sendRequest('search "date" ""') + self.assertInResponse('OK') + def test_search_else_should_fail(self): self.sendRequest('search "sometype" "something"') self.assertEqualResponse('ACK [2@0] {search} incorrect arguments') From 39b9429dfcd0e88ad3cd65a8a360662177bb39df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:03:53 +0100 Subject: [PATCH 314/323] tests: Use track URIs matching the backend in use --- .../mpd/protocol/stored_playlists_test.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 414f0b25..be2afd4c 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -10,18 +10,18 @@ from tests.frontends.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + Playlist(name='name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylist "name"') - self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_without_quotes(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + Playlist(name='name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylist name') - self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('file: dummy:a') self.assertInResponse('OK') def test_listplaylist_fails_if_no_playlist_is_found(self): @@ -30,20 +30,20 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + Playlist(name='name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylistinfo "name"') - self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('file: dummy:a') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') def test_listplaylistinfo_without_quotes(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + Playlist(name='name', tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylistinfo name') - self.assertInResponse('file: file:///dev/urandom') + self.assertInResponse('file: dummy:a') self.assertInResponse('Track: 0') self.assertNotInResponse('Pos: 0') self.assertInResponse('OK') @@ -96,7 +96,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse('ACK [50@0] {load} No such playlist') def test_playlistadd(self): - self.sendRequest('playlistadd "name" "file:///dev/urandom"') + self.sendRequest('playlistadd "name" "dummy:a"') self.assertEqualResponse('ACK [0@0] {} Not implemented') def test_playlistclear(self): From 09d7279b6ba44326af7778ee004571daab48e8d4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:13:05 +0100 Subject: [PATCH 315/323] mpd: Compile protocol matching regexpes This caused a single test failure, which was fixed. --- mopidy/frontends/mpd/protocol/__init__.py | 5 +++-- mopidy/frontends/mpd/protocol/current_playlist.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 3a9f3674..ded65315 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -56,10 +56,11 @@ def handle_request(pattern, auth_required=True): if match is not None: mpd_commands.add( MpdCommand(name=match.group(), auth_required=auth_required)) - if pattern in request_handlers: + compiled_pattern = re.compile(pattern) + if compiled_pattern in request_handlers: raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) - request_handlers[pattern] = func + request_handlers[compiled_pattern] = func func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % ( pattern, func.__doc__ or '') return func diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index fbc92b46..f0d2e8f9 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -232,7 +232,6 @@ def playlistid(context, tlid=None): @handle_request(r'^playlistinfo$') -@handle_request(r'^playlistinfo "-1"$') @handle_request(r'^playlistinfo "(?P-?\d+)"$') @handle_request(r'^playlistinfo "(?P\d+):(?P\d+)*"$') def playlistinfo(context, songpos=None, start=None, end=None): @@ -250,6 +249,8 @@ def playlistinfo(context, songpos=None, start=None, end=None): - uses negative indexes, like ``playlistinfo "-1"``, to request the entire playlist """ + if songpos == '-1': + songpos = None if songpos is not None: songpos = int(songpos) tl_track = context.core.tracklist.tl_tracks.get()[songpos] From 0bc8fc6bf17e1a6db691241eb481aeb7a231a0b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:14:12 +0100 Subject: [PATCH 316/323] mpd: Interpret regexp groups with unicode semantics Compiling the regexpes with either re.UNICODE or re.LOCALE both seems to fix the mystical failure of test_listplaylistinfo. --- mopidy/frontends/mpd/protocol/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index ded65315..a8bdc2c7 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -56,7 +56,7 @@ def handle_request(pattern, auth_required=True): if match is not None: mpd_commands.add( MpdCommand(name=match.group(), auth_required=auth_required)) - compiled_pattern = re.compile(pattern) + compiled_pattern = re.compile(pattern, flags=re.UNICODE) if compiled_pattern in request_handlers: raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) From 512b95fdb06e74ee49462bb0f50dcda3b50ea520 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:18:19 +0100 Subject: [PATCH 317/323] docs: Update changelog with MPD search/find change --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 485ac0fd..583e5c46 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -79,6 +79,9 @@ long time been our most requested feature. Finally, it's here! - The MPD commands ``search`` and ``find`` now allows the key ``file``, which is used by ncmpcpp instead of ``filename``. +- The MPD commands ``search`` and ``find`` now allow search query values to be + empty strings. + - The MPD command ``listplaylists`` will no longer return playlists without a name. This could crash ncmpcpp. From 3af3eb5127d5e15c4b04d96c34e4e55c743de784 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:29:14 +0100 Subject: [PATCH 318/323] mpd: Make 'decoders' return an empty response to please ncmpcpp --- docs/changes.rst | 4 ++++ mopidy/frontends/mpd/protocol/reflection.py | 10 ++++++++-- tests/frontends/mpd/protocol/reflection_test.py | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 583e5c46..d62faf8e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -88,6 +88,10 @@ long time been our most requested feature. Finally, it's here! - The MPD command ``list`` will no longer return artist names, album names, or dates that are blank. +- The MPD command ``decoders`` will now return an empty response instead of a + "not implemented" error to make the ncmpcpp browse view work the first time + it is opened. + **MPRIS frontend** - The MPRIS playlists interface is now supported by our MPRIS frontend. This diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 5af86a1a..d9c35743 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from mopidy.frontends.mpd.protocol import handle_request, mpd_commands -from mopidy.frontends.mpd.exceptions import MpdNotImplemented @handle_request(r'^commands$', auth_required=False) @@ -47,8 +46,15 @@ def decoders(context): mime_type: audio/mpeg plugin: mpcdec suffix: mpc + + *Clarifications:* + + - ncmpcpp asks for decoders the first time you open the browse view. By + returning nothing and OK instead of an not implemented error, we avoid + "Not implemented" showing up in the ncmpcpp interface, and we get the + list of playlists without having to enter the browse interface twice. """ - raise MpdNotImplemented # TODO + return # TODO @handle_request(r'^notcommands$', auth_required=False) diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py index 33032d73..9c07f104 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -38,7 +38,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase): def test_decoders(self): self.sendRequest('decoders') - self.assertInResponse('ACK [0@0] {} Not implemented') + self.assertInResponse('OK') def test_notcommands_returns_only_kill_and_ok(self): response = self.sendRequest('notcommands') From 50708f9fd77b82460cd8543b1ab0cdbb13d630b5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:30:07 +0100 Subject: [PATCH 319/323] mpd: Change to interpret regexp groups with the old locale semantics --- mopidy/frontends/mpd/protocol/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index a8bdc2c7..df321d94 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -56,7 +56,7 @@ def handle_request(pattern, auth_required=True): if match is not None: mpd_commands.add( MpdCommand(name=match.group(), auth_required=auth_required)) - compiled_pattern = re.compile(pattern, flags=re.UNICODE) + compiled_pattern = re.compile(pattern, flags=re.LOCALE) if compiled_pattern in request_handlers: raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) From ab906c5684b9cb3977debbd58c1a8cac65e10ea4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:34:49 +0100 Subject: [PATCH 320/323] Revert "mpd: Change to interpret regexp groups with the old locale semantics" This reverts commit 50708f9fd77b82460cd8543b1ab0cdbb13d630b5. --- mopidy/frontends/mpd/protocol/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index df321d94..a8bdc2c7 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -56,7 +56,7 @@ def handle_request(pattern, auth_required=True): if match is not None: mpd_commands.add( MpdCommand(name=match.group(), auth_required=auth_required)) - compiled_pattern = re.compile(pattern, flags=re.LOCALE) + compiled_pattern = re.compile(pattern, flags=re.UNICODE) if compiled_pattern in request_handlers: raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) From bb32ff6b6bb422b74e9f851dfb50b3d5078cffb8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:35:21 +0100 Subject: [PATCH 321/323] mpd: Don't use the \S regexp group --- mopidy/frontends/mpd/protocol/current_playlist.py | 2 +- mopidy/frontends/mpd/protocol/stored_playlists.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index f0d2e8f9..d1b0e59a 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -270,7 +270,7 @@ def playlistinfo(context, songpos=None, start=None, end=None): @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') -@handle_request(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') +@handle_request(r'^playlistsearch (?P\w+) "(?P[^"]+)"$') def playlistsearch(context, tag, needle): """ *musicpd.org, current playlist section:* diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index a7be2399..eef1f3d1 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -7,7 +7,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import playlist_to_mpd_format -@handle_request(r'^listplaylist (?P\S+)$') +@handle_request(r'^listplaylist (?P\w+)$') @handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ @@ -29,7 +29,7 @@ def listplaylist(context, name): return ['file: %s' % t.uri for t in playlists[0].tracks] -@handle_request(r'^listplaylistinfo (?P\S+)$') +@handle_request(r'^listplaylistinfo (?P\w+)$') @handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ From 88eef7de49937e0d9434a38f590db40cbb98ec1f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:42:51 +0100 Subject: [PATCH 322/323] Bump version number to 0.9.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 072a604c..918e1459 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.8.1' +__version__ = '0.9.0' from mopidy import settings as default_settings_module diff --git a/tests/version_test.py b/tests/version_test.py index 978660b0..966b8b94 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -30,5 +30,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.1'), SV('0.7.2')) self.assertLess(SV('0.7.2'), SV('0.7.3')) self.assertLess(SV('0.7.3'), SV('0.8.0')) - self.assertLess(SV('0.8.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.8.2')) + self.assertLess(SV('0.8.0'), SV('0.8.1')) + self.assertLess(SV('0.8.1'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.9.1')) From a5d222dee31da2bdde60602dc8978b14234eed9b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 01:43:16 +0100 Subject: [PATCH 323/323] Update changelog for v0.9.0 --- docs/changes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index d62faf8e..64fe1ad6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,8 +5,8 @@ Changes This change log is used to track all major changes to Mopidy. -v0.9.0 (in development) -======================= +v0.9.0 (2012-11-21) +=================== Support for using the local and Spotify backends simultaneously have for a very long time been our most requested feature. Finally, it's here!