From 00cec3375feece54f71a70df77565b89782495c5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 15:16:38 +0200 Subject: [PATCH 01/71] Add TODO on testing playlist repeat --- tests/backends/base/playback.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index f8e9dd87..3a5d86e1 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -10,6 +10,8 @@ from mopidy.utils import get_class from tests import SkipTest from tests.backends.base import populate_playlist +# TODO Test 'playlist repeat', e.g. repeat=1,single=0 + class BasePlaybackControllerTest(object): tracks = [] From 2a582a924f68d41439363e09c2550ccd958f23b6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 19:15:26 +0200 Subject: [PATCH 02/71] Ready for 0.2 development --- 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 15b7b1ad..46d55873 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,): sys.exit(u'Mopidy requires Python >= 2.6, < 3') def get_version(): - return u'0.1.0' + return u'0.2.0a1' class MopidyException(Exception): def __init__(self, message, *args, **kwargs): diff --git a/tests/version_test.py b/tests/version_test.py index a44e4e89..b2ef1fce 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -11,7 +11,8 @@ class VersionTest(unittest.TestCase): 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(get_version())) - self.assert_(SV(get_version()) < SV('0.1.1')) + self.assert_(SV('0.1.0a3') < SV('0.1.0')) + self.assert_(SV('0.1.0') < SV(get_version())) + self.assert_(SV(get_version()) < SV('0.2.0')) self.assert_(SV('0.1.1') < SV('0.2.0')) self.assert_(SV('0.2.0') < SV('1.0.0')) From 0ded6698fa59ef132d6a47dd749d30766423ad57 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 20:35:12 +0200 Subject: [PATCH 03/71] Add MPD client compatability docs --- docs/clients/index.rst | 8 ++++ docs/clients/mpd.rst | 92 ++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 3 files changed, 101 insertions(+) create mode 100644 docs/clients/index.rst create mode 100644 docs/clients/mpd.rst diff --git a/docs/clients/index.rst b/docs/clients/index.rst new file mode 100644 index 00000000..6ebfd948 --- /dev/null +++ b/docs/clients/index.rst @@ -0,0 +1,8 @@ +******* +Clients +******* + +.. toctree:: + :glob: + + ** diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst new file mode 100644 index 00000000..46e47083 --- /dev/null +++ b/docs/clients/mpd.rst @@ -0,0 +1,92 @@ +************************ +MPD client compatability +************************ + +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. + + +Console clients +=============== + +ncmpc +----- + +A console client. Uses the ``idle`` command heavily, which Mopidy doesn't +support yet. If you want a console client, use ncmpcpp instead. + +ncmpcpp +------- + +A console client that generally works well with Mopidy, and is regularly used +by Mopidy developers. + +Search only works for ncmpcpp versions 0.5.1 and higher, and 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. + +If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp +from `Launchpad `_. + + + +Graphical clients +================= + +GMPC +---- + +A GTK+ client which works well with Mopidy, and is regularly used by Mopidy +developers. + +Sonata +------ + +A GTK+ client. Generally works well with Mopidy. + +Search does not work, because they do most of the search on the client side. +See :issue:`1` for details. + + +Android clients +=============== + +BitMPC +------ + +Works well with Mopidy. + +Droid MPD +--------- + +Works well with Mopidy. + +MPDroid +------- + +Works well with Mopidy, and is regularly used by Mopidy developers. + +PMix +---- + +Works well with Mopidy. + +ThreeMPD +-------- + +Does not work well with Mopidy, because we haven't implemented ``listallinfo`` +yet. + + +iPhone/iPod Touch clients +========================= + +MPod +---- + +Works well with Mopidy as far as we've heard from users. diff --git a/docs/index.rst b/docs/index.rst index 7c53572c..19053ed5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ User documentation :maxdepth: 3 installation/index + clients/index changes authors licenses From e91d33374f84464569f9b12b961996018e7ba5d4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 20:48:01 +0200 Subject: [PATCH 04/71] docs: Split installation/settings/running into three pages --- docs/index.rst | 2 ++ docs/installation/index.rst | 60 ++++--------------------------------- docs/running.rst | 13 ++++++++ docs/settings.rst | 57 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 55 deletions(-) create mode 100644 docs/running.rst create mode 100644 docs/settings.rst diff --git a/docs/index.rst b/docs/index.rst index 19053ed5..7a4dc27d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,8 @@ User documentation :maxdepth: 3 installation/index + settings + running clients/index changes authors diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 26b864d2..14ebdebc 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -70,6 +70,9 @@ To later upgrade to the latest release:: If you for some reason can't use ``pip``, try ``easy_install``. +Next, you need to set a couple of :doc:`settings `, and then you're +ready to :doc:`run Mopidy `. + Install development version =========================== @@ -92,58 +95,5 @@ To later update to the very latest version:: For an introduction to ``git``, please visit `git-scm.com `_. - -Settings -======== - -Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~`` -means your *home directory*. If your username is ``alice`` and you are running -Linux, the settings file should probably be at -``/home/alice/.mopidy/settings.py``. - -You can either create this file yourself, or run the ``mopidy`` command, and it -will create an empty settings file for you. - -Music from Spotify ------------------- - -If you are using the Spotify backend, which is the default, enter your Spotify -Premium account's username and password into the file, like this:: - - SPOTIFY_USERNAME = u'myusername' - SPOTIFY_PASSWORD = u'mysecret' - -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. - -Connecting from other machines on the network ---------------------------------------------- - -As a secure default, Mopidy only accepts connections from ``localhost``. If you -want to open it for connections from other machines on your network, see -the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. - - -Running Mopidy -============== - -To start Mopidy, simply open a terminal and run:: - - mopidy - -When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to -accept connections by any MPD client. You can find tons of MPD clients at -http://mpd.wikia.com/wiki/Clients. We use GMPC and ncmpcpp during development. -The first is a GUI client, and the second is a terminal client. - -To stop Mopidy, press ``CTRL+C``. +Next, you need to set a couple of :doc:`settings `, and then you're +ready to :doc:`run Mopidy `. diff --git a/docs/running.rst b/docs/running.rst new file mode 100644 index 00000000..4912512f --- /dev/null +++ b/docs/running.rst @@ -0,0 +1,13 @@ +************** +Running Mopidy +************** + +To start Mopidy, simply open a terminal and run:: + + mopidy + +When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to +accept connections by any MPD client. Check out our non-exhaustive +:doc:`/clients/mpd` list to find recommended clients. + +To stop Mopidy, press ``CTRL+C``. diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 00000000..6545a6b1 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,57 @@ +******** +Settings +******** + +Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~`` +means your *home directory*. If your username is ``alice`` and you are running +Linux, the settings file should probably be at +``/home/alice/.mopidy/settings.py``. + +You can either create this file yourself, or run the ``mopidy`` command, and it +will create an empty settings file for you. + + +Music from Spotify +================== + +If you are using the Spotify backend, which is the default, enter your Spotify +Premium account's username and password into the file, like this:: + + SPOTIFY_USERNAME = u'myusername' + SPOTIFY_PASSWORD = u'mysecret' + + +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. + + +Connecting from other machines on the network +============================================= + +As a secure default, Mopidy only accepts connections from ``localhost``. If you +want to open it for connections from other machines on your network, see +the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. + + +Running Mopidy +============== + +To start Mopidy, simply open a terminal and run:: + + mopidy + +When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to +accept connections by any MPD client. Check out our non-exhaustive +:doc:`/clients/mpd` list to find recommended clients. + +To stop Mopidy, press ``CTRL+C``. From 754d45cdf72569ae250eb71439ad98b5134fc740 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 20:54:18 +0200 Subject: [PATCH 05/71] Update roadmap --- docs/development/roadmap.rst | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index dff9a9d7..4c333dcd 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -6,26 +6,20 @@ This is the current roadmap and collection of wild ideas for future Mopidy development. This is intended to be a living document and may change at any time. -Version 0.1 -=========== - -- Core MPD server functionality working. Gracefully handle clients' use of - non-supported functionality. -- Read-only support for Spotify through :mod:`mopidy.backends.libspotify`. -- Initial support for local file playback through - :mod:`mopidy.backends.local`. The state of local file playback will not - block the release of 0.1. +We intend to have about one timeboxed release every month. Thus, the roadmap is +oriented around "soon" and "later" instead of mapping each feature to a future +release. -Version 0.2 and 0.3 -=================== +Possible targets for the next version +===================================== -0.2 will be released when we reach one of the following two goals. 0.3 will be -released when we reach the other goal. - -- Write-support for Spotify. I.e. playlist management. +- Reintroduce support for OS X. See :issue:`14` for details. - Support for using multiple Mopidy backends simultaneously. Should make it possible to have both Spotify tracks and local tracks in the same playlist. +- Write-support for Spotify, i.e. playlist management. +- Virtual directories with e.g. starred tracks from Spotify. +- Last.fm scrobbling. Stuff we want to do, but not right now, and maybe never @@ -45,15 +39,12 @@ Stuff we want to do, but not right now, and maybe never - Compatability: - Run frontend tests against a real MPD server to ensure we are in sync. - - Start working with MPD client maintainers to get rid of weird assumptions - like only searching for first two letters and doing the rest of the - filtering locally in the client (:issue:`1`), etc. - Backends: - `Last.fm `_ - `WIMP `_ - - DNLA/UPnP to Mopidy can play music from other DNLA MediaServers. + - DNLA/UPnP so Mopidy can play music from other DNLA MediaServers. - Frontends: @@ -63,7 +54,7 @@ Stuff we want to do, but not right now, and maybe never - REST/JSON web service with a jQuery client as example application. Maybe based upon `Tornado `_ and `jQuery Mobile `_. - - DNLA/UPnP to Mopidy can be controlled from i.e. TVs. + - DNLA/UPnP so Mopidy can be controlled from i.e. TVs. - `XMMS2 `_ - LIRC frontend for controlling Mopidy with a remote. From 8e9d0a6bc61a4c3f74e30f97b805478152939bdf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 20:55:01 +0200 Subject: [PATCH 06/71] Add 0.2.0 to changelog --- docs/changes.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index e84d7aa9..986dd953 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,16 @@ Changes This change log is used to track all major changes to Mopidy. +0.2.0 (in development) +====================== + +No description yet. + +**Changes** + +- None + + 0.1.0 (2010-08-23) ================== From f541724ff975cd3b5917521d204e91351847325b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 21:01:29 +0200 Subject: [PATCH 07/71] Skip GStreamer tests on Windows --- tests/outputs/gstreamer_test.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index 52d1fbe1..b380197d 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -1,12 +1,19 @@ import multiprocessing import unittest +from tests import SkipTest + +# FIXME Our Windows build server does not support GStreamer yet +import sys +if sys.platform == 'win32': + raise SkipTest + from mopidy import settings from mopidy.outputs.gstreamer import GStreamerOutput from mopidy.utils.path import path_to_uri from mopidy.utils.process import pickle_connection -from tests import data_folder, SkipTest +from tests import data_folder class GStreamerOutputTest(unittest.TestCase): def setUp(self): From d15d9bf39fedc103b8b6740c0bba665483334214 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 21:04:51 +0200 Subject: [PATCH 08/71] Add note on mpc compatability --- docs/clients/mpd.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 46e47083..de54dfcb 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -10,6 +10,12 @@ http://mpd.wikia.com/wiki/Clients. Console clients =============== +mpc +--- + +A command line client. Version 0.14 had some issues with Mopidy (see +:issue:`5`), but 0.16 seems to work nicely. + ncmpc ----- From e760a40a3bfa5b0614a2d78446524ad462595dae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 21:34:47 +0200 Subject: [PATCH 09/71] More possibly-next-version stuff on the roadmap --- docs/development/roadmap.rst | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 4c333dcd..835df489 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -17,8 +17,21 @@ Possible targets for the next version - Reintroduce support for OS X. See :issue:`14` for details. - Support for using multiple Mopidy backends simultaneously. Should make it possible to have both Spotify tracks and local tracks in the same playlist. -- Write-support for Spotify, i.e. playlist management. -- Virtual directories with e.g. starred tracks from Spotify. +- MPD frontend: + + - ``idle`` support. + +- Spotify backend: + + - Write-support for Spotify, i.e. playlist management. + - Virtual directories with e.g. starred tracks from Spotify. + +- Local backend: + + - Better library support. + - A script for creating a tag cache. + - An alternative to tag cache for caching metadata, i.e. Sqlite. + - Last.fm scrobbling. From a40d25b1bc127c7e6025042b6bd77e26eea57fb8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 21:58:20 +0200 Subject: [PATCH 10/71] Forgot to remove 'running' docs from the 'settings' page --- docs/settings.rst | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 6545a6b1..a39e6fd8 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -41,17 +41,3 @@ Connecting from other machines on the network As a secure default, Mopidy only accepts connections from ``localhost``. If you want to open it for connections from other machines on your network, see the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. - - -Running Mopidy -============== - -To start Mopidy, simply open a terminal and run:: - - mopidy - -When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to -accept connections by any MPD client. Check out our non-exhaustive -:doc:`/clients/mpd` list to find recommended clients. - -To stop Mopidy, press ``CTRL+C``. From 53aa64d52cde16b67250793ba031cd029fcd770b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 22:25:46 +0200 Subject: [PATCH 11/71] Move MPD message processing knowledge into the MPD module --- mopidy/core.py | 14 ++++------- mopidy/frontends/mpd/__init__.py | 40 ++++++++++++++++++-------------- mopidy/frontends/mpd/session.py | 1 + 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index 3296fa6b..260c8f8c 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -6,7 +6,7 @@ from mopidy import get_version, settings from mopidy.utils import get_class 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 BaseProcess, unpickle_connection +from mopidy.utils.process import BaseProcess from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') @@ -67,19 +67,15 @@ class CoreProcess(BaseProcess): return get_class(settings.BACKENDS[0])(core_queue, output_queue) def setup_frontend(self, core_queue, backend): - frontend = get_class(settings.FRONTENDS[0])() - frontend.start_server(core_queue) - frontend.create_dispatcher(backend) + frontend = get_class(settings.FRONTENDS[0])(core_queue, backend) + frontend.start() return frontend def process_message(self, message): if message.get('to') == 'output': self.output_queue.put(message) - elif message['command'] == 'mpd_request': - response = self.frontend.dispatcher.handle_request( - message['request']) - connection = unpickle_connection(message['reply_to']) - connection.send(response) + elif message.get('to') == 'frontend': + self.frontend.process_message(message) elif message['command'] == 'end_of_track': self.backend.playback.on_end_of_track() elif message['command'] == 'stop_playback': diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 6c06279f..8e7d65ab 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,5 +1,10 @@ +import logging + from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.process import MpdProcess +from mopidy.utils.process import unpickle_connection + +logger = logging.getLogger('mopidy.frontends.mpd') class MpdFrontend(object): """ @@ -11,27 +16,28 @@ class MpdFrontend(object): - :attr:`mopidy.settings.MPD_SERVER_PORT` """ - def __init__(self): + def __init__(self, core_queue, backend): + self.core_queue = core_queue self.process = None - self.dispatcher = None + self.dispatcher = MpdDispatcher(backend) - def start_server(self, core_queue): - """ - Starts the MPD server. - - :param core_queue: the core queue - :type core_queue: :class:`multiprocessing.Queue` - """ - self.process = MpdProcess(core_queue) + def start(self): + """Starts the MPD server.""" + self.process = MpdProcess(self.core_queue) self.process.start() - def create_dispatcher(self, backend): + def process_message(self, message): """ - Creates a dispatcher for MPD requests. + Processes messages with the MPD frontend as destination. - :param backend: the backend - :type backend: :class:`mopidy.backends.base.BaseBackend` - :rtype: :class:`mopidy.frontends.mpd.dispatcher.MpdDispatcher` + :param message: the message + :type message: dict """ - self.dispatcher = MpdDispatcher(backend) - return self.dispatcher + assert message['to'] == 'frontend', \ + u'Message recipient must be "frontend".' + if message['command'] == 'mpd_request': + response = self.dispatcher.handle_request(message['request']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) + else: + logger.warning(u'Cannot handle message: %s', message) diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 72a1f845..580b5905 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -48,6 +48,7 @@ class MpdSession(asynchat.async_chat): """Handle request by sending it to the MPD frontend.""" my_end, other_end = multiprocessing.Pipe() self.core_queue.put({ + 'to': 'frontend', 'command': 'mpd_request', 'request': request, 'reply_to': pickle_connection(other_end), From ea9385defe7b91a9c6d1e32b355a42a1dbd2e1b3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 22:48:41 +0200 Subject: [PATCH 12/71] Add BaseOutput and DummyOutput --- mopidy/outputs/base.py | 16 ++++++++++++++++ mopidy/outputs/dummy.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 mopidy/outputs/base.py create mode 100644 mopidy/outputs/dummy.py diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py new file mode 100644 index 00000000..0e2cabfe --- /dev/null +++ b/mopidy/outputs/base.py @@ -0,0 +1,16 @@ +class BaseOutput(object): + """ + Base class for audio outputs. + """ + + def start(self): + """Start the output.""" + pass + + def destroy(self): + """Destroy the output.""" + pass + + def process_message(self, message): + """Process messages with the output as destination.""" + raise NotImplementedError diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py new file mode 100644 index 00000000..26c750ae --- /dev/null +++ b/mopidy/outputs/dummy.py @@ -0,0 +1,24 @@ +from mopidy.outputs.base import BaseOutput + +class DummyOutput(BaseOutput): + """ + Audio output used for testing. + """ + + #: For testing. :class:`True` if :meth:`start` has been called. + start_called = False + + #: For testing. :class:`True` if :meth:`destroy` has been called. + destroy_called = False + + #: For testing. Contains all messages :meth:`process_message` has received. + messages = [] + + def start(self): + self.start_called = True + + def destroy(self): + self.destroy_called = True + + def process_message(self, message): + self.messages.append(message) From 947b91aa5c97f3f09c6334694a45e0b5899b0376 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 23 Aug 2010 23:30:20 +0200 Subject: [PATCH 13/71] Add basic runtime setting support --- mopidy/utils/settings.py | 8 ++++++++ tests/utils/settings_test.py | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index e45c5521..9340dc01 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -15,6 +15,7 @@ class SettingsProxy(object): self.default = self._get_settings_dict_from_module( default_settings_module) self.local = self._get_local_settings() + self.runtime = {} def _get_local_settings(self): dotdir = os.path.expanduser(u'~/.mopidy/') @@ -37,6 +38,7 @@ class SettingsProxy(object): def current(self): current = copy(self.default) current.update(self.local) + current.update(self.runtime) return current def __getattr__(self, attr): @@ -49,6 +51,12 @@ class SettingsProxy(object): raise SettingsError(u'Setting "%s" is empty.' % attr) return value + def __setattr__(self, attr, value): + if self._is_setting(attr): + self.runtime[attr] = value + else: + super(SettingsProxy, self).__setattr__(attr, value) + def validate(self): if self.get_errors(): logger.error(u'Settings validation errors: %s', diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 5bf0f9b4..0c06ae5c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,6 +1,7 @@ import unittest -from mopidy.utils.settings import validate_settings +from mopidy import settings as default_settings_module +from mopidy.utils.settings import validate_settings, SettingsProxy class ValidateSettingsTest(unittest.TestCase): def setUp(self): @@ -43,3 +44,24 @@ class ValidateSettingsTest(unittest.TestCase): result = validate_settings(self.defaults, {'FOO': '', 'BAR': ''}) self.assertEquals(len(result), 2) + + +class SettingsProxyTest(unittest.TestCase): + def setUp(self): + self.settings = SettingsProxy(default_settings_module) + + def test_set_and_get_attr(self): + self.settings.TEST = 'test' + self.assertEqual(self.settings.TEST, 'test') + + def test_setattr_updates_runtime_settings(self): + self.settings.TEST = 'test' + self.assert_('TEST' in self.settings.runtime) + + def test_setattr_updates_runtime_with_value(self): + self.settings.TEST = 'test' + self.assertEqual(self.settings.runtime['TEST'], 'test') + + def test_runtime_value_included_in_current(self): + self.settings.TEST = 'test' + self.assertEqual(self.settings.current['TEST'], 'test') From 5d61bb1f7d2e6233c72d34a454d406a4e9d94ade Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 23:40:48 +0200 Subject: [PATCH 14/71] BaseOutput takes a reference to core_queue --- mopidy/outputs/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py index 0e2cabfe..b6b024a6 100644 --- a/mopidy/outputs/base.py +++ b/mopidy/outputs/base.py @@ -3,6 +3,9 @@ class BaseOutput(object): Base class for audio outputs. """ + def __init__(self, core_queue): + self.core_queue = core_queue + def start(self): """Start the output.""" pass From 1ebd95e87938dfe1a5ebc530a98dfcdd7b420889 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 23:44:28 +0200 Subject: [PATCH 15/71] Pass around an 'output' instead of an 'output_queue' --- mopidy/backends/base/__init__.py | 8 ++++---- mopidy/backends/libspotify/__init__.py | 2 +- mopidy/backends/libspotify/playback.py | 2 +- mopidy/backends/libspotify/session_manager.py | 13 ++++++++---- mopidy/backends/local/__init__.py | 9 ++++++--- mopidy/core.py | 18 ++++++++--------- mopidy/outputs/gstreamer.py | 20 ++++++++++++++++--- 7 files changed, 47 insertions(+), 25 deletions(-) diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 80c4d0c0..491c5b73 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -23,17 +23,17 @@ class BaseBackend(object): :param core_queue: a queue for sending messages to :class:`mopidy.process.CoreProcess` :type core_queue: :class:`multiprocessing.Queue` - :param output_queue: a queue for sending messages to the output process - :type output_queue: :class:`multiprocessing.Queue` + :param output: the audio output + :type output: :class:`mopidy.outputs.gstreamer.GStreamerOutput` or similar :param mixer_class: either a mixer class, or :class:`None` to use the mixer defined in settings :type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or :class:`None` """ - def __init__(self, core_queue=None, output_queue=None, mixer_class=None): + def __init__(self, core_queue=None, output=None, mixer_class=None): self.core_queue = core_queue - self.output_queue = output_queue + self.output = output if mixer_class is None: mixer_class = get_class(settings.MIXER) self.mixer = mixer_class(self) diff --git a/mopidy/backends/libspotify/__init__.py b/mopidy/backends/libspotify/__init__.py index 07f3e2f7..0d7e5d0b 100644 --- a/mopidy/backends/libspotify/__init__.py +++ b/mopidy/backends/libspotify/__init__.py @@ -55,6 +55,6 @@ class LibspotifyBackend(BaseBackend): spotify = LibspotifySessionManager( settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, core_queue=self.core_queue, - output_queue=self.output_queue) + output=self.output) spotify.start() return spotify diff --git a/mopidy/backends/libspotify/playback.py b/mopidy/backends/libspotify/playback.py index ed5ba697..58f6ec3a 100644 --- a/mopidy/backends/libspotify/playback.py +++ b/mopidy/backends/libspotify/playback.py @@ -12,7 +12,7 @@ class LibspotifyPlaybackController(BasePlaybackController): def _set_output_state(self, state_name): logger.debug(u'Setting output state to %s ...', state_name) (my_end, other_end) = multiprocessing.Pipe() - self.backend.output_queue.put({ + self.backend.output.process_message({ 'command': 'set_state', 'state': state_name, 'reply_to': pickle_connection(other_end), diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py index 22cbb0a0..f58a32f8 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -16,14 +16,14 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() - def __init__(self, username, password, core_queue, output_queue): + def __init__(self, username, password, core_queue, output): SpotifySessionManager.__init__(self, username, password) threading.Thread.__init__(self, name='LibspotifySessionManagerThread') # Run as a daemon thread, so Mopidy won't wait for this thread to exit # before Mopidy exits. self.daemon = True self.core_queue = core_queue - self.output_queue = output_queue + self.output = output self.connected = threading.Event() self.session = None @@ -48,6 +48,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): playlists.append( LibspotifyTranslator.to_mopidy_playlist(spotify_playlist)) self.core_queue.put({ + 'to': 'output', 'command': 'set_stored_playlists', 'playlists': playlists, }) @@ -77,7 +78,8 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): signed=True, rate=(int)44100 """ - self.output_queue.put({ + self.output.process_message({ + 'to': 'output', 'command': 'deliver_data', 'caps': caps_string, 'data': bytes(frames), @@ -95,7 +97,10 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug('End of data stream.') - self.output_queue.put({'command': 'end_of_data_stream'}) + self.output.process_message({ + 'to': 'output', + 'command': 'end_of_data_stream', + }) def search(self, query, connection): """Search method used by Mopidy backend""" diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 50b3d84d..7112fdf1 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -43,13 +43,16 @@ class LocalPlaybackController(BasePlaybackController): def _send_recv(self, message): (my_end, other_end) = multiprocessing.Pipe() - message.update({'reply_to': pickle_connection(other_end)}) - self.backend.output_queue.put(message) + message.update({ + 'to': 'output', + 'reply_to': pickle_connection(other_end), + }) + self.backend.output.process_message(message) my_end.poll(None) return my_end.recv() def _send(self, message): - self.backend.output_queue.put(message) + self.backend.output.process_message(message) def _set_state(self, state): return self._send_recv({'command': 'set_state', 'state': state}) diff --git a/mopidy/core.py b/mopidy/core.py index 260c8f8c..d3b2c94f 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -16,7 +16,7 @@ class CoreProcess(BaseProcess): super(CoreProcess, self).__init__(name='CoreProcess') self.core_queue = multiprocessing.Queue() self.options = self.parse_options() - self.output_queue = None + self.output = None self.backend = None self.frontend = None @@ -46,8 +46,8 @@ class CoreProcess(BaseProcess): def setup(self): self.setup_logging() self.setup_settings() - self.output_queue = self.setup_output(self.core_queue) - self.backend = self.setup_backend(self.core_queue, self.output_queue) + self.output = self.setup_output(self.core_queue) + self.backend = self.setup_backend(self.core_queue, self.output) self.frontend = self.setup_frontend(self.core_queue, self.backend) def setup_logging(self): @@ -59,12 +59,12 @@ class CoreProcess(BaseProcess): settings.validate() def setup_output(self, core_queue): - output_queue = multiprocessing.Queue() - get_class(settings.OUTPUT)(core_queue, output_queue) - return output_queue + output = get_class(settings.OUTPUT)(core_queue) + output.start() + return output - def setup_backend(self, core_queue, output_queue): - return get_class(settings.BACKENDS[0])(core_queue, output_queue) + def setup_backend(self, core_queue, output): + return get_class(settings.BACKENDS[0])(core_queue, output) def setup_frontend(self, core_queue, backend): frontend = get_class(settings.FRONTENDS[0])(core_queue, backend) @@ -73,7 +73,7 @@ class CoreProcess(BaseProcess): def process_message(self, message): if message.get('to') == 'output': - self.output_queue.put(message) + self.output.process_message(message) elif message.get('to') == 'frontend': self.frontend.process_message(message) elif message['command'] == 'end_of_track': diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 554e986e..9e9a843b 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -6,14 +6,16 @@ pygst.require('0.10') import gst import logging +import multiprocessing import threading from mopidy import settings +from mopidy.outputs.base import BaseOutput from mopidy.utils.process import BaseProcess, unpickle_connection logger = logging.getLogger('mopidy.outputs.gstreamer') -class GStreamerOutput(object): +class GStreamerOutput(BaseOutput): """ Audio output through GStreamer. @@ -24,13 +26,25 @@ class GStreamerOutput(object): - :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` """ - def __init__(self, core_queue, output_queue): - self.process = GStreamerProcess(core_queue, output_queue) + def __init__(self, core_queue): + super(GStreamerOutput, self).__init__(core_queue) + self.output_queue = multiprocessing.Queue() + self.process = GStreamerProcess(core_queue, self.output_queue) + + def start(self): self.process.start() def destroy(self): self.process.terminate() + def process_message(self, message): + """ + Processes messages with the GStreamer output as destination. + """ + assert message['to'] == 'output', \ + u'Message recipient must be "output".' + self.output_queue.put(message) + class GStreamerMessagesThread(threading.Thread): def run(self): gobject.MainLoop().run() From f099161e24e24571b9361685b3f2b0088f2a5a26 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 23:45:21 +0200 Subject: [PATCH 16/71] Never block without timeout in a test --- tests/backends/base/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 3a5d86e1..04b5ed7b 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -534,7 +534,7 @@ class BasePlaybackControllerTest(object): self.playback.play() result = self.playback.seek(self.tracks[0].length - 10) self.assert_(result, 'Seek failed') - message = self.core_queue.get() + message = self.core_queue.get(True, 1) self.assertEqual('end_of_track', message['command']) @populate_playlist From a62a6e01a7f8e6bde1b8a5178e3889c7e183009a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 23:45:42 +0200 Subject: [PATCH 17/71] Use DummyOutput in tests --- tests/backends/base/current_playlist.py | 7 +++---- tests/backends/base/playback.py | 7 +++---- tests/outputs/gstreamer_test.py | 14 ++++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 1b312c2f..59c7b39f 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -4,6 +4,7 @@ import random from mopidy import settings from mopidy.mixers.dummy import DummyMixer from mopidy.models import Playlist, Track +from mopidy.outputs.dummy import DummyOutput from mopidy.utils import get_class from tests.backends.base import populate_playlist @@ -12,12 +13,10 @@ class BaseCurrentPlaylistControllerTest(object): tracks = [] def setUp(self): - self.output_queue = multiprocessing.Queue() self.core_queue = multiprocessing.Queue() - self.output = get_class(settings.OUTPUT)( - self.core_queue, self.output_queue) + self.output = DummyOutput(self.core_queue) self.backend = self.backend_class( - self.core_queue, self.output_queue, DummyMixer) + self.core_queue, self.output, DummyMixer) self.controller = self.backend.current_playlist self.playback = self.backend.playback diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 04b5ed7b..afbfea6c 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -5,6 +5,7 @@ import time from mopidy import settings from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track +from mopidy.outputs.dummy import DummyOutput from mopidy.utils import get_class from tests import SkipTest @@ -16,12 +17,10 @@ class BasePlaybackControllerTest(object): tracks = [] def setUp(self): - self.output_queue = multiprocessing.Queue() self.core_queue = multiprocessing.Queue() - self.output = get_class(settings.OUTPUT)( - self.core_queue, self.output_queue) + self.output = DummyOutput(self.core_queue) self.backend = self.backend_class( - self.core_queue, self.output_queue, DummyMixer) + self.core_queue, self.output, DummyMixer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index b380197d..2094c038 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -20,9 +20,9 @@ class GStreamerOutputTest(unittest.TestCase): self.original_backends = settings.BACKENDS settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) self.song_uri = path_to_uri(data_folder('song1.wav')) - self.output_queue = multiprocessing.Queue() self.core_queue = multiprocessing.Queue() - self.output = GStreamerOutput(self.core_queue, self.output_queue) + self.output = GStreamerOutput(self.core_queue) + self.output.start() def tearDown(self): self.output.destroy() @@ -30,14 +30,16 @@ class GStreamerOutputTest(unittest.TestCase): def send_recv(self, message): (my_end, other_end) = multiprocessing.Pipe() - message.update({'reply_to': pickle_connection(other_end)}) - self.output_queue.put(message) + message.update({ + 'to': 'output', + 'reply_to': pickle_connection(other_end), + }) + self.output.process_message(message) my_end.poll(None) return my_end.recv() - def send(self, message): - self.output_queue.put(message) + self.output.process_message(message) def test_play_uri_existing_file(self): message = {'command': 'play_uri', 'uri': self.song_uri} From d85684033ae6a2b001e0de0817a3c31a9e0fd433 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 23:46:41 +0200 Subject: [PATCH 18/71] Let DummyOutput respons to requests --- mopidy/outputs/dummy.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py index 26c750ae..b6a42a29 100644 --- a/mopidy/outputs/dummy.py +++ b/mopidy/outputs/dummy.py @@ -1,4 +1,5 @@ from mopidy.outputs.base import BaseOutput +from mopidy.utils.process import unpickle_connection class DummyOutput(BaseOutput): """ @@ -22,3 +23,7 @@ class DummyOutput(BaseOutput): def process_message(self, message): self.messages.append(message) + if 'reply_to' in message: + connection = unpickle_connection(message['reply_to']) + # FIXME This is too simple. Some callers expect something else. + connection.send(True) From efe14032ceca5a360920670b9306cea8c38a2928 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 23 Aug 2010 23:49:59 +0200 Subject: [PATCH 19/71] Fix three more tests --- tests/outputs/gstreamer_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index 2094c038..15260ee0 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -39,6 +39,7 @@ class GStreamerOutputTest(unittest.TestCase): return my_end.recv() def send(self, message): + message.update({'to': 'output'}) self.output.process_message(message) def test_play_uri_existing_file(self): From 405d411464242d2ae680e365ce1e362b19e3e076 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 24 Aug 2010 00:19:05 +0200 Subject: [PATCH 20/71] Update tests to clear runtime settings --- tests/backends/base/stored_playlists.py | 8 +------- tests/backends/local/current_playlist_test.py | 3 +-- tests/backends/local/library_test.py | 6 +----- tests/backends/local/playback_test.py | 3 +-- tests/outputs/gstreamer_test.py | 4 +--- 5 files changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 630898de..ef5806ef 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -10,10 +10,6 @@ from tests import SkipTest, data_folder class BaseStoredPlaylistsControllerTest(object): def setUp(self): - self.original_playlist_folder = settings.LOCAL_PLAYLIST_FOLDER - self.original_tag_cache = settings.LOCAL_TAG_CACHE - self.original_music_folder = settings.LOCAL_MUSIC_FOLDER - settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp() settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') settings.LOCAL_MUSIC_FOLDER = data_folder('') @@ -27,9 +23,7 @@ class BaseStoredPlaylistsControllerTest(object): if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER): shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER) - settings.LOCAL_PLAYLIST_FOLDER = self.original_playlist_folder - settings.LOCAL_TAG_CACHE = self.original_tag_cache - settings.LOCAL_MUSIC_FOLDER = self.original_music_folder + settings.runtime.clear() def test_create(self): playlist = self.stored.create('test') diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index 01354a06..3895497a 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -22,10 +22,9 @@ class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, for i in range(1, 4)] def setUp(self): - self.original_backends = settings.BACKENDS settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) super(LocalCurrentPlaylistControllerTest, self).setUp() def tearDown(self): super(LocalCurrentPlaylistControllerTest, self).tearDown() - settings.BACKENDS = settings.original_backends + settings.runtime.clear() diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 75751e3d..c0605ef2 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -17,16 +17,12 @@ class LocalLibraryControllerTest(BaseLibraryControllerTest, unittest.TestCase): backend_class = LocalBackend def setUp(self): - self.original_tag_cache = settings.LOCAL_TAG_CACHE - self.original_music_folder = settings.LOCAL_MUSIC_FOLDER - settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') settings.LOCAL_MUSIC_FOLDER = data_folder('') super(LocalLibraryControllerTest, self).setUp() def tearDown(self): - settings.LOCAL_TAG_CACHE = self.original_tag_cache - settings.LOCAL_MUSIC_FOLDER = self.original_music_folder + settings.runtime.clear() super(LocalLibraryControllerTest, self).tearDown() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4a385a9d..a84dfcde 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -23,7 +23,6 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest, for i in range(1, 4)] def setUp(self): - self.original_backends = settings.BACKENDS settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) super(LocalPlaybackControllerTest, self).setUp() @@ -32,7 +31,7 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest, def tearDown(self): super(LocalPlaybackControllerTest, self).tearDown() - settings.BACKENDS = settings.original_backends + settings.runtime.clear() def add_track(self, path): uri = path_to_uri(data_folder(path)) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index b380197d..31d90b2a 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -17,7 +17,6 @@ from tests import data_folder class GStreamerOutputTest(unittest.TestCase): def setUp(self): - self.original_backends = settings.BACKENDS settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) self.song_uri = path_to_uri(data_folder('song1.wav')) self.output_queue = multiprocessing.Queue() @@ -26,7 +25,7 @@ class GStreamerOutputTest(unittest.TestCase): def tearDown(self): self.output.destroy() - settings.BACKENDS = settings.original_backends + settings.runtime.clear() def send_recv(self, message): (my_end, other_end) = multiprocessing.Pipe() @@ -35,7 +34,6 @@ class GStreamerOutputTest(unittest.TestCase): my_end.poll(None) return my_end.recv() - def send(self, message): self.output_queue.put(message) From d526f80473dcc19dd90fb80ee928aad5bc75b933 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 00:47:24 +0200 Subject: [PATCH 21/71] Validate if there is anything to seek in in the BaseBackend --- mopidy/backends/base/playback.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 933424ad..df588f39 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -442,8 +442,9 @@ class BasePlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - # FIXME I think return value is only really useful for internal - # testing, as such it should probably not be exposed in API. + if not self.backend.current_playlist.tracks: + return False + if self.state == self.STOPPED: self.play() elif self.state == self.PAUSED: @@ -451,9 +452,9 @@ class BasePlaybackController(object): if time_position < 0: time_position = 0 - elif self.current_track and time_position > self.current_track.length: + elif time_position > self.current_track.length: self.next() - return + return True self._play_time_started = self._current_wall_time self._play_time_accumulated = time_position From f7d99b5c0a4e55056a15bb0cb05acae4b4aeedf9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 00:48:16 +0200 Subject: [PATCH 22/71] GStreamerOutput returns something from set_volume too for consistency --- mopidy/outputs/gstreamer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 9e9a843b..85e86171 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -125,7 +125,9 @@ class GStreamerProcess(BaseProcess): connection = unpickle_connection(message['reply_to']) connection.send(volume) elif message['command'] == 'set_volume': - self.set_volume(message['volume']) + response = self.set_volume(message['volume']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) elif message['command'] == 'set_position': response = self.set_position(message['position']) connection = unpickle_connection(message['reply_to']) @@ -209,6 +211,7 @@ class GStreamerProcess(BaseProcess): """Set volume in range [0..100]""" gst_volume = self.gst_pipeline.get_by_name('volume') gst_volume.set_property('volume', volume / 100.0) + return True def set_position(self, position): self.gst_pipeline.get_state() # block until state changes are done From 64b760bf96c48e9865aca92631befd42bf7ba46e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 00:50:02 +0200 Subject: [PATCH 23/71] Ignore tests which uses time.sleep (and that don't work with DummyOutput+LocalBackend) --- tests/backends/base/playback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index afbfea6c..10c69a66 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -607,6 +607,7 @@ class BasePlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) + @SkipTest # Uses sleep and does not work with DummyOutput+LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -627,8 +628,7 @@ class BasePlaybackControllerTest(object): self.assert_(position >= 990, position) def test_seek_on_empty_playlist(self): - result = self.playback.seek(0) - self.assert_(not result, 'Seek return value was %s' % result) + self.assertFalse(self.playback.seek(0)) def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0) @@ -739,15 +739,16 @@ class BasePlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): self.assertEqual(self.playback.time_position, 0) + @SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput @populate_playlist def test_time_position_when_playing(self): self.playback.play() first = self.playback.time_position time.sleep(1) second = self.playback.time_position - self.assert_(second > first, '%s - %s' % (first, second)) + @SkipTest # Uses sleep @populate_playlist def test_time_position_when_paused(self): self.playback.play() @@ -756,7 +757,6 @@ class BasePlaybackControllerTest(object): time.sleep(0.2) first = self.playback.time_position second = self.playback.time_position - self.assertEqual(first, second) @populate_playlist From 9fe58740693f0448f695b683db40f43f227afb91 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:04:43 +0200 Subject: [PATCH 24/71] Skip another backend test that does not work with DummyOutput --- tests/backends/base/playback.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 10c69a66..ca4d9941 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -528,11 +528,12 @@ class BasePlaybackControllerTest(object): self.assert_(wrapper.called) + @SkipTest # Blocks for 10ms and does not work with DummyOutput @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() result = self.playback.seek(self.tracks[0].length - 10) - self.assert_(result, 'Seek failed') + self.assertTrue(result, 'Seek failed') message = self.core_queue.get(True, 1) self.assertEqual('end_of_track', message['command']) From abce165aa3b356081503d032db16d92b6c2f6b37 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:06:20 +0200 Subject: [PATCH 25/71] Extend output API with all methods needed for GStreamerOutput --- mopidy/outputs/base.py | 69 +++++++++++++++++++++++++++++++++ mopidy/outputs/dummy.py | 56 ++++++++++++++++++++++++-- mopidy/outputs/gstreamer.py | 48 +++++++++++++++++++++-- tests/outputs/gstreamer_test.py | 49 +++++++++++------------ 4 files changed, 187 insertions(+), 35 deletions(-) diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py index b6b024a6..bb312323 100644 --- a/mopidy/outputs/base.py +++ b/mopidy/outputs/base.py @@ -17,3 +17,72 @@ class BaseOutput(object): def process_message(self, message): """Process messages with the output as destination.""" raise NotImplementedError + + def play_uri(self, uri): + """ + Play URI. + + :param uri: the URI to play + :type uri: string + :rtype: :class:`True` if successful, else :class:`False` + """ + raise NotImplementedError + + def deliver_data(self, capabilities, data): + """ + Deliver audio data to be played. + + :param capabilities: a GStreamer capabilities string + :type capabilities: string + """ + raise NotImplementedError + + def end_of_data_stream(self): + """Signal that the last audio data has been delivered.""" + raise NotImplementedError + + def get_position(self): + """ + Get position in milliseconds. + + :rtype: int + """ + raise NotImplementedError + + 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` + """ + raise NotImplementedError + + def set_state(self, state): + """ + Set playback state. + + :param state: the state + :type state: string + :rtype: :class:`True` if successful, else :class:`False` + """ + raise NotImplementedError + + def get_volume(self): + """ + Get volume level for software mixer. + + :rtype: int in range [0..100] + """ + raise NotImplementedError + + def set_volume(self, volume): + """ + Set volume level for software mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + raise NotImplementedError diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py index b6a42a29..a98e38af 100644 --- a/mopidy/outputs/dummy.py +++ b/mopidy/outputs/dummy.py @@ -15,6 +15,29 @@ class DummyOutput(BaseOutput): #: For testing. Contains all messages :meth:`process_message` has received. messages = [] + #: For testing. Contains the last URI passed to :meth:`play_uri`. + uri = None + + #: For testing. Contains the last capabilities passed to + #: :meth:`deliver_data`. + capabilities = None + + #: For testing. Contains the last data passed to :meth:`deliver_data`. + data = None + + #: For testing. :class:`True` if :meth:`end_of_data_stream` has been + #: called. + end_of_data_stream_called = False + + #: For testing. Contains the current position. + position = 0 + + #: For testing. Contains the current state. + state = 'NULL' + + #: For testing. Contains the current volume. + volume = 100 + def start(self): self.start_called = True @@ -23,7 +46,32 @@ class DummyOutput(BaseOutput): def process_message(self, message): self.messages.append(message) - if 'reply_to' in message: - connection = unpickle_connection(message['reply_to']) - # FIXME This is too simple. Some callers expect something else. - connection.send(True) + + def play_uri(self, uri): + self.uri = uri + return True + + def deliver_data(self, capabilities, data): + self.capabilities = capabilities + self.data = data + + def end_of_data_stream(self): + self.end_of_data_stream_called = True + + def get_position(self): + return self.position + + def set_position(self, position): + self.position = position + return True + + def set_state(self, state): + self.state = state + return True + + def get_volume(self): + return self.volume + + def set_volume(self, volume): + self.volume = volume + return True diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 85e86171..e2aa6436 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -11,7 +11,8 @@ import threading from mopidy import settings from mopidy.outputs.base import BaseOutput -from mopidy.utils.process import BaseProcess, unpickle_connection +from mopidy.utils.process import (BaseProcess, pickle_connection, + unpickle_connection) logger = logging.getLogger('mopidy.outputs.gstreamer') @@ -38,17 +39,56 @@ class GStreamerOutput(BaseOutput): self.process.terminate() def process_message(self, message): - """ - Processes messages with the GStreamer output as destination. - """ assert message['to'] == 'output', \ u'Message recipient must be "output".' self.output_queue.put(message) + def _send_recv(self, message): + (my_end, other_end) = multiprocessing.Pipe() + message['to'] = 'output' + message['reply_to'] = pickle_connection(other_end) + self.process_message(message) + my_end.poll(None) + return my_end.recv() + + def _send(self, message): + message['to'] = 'output' + self.process_message(message) + + def play_uri(self, uri): + return self._send_recv({'command': 'play_uri', 'uri': uri}) + + def deliver_data(self, capabilities, data): + return self._send({ + 'command': 'deliver_data', + 'caps': capabilities, + 'data': data, + }) + + def end_of_data_stream(self): + return self._send({'command': 'end_of_data_stream'}) + + def get_position(self): + return self._send_recv({'command': 'get_position'}) + + def set_position(self, position): + return self._send_recv({'command': 'set_position'}) + + def set_state(self, state): + return self._send_recv({'command': 'set_state', 'state': state}) + + def get_volume(self): + return self._send_recv({'command': 'get_volume'}) + + def set_volume(self, volume): + return self._send_recv({'command': 'set_volume', 'volume': volume}) + + class GStreamerMessagesThread(threading.Thread): def run(self): gobject.MainLoop().run() + class GStreamerProcess(BaseProcess): """ A process for all work related to GStreamer. diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index 15260ee0..3f18739d 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -28,44 +28,39 @@ class GStreamerOutputTest(unittest.TestCase): self.output.destroy() settings.BACKENDS = settings.original_backends - def send_recv(self, message): - (my_end, other_end) = multiprocessing.Pipe() - message.update({ - 'to': 'output', - 'reply_to': pickle_connection(other_end), - }) - self.output.process_message(message) - my_end.poll(None) - return my_end.recv() - - def send(self, message): - message.update({'to': 'output'}) - self.output.process_message(message) - def test_play_uri_existing_file(self): - message = {'command': 'play_uri', 'uri': self.song_uri} - self.assertEqual(True, self.send_recv(message)) + self.assertTrue(self.output.play_uri(self.song_uri)) def test_play_uri_non_existing_file(self): - message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'} - self.assertEqual(False, self.send_recv(message)) + self.assertFalse(self.output.play_uri(self.song_uri + 'bogus')) + + @SkipTest + def test_deliver_data(self): + pass # TODO + + @SkipTest + def test_end_of_data_stream(self): + pass # TODO def test_default_get_volume_result(self): - message = {'command': 'get_volume'} - self.assertEqual(100, self.send_recv(message)) + self.assertEqual(100, self.output.get_volume()) def test_set_volume(self): - self.send({'command': 'set_volume', 'volume': 50}) - self.assertEqual(50, self.send_recv({'command': 'get_volume'})) + self.assertTrue(self.output.set_volume(50)) + self.assertEqual(50, self.output.get_volume()) def test_set_volume_to_zero(self): - self.send({'command': 'set_volume', 'volume': 0}) - self.assertEqual(0, self.send_recv({'command': 'get_volume'})) + self.assertTrue(self.output.set_volume(0)) + self.assertEqual(0, self.output.get_volume()) def test_set_volume_to_one_hundred(self): - self.send({'command': 'set_volume', 'volume': 100}) - self.assertEqual(100, self.send_recv({'command': 'get_volume'})) + self.assertTrue(self.output.set_volume(100)) + self.assertEqual(100, self.output.get_volume()) @SkipTest def test_set_state(self): - raise NotImplementedError + pass # TODO + + @SkipTest + def test_set_position(self): + pass # TODO From 98894f49e918bae30047691180366693c2d1e1ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:08:31 +0200 Subject: [PATCH 26/71] Update LocalBackend to use new output API --- mopidy/backends/local/__init__.py | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 7112fdf1..e5bfe8f8 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -41,41 +41,24 @@ class LocalPlaybackController(BasePlaybackController): super(LocalPlaybackController, self).__init__(backend) self.stop() - def _send_recv(self, message): - (my_end, other_end) = multiprocessing.Pipe() - message.update({ - 'to': 'output', - 'reply_to': pickle_connection(other_end), - }) - self.backend.output.process_message(message) - my_end.poll(None) - return my_end.recv() - - def _send(self, message): - self.backend.output.process_message(message) - - def _set_state(self, state): - return self._send_recv({'command': 'set_state', 'state': state}) - def _play(self, track): - return self._send_recv({'command': 'play_uri', 'uri': track.uri}) + return self.backend.output.play_uri(track.uri) def _stop(self): - return self._set_state('READY') + return self.backend.output.set_state('READY') def _pause(self): - return self._set_state('PAUSED') + return self.backend.output.set_state('PAUSED') def _resume(self): - return self._set_state('PLAYING') + return self.backend.output.set_state('PLAYING') def _seek(self, time_position): - return self._send_recv({'command': 'set_position', - 'position': time_position}) + return self.backend.output.set_position(time_position) @property def time_position(self): - return self._send_recv({'command': 'get_position'}) + return self.backend.output.get_position() class LocalStoredPlaylistsController(BaseStoredPlaylistsController): From efc37614538ac6de14a447fbffa4092c5697dab6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:08:57 +0200 Subject: [PATCH 27/71] Update LibspotifyBackend to use new output API --- mopidy/backends/libspotify/playback.py | 25 +++++-------------- mopidy/backends/libspotify/session_manager.py | 15 +++-------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/mopidy/backends/libspotify/playback.py b/mopidy/backends/libspotify/playback.py index 58f6ec3a..39c56bf6 100644 --- a/mopidy/backends/libspotify/playback.py +++ b/mopidy/backends/libspotify/playback.py @@ -1,30 +1,17 @@ import logging -import multiprocessing from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackController -from mopidy.utils.process import pickle_connection logger = logging.getLogger('mopidy.backends.libspotify.playback') class LibspotifyPlaybackController(BasePlaybackController): - def _set_output_state(self, state_name): - logger.debug(u'Setting output state to %s ...', state_name) - (my_end, other_end) = multiprocessing.Pipe() - self.backend.output.process_message({ - 'command': 'set_state', - 'state': state_name, - 'reply_to': pickle_connection(other_end), - }) - my_end.poll(None) - return my_end.recv() - def _pause(self): - return self._set_output_state('PAUSED') + return self.backend.output.set_state('PAUSED') def _play(self, track): - self._set_output_state('READY') + self.backend.output.set_state('READY') if self.state == self.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: @@ -33,7 +20,7 @@ class LibspotifyPlaybackController(BasePlaybackController): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self._set_output_state('PLAYING') + self.backend.output.set_state('PLAYING') return True except SpotifyError as e: logger.warning('Play %s failed: %s', track.uri, e) @@ -43,12 +30,12 @@ class LibspotifyPlaybackController(BasePlaybackController): return self._seek(self.time_position) def _seek(self, time_position): - self._set_output_state('READY') + self.backend.output.set_state('READY') self.backend.spotify.session.seek(time_position) - self._set_output_state('PLAYING') + self.backend.output.set_state('PLAYING') return True def _stop(self): - result = self._set_output_state('READY') + result = self.backend.output.set_state('READY') self.backend.spotify.session.play(0) return result diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py index f58a32f8..62a3c7dd 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -48,7 +48,6 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): playlists.append( LibspotifyTranslator.to_mopidy_playlist(spotify_playlist)) self.core_queue.put({ - 'to': 'output', 'command': 'set_stored_playlists', 'playlists': playlists, }) @@ -69,7 +68,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): sample_type, sample_rate, channels): """Callback used by pyspotify""" # TODO Base caps_string on arguments - caps_string = """ + capabilites = """ audio/x-raw-int, endianness=(int)1234, channels=(int)2, @@ -78,12 +77,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): signed=True, rate=(int)44100 """ - self.output.process_message({ - 'to': 'output', - 'command': 'deliver_data', - 'caps': caps_string, - 'data': bytes(frames), - }) + self.output.deliver_data(capabilites, bytes(frames)) def play_token_lost(self, session): """Callback used by pyspotify""" @@ -97,10 +91,7 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug('End of data stream.') - self.output.process_message({ - 'to': 'output', - 'command': 'end_of_data_stream', - }) + self.output.end_of_data_stream() def search(self, query, connection): """Search method used by Mopidy backend""" From d535cf76b3129c0e5b6908a720bdf3e3a804e41b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:11:01 +0200 Subject: [PATCH 28/71] Update GStreamer software mixer to use new output API --- mopidy/mixers/gstreamer_software.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py index 1225cafd..333690ea 100644 --- a/mopidy/mixers/gstreamer_software.py +++ b/mopidy/mixers/gstreamer_software.py @@ -1,7 +1,4 @@ -import multiprocessing - from mopidy.mixers import BaseMixer -from mopidy.utils.process import pickle_connection class GStreamerSoftwareMixer(BaseMixer): """Mixer which uses GStreamer to control volume in software.""" @@ -10,16 +7,7 @@ class GStreamerSoftwareMixer(BaseMixer): super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs) def _get_volume(self): - my_end, other_end = multiprocessing.Pipe() - self.backend.output_queue.put({ - 'command': 'get_volume', - 'reply_to': pickle_connection(other_end), - }) - my_end.poll(None) - return my_end.recv() + return self.backend.output.get_volume() def _set_volume(self, volume): - self.backend.output_queue.put({ - 'command': 'set_volume', - 'volume': volume, - }) + self.backend.output.set_volume(volume) From cf2b8776a56aaf23a7e824ab18a0ee1524f89e0c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:11:11 +0200 Subject: [PATCH 29/71] Remove unused import --- mopidy/outputs/dummy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py index a98e38af..fd42b38b 100644 --- a/mopidy/outputs/dummy.py +++ b/mopidy/outputs/dummy.py @@ -1,5 +1,4 @@ from mopidy.outputs.base import BaseOutput -from mopidy.utils.process import unpickle_connection class DummyOutput(BaseOutput): """ From f42d2264917f109b8cee1d641a475934a456aa61 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:23:59 +0200 Subject: [PATCH 30/71] Add a BaseFrontend --- mopidy/frontends/base.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 mopidy/frontends/base.py diff --git a/mopidy/frontends/base.py b/mopidy/frontends/base.py new file mode 100644 index 00000000..92545b73 --- /dev/null +++ b/mopidy/frontends/base.py @@ -0,0 +1,30 @@ +class BaseFrontend(object): + """ + Base class for frontends. + + :param core_queue: queue for messaging the core + :type core_queue: :class:`multiprocessing.Queue` + :param backend: the backend + :type backend: :class:`mopidy.backends.base.BaseBackend` + """ + + def __init__(self, core_queue, backend): + self.core_queue = core_queue + self.backend = backend + + def start(self): + """Start the frontend.""" + pass + + def destroy(self): + """Destroy the frontend.""" + pass + + def process_message(self, message): + """ + Process messages for the frontend. + + :param message: the message + :type message: dict + """ + raise NotImplementedError From 556e6ba4d9bc32384526501acbbc4c0c2b6f983e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:25:27 +0200 Subject: [PATCH 31/71] Make MpdFrontend a subclass of BaseFrontend --- mopidy/frontends/mpd/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 8e7d65ab..f1bfdd57 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,12 +1,13 @@ import logging +from mopidy.frontends.base import BaseFrontend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.process import MpdProcess from mopidy.utils.process import unpickle_connection logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(object): +class MpdFrontend(BaseFrontend): """ The MPD frontend. @@ -16,16 +17,20 @@ class MpdFrontend(object): - :attr:`mopidy.settings.MPD_SERVER_PORT` """ - def __init__(self, core_queue, backend): - self.core_queue = core_queue + def __init__(self, *args, **kwargs): + super(MpdFrontend, self).__init__(*args, **kwargs) self.process = None - self.dispatcher = MpdDispatcher(backend) + self.dispatcher = MpdDispatcher(self.backend) def start(self): """Starts the MPD server.""" self.process = MpdProcess(self.core_queue) self.process.start() + def destroy(self): + """Destroys the MPD server.""" + self.process.destroy() + def process_message(self, message): """ Processes messages with the MPD frontend as destination. From fc423146cf7f63378c22b3e8e4b1fb23f4fafe06 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:29:24 +0200 Subject: [PATCH 32/71] Add LastfmFrontend --- mopidy/frontends/lastfm.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 mopidy/frontends/lastfm.py diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py new file mode 100644 index 00000000..411eef60 --- /dev/null +++ b/mopidy/frontends/lastfm.py @@ -0,0 +1,14 @@ +from mopidy.frontends.base import BaseFrontend + +class LastfmFrontend(BaseFrontend): + def __init__(self, *args, **kwargs): + super(LastfmFrontend, self).__init__(*args, **kwargs) + + def start(self): + pass + + def destroy(self): + pass + + def process_message(self, message): + pass From 0abfb25a998dae187445daab5548701c7b8a2410 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:31:20 +0200 Subject: [PATCH 33/71] Add requirements-lastfm.txt --- requirements-lastfm.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements-lastfm.txt diff --git a/requirements-lastfm.txt b/requirements-lastfm.txt new file mode 100644 index 00000000..642735be --- /dev/null +++ b/requirements-lastfm.txt @@ -0,0 +1 @@ +pylast >= 0.4.30 From 8cb015b54cb379ba18a4c889c1b0dfd6e5a8fe13 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:54:20 +0200 Subject: [PATCH 34/71] Add settings for LastfmFrontend --- mopidy/settings.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mopidy/settings.py b/mopidy/settings.py index 699eb16a..6426398f 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -58,6 +58,16 @@ FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) #: GSTREAMER_AUDIO_SINK = u'autoaudiosink' GSTREAMER_AUDIO_SINK = u'autoaudiosink' +#: Your `Last.fm `_ username. +#: +#: Used by :mod:`mopidy.frontends.lastfm`. +LASTFM_USERNAME = u'' + +#: Your `Last.fm `_ password. +#: +#: Used by :mod:`mopidy.frontends.lastfm`. +LASTFM_PASSWORD = u'' + #: Path to folder with local music. #: #: Used by :mod:`mopidy.backends.local`. From 5fb17ccf0311500e5ce14a49e246d1a6cbc427a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:55:44 +0200 Subject: [PATCH 35/71] Make MpdFrontend ignore unknown messages --- mopidy/frontends/mpd/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f1bfdd57..6450889e 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -45,4 +45,4 @@ class MpdFrontend(BaseFrontend): connection = unpickle_connection(message['reply_to']) connection.send(response) else: - logger.warning(u'Cannot handle message: %s', message) + pass # Ignore messages for other frontends From e67cf3805a7e7642fb1867960c345c158b287ab6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:56:20 +0200 Subject: [PATCH 36/71] Issue events from backend to frontend on 'now_playing' and 'end_of_track' --- mopidy/backends/base/playback.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index df588f39..67a86a28 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -323,6 +323,13 @@ class BasePlaybackController(object): if self.consume: self.backend.current_playlist.remove(cpid=original_cp_track[0]) + # Notify frontends of the end_of_track event + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'end_of_track', + 'track': original_cp_track[1], + }) + def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. @@ -400,6 +407,13 @@ class BasePlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) + # Notify frontends of the now_playing event + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'now_playing', + 'track': cp_track[1], + }) + def _play(self, track): """ To be overridden by subclass. Implement your backend's play From d4541bb505b5d20be2ac9c88e50d7a5dc6502726 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:56:44 +0200 Subject: [PATCH 37/71] Update CoreProcess to handle multiple frontends --- mopidy/core.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index d3b2c94f..06149c82 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -18,7 +18,7 @@ class CoreProcess(BaseProcess): self.options = self.parse_options() self.output = None self.backend = None - self.frontend = None + self.frontends = [] def parse_options(self): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) @@ -48,7 +48,7 @@ class CoreProcess(BaseProcess): self.setup_settings() self.output = self.setup_output(self.core_queue) self.backend = self.setup_backend(self.core_queue, self.output) - self.frontend = self.setup_frontend(self.core_queue, self.backend) + self.frontends = self.setup_frontends(self.core_queue, self.backend) def setup_logging(self): setup_logging(self.options.verbosity_level, self.options.dump) @@ -66,16 +66,20 @@ class CoreProcess(BaseProcess): def setup_backend(self, core_queue, output): return get_class(settings.BACKENDS[0])(core_queue, output) - def setup_frontend(self, core_queue, backend): - frontend = get_class(settings.FRONTENDS[0])(core_queue, backend) - frontend.start() - return frontend + def setup_frontends(self, core_queue, backend): + frontends = [] + for frontend_class_name in settings.FRONTENDS: + frontend = get_class(frontend_class_name)(core_queue, backend) + frontend.start() + frontends.append(frontend) + return frontends def process_message(self, message): if message.get('to') == 'output': self.output.process_message(message) elif message.get('to') == 'frontend': - self.frontend.process_message(message) + for frontend in self.frontends: + frontend.process_message(message) elif message['command'] == 'end_of_track': self.backend.playback.on_end_of_track() elif message['command'] == 'stop_playback': From 2ccd3000cdb032c0d272ad6c1515d4b7c0ed2414 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:57:05 +0200 Subject: [PATCH 38/71] Add LastfmFrontend to the FRONTENDS default --- mopidy/settings.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 6426398f..41f6f996 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -45,11 +45,14 @@ DUMP_LOG_FILENAME = u'dump.log' #: #: Default:: #: -#: FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) -#: -#: .. note:: -#: Currently only the first frontend in the list is used. -FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) +#: FRONTENDS = ( +#: u'mopidy.frontends.mpd.MpdFrontend', +#: u'mopidy.frontends.lastfm.LastfmFrontend', +#: ) +FRONTENDS = ( + u'mopidy.frontends.mpd.MpdFrontend', + u'mopidy.frontends.lastfm.LastfmFrontend', +) #: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`. #: From e0f7fc741a5bd465ecd3174d1f2071637a8ada74 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:58:28 +0200 Subject: [PATCH 39/71] Working Last.fm scrobbler --- mopidy/frontends/lastfm.py | 70 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 411eef60..380a37d1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,14 +1,80 @@ +import logging +import socket +import time + +import pylast + +from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend +logger = logging.getLogger('mopidy.frontends.lastfm') + +CLIENT_ID = u'mop' +CLIENT_VERSION = get_version() + class LastfmFrontend(BaseFrontend): + """ + Frontend which scrobbles the music you plays to your Last.fm profile. + + **Settings:** + + - :mod:`mopidy.settings.LASTFM_USERNAME` + - :mod:`mopidy.settings.LASTFM_PASSWORD` + """ + def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) + self.lastfm = None + self.scrobbler = None def start(self): - pass + # TODO Split into own thread/process + try: + username = settings.LASTFM_USERNAME + password_hash = pylast.md5(settings.LASTFM_PASSWORD) + self.lastfm = pylast.get_lastfm_network( + username=username, password_hash=password_hash) + self.scrobbler = self.lastfm.get_scrobbler( + CLIENT_ID, CLIENT_VERSION) + logger.info(u'Connected to Last.fm') + except SettingsError as e: + logger.info(u'Last.fm scrobbler did not start.') + logger.debug(u'Last.fm settings error: %s', e) + except (pylast.WSError, socket.error) as e: + logger.error(u'Last.fm connection error: %s', e) def destroy(self): pass def process_message(self, message): - pass + if message['command'] == 'now_playing': + self.report_now_playing(message['track']) + elif message['command'] == 'end_of_track': + self.scrobble(message['track']) + else: + pass # Ignore commands for other frontends + + def report_now_playing(self, track): + artists = ', '.join([a.name for a in track.artists]) + logger.debug(u'Now playing track: %s - %s', artists, track.name) + duration = track.length // 1000 + try: + self.scrobbler.report_now_playing(artists, track.name, + album=track.album.name, duration=duration, + track_number=track.track_no) + except (pylast.ScrobblingError, socket.error) as e: + logger.error(u'Last.fm now playing error: %s', e) + + def scrobble(self, track): + artists = ', '.join([a.name for a in track.artists]) + logger.debug(u'Scrobbling track: %s - %s', artists, track.name) + # FIXME Get actual time when track started playing + duration = track.length // 1000 + time_started = int(time.time()) - duration + try: + self.scrobbler.scrobble(artists, track.name, + time_started=time_started, source=pylast.SCROBBLE_SOURCE_USER, + mode=pylast.SCROBBLE_MODE_PLAYED, duration=duration, + album=track.album.name, track_number=track.track_no) + except (pylast.ScrobblingError, socket.error) as e: + logger.error(u'Last.fm scrobbling error: %s', e) From 67cae2ed4d1b7e068a3e0ba78fb67c50afd0a1c3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 03:03:30 +0200 Subject: [PATCH 40/71] Issue end_of_track event before we start playing the next track. Fixes 'now playing' on Last.fm for the second track in the playlist. --- mopidy/backends/base/playback.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 67a86a28..72b02a25 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -311,6 +311,14 @@ class BasePlaybackController(object): return original_cp_track = self.current_cp_track + + # Notify frontends of the end_of_track event + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'end_of_track', + 'track': original_cp_track[1], + }) + if self.cp_track_at_eot: self.play(self.cp_track_at_eot) @@ -323,13 +331,6 @@ class BasePlaybackController(object): if self.consume: self.backend.current_playlist.remove(cpid=original_cp_track[0]) - # Notify frontends of the end_of_track event - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'end_of_track', - 'track': original_cp_track[1], - }) - def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. From 866f9aac28b3f474c2e8f36eb58eef357a3ce575 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 03:05:26 +0200 Subject: [PATCH 41/71] Update remaining todos --- mopidy/frontends/lastfm.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 380a37d1..bf826141 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -22,13 +22,15 @@ class LastfmFrontend(BaseFrontend): - :mod:`mopidy.settings.LASTFM_PASSWORD` """ + # TODO Split into own thread/process + # TODO Add docs + def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) self.lastfm = None self.scrobbler = None def start(self): - # TODO Split into own thread/process try: username = settings.LASTFM_USERNAME password_hash = pylast.md5(settings.LASTFM_PASSWORD) @@ -68,6 +70,8 @@ class LastfmFrontend(BaseFrontend): def scrobble(self, track): artists = ', '.join([a.name for a in track.artists]) logger.debug(u'Scrobbling track: %s - %s', artists, track.name) + # TODO Scrobble if >50% or >240s of a track has been played + # TODO Do not scrobble if duration <30s # FIXME Get actual time when track started playing duration = track.length // 1000 time_started = int(time.time()) - duration From 97122aca49dacf381cf10e5b9a06455f5702d52f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 10:11:40 +0200 Subject: [PATCH 42/71] We want 320 kbps from Spotify --- docs/development/roadmap.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 835df489..419f7645 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -25,6 +25,7 @@ Possible targets for the next version - Write-support for Spotify, i.e. playlist management. - Virtual directories with e.g. starred tracks from Spotify. + - Support for 320 kbps audio. - Local backend: From 58685065737e099bb4cf211b2a82ad986bf60992 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 10:12:03 +0200 Subject: [PATCH 43/71] Only censor password in settings listing if the password is set --- 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 9340dc01..1688cc5f 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -130,7 +130,7 @@ def list_settings_optparse_callback(*args): lines = [] for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) - if key.endswith('PASSWORD'): + if key.endswith('PASSWORD') and len(value): value = u'********' lines.append(u'%s:' % key) lines.append(u' Value: %s' % repr(value)) From ec9356dc52329e4c7daefdb877b635cca08dadd2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 10:29:09 +0200 Subject: [PATCH 44/71] Add dependencies to docstring --- mopidy/frontends/lastfm.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index bf826141..3d65cb11 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -16,6 +16,10 @@ class LastfmFrontend(BaseFrontend): """ Frontend which scrobbles the music you plays to your Last.fm profile. + **Dependencies:** + + - `pylast `_ >= 0.4.30 + **Settings:** - :mod:`mopidy.settings.LASTFM_USERNAME` @@ -24,6 +28,7 @@ class LastfmFrontend(BaseFrontend): # TODO Split into own thread/process # TODO Add docs + # TODO Log nice error message if pylast isn't found def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) From 0030e2472b3fe14c0ac317ca93fcdbaa65289c7c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 11:56:02 +0200 Subject: [PATCH 45/71] Encode strings as UTF-8 before passing them to pylast --- mopidy/frontends/lastfm.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 3d65cb11..c0417248 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -12,6 +12,11 @@ logger = logging.getLogger('mopidy.frontends.lastfm') CLIENT_ID = u'mop' CLIENT_VERSION = get_version() +# pylast raises UnicodeEncodeError on conversion from unicode objects to +# ascii-encoded bytestrings, so we explicitly encode as utf-8 before passing +# strings to pylast. +ENCODING = u'utf-8' + class LastfmFrontend(BaseFrontend): """ Frontend which scrobbles the music you plays to your Last.fm profile. @@ -66,8 +71,11 @@ class LastfmFrontend(BaseFrontend): logger.debug(u'Now playing track: %s - %s', artists, track.name) duration = track.length // 1000 try: - self.scrobbler.report_now_playing(artists, track.name, - album=track.album.name, duration=duration, + self.scrobbler.report_now_playing( + artists.encode(ENCODING), + track.name.encode(ENCODING), + album=track.album.name.encode(ENCODING), + duration=duration, track_number=track.track_no) except (pylast.ScrobblingError, socket.error) as e: logger.error(u'Last.fm now playing error: %s', e) @@ -81,9 +89,14 @@ class LastfmFrontend(BaseFrontend): duration = track.length // 1000 time_started = int(time.time()) - duration try: - self.scrobbler.scrobble(artists, track.name, - time_started=time_started, source=pylast.SCROBBLE_SOURCE_USER, - mode=pylast.SCROBBLE_MODE_PLAYED, duration=duration, - album=track.album.name, track_number=track.track_no) + self.scrobbler.scrobble( + artists.encode(ENCODING), + track.name.encode(ENCODING), + time_started=time_started, + source=pylast.SCROBBLE_SOURCE_USER, + mode=pylast.SCROBBLE_MODE_PLAYED, + duration=duration, + album=track.album.name.encode(ENCODING), + track_number=track.track_no) except (pylast.ScrobblingError, socket.error) as e: logger.error(u'Last.fm scrobbling error: %s', e) From 448aa479235878e87832c753495c85487dd5ccdc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 18:28:59 +0200 Subject: [PATCH 46/71] Do Last.fm scrobbling in its own process --- mopidy/frontends/lastfm.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index c0417248..2f539a51 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,4 +1,5 @@ import logging +import multiprocessing import socket import time @@ -6,6 +7,7 @@ import pylast from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend +from mopidy.utils.process import BaseProcess logger = logging.getLogger('mopidy.frontends.lastfm') @@ -31,16 +33,41 @@ class LastfmFrontend(BaseFrontend): - :mod:`mopidy.settings.LASTFM_PASSWORD` """ - # TODO Split into own thread/process # TODO Add docs # TODO Log nice error message if pylast isn't found def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) + (self.connection, other_end) = multiprocessing.Pipe() + self.process = LastfmFrontendProcess(other_end) + + def start(self): + self.process.start() + + def destroy(self): + self.process.destroy() + + def process_message(self, message): + self.connection.send(message) + + +class LastfmFrontendProcess(BaseProcess): + def __init__(self, connection): + super(LastfmFrontendProcess, self).__init__() + self.name = u'LastfmFrontendProcess' + self.daemon = True + self.connection = connection self.lastfm = None self.scrobbler = None - def start(self): + def run_inside_try(self): + self.setup() + while True: + self.connection.poll(None) + message = self.connection.recv() + self.process_message(message) + + def setup(self): try: username = settings.LASTFM_USERNAME password_hash = pylast.md5(settings.LASTFM_PASSWORD) @@ -55,9 +82,6 @@ class LastfmFrontend(BaseFrontend): except (pylast.WSError, socket.error) as e: logger.error(u'Last.fm connection error: %s', e) - def destroy(self): - pass - def process_message(self, message): if message['command'] == 'now_playing': self.report_now_playing(message['track']) From 1ed711fb85503bd73bbac9c8dde32541ea12c16f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 18:45:32 +0200 Subject: [PATCH 47/71] Do not scrobble if duration is <30s --- mopidy/frontends/lastfm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 2f539a51..18f48cf2 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -105,12 +105,14 @@ class LastfmFrontendProcess(BaseProcess): logger.error(u'Last.fm now playing error: %s', e) def scrobble(self, track): + duration = track.length // 1000 artists = ', '.join([a.name for a in track.artists]) + if duration < 30: + logger.debug(u'Track too short to scrobble.') + return logger.debug(u'Scrobbling track: %s - %s', artists, track.name) # TODO Scrobble if >50% or >240s of a track has been played - # TODO Do not scrobble if duration <30s # FIXME Get actual time when track started playing - duration = track.length // 1000 time_started = int(time.time()) - duration try: self.scrobbler.scrobble( From a8f035e879ae990f8f8be2550cf5f358e1eac2ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:22:19 +0200 Subject: [PATCH 48/71] Trigger playing/stopped events at play, prev, next, stop, eot. --- mopidy/backends/base/playback.py | 51 ++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 72b02a25..3c887120 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -312,16 +312,9 @@ class BasePlaybackController(object): original_cp_track = self.current_cp_track - # Notify frontends of the end_of_track event - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'end_of_track', - 'track': original_cp_track[1], - }) - if self.cp_track_at_eot: + self._trigger_stopped_playing_event() self.play(self.cp_track_at_eot) - if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) else: @@ -354,6 +347,7 @@ class BasePlaybackController(object): return if self.cp_track_at_next: + self._trigger_stopped_playing_event() self.play(self.cp_track_at_next) else: self.stop() @@ -408,12 +402,7 @@ class BasePlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - # Notify frontends of the now_playing event - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'now_playing', - 'track': cp_track[1], - }) + self._trigger_started_playing_event() def _play(self, track): """ @@ -433,6 +422,7 @@ class BasePlaybackController(object): return if self.state == self.STOPPED: return + self._trigger_stopped_playing_event() self.play(self.cp_track_at_previous, on_error_step=-1) def resume(self): @@ -489,7 +479,10 @@ class BasePlaybackController(object): def stop(self): """Stop playing.""" - if self.state != self.STOPPED and self._stop(): + if self.state == self.STOPPED: + return + self._trigger_stopped_playing_event() + if self._stop(): self.state = self.STOPPED def _stop(self): @@ -500,3 +493,31 @@ class BasePlaybackController(object): :rtype: :class:`True` if successful, else :class:`False` """ raise NotImplementedError + + def _trigger_started_playing_event(self): + """ + Notifies frontends that a track has started playing. + + For internal use only. Should be called by the backend directly after a + track has started playing. + """ + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'started_playing', + 'track': self.current_track, + }) + + def _trigger_stopped_playing_event(self): + """ + Notifies frontends that a track has stopped playing. + + For internal use only. Should be called by the backend before a track + is stopped playing, e.g. at the next, previous, and stop actions and at + end-of-track. + """ + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'stopped_playing', + 'track': self.current_track, + 'stop_position': self.time_position, + }) From 95de6687211eb34b573e4542d9bf9798c4f0e98c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:23:02 +0200 Subject: [PATCH 49/71] Scrobble at other events than EOT if >50% or >240s. --- mopidy/frontends/lastfm.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 18f48cf2..1ddd0774 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -59,6 +59,7 @@ class LastfmFrontendProcess(BaseProcess): self.connection = connection self.lastfm = None self.scrobbler = None + self.last_start_time = None def run_inside_try(self): self.setup() @@ -83,17 +84,18 @@ class LastfmFrontendProcess(BaseProcess): logger.error(u'Last.fm connection error: %s', e) def process_message(self, message): - if message['command'] == 'now_playing': - self.report_now_playing(message['track']) - elif message['command'] == 'end_of_track': - self.scrobble(message['track']) + if message['command'] == 'started_playing': + self.started_playing(message['track']) + elif message['command'] == 'stopped_playing': + self.stopped_playing(message['track'], message['stop_position']) else: pass # Ignore commands for other frontends - def report_now_playing(self, track): + def started_playing(self, track): artists = ', '.join([a.name for a in track.artists]) - logger.debug(u'Now playing track: %s - %s', artists, track.name) duration = track.length // 1000 + self.last_start_time = int(time.time()) + logger.debug(u'Now playing track: %s - %s', artists, track.name) try: self.scrobbler.report_now_playing( artists.encode(ENCODING), @@ -104,21 +106,25 @@ class LastfmFrontendProcess(BaseProcess): except (pylast.ScrobblingError, socket.error) as e: logger.error(u'Last.fm now playing error: %s', e) - def scrobble(self, track): - duration = track.length // 1000 + def stopped_playing(self, track, stop_position): artists = ', '.join([a.name for a in track.artists]) + duration = track.length // 1000 + stop_position = stop_position // 1000 if duration < 30: - logger.debug(u'Track too short to scrobble.') + logger.debug(u'Track too short to scrobble. (30s)') return + if stop_position < duration // 2 and stop_position < 240: + logger.debug( + u'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) - # TODO Scrobble if >50% or >240s of a track has been played - # FIXME Get actual time when track started playing - time_started = int(time.time()) - duration try: self.scrobbler.scrobble( artists.encode(ENCODING), track.name.encode(ENCODING), - time_started=time_started, + time_started=self.last_start_time, source=pylast.SCROBBLE_SOURCE_USER, mode=pylast.SCROBBLE_MODE_PLAYED, duration=duration, From 9d62ef4cf368f442e3a8fddc30d26ff75bbf4ca9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:31:02 +0200 Subject: [PATCH 50/71] Generate doc for BaseFrontend --- docs/api/frontends/index.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst index 052c7781..805f5295 100644 --- a/docs/api/frontends/index.rst +++ b/docs/api/frontends/index.rst @@ -8,8 +8,14 @@ A frontend is responsible for exposing Mopidy for a type of clients. Frontend API ============ -A stable frontend API is not available yet, as we've only implemented a single -frontend module. +.. warning:: + + A stable frontend API is not available yet, as we've only implemented a + couple of frontend modules. + +.. automodule:: mopidy.frontends.base + :synopsis: Base class for frontends + :members: Frontends From 67ab8ecbe54f4092433d84a50c2e7de04b2f8e07 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:31:30 +0200 Subject: [PATCH 51/71] Generate docs for lastfm frontend --- docs/api/frontends/index.rst | 1 + docs/api/frontends/lastfm.rst | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 docs/api/frontends/lastfm.rst diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst index 805f5295..05595418 100644 --- a/docs/api/frontends/index.rst +++ b/docs/api/frontends/index.rst @@ -21,4 +21,5 @@ Frontend API Frontends ========= +* :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` diff --git a/docs/api/frontends/lastfm.rst b/docs/api/frontends/lastfm.rst new file mode 100644 index 00000000..bd3e218e --- /dev/null +++ b/docs/api/frontends/lastfm.rst @@ -0,0 +1,7 @@ +****************************** +:mod:`mopidy.frontends.lastfm` +****************************** + +.. automodule:: mopidy.frontends.lastfm + :synopsis: Last.fm scrobbler frontend + :members: From 02a1592d337a92256877360d3e91c19a74c617c8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:31:47 +0200 Subject: [PATCH 52/71] Fix references to settings --- mopidy/frontends/lastfm.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 1ddd0774..998d8ec1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -3,6 +3,7 @@ import multiprocessing import socket import time +# TODO Log nice error message if pylast isn't found import pylast from mopidy import get_version, settings, SettingsError @@ -29,13 +30,10 @@ class LastfmFrontend(BaseFrontend): **Settings:** - - :mod:`mopidy.settings.LASTFM_USERNAME` - - :mod:`mopidy.settings.LASTFM_PASSWORD` + - :attr:`mopidy.settings.LASTFM_USERNAME` + - :attr:`mopidy.settings.LASTFM_PASSWORD` """ - # TODO Add docs - # TODO Log nice error message if pylast isn't found - def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) (self.connection, other_end) = multiprocessing.Pipe() From b87515368dbfd60bbb49420917d524f232fc60cb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:34:10 +0200 Subject: [PATCH 53/71] Update changelog and roadmap --- docs/changes.rst | 2 +- docs/development/roadmap.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 986dd953..1122be14 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,7 +12,7 @@ No description yet. **Changes** -- None +- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. 0.1.0 (2010-08-23) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 835df489..a0946a0e 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -32,7 +32,7 @@ Possible targets for the next version - A script for creating a tag cache. - An alternative to tag cache for caching metadata, i.e. Sqlite. -- Last.fm scrobbling. +- **[DONE]** Last.fm scrobbling. Stuff we want to do, but not right now, and maybe never From ab6e9fccfd824b54d37f55c6116f829c9dd7fa65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:38:42 +0200 Subject: [PATCH 54/71] Add note to settings doc on setting up Last.fm scrobbling --- docs/settings.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/settings.rst b/docs/settings.rst index a39e6fd8..c657e0bf 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -41,3 +41,13 @@ Connecting from other machines on the network As a secure default, Mopidy only accepts connections from ``localhost``. If you want to open it for connections from other machines on your network, see the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. + + +Scrobbling tracks to Last.fm +============================ + +If you want to submit the tracks you are playing to your `Last.fm +`_ profile, add the following to your settings file:: + + LASTFM_USERNAME = u'myusername' + LASTFM_PASSWORD = u'mysecret' From 7d545f889e06c61e0dc95a1d8c58b0e12f2f7cfe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:40:32 +0200 Subject: [PATCH 55/71] Update docstring --- docs/settings.rst | 4 +++- mopidy/frontends/lastfm.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index c657e0bf..afdd39dc 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -47,7 +47,9 @@ Scrobbling tracks to Last.fm ============================ If you want to submit the tracks you are playing to your `Last.fm -`_ profile, add the following to your settings file:: +`_ profile, make sure you've installed the dependencies +found at :mod:`mopidy.frontends.lastfm` and add the following to your settings +file:: LASTFM_USERNAME = u'myusername' LASTFM_PASSWORD = u'mysecret' diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 998d8ec1..3cab010a 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -22,7 +22,12 @@ ENCODING = u'utf-8' class LastfmFrontend(BaseFrontend): """ - Frontend which scrobbles the music you plays to your Last.fm profile. + Frontend which scrobbles the music you play to your `Last.fm + `_ profile. + + .. note:: + + This frontend requires a free user account at Last.fm. **Dependencies:** From 89c183e3811f95d5f7aca6f478999459f95db953 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:43:04 +0200 Subject: [PATCH 56/71] Add pylast to deps list --- docs/installation/index.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 14ebdebc..9577c383 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -54,6 +54,12 @@ Make sure you got the required dependencies installed. - No additional dependencies. +- Optional dependencies: + + - :mod:`mopidy.frontends.lastfm` + + - pylast >= 4.3.0 + Install latest release ====================== From 16e6a7fdc03f54e16b835809a1466b73a029ab00 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:20:47 +0200 Subject: [PATCH 57/71] Fail nicely when optional dependencies are missing --- mopidy/__init__.py | 3 +++ mopidy/core.py | 11 +++++++---- mopidy/frontends/lastfm.py | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 46d55873..7d3052c4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -22,6 +22,9 @@ class MopidyException(Exception): 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/core.py b/mopidy/core.py index 06149c82..a97d1e88 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -2,7 +2,7 @@ import logging import multiprocessing import optparse -from mopidy import get_version, settings +from mopidy import get_version, settings, OptionalDependencyError from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file @@ -69,9 +69,12 @@ class CoreProcess(BaseProcess): def setup_frontends(self, core_queue, backend): frontends = [] for frontend_class_name in settings.FRONTENDS: - frontend = get_class(frontend_class_name)(core_queue, backend) - frontend.start() - frontends.append(frontend) + try: + frontend = get_class(frontend_class_name)(core_queue, backend) + frontend.start() + frontends.append(frontend) + except OptionalDependencyError as e: + logger.info(u'Disabled: %s (%s)', frontend_class_name, e) return frontends def process_message(self, message): diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 3cab010a..13a8f6b4 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -3,8 +3,11 @@ import multiprocessing import socket import time -# TODO Log nice error message if pylast isn't found -import pylast +try: + import pylast +except ImportError as e: + from mopidy import OptionalDependencyError + raise OptionalDependencyError(e) from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend From 05d44d29157076fb7d3cf772df62f5056f133a92 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:22:45 +0200 Subject: [PATCH 58/71] Log error instead of passing it to sys.exit() --- mopidy/frontends/mpd/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index db13e516..710366d7 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -36,7 +36,8 @@ class MpdServer(asyncore.dispatcher): self._format_hostname(settings.MPD_SERVER_HOSTNAME), settings.MPD_SERVER_PORT) except IOError, e: - sys.exit('MPD server startup failed: %s' % e) + logger.error('MPD server startup failed: %s' % e) + sys.exit(1) def handle_accept(self): """Handle new client connection.""" From 5be0aadf0c8ac54b69d8cf0964353ec3ab7c6f23 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:24:26 +0200 Subject: [PATCH 59/71] Mock event triggering to fix tests --- 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 98257f18..2535ad32 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -64,6 +64,12 @@ class DummyPlaybackController(BasePlaybackController): def _stop(self): return True + def _trigger_started_playing_event(self): + pass # noop + + def _trigger_stopped_playing_event(self): + pass # noop + class DummyStoredPlaylistsController(BaseStoredPlaylistsController): _playlists = [] From e4807d894f9b6d70eb91fa37542109dceeafa064 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:30:53 +0200 Subject: [PATCH 60/71] Cleanup logging from libspotify --- mopidy/backends/libspotify/__init__.py | 2 +- mopidy/backends/libspotify/session_manager.py | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/mopidy/backends/libspotify/__init__.py b/mopidy/backends/libspotify/__init__.py index 0d7e5d0b..223d9968 100644 --- a/mopidy/backends/libspotify/__init__.py +++ b/mopidy/backends/libspotify/__init__.py @@ -51,7 +51,7 @@ class LibspotifyBackend(BaseBackend): from .session_manager import LibspotifySessionManager logger.info(u'Mopidy uses SPOTIFY(R) CORE') - logger.info(u'Connecting to Spotify') + logger.debug(u'Connecting to Spotify') spotify = LibspotifySessionManager( settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, core_queue=self.core_queue, diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/libspotify/session_manager.py index 62a3c7dd..fda5216a 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -18,7 +18,8 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def __init__(self, username, password, core_queue, output): SpotifySessionManager.__init__(self, username, password) - threading.Thread.__init__(self, name='LibspotifySessionManagerThread') + threading.Thread.__init__(self) + self.name = 'LibspotifySMThread' # Run as a daemon thread, so Mopidy won't wait for this thread to exit # before Mopidy exits. self.daemon = True @@ -32,17 +33,17 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def logged_in(self, session, error): """Callback used by pyspotify""" - logger.info('Logged in') + logger.info(u'Connected to Spotify') self.session = session self.connected.set() def logged_out(self, session): """Callback used by pyspotify""" - logger.info('Logged out') + logger.info(u'Disconnected from Spotify') def metadata_updated(self, session): """Callback used by pyspotify""" - logger.debug('Metadata updated, refreshing stored playlists') + logger.debug(u'Metadata updated, refreshing stored playlists') playlists = [] for spotify_playlist in session.playlist_container(): playlists.append( @@ -54,15 +55,15 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def connection_error(self, session, error): """Callback used by pyspotify""" - logger.error('Connection error: %s', error) + logger.error(u'Connection error: %s', error) def message_to_user(self, session, message): """Callback used by pyspotify""" - logger.info(message.strip()) + logger.debug(u'User message: %s', message.strip()) def notify_main_thread(self, session): """Callback used by pyspotify""" - logger.debug('Notify main thread') + logger.debug(u'notify_main_thread() called') def music_delivery(self, session, frames, frame_size, num_frames, sample_type, sample_rate, channels): @@ -81,16 +82,16 @@ class LibspotifySessionManager(SpotifySessionManager, threading.Thread): def play_token_lost(self, session): """Callback used by pyspotify""" - logger.debug('Play token lost') + logger.debug(u'Play token lost') self.core_queue.put({'command': 'stop_playback'}) def log_message(self, session, data): """Callback used by pyspotify""" - logger.debug(data.strip()) + logger.debug(u'System message: %s' % data.strip()) def end_of_track(self, session): """Callback used by pyspotify""" - logger.debug('End of data stream.') + logger.debug(u'End of data stream reached') self.output.end_of_data_stream() def search(self, query, connection): From 9ec4368651c997874aa7d09aac6b1abea93dfbc0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:35:54 +0200 Subject: [PATCH 61/71] Cleanup MPD logging --- mopidy/frontends/mpd/process.py | 4 ++-- mopidy/frontends/mpd/server.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/process.py b/mopidy/frontends/mpd/process.py index 7bd95900..95f3e271 100644 --- a/mopidy/frontends/mpd/process.py +++ b/mopidy/frontends/mpd/process.py @@ -8,11 +8,11 @@ logger = logging.getLogger('mopidy.frontends.mpd.process') class MpdProcess(BaseProcess): def __init__(self, core_queue): - super(MpdProcess, self).__init__(name='MpdProcess') + super(MpdProcess, self).__init__() + self.name = 'MpdProcess' self.core_queue = core_queue def run_inside_try(self): - logger.debug(u'Starting MPD server process') server = MpdServer(self.core_queue) server.start() asyncore.loop() diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 710366d7..4381fe0a 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -29,7 +29,7 @@ class MpdServer(asyncore.dispatcher): self.set_reuse_addr() hostname = self._format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT - logger.debug(u'Binding to [%s]:%s', hostname, port) + logger.debug(u'MPD server is binding to [%s]:%s', hostname, port) self.bind((hostname, port)) self.listen(1) logger.info(u'MPD server running at [%s]:%s', From 3d40aa71684acb66b0acedca8522282edf190402 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:39:00 +0200 Subject: [PATCH 62/71] Add method from feature/threads-not-processes branch which is already in use --- mopidy/utils/process.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 73224840..c66a8ee8 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -37,3 +37,6 @@ class BaseProcess(multiprocessing.Process): def run_inside_try(self): raise NotImplementedError + + def destroy(self): + self.terminate() From 0cbce060371303d32a62bc66443eea645517fac4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 21:00:10 +0200 Subject: [PATCH 63/71] Rename --dump to --save-debug-log. Rename related settings. --- .gitignore | 1 + docs/changes.rst | 10 +++++++++- mopidy/core.py | 11 ++++++----- mopidy/settings.py | 10 +++++----- mopidy/utils/log.py | 12 ++++++------ mopidy/utils/settings.py | 2 ++ 6 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 06ead765..e0026170 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ cover/ coverage.xml dist/ docs/_build/ +mopidy.log nosetests.xml diff --git a/docs/changes.rst b/docs/changes.rst index 1122be14..feb6be0d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,10 +10,18 @@ This change log is used to track all major changes to Mopidy. No description yet. -**Changes** +**Important changes** - Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. +**Changes** + +- Rename :option:`--dump` to :option:`--save-debug-log`. +- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to + :attr:`mopidy.settings.DEBUG_LOG_FORMAT`. +- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to + :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. + 0.1.0 (2010-08-23) ================== diff --git a/mopidy/core.py b/mopidy/core.py index a97d1e88..5351e2a5 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -28,16 +28,15 @@ class CoreProcess(BaseProcess): parser.add_option('-v', '--verbose', action='store_const', const=2, dest='verbosity_level', help='more output (debug level)') - parser.add_option('--dump', - action='store_true', dest='dump', - help='dump debug log to file') + 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', action='callback', callback=list_settings_optparse_callback, help='list current settings') return parser.parse_args()[0] def run_inside_try(self): - logger.info(u'-- Starting Mopidy --') self.setup() while True: message = self.core_queue.get() @@ -51,7 +50,9 @@ class CoreProcess(BaseProcess): self.frontends = self.setup_frontends(self.core_queue, self.backend) def setup_logging(self): - setup_logging(self.options.verbosity_level, self.options.dump) + setup_logging(self.options.verbosity_level, + self.options.save_debug_log) + logger.info(u'-- Starting Mopidy --') def setup_settings(self): get_or_create_folder('~/.mopidy/') diff --git a/mopidy/settings.py b/mopidy/settings.py index 41f6f996..b90205c7 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -30,16 +30,16 @@ CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ #: #: Default:: #: -#: DUMP_LOG_FILENAME = CONSOLE_LOG_FORMAT -DUMP_LOG_FORMAT = CONSOLE_LOG_FORMAT +#: DEBUG_LOG_FILENAME = CONSOLE_LOG_FORMAT +DEBUG_LOG_FORMAT = CONSOLE_LOG_FORMAT #: The file to dump debug log data to when Mopidy is run with the -#: :option:`--dump` option. +#: :option:`--save-debug-log` option. #: #: Default:: #: -#: DUMP_LOG_FILENAME = u'dump.log' -DUMP_LOG_FILENAME = u'dump.log' +#: DEBUG_LOG_FILENAME = u'mopidy.log' +DEBUG_LOG_FILENAME = u'mopidy.log' #: List of server frontends to use. #: diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index c892102a..2806f4f3 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,10 +3,10 @@ import logging.handlers from mopidy import settings -def setup_logging(verbosity_level, dump): +def setup_logging(verbosity_level, save_debug_log): setup_console_logging(verbosity_level) - if dump: - setup_dump_logging() + if save_debug_log: + setup_debug_logging_to_file() def setup_console_logging(verbosity_level): if verbosity_level == 0: @@ -17,12 +17,12 @@ def setup_console_logging(verbosity_level): level = logging.INFO logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level) -def setup_dump_logging(): +def setup_debug_logging_to_file(): root = logging.getLogger('') root.setLevel(logging.DEBUG) - formatter = logging.Formatter(settings.DUMP_LOG_FORMAT) + formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( - settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3) + settings.DEBUG_LOG_FILENAME, maxBytes=102400, backupCount=3) handler.setFormatter(formatter) root.addHandler(handler) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 1688cc5f..1d3a0fa0 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -89,6 +89,8 @@ def validate_settings(defaults, settings): errors = {} changed = { + 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', + 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', From cc98052df8f5b01b3637485153bc70db70346db2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 21:16:57 +0200 Subject: [PATCH 64/71] Simplify default log output --- docs/changes.rst | 8 ++++++-- mopidy/settings.py | 20 ++++++++++---------- mopidy/utils/log.py | 11 +++++++---- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index feb6be0d..3d85f99b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -16,9 +16,13 @@ No description yet. **Changes** -- Rename :option:`--dump` to :option:`--save-debug-log`. +- Simplify the default log format, :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. + From a user's point of view: Less noise, more information. +- Rename the :option:`--dump` command line option to + :option:`--save-debug-log`. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to - :attr:`mopidy.settings.DEBUG_LOG_FORMAT`. + :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` + too. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. diff --git a/mopidy/settings.py b/mopidy/settings.py index b90205c7..c9d7b9fc 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,18 +20,18 @@ BACKENDS = ( u'mopidy.backends.libspotify.LibspotifyBackend', ) -#: The log format used on the console. See -#: http://docs.python.org/library/logging.html#formatter-objects for details on -#: the format. -CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ - ' [%(process)d:%(threadName)s] %(name)s\n %(message)s' +#: The log format used for informational logging. +#: +#: See http://docs.python.org/library/logging.html#formatter-objects for +#: details on the format. +CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' -#: The log format used for dump logs. +#: The log format used for debug logging. #: -#: Default:: -#: -#: DEBUG_LOG_FILENAME = CONSOLE_LOG_FORMAT -DEBUG_LOG_FORMAT = CONSOLE_LOG_FORMAT +#: See http://docs.python.org/library/logging.html#formatter-objects for +#: details on the format. +DEBUG_LOG_FORMAT = u'%(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 #: :option:`--save-debug-log` option. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 2806f4f3..57c5c494 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -10,12 +10,15 @@ def setup_logging(verbosity_level, save_debug_log): def setup_console_logging(verbosity_level): if verbosity_level == 0: - level = logging.WARNING + log_level = logging.WARNING + log_format = settings.CONSOLE_LOG_FORMAT elif verbosity_level == 2: - level = logging.DEBUG + log_level = logging.DEBUG + log_format = settings.DEBUG_LOG_FORMAT else: - level = logging.INFO - logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level) + log_level = logging.INFO + log_format = settings.CONSOLE_LOG_FORMAT + logging.basicConfig(format=log_format, level=log_level) def setup_debug_logging_to_file(): root = logging.getLogger('') From 19909d7acadf968bdfcf83b063c13c8d4a013541 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 22:37:40 +0200 Subject: [PATCH 65/71] Rotate log at 10M instead of 100k --- mopidy/utils/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 57c5c494..b903c7c7 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -25,7 +25,7 @@ def setup_debug_logging_to_file(): root.setLevel(logging.DEBUG) formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( - settings.DEBUG_LOG_FILENAME, maxBytes=102400, backupCount=3) + settings.DEBUG_LOG_FILENAME, maxBytes=10485760, backupCount=3) handler.setFormatter(formatter) root.addHandler(handler) From c89d58fccfb815a2bc9a97fbc8729e315125dfdb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 23:11:22 +0200 Subject: [PATCH 66/71] Fix 'add ' and 'addid ' --- docs/changes.rst | 3 +++ mopidy/frontends/mpd/protocol/current_playlist.py | 8 ++++++-- tests/frontends/mpd/current_playlist_test.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3d85f99b..91ef6d24 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -25,6 +25,9 @@ No description yet. too. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. +- MPD frontend: + + - ``add ""`` and ``addid ""`` now behaves as expected. 0.1.0 (2010-08-23) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 90a53f5f..1cc76732 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -12,13 +12,14 @@ def add(frontend, uri): Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. """ + if not uri: + return for handler_prefix in frontend.backend.uri_handlers: if uri.startswith(handler_prefix): track = frontend.backend.library.lookup(uri) if track is not None: frontend.backend.current_playlist.add(track) return - raise MpdNoExistError( u'directory or file not found', command=u'add') @@ -37,6 +38,8 @@ def addid(frontend, uri, songpos=None): Id: 999 OK """ + if not uri: + raise MpdNoExistError(u'No such song', command=u'addid') if songpos is not None: songpos = int(songpos) track = frontend.backend.library.lookup(uri) @@ -44,7 +47,8 @@ def addid(frontend, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos and songpos > len(frontend.backend.current_playlist.tracks): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = frontend.backend.current_playlist.add(track, at_position=songpos) + cp_track = frontend.backend.current_playlist.add(track, + at_position=songpos) return ('Id', cp_track[0]) @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index c53e2b8d..8e4b62f9 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -33,6 +33,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): self.assertEqual(result[0], u'ACK [50@0] {add} directory or file not found') + def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self): + result = self.h.handle_request(u'add ""') + # TODO check that we add all tracks (we currently don't) + self.assert_(u'OK' in result) + def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] @@ -46,6 +51,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): in result) self.assert_(u'OK' in result) + def test_addid_with_empty_uri_does_not_lookup_and_acks(self): + self.b.library.lookup = lambda uri: self.fail("Shouldn't run") + result = self.h.handle_request(u'addid ""') + self.assertEqual(result[0], u'ACK [50@0] {addid} No such song') + def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') self.b.library._library = [Track(), Track(), needle, Track()] From 8f30e9a13925144c64a481971d25b062353d1e92 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 23:16:13 +0200 Subject: [PATCH 67/71] Update protocol docs with change from previous commit --- mopidy/frontends/mpd/protocol/current_playlist.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 1cc76732..2f0a9f8f 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -11,6 +11,10 @@ def add(frontend, uri): Adds the file ``URI`` to the playlist (directories add recursively). ``URI`` can also be a single file. + + *Clarifications:* + + - ``add ""`` should add all tracks in the library to the current playlist. """ if not uri: return @@ -37,6 +41,10 @@ def addid(frontend, uri, songpos=None): addid "foo.mp3" Id: 999 OK + + *Clarifications:* + + - ``addid ""`` should return an error. """ if not uri: raise MpdNoExistError(u'No such song', command=u'addid') From 12a4c9d73d2d2485a1d62cab44076021d849dcfc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 23:33:16 +0200 Subject: [PATCH 68/71] Catch SpotifyError in library.lookup() --- mopidy/backends/libspotify/library.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/libspotify/library.py b/mopidy/backends/libspotify/library.py index ffb9ee57..0d5026ed 100644 --- a/mopidy/backends/libspotify/library.py +++ b/mopidy/backends/libspotify/library.py @@ -1,7 +1,7 @@ import logging import multiprocessing -from spotify import Link +from spotify import Link, SpotifyError from mopidy.backends.base import BaseLibraryController from mopidy.backends.libspotify import ENCODING @@ -14,10 +14,15 @@ class LibspotifyLibraryController(BaseLibraryController): return self.search(**query) def lookup(self, uri): - spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before that the - # track will be unloaded, unless it's already in the stored playlists. - return LibspotifyTranslator.to_mopidy_track(spotify_track) + try: + spotify_track = Link.from_string(uri).as_track() + # TODO Block until metadata_updated callback is called. Before that + # the track will be unloaded, unless it's already in the stored + # playlists. + return LibspotifyTranslator.to_mopidy_track(spotify_track) + except SpotifyError as e: + logger.warning(u'Failed to lookup: %s', track.uri, e) + return None def refresh(self, uri=None): pass # TODO From c7218e87e2e71be072e80e18b959e5c66c0b3e45 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 23:33:32 +0200 Subject: [PATCH 69/71] Don't let debug logging to file affect console logging (i.e. get rid of logging.basicConfig()) --- mopidy/utils/log.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index b903c7c7..cc1c19c1 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -4,10 +4,15 @@ import logging.handlers from mopidy import settings def setup_logging(verbosity_level, save_debug_log): + setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: setup_debug_logging_to_file() +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 @@ -18,15 +23,20 @@ def setup_console_logging(verbosity_level): else: log_level = logging.INFO log_format = settings.CONSOLE_LOG_FORMAT - logging.basicConfig(format=log_format, level=log_level) + formatter = logging.Formatter(log_format) + handler = logging.StreamHandler() + handler.setFormatter(formatter) + handler.setLevel(log_level) + root = logging.getLogger('') + root.addHandler(handler) def setup_debug_logging_to_file(): - root = logging.getLogger('') - root.setLevel(logging.DEBUG) formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( settings.DEBUG_LOG_FILENAME, maxBytes=10485760, backupCount=3) handler.setFormatter(formatter) + handler.setLevel(logging.DEBUG) + root = logging.getLogger('') root.addHandler(handler) def indent(string, places=4, linebreak='\n'): From e250884fd60883cd1f7d2105e7c60f7ed94eef00 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 25 Aug 2010 22:15:50 +0200 Subject: [PATCH 70/71] Fix stupid mistake in output.set_position that should have been chaught by the tests we don't have for output... --- mopidy/outputs/gstreamer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index e2aa6436..def37f72 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -72,7 +72,7 @@ class GStreamerOutput(BaseOutput): return self._send_recv({'command': 'get_position'}) def set_position(self, position): - return self._send_recv({'command': 'set_position'}) + return self._send_recv({'command': 'set_position', 'position': position}) def set_state(self, state): return self._send_recv({'command': 'set_state', 'state': state}) From 8f18d9582f7f5afcacc608a5ffa96600171c5817 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 25 Aug 2010 23:53:58 +0200 Subject: [PATCH 71/71] DummyLibraryController._seek() should return True like the rest --- mopidy/backends/dummy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 2535ad32..6d4f3156 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -59,7 +59,7 @@ class DummyPlaybackController(BasePlaybackController): return True def _seek(self, time_position): - pass + return True def _stop(self): return True