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/README.rst b/README.rst index 6855135e..c6187119 100644 --- a/README.rst +++ b/README.rst @@ -11,8 +11,9 @@ platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones. To install Mopidy, check out `the installation docs `_. -* `Documentation `_ +* `Documentation (latest release) `_ * `Documentation (development version) `_ * `Source code `_ * `Issue tracker `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ +* `Download development snapshot `_ diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst index 052c7781..05595418 100644 --- a/docs/api/frontends/index.rst +++ b/docs/api/frontends/index.rst @@ -8,11 +8,18 @@ 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 ========= +* :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: diff --git a/docs/changes.rst b/docs/changes.rst index e84d7aa9..eadf8e75 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,74 @@ Changes This change log is used to track all major changes to Mopidy. +0.2.0 (2010-10-24) +================== + +In Mopidy 0.2.0 we've added a `Last.fm `_ scrobbling +support, which means that Mopidy now can submit meta data about the tracks you +play to your Last.fm profile. See :mod:`mopidy.frontends.lastfm` for +details on new dependencies and settings. If you use Mopidy's Last.fm support, +please join the `Mopidy group at Last.fm `_. + +With the exception of the work on the Last.fm scrobbler, there has been a +couple of quiet months in the Mopidy camp. About the only thing going on, has +been stabilization work and bug fixing. All bugs reported on GitHub, plus some, +have been fixed in 0.2.0. Thus, we hope this will be a great release! + +We've worked a bit on OS X support, but not all issues are completely solved +yet. :issue:`25` is the one that is currently blocking OS X support. Any help +solving it will be greatly appreciated! + +Finally, please :ref:`update your pyspotify installation +` when upgrading to Mopidy 0.2.0. The latest pyspotify +got a fix for the segmentation fault that occurred when playing music and +searching at the same time, thanks to Valentin David. + +**Important changes** + +- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. + +**Changes** + +- Logging and command line options: + + - 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` and use it for :option:`--verbose` + too. + - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to + :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. + +- MPD frontend: + + - MPD command ``list`` now supports queries by artist, album name, and date, + as used by e.g. the Ario client. (Fixes: :issue:`20`) + - MPD command ``add ""`` and ``addid ""`` now behaves as expected. (Fixes + :issue:`16`) + - MPD command ``playid "-1"`` now correctly resumes playback if paused. + +- Random mode: + + - Fix wrong behavior on end of track and next after random mode has been + used. (Fixes: :issue:`18`) + - Fix infinite recursion loop crash on playback of non-playable tracks when + in random mode. (Fixes :issue:`17`) + - Fix assertion error that happened if one removed tracks from the current + playlist, while in random mode. (Fixes :issue:`22`) + +- Switched from using subprocesses to threads. (Fixes: :issue:`14`) +- :mod:`mopidy.outputs.gstreamer`: Set ``caps`` on the ``appsrc`` bin before + use. This makes sound output work with GStreamer >= 0.10.29, which includes + the versions used in Ubuntu 10.10 and on OS X if using Homebrew. (Fixes: + :issue:`21`, :issue:`24`, contributes to :issue:`14`) +- Improved handling of uncaught exceptions in threads. The entire process + should now exit immediately. + + 0.1.0 (2010-08-23) ================== 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..de54dfcb --- /dev/null +++ b/docs/clients/mpd.rst @@ -0,0 +1,98 @@ +************************ +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 +=============== + +mpc +--- + +A command line client. Version 0.14 had some issues with Mopidy (see +:issue:`5`), but 0.16 seems to work nicely. + +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/development/contributing.rst b/docs/development/contributing.rst index eac94799..4adde637 100644 --- a/docs/development/contributing.rst +++ b/docs/development/contributing.rst @@ -151,20 +151,25 @@ Then, to generate docs:: Creating releases ================= -1. Update changelog and commit it. +#. Update changelog and commit it. -2. Tag release:: +#. Merge the release branch (``develop`` in the example) into master:: - git tag -a -m "Release v0.1.0a0" v0.1.0a0 + git checkout master + git merge --no-ff -m "Release v0.2.0" develop -3. Push to GitHub:: +#. Tag the release:: + + git tag -a -m "Release v0.2.0" v0.2.0 + +#. Push to GitHub:: git push git push --tags -4. Build package and upload to PyPI:: +#. Build package and upload to PyPI:: rm MANIFEST # Will be regenerated by setup.py python setup.py sdist upload -5. Spread the word. +#. Spread the word. diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index dff9a9d7..645cbd30 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -6,26 +6,34 @@ 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. +- MPD frontend: + + - ``idle`` support. + +- Spotify backend: + + - Write-support for Spotify, i.e. playlist management. + - Virtual directories with e.g. starred tracks from Spotify. + - Support for 320 kbps audio. + +- Local backend: + + - Better library support. + - A script for creating a tag cache. + - An alternative to tag cache for caching metadata, i.e. Sqlite. + +- **[DONE]** Last.fm scrobbling. Stuff we want to do, but not right now, and maybe never @@ -45,15 +53,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 +68,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. diff --git a/docs/index.rst b/docs/index.rst index 7c53572c..7a4dc27d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,9 @@ User documentation :maxdepth: 3 installation/index + settings + running + clients/index changes authors licenses diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 26b864d2..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 ====================== @@ -70,6 +76,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 +101,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..afdd39dc --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,55 @@ +******** +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`. + + +Scrobbling tracks to Last.fm +============================ + +If you want to submit the tracks you are playing to your `Last.fm +`_ 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/__init__.py b/mopidy/__init__.py index 15b7b1ad..5e1b26de 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.0' class MopidyException(Exception): def __init__(self, message, *args, **kwargs): @@ -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/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/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index c8c83a62..34a16369 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -12,13 +12,10 @@ class BaseCurrentPlaylistController(object): :type backend: :class:`BaseBackend` """ - #: The current playlist version. Integer which is increased every time the - #: current playlist is changed. Is not reset before Mopidy is restarted. - version = 0 - def __init__(self, backend): self.backend = backend self._cp_tracks = [] + self._version = 0 def destroy(self): """Cleanup after component.""" @@ -42,6 +39,19 @@ class BaseCurrentPlaylistController(object): """ return [ct[1] for ct in self._cp_tracks] + @property + def version(self): + """ + The current playlist version. Integer which is increased every time the + current playlist is changed. Is not reset before Mopidy is restarted. + """ + return self._version + + @version.setter + def version(self, version): + self._version = version + self.backend.playback.on_current_playlist_change() + def add(self, track, at_position=None): """ Add the track to the end of, or at the given position in the current @@ -71,16 +81,13 @@ class BaseCurrentPlaylistController(object): :param tracks: tracks to append :type tracks: list of :class:`mopidy.models.Track` """ - self.version += 1 for track in tracks: self.add(track) - self.backend.playback.on_current_playlist_change() def clear(self): """Clear the current playlist.""" self._cp_tracks = [] self.version += 1 - self.backend.playback.on_current_playlist_change() def get(self, **criteria): """ @@ -146,7 +153,6 @@ class BaseCurrentPlaylistController(object): to_position += 1 self._cp_tracks = new_cp_tracks self.version += 1 - self.backend.playback.on_current_playlist_change() def remove(self, **criteria): """ @@ -191,7 +197,6 @@ class BaseCurrentPlaylistController(object): random.shuffle(shuffled) self._cp_tracks = before + shuffled + after self.version += 1 - self.backend.playback.on_current_playlist_change() def mpd_format(self, *args, **kwargs): """Not a part of the generic backend API.""" diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 933424ad..c4ef5fbf 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -142,7 +142,7 @@ class BasePlaybackController(object): random.shuffle(self._shuffled) self._first_shuffle = False - if self._shuffled: + if self.random and self._shuffled: return self._shuffled[0] if self.current_cp_track is None: @@ -195,7 +195,7 @@ class BasePlaybackController(object): random.shuffle(self._shuffled) self._first_shuffle = False - if self._shuffled: + if self.random and self._shuffled: return self._shuffled[0] if self.current_cp_track is None: @@ -311,14 +311,12 @@ class BasePlaybackController(object): return original_cp_track = self.current_cp_track - if self.cp_track_at_eot: - 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) + if self.cp_track_at_eot: + self._trigger_stopped_playing_event() + self.play(self.cp_track_at_eot) else: - self.stop() - self.current_cp_track = None + self.stop(clear_current_track=True) if self.consume: self.backend.current_playlist.remove(cpid=original_cp_track[0]) @@ -332,13 +330,10 @@ class BasePlaybackController(object): self._first_shuffle = True self._shuffled = [] - if not self.backend.current_playlist.cp_tracks: - self.stop() - self.current_cp_track = None - elif (self.current_cp_track not in + if (not self.backend.current_playlist.cp_tracks or + self.current_cp_track not in self.backend.current_playlist.cp_tracks): - self.current_cp_track = None - self.stop() + self.stop(clear_current_track=True) def next(self): """Play the next track.""" @@ -346,13 +341,10 @@ 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() - self.current_cp_track = None - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) + self.stop(clear_current_track=True) def pause(self): """Pause playback.""" @@ -383,15 +375,21 @@ class BasePlaybackController(object): if cp_track is not None: assert cp_track in self.backend.current_playlist.cp_tracks - elif not self.current_cp_track: + + if cp_track is None and self.current_cp_track is None: cp_track = self.cp_track_at_next - if self.state == self.PAUSED and cp_track is None: + if cp_track is None and self.state == self.PAUSED: self.resume() - elif cp_track is not None: + + if cp_track is not None: + self.state = self.STOPPED self.current_cp_track = cp_track self.state = self.PLAYING if not self._play(cp_track[1]): + # Track is not playable + if self.random and self._shuffled: + self._shuffled.remove(cp_track) if on_error_step == 1: self.next() elif on_error_step == -1: @@ -400,6 +398,8 @@ class BasePlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) + self._trigger_started_playing_event() + def _play(self, track): """ To be overridden by subclass. Implement your backend's play @@ -418,6 +418,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): @@ -442,8 +443,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 +453,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 @@ -471,10 +473,21 @@ class BasePlaybackController(object): """ raise NotImplementedError - def stop(self): - """Stop playing.""" - if self.state != self.STOPPED and self._stop(): + def stop(self, clear_current_track=False): + """ + Stop playing. + + :param clear_current_track: whether to clear the current track _after_ + stopping + :type clear_current_track: boolean + """ + if self.state == self.STOPPED: + return + self._trigger_stopped_playing_event() + if self._stop(): self.state = self.STOPPED + if clear_current_track: + self.current_cp_track = None def _stop(self): """ @@ -484,3 +497,33 @@ 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. + """ + if self.current_track is not None: + 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. + """ + if self.current_track is not None: + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'stopped_playing', + 'track': self.current_track, + 'stop_position': self.time_position, + }) diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 98257f18..62cbd7e2 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -44,26 +44,35 @@ class DummyLibraryController(BaseLibraryController): class DummyPlaybackController(BasePlaybackController): def _next(self, track): - return True + """Pass None as track to force failure""" + return track is not None def _pause(self): return True def _play(self, track): - return True + """Pass None as track to force failure""" + return track is not None def _previous(self, track): - return True + """Pass None as track to force failure""" + return track is not None def _resume(self): return True def _seek(self, time_position): - pass + return True 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 = [] diff --git a/mopidy/backends/libspotify/__init__.py b/mopidy/backends/libspotify/__init__.py index 07f3e2f7..223d9968 100644 --- a/mopidy/backends/libspotify/__init__.py +++ b/mopidy/backends/libspotify/__init__.py @@ -51,10 +51,10 @@ 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, - output_queue=self.output_queue) + output=self.output) spotify.start() return spotify diff --git a/mopidy/backends/libspotify/library.py b/mopidy/backends/libspotify/library.py index ffb9ee57..972eaf03 100644 --- a/mopidy/backends/libspotify/library.py +++ b/mopidy/backends/libspotify/library.py @@ -1,11 +1,12 @@ 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 from mopidy.backends.libspotify.translator import LibspotifyTranslator +from mopidy.models import Playlist logger = logging.getLogger('mopidy.backends.libspotify.library') @@ -14,24 +15,41 @@ 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', uri, e) + return None def refresh(self, uri=None): pass # TODO def search(self, **query): + if not query: + # Since we can't search for the entire Spotify library, we return + # all tracks in the stored playlists when the query is empty. + tracks = [] + for playlist in self.backend.stored_playlists.playlists: + tracks += playlist.tracks + return Playlist(tracks=tracks) spotify_query = [] for (field, values) in query.iteritems(): + if field == u'track': + field = u'title' + if field == u'date': + field = u'year' if not hasattr(values, '__iter__'): values = [values] for value in values: - if field == u'track': - field = u'title' if field == u'any': spotify_query.append(value) + elif field == u'year': + value = int(value.split('-')[0]) # Extract year + spotify_query.append(u'%s:%d' % (field, value)) else: spotify_query.append(u'%s:"%s"' % (field, value)) spotify_query = u' '.join(spotify_query) diff --git a/mopidy/backends/libspotify/playback.py b/mopidy/backends/libspotify/playback.py index ed5ba697..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_queue.put({ - '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 22cbb0a0..7f541236 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/libspotify/session_manager.py @@ -5,44 +5,42 @@ import threading from spotify.manager import SpotifySessionManager from mopidy import get_version, settings -from mopidy.models import Playlist from mopidy.backends.libspotify.translator import LibspotifyTranslator +from mopidy.models import Playlist +from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.libspotify.session_manager') -class LibspotifySessionManager(SpotifySessionManager, threading.Thread): +class LibspotifySessionManager(SpotifySessionManager, BaseThread): cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) 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 + BaseThread.__init__(self, core_queue) + self.name = 'LibspotifySMThread' + self.output = output self.connected = threading.Event() self.session = None - def run(self): + def run_inside_try(self): self.connect() 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,52 +52,51 @@ 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): """Callback used by pyspotify""" - # TODO Base caps_string on arguments - caps_string = """ + assert sample_type == 0, u'Expects 16-bit signed integer samples' + capabilites = """ audio/x-raw-int, endianness=(int)1234, - channels=(int)2, + channels=(int)%(channels)d, width=(int)16, depth=(int)16, - signed=True, - rate=(int)44100 - """ - self.output_queue.put({ - 'command': 'deliver_data', - 'caps': caps_string, - 'data': bytes(frames), - }) + signed=(boolean)true, + rate=(int)%(sample_rate)d + """ % { + 'sample_rate': sample_rate, + 'channels': channels, + } + self.output.deliver_data(capabilites, bytes(frames)) 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.') - self.output_queue.put({'command': 'end_of_data_stream'}) + logger.debug(u'End of data stream reached') + self.output.end_of_data_stream() def search(self, query, connection): """Search method used by Mopidy backend""" - def callback(results, userdata): + def callback(results, userdata=None): # TODO Include results from results.albums(), etc. too playlist = Playlist(tracks=[ LibspotifyTranslator.to_mopidy_track(t) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 50b3d84d..e5bfe8f8 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -41,38 +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({'reply_to': pickle_connection(other_end)}) - self.backend.output_queue.put(message) - my_end.poll(None) - return my_end.recv() - - def _send(self, message): - self.backend.output_queue.put(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): diff --git a/mopidy/core.py b/mopidy/core.py index 3296fa6b..69760094 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,24 +1,26 @@ import logging import multiprocessing import optparse +import sys -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 -from mopidy.utils.process import BaseProcess, unpickle_connection +from mopidy.utils.process import BaseThread from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') -class CoreProcess(BaseProcess): +class CoreProcess(BaseThread): def __init__(self): - super(CoreProcess, self).__init__(name='CoreProcess') self.core_queue = multiprocessing.Queue() + super(CoreProcess, self).__init__(self.core_queue) + self.name = 'CoreProcess' self.options = self.parse_options() - self.output_queue = None + self.output = None self.backend = None - self.frontend = None + self.frontends = [] def parse_options(self): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) @@ -28,16 +30,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() @@ -46,12 +47,14 @@ 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.frontend = self.setup_frontend(self.core_queue, self.backend) + self.output = self.setup_output(self.core_queue) + self.backend = self.setup_backend(self.core_queue, self.output) + 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/') @@ -59,27 +62,32 @@ 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])() - frontend.start_server(core_queue) - frontend.create_dispatcher(backend) - return frontend + def setup_frontends(self, core_queue, backend): + frontends = [] + for frontend_class_name in settings.FRONTENDS: + 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): - 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) + if message.get('to') == 'core': + self.process_message_to_core(message) + elif message.get('to') == 'output': + self.output.process_message(message) + elif message.get('to') == 'frontend': + 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': @@ -88,3 +96,12 @@ class CoreProcess(BaseProcess): self.backend.stored_playlists.playlists = message['playlists'] else: logger.warning(u'Cannot handle message: %s', message) + + def process_message_to_core(self, message): + assert message['to'] == 'core', u'Message recipient must be "core".' + if message['command'] == 'exit': + if message['reason'] is not None: + logger.info(u'Exiting (%s)', message['reason']) + sys.exit(message['status']) + else: + logger.warning(u'Cannot handle message: %s', message) 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 diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py new file mode 100644 index 00000000..e91dd272 --- /dev/null +++ b/mopidy/frontends/lastfm.py @@ -0,0 +1,139 @@ +import logging +import multiprocessing +import socket +import time + +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 mopidy.utils.process import BaseThread + +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 play to your `Last.fm + `_ profile. + + .. note:: + + This frontend requires a free user account at Last.fm. + + **Dependencies:** + + - `pylast `_ >= 0.4.30 + + **Settings:** + + - :attr:`mopidy.settings.LASTFM_USERNAME` + - :attr:`mopidy.settings.LASTFM_PASSWORD` + """ + + def __init__(self, *args, **kwargs): + super(LastfmFrontend, self).__init__(*args, **kwargs) + (self.connection, other_end) = multiprocessing.Pipe() + self.thread = LastfmFrontendThread(self.core_queue, other_end) + + def start(self): + self.thread.start() + + def destroy(self): + self.thread.destroy() + + def process_message(self, message): + self.connection.send(message) + + +class LastfmFrontendThread(BaseThread): + def __init__(self, core_queue, connection): + super(LastfmFrontendThread, self).__init__(core_queue) + self.name = u'LastfmFrontendThread' + self.connection = connection + self.lastfm = None + self.scrobbler = None + self.last_start_time = None + + 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) + 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 not started') + 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 process_message(self, message): + 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 started_playing(self, track): + artists = ', '.join([a.name for a in track.artists]) + 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), + 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.warning(u'Last.fm now playing error: %s', e) + + 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. (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) + try: + self.scrobbler.scrobble( + artists.encode(ENCODING), + track.name.encode(ENCODING), + time_started=self.last_start_time, + 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.warning(u'Last.fm scrobbling error: %s', e) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 6c06279f..ce9abc6d 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,7 +1,13 @@ -from mopidy.frontends.mpd.dispatcher import MpdDispatcher -from mopidy.frontends.mpd.process import MpdProcess +import logging -class MpdFrontend(object): +from mopidy.frontends.base import BaseFrontend +from mopidy.frontends.mpd.dispatcher import MpdDispatcher +from mopidy.frontends.mpd.thread import MpdThread +from mopidy.utils.process import unpickle_connection + +logger = logging.getLogger('mopidy.frontends.mpd') + +class MpdFrontend(BaseFrontend): """ The MPD frontend. @@ -11,27 +17,32 @@ class MpdFrontend(object): - :attr:`mopidy.settings.MPD_SERVER_PORT` """ - def __init__(self): - self.process = None - self.dispatcher = None + def __init__(self, *args, **kwargs): + super(MpdFrontend, self).__init__(*args, **kwargs) + self.thread = None + self.dispatcher = MpdDispatcher(self.backend) - def start_server(self, core_queue): - """ - Starts the MPD server. + def start(self): + """Starts the MPD server.""" + self.thread = MpdThread(self.core_queue) + self.thread.start() - :param core_queue: the core queue - :type core_queue: :class:`multiprocessing.Queue` - """ - self.process = MpdProcess(core_queue) - self.process.start() + def destroy(self): + """Destroys the MPD server.""" + self.thread.destroy() - 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: + pass # Ignore messages for other frontends diff --git a/mopidy/frontends/mpd/process.py b/mopidy/frontends/mpd/process.py deleted file mode 100644 index 7bd95900..00000000 --- a/mopidy/frontends/mpd/process.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncore -import logging - -from mopidy.frontends.mpd.server import MpdServer -from mopidy.utils.process import BaseProcess - -logger = logging.getLogger('mopidy.frontends.mpd.process') - -class MpdProcess(BaseProcess): - def __init__(self, core_queue): - super(MpdProcess, self).__init__(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/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 90a53f5f..2f0a9f8f 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -11,14 +11,19 @@ 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 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') @@ -36,7 +41,13 @@ 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') if songpos is not None: songpos = int(songpos) track = frontend.backend.library.lookup(uri) @@ -44,7 +55,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/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index d4dcf50d..fb3a3a09 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -1,7 +1,8 @@ import re +import shlex from mopidy.frontends.mpd.protocol import handle_pattern, stored_playlists -from mopidy.frontends.mpd.exceptions import MpdNotImplemented +from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented def _build_query(mpd_query): """ @@ -81,13 +82,9 @@ def findadd(frontend, query): # TODO Add result to current playlist #result = frontend.find(query) -@handle_pattern(r'^list (?P[Aa]rtist)$') -@handle_pattern(r'^list "(?P[Aa]rtist)"$') -@handle_pattern(r'^list (?Palbum( artist)?)' - '( "(?P[^"]+)")*$') -@handle_pattern(r'^list "(?Palbum(" "artist)?)"' - '( "(?P[^"]+)")*$') -def list_(frontend, field, artist=None): +@handle_pattern(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' + '( (?P.*))?$') +def list_(frontend, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -101,22 +98,70 @@ def list_(frontend, field, artist=None): This filters the result list by an artist. + *Clarifications:* + + The musicpd.org documentation for ``list`` is far from complete. The + command also supports the following variant: + + ``list {TYPE} {QUERY}`` + + Where ``QUERY`` applies to all ``TYPE``. ``QUERY`` is one or more pairs + of a field name and a value. If the ``QUERY`` consists of more than one + pair, the pairs are AND-ed together to find the result. Examples of + valid queries and what they should return: + + ``list "artist" "artist" "ABBA"`` + List artists where the artist name is "ABBA". Response:: + + Artist: ABBA + OK + + ``list "album" "artist" "ABBA"`` + Lists albums where the artist name is "ABBA". Response:: + + Album: More ABBA Gold: More ABBA Hits + Album: Absolute More Christmas + Album: Gold: Greatest Hits + OK + + ``list "artist" "album" "Gold: Greatest Hits"`` + Lists artists where the album name is "Gold: Greatest Hits". + Response:: + + Artist: ABBA + OK + + ``list "artist" "artist" "ABBA" "artist" "TLC"`` + Lists artists where the artist name is "ABBA" *and* "TLC". Should + never match anything. Response:: + + OK + + ``list "date" "artist" "ABBA"`` + Lists dates where artist name is "ABBA". Response:: + + Date: + Date: 1992 + Date: 1993 + OK + + ``list "date" "artist" "ABBA" "album" "Gold: Greatest Hits"`` + Lists dates where artist name is "ABBA" and album name is "Gold: + Greatest Hits". Response:: + + Date: 1992 + OK + + ``list "genre" "artist" "The Rolling Stones"`` + Lists genres where artist name is "The Rolling Stones". Response:: + + Genre: + Genre: Rock + OK + *GMPC:* - does not add quotes around the field argument. - - asks for "list artist" to get available artists and will not query - for artist/album information if this is not retrived - - asks for multiple fields, i.e.:: - - list album artist "an artist name" - - returns the albums available for the asked artist:: - - list album artist "Tiesto" - Album: Radio Trance Vol 4-Promo-CD - Album: Ur A Tear in the Open CDR - Album: Simple Trance 2004 Step One - Album: In Concert 05-10-2003 *ncmpc:* @@ -124,31 +169,70 @@ def list_(frontend, field, artist=None): - capitalizes the field argument. """ field = field.lower() + query = _list_build_query(field, mpd_query) if field == u'artist': - return _list_artist(frontend) - elif field == u'album artist': - return _list_album_artist(frontend, artist) - # TODO More to implement + return _list_artist(frontend, query) + elif field == u'album': + return _list_album(frontend, query) + elif field == u'date': + return _list_date(frontend, query) + elif field == u'genre': + pass # TODO We don't have genre in our internal data structures yet -def _list_artist(frontend): - """ - Since we don't know exactly all available artists, we respond with - the artists we know for sure, which is all artists in our stored playlists. - """ +def _list_build_query(field, mpd_query): + """Converts a ``list`` query to a Mopidy query.""" + if mpd_query is None: + return {} + # shlex does not seem to be friends with unicode objects + tokens = shlex.split(mpd_query.encode('utf-8')) + tokens = [t.decode('utf-8') for t in tokens] + if len(tokens) == 1: + if field == u'album': + return {'artist': [tokens[0]]} + else: + raise MpdArgError( + u'should be "Album" for 3 arguments', command=u'list') + elif len(tokens) % 2 == 0: + query = {} + while tokens: + key = tokens[0].lower() + key = str(key) # Needed for kwargs keys on OS X and Windows + value = tokens[1] + tokens = tokens[2:] + if key not in (u'artist', u'album', u'date', u'genre'): + raise MpdArgError(u'not able to parse args', command=u'list') + if key in query: + query[key].append(value) + else: + query[key] = [value] + return query + else: + raise MpdArgError(u'not able to parse args', command=u'list') + +def _list_artist(frontend, query): artists = set() - for playlist in frontend.backend.stored_playlists.playlists: - for track in playlist.tracks: - for artist in track.artists: - artists.add((u'Artist', artist.name)) + playlist = frontend.backend.library.find_exact(**query) + for track in playlist.tracks: + for artist in track.artists: + artists.add((u'Artist', artist.name)) return artists -def _list_album_artist(frontend, artist): - playlist = frontend.backend.library.find_exact(artist=[artist]) +def _list_album(frontend, query): albums = set() + playlist = frontend.backend.library.find_exact(**query) for track in playlist.tracks: - albums.add((u'Album', track.album.name)) + if track.album is not None: + albums.add((u'Album', track.album.name)) return albums +def _list_date(frontend, query): + dates = set() + playlist = frontend.backend.library.find_exact(**query) + for track in playlist.tracks: + if track.date is not None: + dates.add((u'Date', track.date.strftime('%Y-%m-%d'))) + return dates + @handle_pattern(r'^listall "(?P[^"]+)"') def listall(frontend, uri): """ diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index c3fbdd5f..2f5dd29e 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -138,6 +138,10 @@ def playid(frontend, cpid): at the first track. """ cpid = int(cpid) + paused = (frontend.backend.playback.state == + frontend.backend.playback.PAUSED) + if cpid == -1 and paused: + return frontend.backend.playback.resume() try: if cpid == -1: cp_track = _get_cp_track_for_play_minus_one(frontend) diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index db13e516..7caf21f9 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -24,19 +24,23 @@ class MpdServer(asyncore.dispatcher): try: if socket.has_ipv6: self.create_socket(socket.AF_INET6, socket.SOCK_STREAM) + # Explicitly configure socket to work for both IPv4 and IPv6 + self.socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) else: self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 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', 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.""" 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), diff --git a/mopidy/frontends/mpd/thread.py b/mopidy/frontends/mpd/thread.py new file mode 100644 index 00000000..0ad5ee68 --- /dev/null +++ b/mopidy/frontends/mpd/thread.py @@ -0,0 +1,18 @@ +import asyncore +import logging + +from mopidy.frontends.mpd.server import MpdServer +from mopidy.utils.process import BaseThread + +logger = logging.getLogger('mopidy.frontends.mpd.thread') + +class MpdThread(BaseThread): + def __init__(self, core_queue): + super(MpdThread, self).__init__(core_queue) + self.name = u'MpdThread' + + def run_inside_try(self): + logger.debug(u'Starting MPD server thread') + server = MpdServer(self.core_queue) + server.start() + asyncore.loop() 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) diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 929d2e1d..7a8f006e 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -4,7 +4,7 @@ from multiprocessing import Pipe from mopidy import settings from mopidy.mixers import BaseMixer -from mopidy.utils.process import BaseProcess +from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.mixers.nad') @@ -50,7 +50,7 @@ class NadMixer(BaseMixer): self._pipe.send({'command': 'set_volume', 'volume': volume}) -class NadTalker(BaseProcess): +class NadTalker(BaseThread): """ Independent process which does the communication with the NAD device. diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py new file mode 100644 index 00000000..bb312323 --- /dev/null +++ b/mopidy/outputs/base.py @@ -0,0 +1,88 @@ +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 + + def destroy(self): + """Destroy the output.""" + pass + + 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 new file mode 100644 index 00000000..fd42b38b --- /dev/null +++ b/mopidy/outputs/dummy.py @@ -0,0 +1,76 @@ +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 = [] + + #: 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 + + def destroy(self): + self.destroy_called = True + + def process_message(self, message): + self.messages.append(message) + + 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 554e986e..3714fed6 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -6,36 +6,100 @@ pygst.require('0.10') import gst import logging -import threading +import multiprocessing from mopidy import settings -from mopidy.utils.process import BaseProcess, unpickle_connection +from mopidy.outputs.base import BaseOutput +from mopidy.utils.process import (BaseThread, pickle_connection, + unpickle_connection) logger = logging.getLogger('mopidy.outputs.gstreamer') -class GStreamerOutput(object): +class GStreamerOutput(BaseOutput): """ Audio output through GStreamer. - Starts the :class:`GStreamerProcess`. + Starts :class:`GStreamerMessagesThread` and :class:`GStreamerPlayerThread`. **Settings:** - :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` """ - def __init__(self, core_queue, output_queue): - self.process = GStreamerProcess(core_queue, output_queue) - self.process.start() + def __init__(self, *args, **kwargs): + super(GStreamerOutput, self).__init__(*args, **kwargs) + # Start a helper thread that can run the gobject.MainLoop + self.messages_thread = GStreamerMessagesThread(self.core_queue) + + # Start a helper thread that can process the output_queue + self.output_queue = multiprocessing.Queue() + self.player_thread = GStreamerPlayerThread(self.core_queue, + self.output_queue) + + def start(self): + self.messages_thread.start() + self.player_thread.start() def destroy(self): - self.process.terminate() + self.messages_thread.destroy() + self.player_thread.destroy() -class GStreamerMessagesThread(threading.Thread): - def run(self): + def process_message(self, message): + 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', 'position': 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(BaseThread): + def __init__(self, core_queue): + super(GStreamerMessagesThread, self).__init__(core_queue) + self.name = u'GStreamerMessagesThread' + + def run_inside_try(self): gobject.MainLoop().run() -class GStreamerProcess(BaseProcess): + +class GStreamerPlayerThread(BaseThread): """ A process for all work related to GStreamer. @@ -48,8 +112,8 @@ class GStreamerProcess(BaseProcess): """ def __init__(self, core_queue, output_queue): - super(GStreamerProcess, self).__init__(name='GStreamerProcess') - self.core_queue = core_queue + super(GStreamerPlayerThread, self).__init__(core_queue) + self.name = u'GStreamerPlayerThread' self.output_queue = output_queue self.gst_pipeline = None @@ -62,11 +126,6 @@ class GStreamerProcess(BaseProcess): def setup(self): logger.debug(u'Setting up GStreamer pipeline') - # Start a helper thread that can run the gobject.MainLoop - messages_thread = GStreamerMessagesThread() - messages_thread.daemon = True - messages_thread.start() - self.gst_pipeline = gst.parse_launch(' ! '.join([ 'audioconvert name=convert', 'volume name=volume', @@ -80,7 +139,16 @@ class GStreamerProcess(BaseProcess): uri_bin.connect('pad-added', self.process_new_pad, pad) self.gst_pipeline.add(uri_bin) else: - app_src = gst.element_factory_make('appsrc', 'src') + app_src = gst.element_factory_make('appsrc', 'appsrc') + app_src_caps = gst.Caps(""" + audio/x-raw-int, + endianness=(int)1234, + channels=(int)2, + width=(int)16, + depth=(int)16, + signed=(boolean)true, + rate=(int)44100""") + app_src.set_property('caps', app_src_caps) self.gst_pipeline.add(app_src) app_src.get_pad('src').link(pad) @@ -111,7 +179,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']) @@ -144,12 +214,12 @@ class GStreamerProcess(BaseProcess): def deliver_data(self, caps_string, data): """Deliver audio data to be played""" - data_src = self.gst_pipeline.get_by_name('src') + app_src = self.gst_pipeline.get_by_name('appsrc') caps = gst.caps_from_string(caps_string) buffer_ = gst.Buffer(buffer(data)) buffer_.set_caps(caps) - data_src.set_property('caps', caps) - data_src.emit('push-buffer', buffer_) + app_src.set_property('caps', caps) + app_src.emit('push-buffer', buffer_) def end_of_data_stream(self): """ @@ -158,7 +228,7 @@ class GStreamerProcess(BaseProcess): We will get a GStreamer message when the stream playback reaches the token, and can then do any end-of-stream related tasks. """ - self.gst_pipeline.get_by_name('src').emit('end-of-stream') + self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream') def set_state(self, state_name): """ @@ -195,6 +265,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 diff --git a/mopidy/settings.py b/mopidy/settings.py index 699eb16a..c9d7b9fc 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,36 +20,39 @@ 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' + \ +#: 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 debug logging. +#: +#: See http://docs.python.org/library/logging.html#formatter-objects for +#: details on the format. +DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \ ' [%(process)d:%(threadName)s] %(name)s\n %(message)s' -#: The log format used for dump logs. -#: -#: Default:: -#: -#: DUMP_LOG_FILENAME = CONSOLE_LOG_FORMAT -DUMP_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. #: #: 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`. #: @@ -58,6 +61,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`. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index c892102a..cc1c19c1 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,27 +3,40 @@ import logging.handlers from mopidy import settings -def setup_logging(verbosity_level, dump): +def setup_logging(verbosity_level, save_debug_log): + setup_root_logger() setup_console_logging(verbosity_level) - if dump: - setup_dump_logging() + 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: - 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) - -def setup_dump_logging(): - root = logging.getLogger('') - root.setLevel(logging.DEBUG) - formatter = logging.Formatter(settings.DUMP_LOG_FORMAT) - handler = logging.handlers.RotatingFileHandler( - settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3) + log_level = logging.INFO + log_format = settings.CONSOLE_LOG_FORMAT + 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(): + 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'): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 73224840..7855d69c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,5 +1,6 @@ import logging import multiprocessing +import multiprocessing.dummy from multiprocessing.reduction import reduce_connection import pickle import sys @@ -18,22 +19,70 @@ def unpickle_connection(pickled_connection): class BaseProcess(multiprocessing.Process): + def __init__(self, core_queue): + super(BaseProcess, self).__init__() + self.core_queue = core_queue + def run(self): logger.debug(u'%s: Starting process', self.name) try: self.run_inside_try() except KeyboardInterrupt: - logger.info(u'%s: Interrupted by user', self.name) - sys.exit(0) + logger.info(u'Interrupted by user') + self.exit(0, u'Interrupted by user') except SettingsError as e: logger.error(e.message) - sys.exit(1) + self.exit(1, u'Settings error') except ImportError as e: logger.error(e) - sys.exit(1) + self.exit(2, u'Import error') except Exception as e: logger.exception(e) - raise e + self.exit(3, u'Unknown error') def run_inside_try(self): raise NotImplementedError + + def destroy(self): + self.terminate() + + def exit(self, status=0, reason=None): + self.core_queue.put({'to': 'core', 'command': 'exit', + 'status': status, 'reason': reason}) + self.destroy() + + +class BaseThread(multiprocessing.dummy.Process): + def __init__(self, core_queue): + super(BaseThread, self).__init__() + self.core_queue = core_queue + # No thread should block process from exiting + self.daemon = True + + def run(self): + logger.debug(u'%s: Starting thread', self.name) + try: + self.run_inside_try() + except KeyboardInterrupt: + logger.info(u'Interrupted by user') + self.exit(0, u'Interrupted by user') + except SettingsError as e: + logger.error(e.message) + self.exit(1, u'Settings error') + except ImportError as e: + logger.error(e) + self.exit(2, u'Import error') + except Exception as e: + logger.exception(e) + self.exit(3, u'Unknown error') + + def run_inside_try(self): + raise NotImplementedError + + def destroy(self): + pass + + def exit(self, status=0, reason=None): + self.core_queue.put({'to': 'core', 'command': 'exit', + 'status': status, 'reason': reason}) + self.destroy() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index e45c5521..1d3a0fa0 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', @@ -81,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', @@ -122,7 +132,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)) 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 diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 1b312c2f..05f08e18 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 @@ -129,7 +128,7 @@ class BaseCurrentPlaylistControllerTest(object): def test_append_does_not_reset_version(self): version = self.controller.version self.controller.append([]) - self.assertEqual(self.controller.version, version + 1) + self.assertEqual(self.controller.version, version) @populate_playlist def test_append_preserves_playing_state(self): @@ -250,7 +249,12 @@ class BaseCurrentPlaylistControllerTest(object): self.assertEqual(self.tracks[0], shuffled_tracks[0]) self.assertEqual(set(self.tracks), set(shuffled_tracks)) - def test_version(self): + def test_version_does_not_change_when_appending_nothing(self): version = self.controller.version self.controller.append([]) + self.assertEquals(version, self.controller.version) + + def test_version_increases_when_appending_something(self): + version = self.controller.version + self.controller.append([Track()]) self.assert_(version < self.controller.version) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index f8e9dd87..4caaf44b 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -5,21 +5,22 @@ 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 from tests.backends.base import populate_playlist +# TODO Test 'playlist repeat', e.g. repeat=1,single=0 + 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 @@ -523,16 +524,17 @@ class BasePlaybackControllerTest(object): wrapper.called = False self.playback.on_current_playlist_change = wrapper - self.backend.current_playlist.append([]) + self.backend.current_playlist.append([Track()]) 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') - message = self.core_queue.get() + self.assertTrue(result, 'Seek failed') + message = self.core_queue.get(True, 1) self.assertEqual('end_of_track', message['command']) @populate_playlist @@ -606,6 +608,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() @@ -626,8 +629,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) @@ -738,15 +740,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() @@ -755,7 +758,6 @@ class BasePlaybackControllerTest(object): time.sleep(0.2) first = self.playback.time_position second = self.playback.time_position - self.assertEqual(first, second) @populate_playlist 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/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index c53e2b8d..8a4b9ab5 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()] @@ -125,7 +135,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_deleteid(self): self.b.current_playlist.append([Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 2) - result = self.h.handle_request(u'deleteid "2"') + result = self.h.handle_request(u'deleteid "1"') self.assertEqual(len(self.b.current_playlist.tracks), 1) self.assert_(u'OK' in result) @@ -183,7 +193,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'moveid "5" "2"') + result = self.h.handle_request(u'moveid "4" "2"') self.assertEqual(self.b.current_playlist.tracks[0].name, 'a') self.assertEqual(self.b.current_playlist.tracks[1].name, 'b') self.assertEqual(self.b.current_playlist.tracks[2].name, 'e') @@ -219,7 +229,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): result = self.h.handle_request( u'playlistfind filename "file:///exists"') self.assert_(u'file: file:///exists' in result) - self.assert_(u'Id: 1' in result) + self.assert_(u'Id: 0' in result) self.assert_(u'Pos: 0' in result) self.assert_(u'OK' in result) @@ -232,11 +242,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_playlistid_with_songid(self): self.b.current_playlist.append([Track(name='a'), Track(name='b')]) - result = self.h.handle_request(u'playlistid "2"') + result = self.h.handle_request(u'playlistid "1"') self.assert_(u'Title: a' not in result) - self.assert_(u'Id: 1' not in result) + self.assert_(u'Id: 0' not in result) self.assert_(u'Title: b' in result) - self.assert_(u'Id: 2' in result) + self.assert_(u'Id: 1' in result) self.assert_(u'OK' in result) def test_playlistid_with_not_existing_songid_fails(self): @@ -419,7 +429,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - result = self.h.handle_request(u'swapid "2" "5"') + result = self.h.handle_request(u'swapid "1" "4"') self.assertEqual(self.b.current_playlist.tracks[0].name, 'a') self.assertEqual(self.b.current_playlist.tracks[1].name, 'e') self.assertEqual(self.b.current_playlist.tracks[2].name, 'c') diff --git a/tests/frontends/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py index 5fcc393c..05b8ebd0 100644 --- a/tests/frontends/mpd/music_db_test.py +++ b/tests/frontends/mpd/music_db_test.py @@ -15,6 +15,59 @@ class MusicDatabaseHandlerTest(unittest.TestCase): self.assert_(u'playtime: 0' in result) self.assert_(u'OK' in result) + def test_findadd(self): + result = self.h.handle_request(u'findadd "album" "what"') + self.assert_(u'OK' in result) + + def test_listall(self): + result = self.h.handle_request(u'listall "file:///dev/urandom"') + self.assert_(u'ACK [0@0] {} Not implemented' in result) + + def test_listallinfo(self): + result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') + self.assert_(u'ACK [0@0] {} Not implemented' in result) + + def test_lsinfo_without_path_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEqual(lsinfo_result, listplaylists_result) + + def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo ""') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEqual(lsinfo_result, listplaylists_result) + + def test_lsinfo_for_root_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo "/"') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEqual(lsinfo_result, listplaylists_result) + + def test_update_without_uri(self): + result = self.h.handle_request(u'update') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + def test_update_with_uri(self): + result = self.h.handle_request(u'update "file:///dev/urandom"') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + def test_rescan_without_uri(self): + result = self.h.handle_request(u'rescan') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + def test_rescan_with_uri(self): + result = self.h.handle_request(u'rescan "file:///dev/urandom"') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + +class MusicDatabaseFindTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer_class=DummyMixer) + self.h = dispatcher.MpdDispatcher(backend=self.b) + def test_find_album(self): result = self.h.handle_request(u'find "album" "what"') self.assert_(u'OK' in result) @@ -48,11 +101,20 @@ class MusicDatabaseHandlerTest(unittest.TestCase): u'find album "album_what" artist "artist_what"') self.assert_(u'OK' in result) - def test_findadd(self): - result = self.h.handle_request(u'findadd "album" "what"') - self.assert_(u'OK' in result) - def test_list_artist(self): +class MusicDatabaseListTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer_class=DummyMixer) + self.h = dispatcher.MpdDispatcher(backend=self.b) + + def test_list_foo_returns_ack(self): + result = self.h.handle_request(u'list "foo"') + self.assertEqual(result[0], + u'ACK [2@0] {list} incorrect arguments') + + ### Artist + + def test_list_artist_with_quotes(self): result = self.h.handle_request(u'list "artist"') self.assert_(u'OK' in result) @@ -64,44 +126,177 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'list Artist') self.assert_(u'OK' in result) - def test_list_artist_with_artist_should_fail(self): + def test_list_artist_with_query_of_one_token(self): result = self.h.handle_request(u'list "artist" "anartist"') - self.assertEqual(result[0], u'ACK [2@0] {list} incorrect arguments') + self.assertEqual(result[0], + u'ACK [2@0] {list} should be "Album" for 3 arguments') - def test_list_album_without_artist(self): + def test_list_artist_with_unknown_field_in_query_returns_ack(self): + result = self.h.handle_request(u'list "artist" "foo" "bar"') + self.assertEqual(result[0], + u'ACK [2@0] {list} not able to parse args') + + def test_list_artist_by_artist(self): + result = self.h.handle_request(u'list "artist" "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_list_artist_by_album(self): + result = self.h.handle_request(u'list "artist" "album" "analbum"') + self.assert_(u'OK' in result) + + def test_list_artist_by_full_date(self): + result = self.h.handle_request(u'list "artist" "date" "2001-01-01"') + self.assert_(u'OK' in result) + + def test_list_artist_by_year(self): + result = self.h.handle_request(u'list "artist" "date" "2001"') + self.assert_(u'OK' in result) + + def test_list_artist_by_genre(self): + result = self.h.handle_request(u'list "artist" "genre" "agenre"') + self.assert_(u'OK' in result) + + def test_list_artist_by_artist_and_album(self): + result = self.h.handle_request( + u'list "artist" "artist" "anartist" "album" "analbum"') + self.assert_(u'OK' in result) + + ### Album + + def test_list_album_with_quotes(self): result = self.h.handle_request(u'list "album"') self.assert_(u'OK' in result) - def test_list_album_with_artist(self): + def test_list_album_without_quotes(self): + result = self.h.handle_request(u'list album') + self.assert_(u'OK' in result) + + def test_list_album_without_quotes_and_capitalized(self): + result = self.h.handle_request(u'list Album') + self.assert_(u'OK' in result) + + def test_list_album_with_artist_name(self): result = self.h.handle_request(u'list "album" "anartist"') self.assert_(u'OK' in result) - def test_list_album_artist_with_artist_without_quotes(self): - result = self.h.handle_request(u'list album artist "anartist"') + def test_list_album_by_artist(self): + result = self.h.handle_request(u'list "album" "artist" "anartist"') self.assert_(u'OK' in result) - def test_listall(self): - result = self.h.handle_request(u'listall "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + def test_list_album_by_album(self): + result = self.h.handle_request(u'list "album" "album" "analbum"') + self.assert_(u'OK' in result) - def test_listallinfo(self): - result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + def test_list_album_by_full_date(self): + result = self.h.handle_request(u'list "album" "date" "2001-01-01"') + self.assert_(u'OK' in result) - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) + def test_list_album_by_year(self): + result = self.h.handle_request(u'list "album" "date" "2001"') + self.assert_(u'OK' in result) - def test_lsinfo_with_empty_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo ""') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) + def test_list_album_by_genre(self): + result = self.h.handle_request(u'list "album" "genre" "agenre"') + self.assert_(u'OK' in result) - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo "/"') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEqual(lsinfo_result, listplaylists_result) + def test_list_album_by_artist_and_album(self): + result = self.h.handle_request( + u'list "album" "artist" "anartist" "album" "analbum"') + self.assert_(u'OK' in result) + + ### Date + + def test_list_date_with_quotes(self): + result = self.h.handle_request(u'list "date"') + self.assert_(u'OK' in result) + + def test_list_date_without_quotes(self): + result = self.h.handle_request(u'list date') + self.assert_(u'OK' in result) + + def test_list_date_without_quotes_and_capitalized(self): + result = self.h.handle_request(u'list Date') + self.assert_(u'OK' in result) + + def test_list_date_with_query_of_one_token(self): + result = self.h.handle_request(u'list "date" "anartist"') + self.assertEqual(result[0], + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_date_by_artist(self): + result = self.h.handle_request(u'list "date" "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_list_date_by_album(self): + result = self.h.handle_request(u'list "date" "album" "analbum"') + self.assert_(u'OK' in result) + + def test_list_date_by_full_date(self): + result = self.h.handle_request(u'list "date" "date" "2001-01-01"') + self.assert_(u'OK' in result) + + def test_list_date_by_year(self): + result = self.h.handle_request(u'list "date" "date" "2001"') + self.assert_(u'OK' in result) + + def test_list_date_by_genre(self): + result = self.h.handle_request(u'list "date" "genre" "agenre"') + self.assert_(u'OK' in result) + + def test_list_date_by_artist_and_album(self): + result = self.h.handle_request( + u'list "date" "artist" "anartist" "album" "analbum"') + self.assert_(u'OK' in result) + + ### Genre + + def test_list_genre_with_quotes(self): + result = self.h.handle_request(u'list "genre"') + self.assert_(u'OK' in result) + + def test_list_genre_without_quotes(self): + result = self.h.handle_request(u'list genre') + self.assert_(u'OK' in result) + + def test_list_genre_without_quotes_and_capitalized(self): + result = self.h.handle_request(u'list Genre') + self.assert_(u'OK' in result) + + def test_list_genre_with_query_of_one_token(self): + result = self.h.handle_request(u'list "genre" "anartist"') + self.assertEqual(result[0], + u'ACK [2@0] {list} should be "Album" for 3 arguments') + + def test_list_genre_by_artist(self): + result = self.h.handle_request(u'list "genre" "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_list_genre_by_album(self): + result = self.h.handle_request(u'list "genre" "album" "analbum"') + self.assert_(u'OK' in result) + + def test_list_genre_by_full_date(self): + result = self.h.handle_request(u'list "genre" "date" "2001-01-01"') + self.assert_(u'OK' in result) + + def test_list_genre_by_year(self): + result = self.h.handle_request(u'list "genre" "date" "2001"') + self.assert_(u'OK' in result) + + def test_list_genre_by_genre(self): + result = self.h.handle_request(u'list "genre" "genre" "agenre"') + self.assert_(u'OK' in result) + + def test_list_genre_by_artist_and_album(self): + result = self.h.handle_request( + u'list "genre" "artist" "anartist" "album" "analbum"') + self.assert_(u'OK' in result) + + +class MusicDatabaseSearchTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer_class=DummyMixer) + self.h = dispatcher.MpdDispatcher(backend=self.b) def test_search_album(self): result = self.h.handle_request(u'search "album" "analbum"') @@ -147,22 +342,4 @@ class MusicDatabaseHandlerTest(unittest.TestCase): result = self.h.handle_request(u'search "sometype" "something"') self.assertEqual(result[0], u'ACK [2@0] {search} incorrect arguments') - def test_update_without_uri(self): - result = self.h.handle_request(u'update') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - def test_update_with_uri(self): - result = self.h.handle_request(u'update "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_without_uri(self): - result = self.h.handle_request(u'rescan') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_with_uri(self): - result = self.h.handle_request(u'rescan "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py index 3ba48a54..4e60546d 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -254,7 +254,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_playid(self): self.b.current_playlist.append([Track()]) - result = self.h.handle_request(u'playid "1"') + result = self.h.handle_request(u'playid "0"') self.assert_(u'OK' in result) self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) @@ -285,6 +285,18 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.STOPPED, self.b.playback.state) self.assertEqual(self.b.playback.current_track, None) + def test_playid_minus_one_resumes_if_paused(self): + self.b.current_playlist.append([Track(length=40000)]) + self.b.playback.seek(30000) + self.assert_(self.b.playback.time_position >= 30000) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + self.b.playback.pause() + self.assertEquals(self.b.playback.PAUSED, self.b.playback.state) + result = self.h.handle_request(u'playid "-1"') + self.assert_(u'OK' in result) + self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) + self.assert_(self.b.playback.time_position >= 30000) + def test_playid_which_does_not_exist(self): self.b.current_playlist.append([Track()]) result = self.h.handle_request(u'playid "12345"') @@ -310,7 +322,7 @@ class PlaybackControlHandlerTest(unittest.TestCase): def test_seekid(self): self.b.current_playlist.append([Track(length=40000)]) - result = self.h.handle_request(u'seekid "1" "30"') + result = self.h.handle_request(u'seekid "0" "30"') self.assert_(u'OK' in result) self.assert_(self.b.playback.time_position >= 30000) @@ -318,8 +330,8 @@ class PlaybackControlHandlerTest(unittest.TestCase): seek_track = Track(uri='2', length=40000) self.b.current_playlist.append( [Track(length=40000), seek_track]) - result = self.h.handle_request(u'seekid "2" "30"') - self.assertEqual(self.b.playback.current_cpid, 2) + result = self.h.handle_request(u'seekid "1" "30"') + self.assertEqual(self.b.playback.current_cpid, 1) self.assertEqual(self.b.playback.current_track, seek_track) def test_stop(self): diff --git a/tests/frontends/mpd/regression_test.py b/tests/frontends/mpd/regression_test.py new file mode 100644 index 00000000..3cfdb855 --- /dev/null +++ b/tests/frontends/mpd/regression_test.py @@ -0,0 +1,110 @@ +import random +import unittest + +from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import dispatcher +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Track + +class IssueGH17RegressionTest(unittest.TestCase): + """ + The issue: http://github.com/jodal/mopidy/issues#issue/17 + + How to reproduce: + + - Play a playlist where one track cannot be played + - Turn on random mode + - Press next until you get to the unplayable track + """ + + def setUp(self): + self.backend = DummyBackend(mixer_class=DummyMixer) + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), None, + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.mpd = dispatcher.MpdDispatcher(backend=self.backend) + + def test(self): + random.seed(1) # Playlist order: abcfde + self.mpd.handle_request(u'play') + self.assertEquals('a', self.backend.playback.current_track.uri) + self.mpd.handle_request(u'random "1"') + self.mpd.handle_request(u'next') + self.assertEquals('b', self.backend.playback.current_track.uri) + self.mpd.handle_request(u'next') + # Should now be at track 'c', but playback fails and it skips ahead + self.assertEquals('f', self.backend.playback.current_track.uri) + self.mpd.handle_request(u'next') + self.assertEquals('d', self.backend.playback.current_track.uri) + self.mpd.handle_request(u'next') + self.assertEquals('e', self.backend.playback.current_track.uri) + + +class IssueGH18RegressionTest(unittest.TestCase): + """ + The issue: http://github.com/jodal/mopidy/issues#issue/18 + + How to reproduce: + + Play, random on, next, random off, next, next. + + At this point it gives the same song over and over. + """ + + def setUp(self): + self.backend = DummyBackend(mixer_class=DummyMixer) + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.mpd = dispatcher.MpdDispatcher(backend=self.backend) + + def test(self): + random.seed(1) + self.mpd.handle_request(u'play') + self.mpd.handle_request(u'random "1"') + self.mpd.handle_request(u'next') + self.mpd.handle_request(u'random "0"') + self.mpd.handle_request(u'next') + + self.mpd.handle_request(u'next') + cp_track_1 = self.backend.playback.current_cp_track + self.mpd.handle_request(u'next') + cp_track_2 = self.backend.playback.current_cp_track + self.mpd.handle_request(u'next') + cp_track_3 = self.backend.playback.current_cp_track + + self.assertNotEqual(cp_track_1, cp_track_2) + self.assertNotEqual(cp_track_2, cp_track_3) + + +class IssueGH22RegressionTest(unittest.TestCase): + """ + The issue: http://github.com/jodal/mopidy/issues/#issue/22 + + How to reproduce: + + Play, random on, remove all tracks from the current playlist (as in + "delete" each one, not "clear"). + + Alternatively: Play, random on, remove a random track from the current + playlist, press next until it crashes. + """ + + def setUp(self): + self.backend = DummyBackend(mixer_class=DummyMixer) + self.backend.current_playlist.append([ + Track(uri='a'), Track(uri='b'), Track(uri='c'), + Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.mpd = dispatcher.MpdDispatcher(backend=self.backend) + + def test(self): + random.seed(1) + self.mpd.handle_request(u'play') + self.mpd.handle_request(u'random "1"') + self.mpd.handle_request(u'deleteid "1"') + self.mpd.handle_request(u'deleteid "2"') + self.mpd.handle_request(u'deleteid "3"') + self.mpd.handle_request(u'deleteid "4"') + self.mpd.handle_request(u'deleteid "5"') + self.mpd.handle_request(u'deleteid "6"') + self.mpd.handle_request(u'status') diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index fbd0ff9e..1afe6ccd 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -27,7 +27,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'Track: 0' in result) self.assert_(u'Date: ' in result) self.assert_(u'Pos: 0' in result) - self.assert_(u'Id: 1' in result) + self.assert_(u'Id: 0' in result) self.assert_(u'OK' in result) def test_currentsong_without_song(self): @@ -166,7 +166,7 @@ class StatusHandlerTest(unittest.TestCase): self.b.playback.play() result = dict(dispatcher.status.status(self.h)) self.assert_('songid' in result) - self.assertEqual(int(result['songid']), 1) + self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): self.b.current_playlist.append([Track(length=None)]) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py index 52d1fbe1..3a578280 100644 --- a/tests/outputs/gstreamer_test.py +++ b/tests/outputs/gstreamer_test.py @@ -1,61 +1,65 @@ 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): - 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() - settings.BACKENDS = settings.original_backends - - def send_recv(self, message): - (my_end, other_end) = multiprocessing.Pipe() - message.update({'reply_to': pickle_connection(other_end)}) - self.output_queue.put(message) - my_end.poll(None) - return my_end.recv() - - - def send(self, message): - self.output_queue.put(message) + settings.runtime.clear() 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 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') diff --git a/tests/version_test.py b/tests/version_test.py index a44e4e89..fcc95c4c 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -11,7 +11,7 @@ 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.1') < SV('0.2.0')) + 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.1')) self.assert_(SV('0.2.0') < SV('1.0.0'))