Merge branch 'develop' into feature/multi-backend

Conflicts:
	mopidy/backends/local/__init__.py
	mopidy/outputs/gstreamer.py
	tests/frontends/mpd/audio_output_test.py
	tests/frontends/mpd/command_list_test.py
	tests/frontends/mpd/connection_test.py
	tests/frontends/mpd/current_playlist_test.py
	tests/frontends/mpd/dispatcher_test.py
	tests/frontends/mpd/music_db_test.py
	tests/frontends/mpd/playback_test.py
	tests/frontends/mpd/reflection_test.py
	tests/frontends/mpd/regression_test.py
	tests/frontends/mpd/status_test.py
	tests/frontends/mpd/stickers_test.py
	tests/frontends/mpd/stored_playlists_test.py
This commit is contained in:
Stein Magnus Jodal 2011-04-25 15:05:33 +02:00
commit 5f7988d974
121 changed files with 2073 additions and 1464 deletions

3
.gitignore vendored
View File

@ -2,11 +2,12 @@
*.swp
.coverage
.noseids
.tox
MANIFEST
build/
cover/
coverage.xml
dist/
docs/_build/
mopidy.log
mopidy.log*
nosetests.xml

View File

@ -1,5 +1,5 @@
include LICENSE pylintrc *.rst data/mopidy.desktop
include mopidy/backends/libspotify/spotify_appkey.key
include mopidy/backends/spotify/spotify_appkey.key
recursive-include docs *
prune docs/_build
recursive-include requirements *

View File

@ -6,14 +6,15 @@ Mopidy is a music server which can play music from `Spotify
<http://www.spotify.com/>`_ or from your local hard drive. To search for music
in Spotify's vast archive, manage playlists, and play music, you can use most
`MPD clients <http://mpd.wikia.com/>`_. MPD clients are available for most
platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones.
platforms, including Windows, Mac OS X, Linux, Android and iOS.
To install Mopidy, check out
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
* `Documentation (latest release) <http://www.mopidy.com/docs/master/>`_
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
* `Source code <http://github.com/mopidy/mopidy>`_
* `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
* `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_
- `Documentation for the development version
<http://www.mopidy.com/docs/develop/>`_
- `Source code <http://github.com/mopidy/mopidy>`_
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_

View File

@ -1,5 +1,5 @@
#! /usr/bin/env python
if __name__ == '__main__':
from mopidy.__main__ import main
from mopidy.core import main
main()

BIN
docs/_static/mopidy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -5,21 +5,134 @@ Changes
This change log is used to track all major changes to Mopidy.
0.3.0 (in development)
0.4.0 (in development)
======================
No description yet.
**Important changes**
- Mopidy now depends on `Pykka <http://jodal.github.com/pykka>`_ >=0.12. If you
install from APT, Pykka will automatically be installed. If you are not
installing from APT, you may install Pykka from PyPI::
sudo pip install -U Pykka
- If you use the Spotify backend, you *should* upgrade to libspotify 0.0.7 and
the latest pyspotify from the Mopidy developers. If you install from APT,
libspotify and pyspotify will automatically be upgraded. If you are not
installing from APT, follow the instructions at
:doc:`/installation/libspotify/`.
**Changes**
- Mopidy now use Pykka actors for thread management and inter-thread
communication. The immediate advantage of this is that Mopidy now works on
Python 2.7. (Fixes: :issue:`66`)
- Spotify backend:
- If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and
the latest pyspotify from the Mopidy developers. Follow the instructions at
:doc:`/installation/libspotify/`.
- Fixed multiple segmentation faults due to bugs in Pyspotify. Thanks to
Antoine Pierlot-Garcin and Jamie Kirkpatrick for patches to Pyspotify.
- Support high bitrate (320k) audio. See
:attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` for details.
- Better error messages on wrong login or network problems. Thanks to Antoine
Pierlot-Garcin for patches to Mopidy and Pyspotify. (Fixes: :issue:`77`)
- Reduce log level for trivial log messages from warning to info. (Fixes:
:issue:`71`)
- Local backend:
- Fix crash in :command:`mopidy-scan` if a track has no artist name. Thanks
to Martins Grunskis for test and patch and "octe" for patch.
- Fix crash in `tag_cache` parsing if a track has no total number of tracks
in the album. Thanks to Martins Grunskis for the patch.
- MPD frontend:
- Add support for "date" queries to both the ``find`` and ``search``
commands. This makes media library browsing in ncmpcpp work, though very
slow due to all the meta data requests to Spotify.
- Add support for ``play "-1"`` when in playing or paused state, which fixes
resume and addition of tracks to the current playlist while playing for the
MPoD client.
- Fix bug where ``status`` returned ``song: None``, which caused MPDroid to
crash. (Fixes: :issue:`69`)
- Settings:
- Fix crash on ``--list-settings`` on clean installation. Thanks to Martins
Grunskis for the bug report and patch. (Fixes: :issue:`63`)
- Packaging:
- Replace test data symlinks with real files to avoid symlink issues when
installing with pip. (Fixes: :issue:`68`)
0.3.1 (2010-01-22)
==================
A couple of fixes to the 0.3.0 release is needed to get a smooth installation.
**Bugfixes**
- The Spotify application key was missing from the Python package.
- Installation of the Python package as a normal user failed because it did not
have permissions to install ``mopidy.desktop``. The file is now only
installed if the installation is executed as the root user.
0.3.0 (2010-01-22)
==================
Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large
changes. The main features are support for high bitrate audio from Spotify, and
MPD password authentication.
Regarding the docs, we've improved the :ref:`installation instructions
<installation>` and done a bit of testing of the available :ref:`Android
<android_mpd_clients>` and :ref:`iOS clients <ios_mpd_clients>` for MPD.
Please note that 0.3.0 requires some updated dependencies, as listed under
*Important changes* below. Also, there is a known bug in the Spotify playlist
loading, as described below. As the bug will take some time to fix and has a
known workaround, we did not want to delay the release while waiting for a fix
to this problem.
.. warning:: Known bug in Spotify playlist loading
There is a known bug in the loading of Spotify playlists. This bug affects
both Mopidy 0.2.1 and 0.3.0, given that you use libspotify 0.0.6. To avoid
the bug, either use Mopidy 0.2.1 with libspotify 0.0.4, or use either
Mopidy version with libspotify 0.0.6 and follow the simple workaround
described at :issue:`59`.
**Important changes**
- If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and
the latest pyspotify from the Mopidy developers. Follow the instructions at
:doc:`/installation/libspotify/`.
- If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run
``sudo pip install --upgrade pylast`` or install Mopidy from APT.
**Changes**
- Spotify backend:
- Support high bitrate (320k) audio. Set the new setting
:attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` to :class:`True` to switch to
high bitrate audio.
- Rename :mod:`mopidy.backends.libspotify` to :mod:`mopidy.backends.spotify`.
If you have set :attr:`mopidy.settings.BACKENDS` explicitly, you may need
@ -27,78 +140,106 @@ No description yet.
- Catch and log error caused by playlist folder boundaries being threated as
normal playlists. More permanent fix requires support for checking playlist
types in pyspotify.
types in pyspotify (see :issue:`62`).
- Last.fm frontend:
- If you use the Last.fm frontend, you need to upgrade to pylast 0.5.
- Update to use Last.fm's new Scrobbling 2.0 API, as the old Submissions
Protocol 1.2.1 is deprecated. (Fixes: :issue:`33`)
**Changes**
- Settings:
- Automatically expand ``~`` to the user's home directory and make the path
absolute for settings with names ending in ``_PATH`` or ``_FILE``.
- Rename the following settings. The settings validator will warn you if you
need to change your local settings.
- ``LOCAL_MUSIC_FOLDER`` to :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
- ``LOCAL_PLAYLIST_FOLDER`` to
:attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
- ``LOCAL_TAG_CACHE`` to :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
- ``SPOTIFY_LIB_CACHE`` to :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
- Packaging and distribution:
- Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome
application menus.
- Create infrastructure for creating Debian packages of Mopidy.
- MPD frontend:
- Support ``setvol 50`` without quotes around the argument. Fixes volume
control in Droid MPD.
- Support ``seek 1 120`` without quotes around the arguments. Fixes seek in
Droid MPD.
- Fix crash on failed lookup of track by URI. (Fixes: :issue:`60`)
- Local backend:
- Add :command:`mopidy-scan` command to generate ``tag_cache`` files without
any help from the original MPD server.
- Support UTF-8 encoded tag caches with non-ASCII characters.
any help from the original MPD server. See :ref:`generating_a_tag_cache`
for instructions on how to use it.
- Models:
- Fix support for UTF-8 encoding in tag caches.
- MPD frontend:
- Add support for password authentication. See
:attr:`mopidy.settings.MPD_SERVER_PASSWORD` and
:ref:`use_mpd_on_a_network` for details on how to use it. (Fixes:
:issue:`41`)
- Support ``setvol 50`` without quotes around the argument. Fixes volume
control in Droid MPD.
- Support ``seek 1 120`` without quotes around the arguments. Fixes seek in
Droid MPD.
- Last.fm frontend:
- Update to use Last.fm's new Scrobbling 2.0 API, as the old Submissions
Protocol 1.2.1 is deprecated. (Fixes: :issue:`33`)
- Fix crash when track object does not contain all the expected meta data.
- Fix crash when response from Last.fm cannot be decoded as UTF-8. (Fixes:
:issue:`37`)
- Fix crash when response from Last.fm contains invalid XML.
- Fix crash when response from Last.fm has an invalid HTTP status line.
- Mixers:
- Support use of unicode strings for settings specific to
:mod:`mopidy.mixers.nad`.
- Settings:
- Automatically expand the "~" characted to the user's home directory and
make the path absolute for settings with names ending in ``_PATH`` or
``_FILE``.
- Rename the following settings. The settings validator will warn you if you
need to change your local settings.
- ``LOCAL_MUSIC_FOLDER`` to :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
- ``LOCAL_PLAYLIST_FOLDER`` to
:attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
- ``LOCAL_TAG_CACHE`` to :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
- ``SPOTIFY_LIB_CACHE`` to :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
- Fix bug which made settings set to :class:`None` or 0 cause a
:exc:`mopidy.SettingsError` to be raised.
- Packaging and distribution:
- Setup APT repository and crate Debian packages of Mopidy. See
:ref:`installation` for instructions for how to install Mopidy, including
all dependencies, from APT.
- Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome
application menus.
- API:
- Rename and generalize ``Playlist._with(**kwargs)`` to
:meth:`mopidy.models.ImmutableObject.copy`.
- Add ``musicbrainz_id`` field to :class:`mopidy.models.Artist`,
:class:`mopidy.models.Album`, and :class:`mopidy.models.Track`.
- Introduce the :ref:`provider concept <backend-concepts>`. Split the backend
API into a :ref:`backend controller API <backend-controller-api>` (for
frontend use) and a :ref:`backend provider API <backend-provider-api>` (for
backend implementation use), which includes the following changes:
- Prepare for multi-backend support (see :issue:`40`) by introducing the
:ref:`provider concept <backend-concepts>`. Split the backend API into a
:ref:`backend controller API <backend-controller-api>` (for frontend use)
and a :ref:`backend provider API <backend-provider-api>` (for backend
implementation use), which includes the following changes:
- Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`.
- Rename ``BaseCurrentPlaylistController`` to
:class:`mopidy.backends.base.CurrentPlaylistController`.
- Split ``BaseLibraryController`` to
:class:`mopidy.backends.base.LibraryController` and
:class:`mopidy.backends.base.BaseLibraryProvider`.
- Split ``BasePlaybackController`` to
:class:`mopidy.backends.base.PlaybackController` and
:class:`mopidy.backends.base.BasePlaybackProvider`.
- Split ``BaseStoredPlaylistsController`` to
:class:`mopidy.backends.base.StoredPlaylistsController` and
:class:`mopidy.backends.base.BaseStoredPlaylistsProvider`.
- Other API and package structure cleaning:
- Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`.
- Rename ``BaseCurrentPlaylistController`` to
:class:`mopidy.backends.base.CurrentPlaylistController`.
- Split ``BaseLibraryController`` to
:class:`mopidy.backends.base.LibraryController` and
:class:`mopidy.backends.base.BaseLibraryProvider`.
- Split ``BasePlaybackController`` to
:class:`mopidy.backends.base.PlaybackController` and
:class:`mopidy.backends.base.BasePlaybackProvider`.
- Split ``BaseStoredPlaylistsController`` to
:class:`mopidy.backends.base.StoredPlaylistsController` and
:class:`mopidy.backends.base.BaseStoredPlaylistsProvider`.
- Move ``BaseMixer`` to :class:`mopidy.mixers.base.BaseMixer`.
- Add docs for the current non-stable output API,
:class:`mopidy.outputs.base.BaseOutput`.

View File

@ -16,11 +16,14 @@ 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.
support yet (see :issue:`32`). If you want a console client, use ncmpcpp
instead.
ncmpcpp
-------
@ -28,6 +31,9 @@ ncmpcpp
A console client that generally works well with Mopidy, and is regularly used
by Mopidy developers.
Search
^^^^^^
Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the
three search modes:
@ -39,6 +45,18 @@ three search modes:
If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
Communication mode
^^^^^^^^^^^^^^^^^^
In newer versions of ncmpcpp, like 0.5.5 shipped with Ubuntu 11.04, ncmcpp
defaults to "notifications" mode for MPD communications, which Mopidy currently
does not support. To workaround this limitation in Mopidy, edit the ncmpcpp
configuration file at ``~/.ncmpcpp/config`` and add the following setting::
mpd_communication_mode = "polling"
You can track the development of "notifications" mode support in Mopidy in
:issue:`32`.
Graphical clients
@ -47,52 +65,260 @@ Graphical clients
GMPC
----
A GTK+ client which works well with Mopidy, and is regularly used by Mopidy
developers.
`GMPC <http://gmpc.wikia.com>`_ is a graphical MPD client (GTK+) which works
well with Mopidy, and is regularly used by Mopidy developers.
GMPC may sometimes requests a lot of meta data of related albums, artists, etc.
This takes more time with Mopidy, which needs to query Spotify for the data,
than with a normal MPD server, which has a local cache of meta data. Thus, GMPC
may sometimes feel frozen, but usually you just need to give it a bit of slack
before it will catch up.
Sonata
------
A GTK+ client. Generally works well with Mopidy.
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+).
It generally works well with Mopidy, except for search.
Search does not work, because they do most of the search on the client side.
See :issue:`1` for details.
When you search in Sonata, it only sends the first to letters of the search
query to Mopidy, and then does the rest of the filtering itself on the client
side. Since Spotify has a collection of millions of tracks and they only return
the first 100 hits for any search query, searching for two-letter combinations
seldom returns any useful results. See :issue:`1` and the matching `Sonata
bug`_ for details.
.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323
Theremin
--------
`Theremin <http://theremin.sigterm.eu/>`_ is a graphical MPD client for OS X.
It generally works well with Mopidy.
.. _android_mpd_clients:
Android clients
===============
We've tested all six MPD clients we could find for Android with Mopidy 0.3 on a
HTC Hero with Android 2.1, using the following test procedure:
#. Connect to Mopidy
#. Search for ``foo``, with search type "any" if it can be selected
#. Add "The Pretender" from the search results to the current playlist
#. Start playback
#. Pause and resume playback
#. Adjust volume
#. Find a playlist and append it to the current playlist
#. Skip to next track
#. Skip to previous track
#. Select the last track from the current playlist
#. Turn on repeat mode
#. Seek to 10 seconds or so before the end of the track
#. Wait for the end of the track and confirm that playback continues at the
start of the playlist
#. Turn off repeat mode
#. Turn on random mode
#. Skip to next track and confirm that it random mode works
#. Turn off random mode
#. Stop playback
#. Check if the app got support for single mode and consume mode
#. Kill Mopidy and confirm that the app handles it without crashing
In summary:
- BitMPC lacks finishing touches on its user interface but supports all
features tested.
- Droid MPD Client works well, but got a couple of bugs one can live with and
does not expose stored playlist anywhere.
- IcyBeats is not usable yet.
- MPDroid is working well and looking good, but does not have search
functionality.
- PMix is just a lesser MPDroid, so use MPDroid instead.
- ThreeMPD is too buggy to even get connected to Mopidy.
Our recommendation:
- If you do not care about looks, use BitMPC.
- If you do not care about stored playlists, use Droid MPD Client.
- If you do not care about searching, use MPDroid.
BitMPC
------
Works well with Mopidy.
We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings,
3.5 stars.
Droid MPD
---------
The user interface lacks some finishing touches. E.g. you can't enter a
hostname for the server. Only IPv4 addresses are allowed.
All features exercised in the test procedure works. BitMPC lacks support for
single mode and consume mode. BitMPC crashes if Mopidy is killed or crash.
Droid MPD Client
----------------
We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings,
4 stars.
To find the search functionality, you have to select the menu, then "Playlist
manager", then the search tab. I do not understand why search is hidden inside
"Playlist manager".
The user interface have some French remnants, like "Rechercher" in the search
field.
When selecting the artist tab, it issues the ``list Artist`` command and
becomes stuck waiting for the results. Same thing happens for the album tab,
which issues ``list Album``, and the folder tab, which issues ``lsinfo``.
Mopidy returned zero hits immediately on all three commands. If Mopidy has
loaded your stored playlists and returns more than zero hits on these commands,
they artist and album tabs do not hang. The folder tab still freezes when
``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've
discovered a couple of bugs in Droid MPD Client.
The volume control is very slick, with a turn knob, just like on an amplifier.
It lends itself to showing off to friends when combined with Mopidy's external
amplifier mixers. Everybody loves turning a knob on a touch screen and see the
physical knob on the amplifier turn as well ;-)
Even though ``lsinfo`` returns the stored playlists for the folder tab, they
are not displayed anywhere. Thus, we had to select an album in the album tab to
complete the test procedure.
At one point, I had problems turning off repeat mode. After I adjusted the
volume and tried again, it worked.
Droid MPD client does not support single mode or consume mode. It does not
detect that the server is killed/crashed. You'll only notice it by no actions
having any effect, e.g. you can't turn the volume knob any more.
In conclusion, some bugs and caveats, but most of the test procedure was
possible to perform.
IcyBeats
--------
We tested version 0.2, which at the time had 50-100 downloads, no ratings.
The app was still in beta when we tried it.
IcyBeats successfully connected to Mopidy and I was able to adjust volume. When
I was searching for some tracks, I could not figure out how to actually start
the search, as there was no search button and pressing enter in the input field
just added a new line. I was stuck. In other words, IcyBeats 0.2 is not usable
with Mopidy.
IcyBeats does have something going for it: IcyBeats uses IPv6 to connect to
Mopidy. The future is just around the corner!
Works well with Mopidy.
MPDroid
-------
Works well with Mopidy, and is regularly used by Mopidy developers.
We tested version 0.6.9, which at the time had 5k-10k downloads, <200 ratings,
4.5 stars. MPDroid started out as a fork of PMix.
First of all, MPDroid's user interface looks nice.
I couldn't find any search functionality, so I added the initial track using
another client. Other than the missing search functionality, everything in the
test procedure worked out flawlessly. Like all other Android clients, MPDroid
does not support single mode or consume mode. When Mopidy is killed, MPDroid
handles it gracefully and asks if you want to try to reconnect.
All in all, MPDroid is a good MPD client without search support.
PMix
----
Works well with Mopidy.
We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings,
4 stars.
Add MPDroid is a fork from PMix, it is no surprise that PMix does not support
search either. In addition, I could not find stored playlists. Other than that,
I was able to complete the test procedure. PMix crashed once during testing,
but handled the killing of Mopidy just as nicely as MPDroid. It does not
support single mode or consume mode.
All in all, PMix works but can do less than MPDroid. Use MPDroid instead.
ThreeMPD
--------
Does not work well with Mopidy, because we haven't implemented ``listallinfo``
yet.
We tested version 0.3.0, which at the time had 1k-5k downloads, <25 ratings,
2.5 average. The developer request users to use MPDroid instead, due to limited
time for maintenance. Does not support password authentication.
ThreeMPD froze during startup, so we were not able to test it.
.. _ios_mpd_clients:
iPhone/iPod Touch clients
=========================
impdclient
----------
There's an open source MPD client for iOS called `impdclient
<http://code.google.com/p/impdclient/>`_ which has not seen any updates since
August 2008. So far, we've not heard of users trying it with Mopidy. Please
notify us of your successes and/or problems if you do try it out.
MPod
----
Works well with Mopidy as far as we've heard from users.
The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ client can be
installed from the `iTunes Store
<http://itunes.apple.com/us/app/mpod/id285063020>`_.
Users have reported varying success in using MPoD together with Mopidy. Thus,
we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d
(pre-0.3) on an iPod Touch 3rd generation. The following are our findings:
- **Works:** Playback control generally works, including stop, play, pause,
previous, next, repeat, random, seek, and volume control.
- **Bug:** Search does not work, neither in the artist, album, or song
tabs. Mopidy gets no requests at all from MPoD when executing searches. Seems
like MPoD only searches in local cache, even if "Use local cache" is turned
off in MPoD's settings. Until this is fixed by the MPoD developer, MPoD will
be much less useful with Mopidy.
- **Bug:** When adding another playlist to the current playlist in MPoD,
the currently playing track restarts at the beginning. I do not currently
know enough about this bug, because I'm not sure if MPoD was in the "add to
active playlist" or "replace active playlist" mode when I tested it. I only
later learned what that button was for. Anyway, what I experienced was:
#. I play a track
#. I select a new playlist
#. MPoD reconnects to Mopidy for unknown reason
#. MPoD issues MPD command ``load "a playlist name"``
#. MPoD issues MPD command ``play "-1"``
#. MPoD issues MPD command ``playlistinfo "-1"``
#. I hear that the currently playing tracks restarts playback
- **Tips:** MPoD seems to cache stored playlists, but they won't work if the
server hasn't loaded stored playlists from e.g. Spotify yet. A trick to force
refetching of playlists from Mopidy is to add a new empty playlist in MPoD.
- **Wishlist:** Modifying the current playlists is not supported by MPoD it
seems.
- **Wishlist:** MPoD supports playback of Last.fm radio streams through the MPD
server. Mopidy does not currently support this, but there is a wishlist bug
at :issue:`38`.
- **Wishlist:** MPoD supports autodetection/-configuration of MPD servers
through the use of Bonjour. Mopidy does not currently support this, but there
is a wishlist bug at :issue:`39`.

View File

@ -43,7 +43,7 @@ master_doc = 'index'
# General information about the project.
project = u'Mopidy'
copyright = u'2010, Stein Magnus Jodal and contributors'
copyright = u'2010-2011, Stein Magnus Jodal and contributors'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@ -116,7 +116,7 @@ html_theme_path = ['_themes']
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
html_logo = '_static/mopidy.png'
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
@ -153,7 +153,7 @@ html_last_updated_fmt = '%b %d, %Y'
#html_split_index = False
# If true, links to the reST sources are added to the pages.
html_show_sourcelink = False
#html_show_sourcelink = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
@ -202,4 +202,4 @@ latex_documents = [
needs_sphinx = '1.0'
extlinks = {'issue': ('http://github.com/mopidy/mopidy/issues#issue/%s', 'GH-')}
extlinks = {'issue': ('http://github.com/mopidy/mopidy/issues/%s', 'GH-')}

View File

@ -74,11 +74,11 @@ Running tests
To run tests, you need a couple of dependencies. They can be installed through
Debian/Ubuntu package management::
sudo aptitude install python-coverage python-nose
sudo aptitude install python-coverage python-mock python-nose
Or, they can be installed using ``pip``::
sudo pip install -r requirements-tests.txt
sudo pip install -r requirements/tests.txt
Then, to run all tests, go to the project directory and run::
@ -107,14 +107,14 @@ For more documentation on testing, check out the `nose documentation
Continuous integration server
=============================
We run a continuous integration server called Hudson at
http://hudson.mopidy.com/ that runs all test on multiple platforms (Ubuntu, OS
X, etc.) for every commit we push to GitHub.
We run a continuous integration (CI) server at http://ci.mopidy.com/ that runs
all test on multiple platforms (Ubuntu, OS X, etc.) for every commit we push to
GitHub.
In addition to running tests, Hudson also does coverage statistics and uses
pylint to check for errors and possible improvements in our code. So, if you're
out of work, the code coverage and pylint data in Hudson should give you a
place to start.
In addition to running tests, the CI server also gathers coverage statistics
and uses pylint to check for errors and possible improvements in our code. So,
if you're out of work, the code coverage and pylint data at the CI server
should give you a place to start.
Writing documentation

View File

@ -2,88 +2,33 @@
Roadmap
*******
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.
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.
Release schedule
================
We intend to have about one timeboxed feature release every month
in periods of active development. The feature releases are numbered 0.x.0. The
features added is a mix of what we feel is most important/requested of the
missing features, and features we develop just because we find them fun to
make, even though they may be useful for very few users or for a limited use
case.
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
that are too serious to wait for the next feature release. We will only release
bugfix releases for the last feature release. E.g. when 0.3.0 is released, we
will no longer provide bugfix releases for the 0.2 series. In other words,
there will be just a single supported release at any point in time.
Possible targets for the next version
=====================================
Feature wishlist
================
- Reintroduce support for OS X. See :issue:`25` for details.
- **[WIP: feature/multi-backend]** 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:
- **[WIP: feature/mpd-password]** Password authentication.
- ``idle`` support.
- Spotify backend:
- Write-support for Spotify, i.e. playlist management.
- Virtual directories with e.g. starred tracks from Spotify.
- **[DONE: v0.3]** Support for 320 kbps audio.
- Local backend:
- Better music library support.
- **[DONE: v0.3]** A script for creating a tag cache.
- An alternative to tag cache for caching metadata, i.e. Sqlite.
- **[DONE: v0.2]** Last.fm scrobbling.
Stuff we want to do, but not right now, and maybe never
=======================================================
- Packaging and distribution:
- **[BLOCKED]** Create `Homebrew <http://mxcl.github.com/homebrew/>`_
recipies for all our dependencies and Mopidy itself to make OS X
installation a breeze. See `Homebrew's issue #1612
<http://github.com/mxcl/homebrew/issues/issue/1612>`_.
- **[DONE]** Create `Debian packages
<http://www.debian.org/doc/maint-guide/>`_ of all our dependencies and
Mopidy itself (hosted in our own Debian repo until we get stuff into the
various distros) to make Debian/Ubuntu installation a breeze.
- Compatability:
- **[WIP: feature/blackbox-testing]** Run frontend tests against a real MPD
server to ensure we are in sync.
- Backends:
- `Last.fm <http://www.last.fm/api>`_
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
- DNLA/UPnP so Mopidy can play music from other DNLA MediaServers.
- Frontends:
- Publish the server's presence to the network using `Zeroconf
<http://en.wikipedia.org/wiki/Zeroconf>`_/Avahi.
- **[WIP: feature/mpris-frontend]** D-Bus/`MPRIS <http://www.mpris.org/>`_
- **[WIP: feature/http-frontend]** REST/JSON web service with a jQuery client
as example application. Maybe based upon `Tornado
<http://github.com/facebook/tornado>`_ and `jQuery
Mobile <http://jquerymobile.com/>`_.
- DNLA/UPnP so Mopidy can be controlled from i.e. TVs.
- `XMMS2 <http://www.xmms2.org/>`_
- LIRC frontend for controlling Mopidy with a remote.
- Mixers:
- LIRC mixer for controlling arbitrary amplifiers remotely.
- Audio streaming:
- Ogg Vorbis/MP3 audio stream over HTTP, to MPD clients, `Squeezeboxes
<http://www.logitechsqueezebox.com/>`_, etc.
- Feed audio to an `Icecast <http://www.icecast.org/>`_ server.
- Stream to AirPort Express using `RAOP
<http://en.wikipedia.org/wiki/Remote_Audio_Output_Protocol>`_.
We maintain our collection of sane or less sane ideas for future Mopidy
features as `issues <https://github.com/mopidy/mopidy/issues>`_ at GitHub
labeled with `the "wishlist" label
<https://github.com/mopidy/mopidy/issues/labels/wishlist>`_. Feel free to vote
up any feature you would love to see in Mopidy, but please refrain from adding
a comment just to say "I want this too!". You are of course free to add
comments if you have suggestions for how the feature should work or be
implemented, and you may add new wishlist issues if your ideas are not already
represented.

View File

@ -1,4 +1,31 @@
.. include:: ../README.rst
******
Mopidy
******
Mopidy is a music server which can play music from `Spotify
<http://www.spotify.com/>`_ or from your local hard drive. To search for music
in Spotify's vast archive, manage playlists, and play music, you can use most
`MPD clients <http://mpd.wikia.com/>`_. MPD clients are available for most
platforms, including Windows, Mac OS X, Linux, Android, and iOS.
To install Mopidy, start out by reading :ref:`installation`.
If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net
<http://freenode.net/>`_. If you stumble into a bug or got a feature request,
please create an issue in the `issue tracker
<http://github.com/mopidy/mopidy/issues>`_.
Project resources
=================
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_
- `Documentation for the development version
<http://www.mopidy.com/docs/develop/>`_
- `Source code <http://github.com/mopidy/mopidy>`_
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
User documentation
==================

View File

@ -1,3 +1,5 @@
.. _installation:
************
Installation
************
@ -23,6 +25,8 @@ Otherwise, make sure you got the required dependencies installed.
- Python >= 2.6, < 3
- `Pykka <http://jodal.github.com/pykka/>`_ >= 0.12
- GStreamer >= 0.10, with Python bindings. See :doc:`gstreamer`.
- Mixer dependencies: The default mixer does not require any additional

View File

@ -30,7 +30,7 @@ If you run a Debian based Linux distribution, like Ubuntu, see
http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source
on your installation. Then, simply run::
sudo apt-get install libspotify6
sudo apt-get install libspotify7
When libspotify has been installed, continue with
:ref:`pyspotify_installation`.
@ -39,14 +39,14 @@ When libspotify has been installed, continue with
On Linux from source
--------------------
Download and install libspotify 0.0.6 for your OS and CPU architecture from
Download and install libspotify 0.0.7 for your OS and CPU architecture from
https://developer.spotify.com/en/libspotify/.
For 64-bit Linux the process is as follows::
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.6-linux6-x86_64.tar.gz
tar zxfv libspotify-0.0.6-linux6-x86_64.tar.gz
cd libspotify-0.0.6-linux6-x86_64/
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.7-linux6-x86_64.tar.gz
tar zxfv libspotify-0.0.7-linux6-x86_64.tar.gz
cd libspotify-0.0.7-linux6-x86_64/
sudo make install prefix=/usr/local
sudo ldconfig
@ -113,4 +113,4 @@ Get the pyspotify code, and install it::
It is important that you install pyspotify from the ``mopidy`` branch of the
``mopidy/pyspotify`` repository, as the upstream repository at
``winjer/pyspotify`` is not updated with changes needed to support e.g.
libspotify 0.0.6 and high bitrate audio.
libspotify 0.0.7 and high bitrate audio.

View File

@ -8,7 +8,7 @@ contributed what, please refer to our git repository.
Source code license
===================
Copyright 2009-2010 Stein Magnus Jodal and contributors
Copyright 2009-2011 Stein Magnus Jodal and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,7 +26,7 @@ limitations under the License.
Documentation license
=====================
Copyright 2010 Stein Magnus Jodal and contributors
Copyright 2010-2011 Stein Magnus Jodal and contributors
This work is licensed under the Creative Commons Attribution-ShareAlike 3.0
Unported License. To view a copy of this license, visit

View File

@ -59,11 +59,13 @@ You may also want to change some of the ``LOCAL_*`` settings. See
hopefully have support for this in the 0.3 release.
.. _generating_a_tag_cache:
Generating a tag cache
----------------------
Previously the local storage backend relied purely on ``tag_cache`` files
generated by the original MPD server. To remedy this the command
Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache``
files generated by the original MPD server. To remedy this the command
:command:`mopidy-scan` has been created. The program will scan your current
:attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible
``tag_cache``.
@ -82,12 +84,14 @@ To make a ``tag_cache`` of your local music available for Mopidy:
mopidy-scan > tag_cache
#. Move the ``tag_cache`` file to the location
:attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` is set to, or change the setting to
point to where your ``tag_cache`` file is.
:attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` is set to, or change the
setting to point to where your ``tag_cache`` file is.
#. Start Mopidy, find the music library in a client, and play some local music!
.. _use_mpd_on_a_network:
Connecting from other machines on the network
=============================================
@ -95,6 +99,13 @@ 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`.
If you open up Mopidy for your local network, you should consider turning on
MPD password authentication by setting
:attr:`mopidy.settings.MPD_SERVER_PASSWORD` to the password you want to use.
If the password is set, Mopidy will require MPD clients to provide the password
before they can do anything else. Mopidy only supports a single password, and
do not support different permission schemes like the original MPD server.
Scrobbling tracks to Last.fm
============================

View File

@ -2,8 +2,27 @@ import sys
if not (2, 6) <= sys.version_info < (3,):
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
from subprocess import PIPE, Popen
VERSION = (0, 4, 0)
def get_git_version():
process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE)
if process.wait() != 0:
raise EnvironmentError('Execution of "git describe" failed')
version = process.stdout.read().strip()
if version.startswith('v'):
version = version[1:]
return version
def get_plain_version():
return '.'.join(map(str, VERSION))
def get_version():
return u'0.3.0'
try:
return get_git_version()
except EnvironmentError:
return get_plain_version()
class MopidyException(Exception):
def __init__(self, message, *args, **kwargs):

View File

@ -1,17 +1,10 @@
import os
import sys
# Add ../ to the path so we can run Mopidy from a Git checkout without
# installing it on the system.
import os
import sys
sys.path.insert(0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
from mopidy.core import CoreProcess
def main():
# Explictly call run() instead of start(), since we don't need to start
# another process.
CoreProcess().run()
if __name__ == '__main__':
from mopidy.core import main
main()

View File

@ -1,12 +1,4 @@
from copy import copy
import logging
import random
import time
from mopidy import settings
from mopidy.frontends.mpd import translator
from mopidy.models import Playlist
from mopidy.utils import get_class
from .current_playlist import CurrentPlaylistController
from .library import LibraryController, BaseLibraryProvider
@ -17,30 +9,6 @@ from .stored_playlists import (StoredPlaylistsController,
logger = logging.getLogger('mopidy.backends.base')
class Backend(object):
"""
:param core_queue: a queue for sending messages to
:class:`mopidy.process.CoreProcess`
:type core_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=None, mixer_class=None):
self.core_queue = core_queue
self.output = output
if mixer_class is None:
mixer_class = get_class(settings.MIXER)
self.mixer = mixer_class(self)
#: A :class:`multiprocessing.Queue` which can be used by e.g. library
#: callbacks executing in other threads to send messages to the core
#: thread, so that action may be taken in the correct thread.
core_queue = None
#: The current playlist controller. An instance of
#: :class:`mopidy.backends.base.CurrentPlaylistController`.
current_playlist = None
@ -49,9 +17,6 @@ class Backend(object):
# :class:`mopidy.backends.base.LibraryController`.
library = None
#: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`.
mixer = None
#: The playback controller. An instance of
#: :class:`mopidy.backends.base.PlaybackController`.
playback = None
@ -62,24 +27,3 @@ class Backend(object):
#: List of URI prefixes this backend can handle.
uri_handlers = []
def destroy(self):
"""
Call destroy on all sub-components in backend so that they can cleanup
after themselves.
"""
if self.current_playlist:
self.current_playlist.destroy()
if self.library:
self.library.destroy()
if self.mixer:
self.mixer.destroy()
if self.playback:
self.playback.destroy()
if self.stored_playlists:
self.stored_playlists.destroy()

View File

@ -2,8 +2,6 @@ from copy import copy
import logging
import random
from mopidy.frontends.mpd import translator
logger = logging.getLogger('mopidy.backends.base')
class CurrentPlaylistController(object):
@ -12,6 +10,8 @@ class CurrentPlaylistController(object):
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
self._cp_tracks = []
@ -197,8 +197,3 @@ class CurrentPlaylistController(object):
random.shuffle(shuffled)
self._cp_tracks = before + shuffled + after
self.version += 1
def mpd_format(self, *args, **kwargs):
"""Not a part of the generic backend API."""
kwargs['cpids'] = [ct[0] for ct in self._cp_tracks]
return translator.tracks_to_mpd_format(self.tracks, *args, **kwargs)

View File

@ -10,6 +10,8 @@ class LibraryController(object):
:type provider: instance of :class:`BaseLibraryProvider`
"""
pykka_traversable = True
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
@ -82,6 +84,8 @@ class BaseLibraryProvider(object):
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend

View File

@ -2,6 +2,11 @@ import logging
import random
import time
from pykka.registry import ActorRegistry
from mopidy.frontends.base import BaseFrontend
from mopidy.outputs.base import BaseOutput
logger = logging.getLogger('mopidy.backends.base')
class PlaybackController(object):
@ -15,6 +20,8 @@ class PlaybackController(object):
# pylint: disable = R0902
# Too many instance attributes
pykka_traversable = True
#: Constant representing the paused state.
PAUSED = u'paused'
@ -62,8 +69,8 @@ class PlaybackController(object):
self._state = self.STOPPED
self._shuffled = []
self._first_shuffle = True
self._play_time_accumulated = 0
self._play_time_started = None
self.play_time_accumulated = 0
self.play_time_started = None
def destroy(self):
"""
@ -240,7 +247,7 @@ class PlaybackController(object):
if self.repeat or self.consume or self.random:
return self.current_cp_track
if self.current_cp_track is None or self.current_playlist_position == 0:
if self.current_playlist_position in (None, 0):
return None
return self.backend.current_playlist.cp_tracks[
@ -269,7 +276,7 @@ class PlaybackController(object):
def state(self, new_state):
(old_state, self._state) = (self.state, new_state)
logger.debug(u'Changing state: %s -> %s', old_state, new_state)
# FIXME _play_time stuff assumes backend does not have a better way of
# FIXME play_time stuff assumes backend does not have a better way of
# handeling this stuff :/
if (old_state in (self.PLAYING, self.STOPPED)
and new_state == self.PLAYING):
@ -282,28 +289,35 @@ class PlaybackController(object):
@property
def time_position(self):
"""Time position in milliseconds."""
output_position = self.backend.output.get_position()
output_position = self._time_position_from_output()
if output_position is not None:
return output_position
if self.state == self.PLAYING:
time_since_started = (self._current_wall_time -
self._play_time_started)
return self._play_time_accumulated + time_since_started
self.play_time_started)
return self.play_time_accumulated + time_since_started
elif self.state == self.PAUSED:
return self._play_time_accumulated
return self.play_time_accumulated
elif self.state == self.STOPPED:
return 0
def _time_position_from_output(self):
output_refs = ActorRegistry.get_by_class(BaseOutput)
if not output_refs:
return None
output = output_refs[0].proxy()
return output.get_position()
def _play_time_start(self):
self._play_time_accumulated = 0
self._play_time_started = self._current_wall_time
self.play_time_accumulated = 0
self.play_time_started = self._current_wall_time
def _play_time_pause(self):
time_since_started = self._current_wall_time - self._play_time_started
self._play_time_accumulated += time_since_started
time_since_started = self._current_wall_time - self.play_time_started
self.play_time_accumulated += time_since_started
def _play_time_resume(self):
self._play_time_started = self._current_wall_time
self.play_time_started = self._current_wall_time
@property
def _current_wall_time(self):
@ -436,8 +450,8 @@ class PlaybackController(object):
self.next()
return True
self._play_time_started = self._current_wall_time
self._play_time_accumulated = time_position
self.play_time_started = self._current_wall_time
self.play_time_accumulated = time_position
return self.provider.seek(time_position)
@ -449,11 +463,10 @@ class PlaybackController(object):
stopping
:type clear_current_track: boolean
"""
if self.state == self.STOPPED:
return
self._trigger_stopped_playing_event()
if self.provider.stop():
self.state = self.STOPPED
if self.state != self.STOPPED:
self._trigger_stopped_playing_event()
if self.provider.stop():
self.state = self.STOPPED
if clear_current_track:
self.current_cp_track = None
@ -464,9 +477,11 @@ class PlaybackController(object):
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',
if self.current_track is None:
return
frontend_refs = ActorRegistry.get_by_class(BaseFrontend)
for frontend_ref in frontend_refs:
frontend_ref.send_one_way({
'command': 'started_playing',
'track': self.current_track,
})
@ -479,9 +494,11 @@ class PlaybackController(object):
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',
if self.current_track is None:
return
frontend_refs = ActorRegistry.get_by_class(BaseFrontend)
for frontend_ref in frontend_refs:
frontend_ref.send_one_way({
'command': 'stopped_playing',
'track': self.current_track,
'stop_position': self.time_position,
@ -494,6 +511,8 @@ class BasePlaybackProvider(object):
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend

View File

@ -11,6 +11,8 @@ class StoredPlaylistsController(object):
:type provider: instance of :class:`BaseStoredPlaylistsProvider`
"""
pykka_traversable = True
def __init__(self, backend, provider):
self.backend = backend
self.provider = provider
@ -125,6 +127,8 @@ class BaseStoredPlaylistsProvider(object):
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
self._playlists = []

View File

@ -1,21 +1,13 @@
from pykka.actor import ThreadingActor
from mopidy.backends.base import (Backend, CurrentPlaylistController,
PlaybackController, BasePlaybackProvider, LibraryController,
BaseLibraryProvider, StoredPlaylistsController,
BaseStoredPlaylistsProvider)
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist
from mopidy.outputs.dummy import DummyOutput
class DummyQueue(object):
def __init__(self):
self.received_messages = []
def put(self, message):
self.received_messages.append(message)
class DummyBackend(Backend):
class DummyBackend(ThreadingActor, Backend):
"""
A backend which implements the backend API in the simplest way possible.
Used in tests of the frontends.
@ -24,9 +16,6 @@ class DummyBackend(Backend):
"""
def __init__(self, *args, **kwargs):
kwargs['core_queue'] = DummyQueue()
kwargs['output'] = DummyOutput(core_queue=DummyQueue())
kwargs['mixer_class'] = DummyMixer
super(DummyBackend, self).__init__(*args, **kwargs)
self.current_playlist = CurrentPlaylistController(backend=self)
@ -49,13 +38,13 @@ class DummyBackend(Backend):
class DummyLibraryProvider(BaseLibraryProvider):
def __init__(self, *args, **kwargs):
super(DummyLibraryProvider, self).__init__(*args, **kwargs)
self._library = []
self.dummy_library = []
def find_exact(self, **query):
return Playlist()
def lookup(self, uri):
matches = filter(lambda t: uri == t.uri, self._library)
matches = filter(lambda t: uri == t.uri, self.dummy_library)
if matches:
return matches[0]

View File

@ -1,22 +1,24 @@
import glob
import logging
import multiprocessing
import os
import shutil
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
from mopidy import settings
from mopidy.backends.base import (Backend, CurrentPlaylistController,
LibraryController, BaseLibraryProvider, PlaybackController,
BasePlaybackProvider, StoredPlaylistsController,
BaseStoredPlaylistsProvider)
from mopidy.models import Playlist, Track, Album
from mopidy.utils.process import pickle_connection
from mopidy.outputs.base import BaseOutput
from .translator import parse_m3u, parse_mpd_tag_cache
logger = logging.getLogger(u'mopidy.backends.local')
class LocalBackend(Backend):
class LocalBackend(ThreadingActor, Backend):
"""
A backend for playing music from a local music archive.
@ -48,22 +50,29 @@ class LocalBackend(Backend):
self.uri_handlers = [u'file://']
self.output = None
def on_start(self):
output_refs = ActorRegistry.get_by_class(BaseOutput)
assert len(output_refs) == 1, 'Expected exactly one running output.'
self.output = output_refs[0].proxy()
class LocalPlaybackProvider(BasePlaybackProvider):
def pause(self):
return self.backend.output.set_state('PAUSED')
return self.backend.output.set_state('PAUSED').get()
def play(self, track):
return self.backend.output.play_uri(track.uri)
return self.backend.output.play_uri(track.uri).get()
def resume(self):
return self.backend.output.set_state('PLAYING')
return self.backend.output.set_state('PLAYING').get()
def seek(self, time_position):
return self.backend.output.set_position(time_position)
return self.backend.output.set_position(time_position).get()
def stop(self):
return self.backend.output.set_state('READY')
return self.backend.output.set_state('READY').get()
class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):

View File

@ -100,8 +100,11 @@ def _convert_mpd_data(data, tracks, music_dir):
albumartist_kwargs = {}
if 'track' in data:
album_kwargs['num_tracks'] = int(data['track'].split('/')[1])
track_kwargs['track_no'] = int(data['track'].split('/')[0])
if '/' in data['track']:
album_kwargs['num_tracks'] = int(data['track'].split('/')[1])
track_kwargs['track_no'] = int(data['track'].split('/')[0])
else:
track_kwargs['track_no'] = int(data['track'])
if 'artist' in data:
artist_kwargs['name'] = data['artist']

View File

@ -1,14 +1,18 @@
import logging
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
from mopidy import settings
from mopidy.backends.base import (Backend, CurrentPlaylistController,
LibraryController, PlaybackController, StoredPlaylistsController)
from mopidy.outputs.base import BaseOutput
logger = logging.getLogger('mopidy.backends.spotify')
ENCODING = 'utf-8'
class SpotifyBackend(Backend):
class SpotifyBackend(ThreadingActor, Backend):
"""
A backend for playing music from the `Spotify <http://www.spotify.com/>`_
music streaming service. The backend uses the official `libspotify
@ -59,6 +63,14 @@ class SpotifyBackend(Backend):
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
self.output = None
self.spotify = None
def on_start(self):
output_refs = ActorRegistry.get_by_class(BaseOutput)
assert len(output_refs) == 1, 'Expected exactly one running output.'
self.output = output_refs[0].proxy()
self.spotify = self._connect()
def _connect(self):
@ -67,8 +79,6 @@ class SpotifyBackend(Backend):
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
logger.debug(u'Connecting to Spotify')
spotify = SpotifySessionManager(
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
core_queue=self.core_queue,
output=self.output)
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD)
spotify.start()
return spotify

View File

@ -1,5 +1,5 @@
import logging
import multiprocessing
import Queue
from spotify import Link, SpotifyError
@ -22,7 +22,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
# playlists.
return SpotifyTranslator.to_mopidy_track(spotify_track)
except SpotifyError as e:
logger.warning(u'Failed to lookup: %s', uri, e)
logger.debug(u'Failed to lookup "%s": %s', uri, e)
return None
def refresh(self, uri=None):
@ -54,8 +54,9 @@ class SpotifyLibraryProvider(BaseLibraryProvider):
spotify_query.append(u'%s:"%s"' % (field, value))
spotify_query = u' '.join(spotify_query)
logger.debug(u'Spotify search query: %s' % spotify_query)
my_end, other_end = multiprocessing.Pipe()
self.backend.spotify.search(spotify_query.encode(ENCODING), other_end)
my_end.poll(None)
playlist = my_end.recv()
return playlist
queue = Queue.Queue()
self.backend.spotify.search(spotify_query.encode(ENCODING), queue)
try:
return queue.get(timeout=3) # XXX What is an reasonable timeout?
except Queue.Empty:
return Playlist(tracks=[])

View File

@ -23,7 +23,7 @@ class SpotifyPlaybackProvider(BasePlaybackProvider):
self.backend.output.set_state('PLAYING')
return True
except SpotifyError as e:
logger.warning('Play %s failed: %s', track.uri, e)
logger.info('Playback of %s failed: %s', track.uri, e)
return False
def resume(self):

View File

@ -2,11 +2,15 @@ import logging
import os
import threading
import spotify.manager
from spotify.manager import SpotifySessionManager as PyspotifySessionManager
from pykka.registry import ActorRegistry
from mopidy import get_version, settings
from mopidy.backends.base import Backend
from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy.models import Playlist
from mopidy.outputs.base import BaseOutput
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.backends.spotify.session_manager')
@ -14,26 +18,41 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager')
# pylint: disable = R0901
# SpotifySessionManager: Too many ancestors (9/7)
class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
class SpotifySessionManager(BaseThread, PyspotifySessionManager):
cache_location = settings.SPOTIFY_CACHE_PATH
settings_location = settings.SPOTIFY_CACHE_PATH
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):
spotify.manager.SpotifySessionManager.__init__(
self, username, password)
BaseThread.__init__(self, core_queue)
def __init__(self, username, password):
PyspotifySessionManager.__init__(self, username, password)
BaseThread.__init__(self)
self.name = 'SpotifySMThread'
self.output = output
self.output = None
self.backend = None
self.connected = threading.Event()
self.session = None
def run_inside_try(self):
self.setup()
self.connect()
def setup(self):
output_refs = ActorRegistry.get_by_class(BaseOutput)
assert len(output_refs) == 1, 'Expected exactly one running output.'
self.output = output_refs[0].proxy()
backend_refs = ActorRegistry.get_by_class(Backend)
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
self.backend = backend_refs[0].proxy()
def logged_in(self, session, error):
"""Callback used by pyspotify"""
if error:
logger.error(u'Spotify login error: %s', error)
return
logger.info(u'Connected to Spotify')
self.session = session
if settings.SPOTIFY_HIGH_BITRATE:
@ -55,7 +74,7 @@ class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
def connection_error(self, session, error):
"""Callback used by pyspotify"""
logger.error(u'Connection error: %s', error)
logger.error(u'Spotify connection error: %s', error)
def message_to_user(self, session, message):
"""Callback used by pyspotify"""
@ -88,7 +107,7 @@ class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
def play_token_lost(self, session):
"""Callback used by pyspotify"""
logger.debug(u'Play token lost')
self.core_queue.put({'command': 'stop_playback'})
self.backend.playback.pause()
def log_message(self, session, data):
"""Callback used by pyspotify"""
@ -107,19 +126,16 @@ class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
playlists.append(
SpotifyTranslator.to_mopidy_playlist(spotify_playlist))
playlists = filter(None, playlists)
self.core_queue.put({
'command': 'set_stored_playlists',
'playlists': playlists,
})
self.backend.stored_playlists.playlists = playlists
logger.debug(u'Refreshed %d stored playlist(s)', len(playlists))
def search(self, query, connection):
def search(self, query, queue):
"""Search method used by Mopidy backend"""
def callback(results, userdata=None):
# TODO Include results from results.albums(), etc. too
playlist = Playlist(tracks=[
SpotifyTranslator.to_mopidy_track(t)
for t in results.tracks()])
connection.send(playlist)
queue.put(playlist)
self.connected.wait()
self.session.search(query, callback)

View File

@ -28,9 +28,9 @@ class SpotifyTranslator(object):
@classmethod
def to_mopidy_track(cls, spotify_track):
if not spotify_track.is_loaded():
return Track(name=u'[loading...]')
uri = str(Link.from_track(spotify_track, 0))
if not spotify_track.is_loaded():
return Track(uri=uri, name=u'[loading...]')
if dt.MINYEAR <= int(spotify_track.album().year()) <= dt.MAXYEAR:
date = dt.date(spotify_track.album().year(), 1, 1)
else:
@ -60,5 +60,5 @@ class SpotifyTranslator(object):
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist],
)
except SpotifyError, e:
logger.warning(u'Failed translating Spotify playlist '
logger.info(u'Failed translating Spotify playlist '
'(probably a playlist folder boundary): %s', e)

View File

@ -1,114 +1,76 @@
import logging
import multiprocessing
import optparse
import sys
import time
from pykka.registry import ActorRegistry
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 BaseThread, GObjectEventThread
from mopidy.utils.process import GObjectEventThread
from mopidy.utils.settings import list_settings_optparse_callback
logger = logging.getLogger('mopidy.core')
class CoreProcess(BaseThread):
def __init__(self):
self.core_queue = multiprocessing.Queue()
super(CoreProcess, self).__init__(self.core_queue)
self.name = 'CoreProcess'
self.options = self.parse_options()
self.gobject_loop = None
self.output = None
self.backend = None
self.frontends = []
def main():
options = parse_options()
setup_logging(options.verbosity_level, options.save_debug_log)
setup_settings()
setup_gobject_loop()
setup_output()
setup_mixer()
setup_backend()
setup_frontends()
try:
while ActorRegistry.get_all():
time.sleep(1)
logger.info(u'No actors left. Exiting...')
except KeyboardInterrupt:
logger.info(u'User interrupt. Exiting...')
ActorRegistry.stop_all()
def parse_options(self):
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
parser.add_option('-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_option('-v', '--verbose',
action='store_const', const=2, dest='verbosity_level',
help='more output (debug level)')
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 parse_options():
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
parser.add_option('-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_option('-v', '--verbose',
action='store_const', const=2, dest='verbosity_level',
help='more output (debug level)')
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):
self.setup()
while True:
message = self.core_queue.get()
self.process_message(message)
def setup_settings():
get_or_create_folder('~/.mopidy/')
get_or_create_file('~/.mopidy/settings.py')
settings.validate()
def setup(self):
self.setup_logging()
self.setup_settings()
self.gobject_loop = self.setup_gobject_loop(self.core_queue)
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_gobject_loop():
gobject_loop = GObjectEventThread()
gobject_loop.start()
return gobject_loop
def setup_logging(self):
setup_logging(self.options.verbosity_level,
self.options.save_debug_log)
logger.info(u'-- Starting Mopidy --')
def setup_output():
return get_class(settings.OUTPUT).start().proxy()
def setup_settings(self):
get_or_create_folder('~/.mopidy/')
get_or_create_file('~/.mopidy/settings.py')
settings.validate()
def setup_mixer():
return get_class(settings.MIXER).start().proxy()
def setup_gobject_loop(self, core_queue):
gobject_loop = GObjectEventThread(core_queue)
gobject_loop.start()
return gobject_loop
def setup_backend():
return get_class(settings.BACKENDS[0]).start().proxy()
def setup_output(self, core_queue):
output = get_class(settings.OUTPUT)(core_queue)
output.start()
return output
def setup_backend(self, core_queue, output):
return get_class(settings.BACKENDS[0])(core_queue, output)
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') == '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':
self.backend.playback.stop()
elif message['command'] == 'set_stored_playlists':
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)
def setup_frontends():
frontends = []
for frontend_class_name in settings.FRONTENDS:
try:
frontend = get_class(frontend_class_name).start().proxy()
frontends.append(frontend)
except OptionalDependencyError as e:
logger.info(u'Disabled: %s (%s)', frontend_class_name, e)
return frontends

View File

@ -1,40 +1,5 @@
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.Backend`
"""
def __init__(self, core_queue, backend):
self.core_queue = core_queue
self.backend = backend
def start(self):
"""
Start the frontend.
*MAY be implemented by subclass.*
"""
pass
def destroy(self):
"""
Destroy the frontend.
*MAY be implemented by subclass.*
"""
pass
def process_message(self, message):
"""
Process messages for the frontend.
*MUST be implemented by subclass.*
:param message: the message
:type message: dict
"""
raise NotImplementedError
pass

View File

@ -1,6 +1,4 @@
import logging
import multiprocessing
import socket
import time
try:
@ -9,16 +7,17 @@ except ImportError as import_error:
from mopidy import OptionalDependencyError
raise OptionalDependencyError(import_error)
from mopidy import get_version, settings, SettingsError
from pykka.actor import ThreadingActor
from mopidy import settings, SettingsError
from mopidy.frontends.base import BaseFrontend
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.frontends.lastfm')
API_KEY = '2236babefa8ebb3d93ea467560d00d04'
API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
class LastfmFrontend(BaseFrontend):
class LastfmFrontend(ThreadingActor, BaseFrontend):
"""
Frontend which scrobbles the music you play to your `Last.fm
<http://www.last.fm>`_ profile.
@ -29,7 +28,7 @@ class LastfmFrontend(BaseFrontend):
**Dependencies:**
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5.7
**Settings:**
@ -37,38 +36,11 @@ class LastfmFrontend(BaseFrontend):
- :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):
if self.thread.is_alive():
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
def __init__(self):
self.lastfm = None
self.last_start_time = None
def run_inside_try(self):
self.setup()
while self.lastfm is not None:
self.connection.poll(None)
message = self.connection.recv()
self.process_message(message)
def setup(self):
def on_start(self):
try:
username = settings.LASTFM_USERNAME
password_hash = pylast.md5(settings.LASTFM_PASSWORD)
@ -79,36 +51,40 @@ class LastfmFrontendThread(BaseThread):
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)
self.stop()
except (pylast.NetworkError, pylast.MalformedResponseError,
pylast.WSError) as e:
logger.error(u'Error during Last.fm setup: %s', e)
self.stop()
def process_message(self, message):
if message['command'] == 'started_playing':
def on_receive(self, message):
if message.get('command') == 'started_playing':
self.started_playing(message['track'])
elif message['command'] == 'stopped_playing':
elif message.get('command') == 'stopped_playing':
self.stopped_playing(message['track'], message['stop_position'])
else:
pass # Ignore commands for other frontends
pass # Ignore any other messages
def started_playing(self, track):
artists = ', '.join([a.name for a in track.artists])
duration = track.length // 1000
duration = track.length and track.length // 1000 or 0
self.last_start_time = int(time.time())
logger.debug(u'Now playing track: %s - %s', artists, track.name)
try:
self.lastfm.update_now_playing(
artists,
track.name,
album=track.album.name,
(track.name or ''),
album=(track.album and track.album.name or ''),
duration=str(duration),
track_number=str(track.track_no),
mbid=(track.musicbrainz_id or ''))
except (pylast.ScrobblingError, socket.error) as e:
logger.warning(u'Last.fm now playing error: %s', e)
except (pylast.ScrobblingError, pylast.NetworkError,
pylast.MalformedResponseError, pylast.WSError) as e:
logger.warning(u'Error submitting playing track to Last.fm: %s', e)
def stopped_playing(self, track, stop_position):
artists = ', '.join([a.name for a in track.artists])
duration = track.length // 1000
duration = track.length and track.length // 1000 or 0
stop_position = stop_position // 1000
if duration < 30:
logger.debug(u'Track too short to scrobble. (30s)')
@ -123,11 +99,12 @@ class LastfmFrontendThread(BaseThread):
try:
self.lastfm.scrobble(
artists,
track.name,
(track.name or ''),
str(self.last_start_time),
album=track.album.name,
album=(track.album and track.album.name or ''),
track_number=str(track.track_no),
duration=str(duration),
mbid=(track.musicbrainz_id or ''))
except (pylast.ScrobblingError, socket.error) as e:
logger.warning(u'Last.fm scrobbling error: %s', e)
except (pylast.ScrobblingError, pylast.NetworkError,
pylast.MalformedResponseError, pylast.WSError) as e:
logger.warning(u'Error submitting played track to Last.fm: %s', e)

View File

@ -1,48 +1,43 @@
import asyncore
import logging
from pykka.actor import ThreadingActor
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
from mopidy.frontends.mpd.server import MpdServer
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.frontends.mpd')
class MpdFrontend(BaseFrontend):
class MpdFrontend(ThreadingActor, BaseFrontend):
"""
The MPD frontend.
**Settings:**
- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
- :attr:`mopidy.settings.MPD_SERVER_PORT`
"""
def __init__(self, *args, **kwargs):
super(MpdFrontend, self).__init__(*args, **kwargs)
self.thread = None
self.dispatcher = MpdDispatcher(self.backend)
def __init__(self):
self._thread = None
def start(self):
"""Starts the MPD server."""
self.thread = MpdThread(self.core_queue)
self.thread.start()
def on_start(self):
self._thread = MpdThread()
self._thread.start()
def destroy(self):
"""Destroys the MPD server."""
self.thread.destroy()
def on_receive(self, message):
pass # Ignore any messages
def process_message(self, message):
"""
Processes messages with the MPD frontend as destination.
:param message: the message
:type message: dict
"""
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
class MpdThread(BaseThread):
def __init__(self):
super(MpdThread, self).__init__()
self.name = u'MpdThread'
def run_inside_try(self):
logger.debug(u'Starting MPD server thread')
server = MpdServer()
server.start()
asyncore.loop()

View File

@ -1,5 +1,8 @@
import re
from pykka.registry import ActorRegistry
from mopidy.backends.base import Backend
from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError,
MpdUnknownCommand)
from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers
@ -10,15 +13,27 @@ from mopidy.frontends.mpd.protocol import (audio_output, command_list,
connection, current_playlist, empty, music_db, playback, reflection,
status, stickers, stored_playlists)
# pylint: enable = W0611
from mopidy.mixers.base import BaseMixer
from mopidy.utils import flatten
class MpdDispatcher(object):
"""
Dispatches MPD requests to the correct handler.
The MPD session feeds the MPD dispatcher with requests. The dispatcher
finds the correct handler, processes the request and sends the response
back to the MPD session.
"""
def __init__(self, backend=None):
self.backend = backend
# XXX Consider merging MpdDispatcher into MpdSession
def __init__(self):
backend_refs = ActorRegistry.get_by_class(Backend)
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
self.backend = backend_refs[0].proxy()
mixer_refs = ActorRegistry.get_by_class(BaseMixer)
assert len(mixer_refs) == 1, 'Expected exactly one running mixer.'
self.mixer = mixer_refs[0].proxy()
self.command_list = False
self.command_list_ok = False

View File

@ -1,22 +1,20 @@
from mopidy import MopidyException
class MpdAckError(MopidyException):
"""
Available MPD error codes::
"""See fields on this class for available MPD error codes"""
ACK_ERROR_NOT_LIST = 1
ACK_ERROR_ARG = 2
ACK_ERROR_PASSWORD = 3
ACK_ERROR_PERMISSION = 4
ACK_ERROR_UNKNOWN = 5
ACK_ERROR_NO_EXIST = 50
ACK_ERROR_PLAYLIST_MAX = 51
ACK_ERROR_SYSTEM = 52
ACK_ERROR_PLAYLIST_LOAD = 53
ACK_ERROR_UPDATE_ALREADY = 54
ACK_ERROR_PLAYER_SYNC = 55
ACK_ERROR_EXIST = 56
"""
ACK_ERROR_NOT_LIST = 1
ACK_ERROR_ARG = 2
ACK_ERROR_PASSWORD = 3
ACK_ERROR_PERMISSION = 4
ACK_ERROR_UNKNOWN = 5
ACK_ERROR_NO_EXIST = 50
ACK_ERROR_PLAYLIST_MAX = 51
ACK_ERROR_SYSTEM = 52
ACK_ERROR_PLAYLIST_LOAD = 53
ACK_ERROR_UPDATE_ALREADY = 54
ACK_ERROR_PLAYER_SYNC = 55
ACK_ERROR_EXIST = 56
def __init__(self, message=u'', error_code=0, index=0, command=u''):
super(MpdAckError, self).__init__(message, error_code, index, command)
@ -37,19 +35,24 @@ class MpdAckError(MopidyException):
class MpdArgError(MpdAckError):
def __init__(self, *args, **kwargs):
super(MpdArgError, self).__init__(*args, **kwargs)
self.error_code = 2 # ACK_ERROR_ARG
self.error_code = MpdAckError.ACK_ERROR_ARG
class MpdPasswordError(MpdAckError):
def __init__(self, *args, **kwargs):
super(MpdPasswordError, self).__init__(*args, **kwargs)
self.error_code = MpdAckError.ACK_ERROR_PASSWORD
class MpdUnknownCommand(MpdAckError):
def __init__(self, *args, **kwargs):
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
self.message = u'unknown command "%s"' % self.command
self.command = u''
self.error_code = 5 # ACK_ERROR_UNKNOWN
self.error_code = MpdAckError.ACK_ERROR_UNKNOWN
class MpdNoExistError(MpdAckError):
def __init__(self, *args, **kwargs):
super(MpdNoExistError, self).__init__(*args, **kwargs)
self.error_code = 50 # ACK_ERROR_NO_EXIST
self.error_code = MpdAckError.ACK_ERROR_NO_EXIST
class MpdNotImplemented(MpdAckError):
def __init__(self, *args, **kwargs):

View File

@ -34,6 +34,6 @@ def outputs(frontend):
"""
return [
('outputid', 0),
('outputname', frontend.backend.__class__.__name__),
('outputname', None),
('outputenabled', 1),
]

View File

@ -1,5 +1,6 @@
from mopidy import settings
from mopidy.frontends.mpd.protocol import handle_pattern
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
from mopidy.frontends.mpd.exceptions import MpdPasswordError
@handle_pattern(r'^close$')
def close(frontend):
@ -33,7 +34,11 @@ def password_(frontend, password):
This is used for authentication with the server. ``PASSWORD`` is
simply the plaintext password.
"""
raise MpdNotImplemented # TODO
# You will not get to this code without being authenticated. This is for
# when you are already authenticated, and are sending additional 'password'
# requests.
if settings.MPD_SERVER_PASSWORD != password:
raise MpdPasswordError(u'incorrect password', command=u'password')
@handle_pattern(r'^ping$')
def ping(frontend):

View File

@ -1,6 +1,7 @@
from mopidy.frontends.mpd.protocol import handle_pattern
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented)
from mopidy.frontends.mpd.protocol import handle_pattern
from mopidy.frontends.mpd.translator import tracks_to_mpd_format
@handle_pattern(r'^add "(?P<uri>[^"]*)"$')
def add(frontend, uri):
@ -18,9 +19,9 @@ def add(frontend, uri):
"""
if not uri:
return
for handler_prefix in frontend.backend.uri_handlers:
for handler_prefix in frontend.backend.uri_handlers.get():
if uri.startswith(handler_prefix):
track = frontend.backend.library.lookup(uri)
track = frontend.backend.library.lookup(uri).get()
if track is not None:
frontend.backend.current_playlist.add(track)
return
@ -50,13 +51,14 @@ def addid(frontend, uri, songpos=None):
raise MpdNoExistError(u'No such song', command=u'addid')
if songpos is not None:
songpos = int(songpos)
track = frontend.backend.library.lookup(uri)
track = frontend.backend.library.lookup(uri).get()
if track is None:
raise MpdNoExistError(u'No such song', command=u'addid')
if songpos and songpos > len(frontend.backend.current_playlist.tracks):
if songpos and songpos > len(
frontend.backend.current_playlist.tracks.get()):
raise MpdArgError(u'Bad song index', command=u'addid')
cp_track = frontend.backend.current_playlist.add(track,
at_position=songpos)
at_position=songpos).get()
return ('Id', cp_track[0])
@handle_pattern(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
@ -72,8 +74,8 @@ def delete_range(frontend, start, end=None):
if end is not None:
end = int(end)
else:
end = len(frontend.backend.current_playlist.tracks)
cp_tracks = frontend.backend.current_playlist.cp_tracks[start:end]
end = len(frontend.backend.current_playlist.tracks.get())
cp_tracks = frontend.backend.current_playlist.cp_tracks.get()[start:end]
if not cp_tracks:
raise MpdArgError(u'Bad song index', command=u'delete')
for (cpid, _) in cp_tracks:
@ -84,7 +86,7 @@ def delete_songpos(frontend, songpos):
"""See :meth:`delete_range`"""
try:
songpos = int(songpos)
(cpid, _) = frontend.backend.current_playlist.cp_tracks[songpos]
(cpid, _) = frontend.backend.current_playlist.cp_tracks.get()[songpos]
frontend.backend.current_playlist.remove(cpid=cpid)
except IndexError:
raise MpdArgError(u'Bad song index', command=u'delete')
@ -100,9 +102,9 @@ def deleteid(frontend, cpid):
"""
try:
cpid = int(cpid)
if frontend.backend.playback.current_cpid == cpid:
if frontend.backend.playback.current_cpid.get() == cpid:
frontend.backend.playback.next()
return frontend.backend.current_playlist.remove(cpid=cpid)
return frontend.backend.current_playlist.remove(cpid=cpid).get()
except LookupError:
raise MpdNoExistError(u'No such song', command=u'deleteid')
@ -128,7 +130,7 @@ def move_range(frontend, start, to, end=None):
``TO`` in the playlist.
"""
if end is None:
end = len(frontend.backend.current_playlist.tracks)
end = len(frontend.backend.current_playlist.tracks.get())
start = int(start)
end = int(end)
to = int(to)
@ -154,8 +156,9 @@ def moveid(frontend, cpid, to):
"""
cpid = int(cpid)
to = int(to)
cp_track = frontend.backend.current_playlist.get(cpid=cpid)
position = frontend.backend.current_playlist.cp_tracks.index(cp_track)
cp_track = frontend.backend.current_playlist.get(cpid=cpid).get()
position = frontend.backend.current_playlist.cp_tracks.get().index(
cp_track)
frontend.backend.current_playlist.move(position, position + 1, to)
@handle_pattern(r'^playlist$')
@ -189,9 +192,9 @@ def playlistfind(frontend, tag, needle):
"""
if tag == 'filename':
try:
cp_track = frontend.backend.current_playlist.get(uri=needle)
cp_track = frontend.backend.current_playlist.get(uri=needle).get()
(cpid, track) = cp_track
position = frontend.backend.current_playlist.cp_tracks.index(
position = frontend.backend.current_playlist.cp_tracks.get().index(
cp_track)
return track.mpd_format(cpid=cpid, position=position)
except LookupError:
@ -211,14 +214,17 @@ def playlistid(frontend, cpid=None):
if cpid is not None:
try:
cpid = int(cpid)
cp_track = frontend.backend.current_playlist.get(cpid=cpid)
position = frontend.backend.current_playlist.cp_tracks.index(
cp_track = frontend.backend.current_playlist.get(cpid=cpid).get()
position = frontend.backend.current_playlist.cp_tracks.get().index(
cp_track)
return cp_track[1].mpd_format(position=position, cpid=cpid)
except LookupError:
raise MpdNoExistError(u'No such song', command=u'playlistid')
else:
return frontend.backend.current_playlist.mpd_format()
cpids = [ct[0] for ct in
frontend.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format(
frontend.backend.current_playlist.tracks.get(), cpids=cpids)
@handle_pattern(r'^playlistinfo$')
@handle_pattern(r'^playlistinfo "(?P<songpos>-?\d+)"$')
@ -248,18 +254,27 @@ def playlistinfo(frontend, songpos=None,
end = songpos + 1
if start == -1:
end = None
return frontend.backend.current_playlist.mpd_format(start, end)
cpids = [ct[0] for ct in
frontend.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format(
frontend.backend.current_playlist.tracks.get(),
start, end, cpids=cpids)
else:
if start is None:
start = 0
start = int(start)
if not (0 <= start <= len(frontend.backend.current_playlist.tracks)):
if not (0 <= start <= len(
frontend.backend.current_playlist.tracks.get())):
raise MpdArgError(u'Bad song index', command=u'playlistinfo')
if end is not None:
end = int(end)
if end > len(frontend.backend.current_playlist.tracks):
if end > len(frontend.backend.current_playlist.tracks.get()):
end = None
return frontend.backend.current_playlist.mpd_format(start, end)
cpids = [ct[0] for ct in
frontend.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format(
frontend.backend.current_playlist.tracks.get(),
start, end, cpids=cpids)
@handle_pattern(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
@handle_pattern(r'^playlistsearch (?P<tag>\S+) "(?P<needle>[^"]+)"$')
@ -298,7 +313,10 @@ def plchanges(frontend, version):
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) < frontend.backend.current_playlist.version:
return frontend.backend.current_playlist.mpd_format()
cpids = [ct[0] for ct in
frontend.backend.current_playlist.cp_tracks.get()]
return tracks_to_mpd_format(
frontend.backend.current_playlist.tracks.get(), cpids=cpids)
@handle_pattern(r'^plchangesposid "(?P<version>\d+)"$')
def plchangesposid(frontend, version):
@ -315,10 +333,10 @@ def plchangesposid(frontend, version):
``playlistlength`` returned by status command.
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) != frontend.backend.current_playlist.version:
if int(version) != frontend.backend.current_playlist.version.get():
result = []
for (position, (cpid, _)) in enumerate(
frontend.backend.current_playlist.cp_tracks):
frontend.backend.current_playlist.cp_tracks.get()):
result.append((u'cpos', position))
result.append((u'Id', cpid))
return result
@ -351,7 +369,7 @@ def swap(frontend, songpos1, songpos2):
"""
songpos1 = int(songpos1)
songpos2 = int(songpos2)
tracks = frontend.backend.current_playlist.tracks
tracks = frontend.backend.current_playlist.tracks.get()
song1 = tracks[songpos1]
song2 = tracks[songpos2]
del tracks[songpos1]
@ -372,8 +390,9 @@ def swapid(frontend, cpid1, cpid2):
"""
cpid1 = int(cpid1)
cpid2 = int(cpid2)
cp_track1 = frontend.backend.current_playlist.get(cpid=cpid1)
cp_track2 = frontend.backend.current_playlist.get(cpid=cpid2)
position1 = frontend.backend.current_playlist.cp_tracks.index(cp_track1)
position2 = frontend.backend.current_playlist.cp_tracks.index(cp_track2)
cp_track1 = frontend.backend.current_playlist.get(cpid=cpid1).get()
cp_track2 = frontend.backend.current_playlist.get(cpid=cpid2).get()
cp_tracks = frontend.backend.current_playlist.cp_tracks.get()
position1 = cp_tracks.index(cp_track1)
position2 = cp_tracks.index(cp_track2)
swap(frontend, position1, position2)

View File

@ -41,8 +41,8 @@ def count(frontend, tag, needle):
return [('songs', 0), ('playtime', 0)] # TODO
@handle_pattern(r'^find '
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"?'
' "[^"]+"\s?)+)$')
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
def find(frontend, mpd_query):
"""
*musicpd.org, music database section:*
@ -62,9 +62,13 @@ def find(frontend, mpd_query):
- does not add quotes around the field argument.
- capitalizes the type argument.
*ncmpcpp:*
- also uses the search type "date".
"""
query = _build_query(mpd_query)
return frontend.backend.library.find_exact(**query).mpd_format()
return frontend.backend.library.find_exact(**query).get().mpd_format()
@handle_pattern(r'^findadd '
r'(?P<query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? '
@ -211,7 +215,7 @@ def _list_build_query(field, mpd_query):
def _list_artist(frontend, query):
artists = set()
playlist = frontend.backend.library.find_exact(**query)
playlist = frontend.backend.library.find_exact(**query).get()
for track in playlist.tracks:
for artist in track.artists:
artists.add((u'Artist', artist.name))
@ -219,7 +223,7 @@ def _list_artist(frontend, query):
def _list_album(frontend, query):
albums = set()
playlist = frontend.backend.library.find_exact(**query)
playlist = frontend.backend.library.find_exact(**query).get()
for track in playlist.tracks:
if track.album is not None:
albums.add((u'Album', track.album.name))
@ -227,7 +231,7 @@ def _list_album(frontend, query):
def _list_date(frontend, query):
dates = set()
playlist = frontend.backend.library.find_exact(**query)
playlist = frontend.backend.library.find_exact(**query).get()
for track in playlist.tracks:
if track.date is not None:
dates.add((u'Date', track.date.strftime('%Y-%m-%d')))
@ -290,8 +294,8 @@ def rescan(frontend, uri=None):
return update(frontend, uri, rescan_unmodified_files=True)
@handle_pattern(r'^search '
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"?'
' "[^"]+"\s?)+)$')
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$')
def search(frontend, mpd_query):
"""
*musicpd.org, music database section:*
@ -314,9 +318,13 @@ def search(frontend, mpd_query):
- does not add quotes around the field argument.
- capitalizes the field argument.
*ncmpcpp:*
- also uses the search type "date".
"""
query = _build_query(mpd_query)
return frontend.backend.library.search(**query).mpd_format()
return frontend.backend.library.search(**query).get().mpd_format()
@handle_pattern(r'^update( "(?P<uri>[^"]+)")*$')
def update(frontend, uri=None, rescan_unmodified_files=False):

View File

@ -1,3 +1,4 @@
from mopidy.backends.base import PlaybackController
from mopidy.frontends.mpd.protocol import handle_pattern
from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError,
MpdNotImplemented)
@ -86,7 +87,7 @@ def next_(frontend):
order as the first time.
"""
return frontend.backend.playback.next()
return frontend.backend.playback.next().get()
@handle_pattern(r'^pause$')
@handle_pattern(r'^pause "(?P<state>[01])"$')
@ -103,11 +104,11 @@ def pause(frontend, state=None):
- Calls ``pause`` without any arguments to toogle pause.
"""
if state is None:
if (frontend.backend.playback.state ==
frontend.backend.playback.PLAYING):
if (frontend.backend.playback.state.get() ==
PlaybackController.PLAYING):
frontend.backend.playback.pause()
elif (frontend.backend.playback.state ==
frontend.backend.playback.PAUSED):
elif (frontend.backend.playback.state.get() ==
PlaybackController.PAUSED):
frontend.backend.playback.resume()
elif int(state):
frontend.backend.playback.pause()
@ -120,7 +121,7 @@ def play(frontend):
The original MPD server resumes from the paused state on ``play``
without arguments.
"""
return frontend.backend.playback.play()
return frontend.backend.playback.play().get()
@handle_pattern(r'^playid "(?P<cpid>\d+)"$')
@handle_pattern(r'^playid "(?P<cpid>-1)"$')
@ -132,22 +133,21 @@ def playid(frontend, cpid):
Begins playing the playlist at song ``SONGID``.
*GMPC:*
*Clarifications:*
- issues ``playid "-1"`` after playlist replacement to start playback
at the first track.
- ``playid "-1"`` when playing is ignored.
- ``playid "-1"`` when paused resumes playback.
- ``playid "-1"`` when stopped with a current track starts playback at the
current track.
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
replacement, starts playback 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()
if cpid == -1:
return _play_minus_one(frontend)
try:
if cpid == -1:
cp_track = _get_cp_track_for_play_minus_one(frontend)
else:
cp_track = frontend.backend.current_playlist.get(cpid=cpid)
return frontend.backend.playback.play(cp_track)
cp_track = frontend.backend.current_playlist.get(cpid=cpid).get()
return frontend.backend.playback.play(cp_track).get()
except LookupError:
raise MpdNoExistError(u'No such song', command=u'playid')
@ -161,33 +161,41 @@ def playpos(frontend, songpos):
Begins playing the playlist at song number ``SONGPOS``.
*Many clients:*
*Clarifications:*
- issue ``play "-1"`` after playlist replacement to start the current
track. If the current track is not set, start playback at the first
track.
- ``playid "-1"`` when playing is ignored.
- ``playid "-1"`` when paused resumes playback.
- ``playid "-1"`` when stopped with a current track starts playback at the
current track.
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
replacement, starts playback at the first track.
*BitMPC:*
- issues ``play 6`` without quotes around the argument.
"""
songpos = int(songpos)
if songpos == -1:
return _play_minus_one(frontend)
try:
if songpos == -1:
cp_track = _get_cp_track_for_play_minus_one(frontend)
else:
cp_track = frontend.backend.current_playlist.cp_tracks[songpos]
return frontend.backend.playback.play(cp_track)
cp_track = frontend.backend.current_playlist.cp_tracks.get()[songpos]
return frontend.backend.playback.play(cp_track).get()
except IndexError:
raise MpdArgError(u'Bad song index', command=u'play')
def _get_cp_track_for_play_minus_one(frontend):
if not frontend.backend.current_playlist.cp_tracks:
def _play_minus_one(frontend):
if (frontend.backend.playback.state.get() == PlaybackController.PLAYING):
return # Nothing to do
elif (frontend.backend.playback.state.get() == PlaybackController.PAUSED):
return frontend.backend.playback.resume().get()
elif frontend.backend.playback.current_cp_track.get() is not None:
cp_track = frontend.backend.playback.current_cp_track.get()
return frontend.backend.playback.play(cp_track).get()
elif frontend.backend.current_playlist.cp_tracks.get():
cp_track = frontend.backend.current_playlist.cp_tracks.get()[0]
return frontend.backend.playback.play(cp_track).get()
else:
return # Fail silently
cp_track = frontend.backend.playback.current_cp_track
if cp_track is None:
cp_track = frontend.backend.current_playlist.cp_tracks[0]
return cp_track
@handle_pattern(r'^previous$')
def previous(frontend):
@ -233,7 +241,7 @@ def previous(frontend):
``previous`` should do a seek to time position 0.
"""
return frontend.backend.playback.previous()
return frontend.backend.playback.previous().get()
@handle_pattern(r'^random (?P<state>[01])$')
@handle_pattern(r'^random "(?P<state>[01])"$')
@ -344,7 +352,7 @@ def setvol(frontend, volume):
volume = 0
if volume > 100:
volume = 100
frontend.backend.mixer.volume = volume
frontend.mixer.volume = volume
@handle_pattern(r'^single (?P<state>[01])$')
@handle_pattern(r'^single "(?P<state>[01])"$')

View File

@ -9,9 +9,12 @@ def commands(frontend):
``commands``
Shows which commands the current user has access to.
As permissions is not implemented, any user has access to all commands.
"""
# FIXME When password auth is turned on and the client is not
# authenticated, 'commands' should list only the commands the client does
# have access to. To implement this we need access to the session object to
# check if the client is authenticated or not.
sorted_commands = sorted(list(mpd_commands))
# Not shown by MPD in its command list
@ -51,9 +54,11 @@ def notcommands(frontend):
``notcommands``
Shows which commands the current user does not have access to.
As permissions is not implemented, any user has access to all commands.
"""
# FIXME When password auth is turned on and the client is not
# authenticated, 'notcommands' should list all the commands the client does
# not have access to. To implement this we need access to the session
# object to check if the client is authenticated or not.
pass
@handle_pattern(r'^tagtypes$')
@ -76,4 +81,4 @@ def urlhandlers(frontend):
Gets a list of available URL handlers.
"""
return [(u'handler', uri) for uri in frontend.backend.uri_handlers]
return [(u'handler', uri) for uri in frontend.backend.uri_handlers.get()]

View File

@ -1,3 +1,4 @@
from mopidy.backends.base import PlaybackController
from mopidy.frontends.mpd.protocol import handle_pattern
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@ -23,10 +24,11 @@ def currentsong(frontend):
Displays the song info of the current song (same song that is
identified in status).
"""
if frontend.backend.playback.current_track is not None:
return frontend.backend.playback.current_track.mpd_format(
position=frontend.backend.playback.current_playlist_position,
cpid=frontend.backend.playback.current_cpid)
current_cp_track = frontend.backend.playback.current_cp_track.get()
if current_cp_track is not None:
return current_cp_track[1].mpd_format(
position=frontend.backend.playback.current_playlist_position.get(),
cpid=current_cp_track[0])
@handle_pattern(r'^idle$')
@handle_pattern(r'^idle (?P<subsystems>.+)$')
@ -90,8 +92,7 @@ def stats(frontend):
'artists': 0, # TODO
'albums': 0, # TODO
'songs': 0, # TODO
# TODO Does not work after multiprocessing branch merge
'uptime': 0, # frontend.session.stats_uptime(),
'uptime': 0, # TODO
'db_playtime': 0, # TODO
'db_update': 0, # TODO
'playtime': 0, # TODO
@ -140,56 +141,59 @@ def status(frontend):
('xfade', _status_xfade(frontend)),
('state', _status_state(frontend)),
]
if frontend.backend.playback.current_track is not None:
if frontend.backend.playback.current_track.get() is not None:
result.append(('song', _status_songpos(frontend)))
result.append(('songid', _status_songid(frontend)))
if frontend.backend.playback.state in (frontend.backend.playback.PLAYING,
frontend.backend.playback.PAUSED):
if frontend.backend.playback.state.get() in (PlaybackController.PLAYING,
PlaybackController.PAUSED):
result.append(('time', _status_time(frontend)))
result.append(('elapsed', _status_time_elapsed(frontend)))
result.append(('bitrate', _status_bitrate(frontend)))
return result
def _status_bitrate(frontend):
if frontend.backend.playback.current_track is not None:
return frontend.backend.playback.current_track.bitrate
current_track = frontend.backend.playback.current_track.get()
if current_track is not None:
return current_track.bitrate
def _status_consume(frontend):
if frontend.backend.playback.consume:
if frontend.backend.playback.consume.get():
return 1
else:
return 0
def _status_playlist_length(frontend):
return len(frontend.backend.current_playlist.tracks)
return len(frontend.backend.current_playlist.tracks.get())
def _status_playlist_version(frontend):
return frontend.backend.current_playlist.version
return frontend.backend.current_playlist.version.get()
def _status_random(frontend):
return int(frontend.backend.playback.random)
return int(frontend.backend.playback.random.get())
def _status_repeat(frontend):
return int(frontend.backend.playback.repeat)
return int(frontend.backend.playback.repeat.get())
def _status_single(frontend):
return int(frontend.backend.playback.single)
return int(frontend.backend.playback.single.get())
def _status_songid(frontend):
if frontend.backend.playback.current_cpid is not None:
return frontend.backend.playback.current_cpid
current_cpid = frontend.backend.playback.current_cpid.get()
if current_cpid is not None:
return current_cpid
else:
return _status_songpos(frontend)
def _status_songpos(frontend):
return frontend.backend.playback.current_playlist_position
return frontend.backend.playback.current_playlist_position.get()
def _status_state(frontend):
if frontend.backend.playback.state == frontend.backend.playback.PLAYING:
state = frontend.backend.playback.state.get()
if state == PlaybackController.PLAYING:
return u'play'
elif frontend.backend.playback.state == frontend.backend.playback.STOPPED:
elif state == PlaybackController.STOPPED:
return u'stop'
elif frontend.backend.playback.state == frontend.backend.playback.PAUSED:
elif state == PlaybackController.PAUSED:
return u'pause'
def _status_time(frontend):
@ -197,19 +201,21 @@ def _status_time(frontend):
_status_time_total(frontend) // 1000)
def _status_time_elapsed(frontend):
return frontend.backend.playback.time_position
return frontend.backend.playback.time_position.get()
def _status_time_total(frontend):
if frontend.backend.playback.current_track is None:
current_track = frontend.backend.playback.current_track.get()
if current_track is None:
return 0
elif frontend.backend.playback.current_track.length is None:
elif current_track.length is None:
return 0
else:
return frontend.backend.playback.current_track.length
return current_track.length
def _status_volume(frontend):
if frontend.backend.mixer.volume is not None:
return frontend.backend.mixer.volume
volume = frontend.mixer.volume.get()
if volume is not None:
return volume
else:
return 0

View File

@ -19,8 +19,8 @@ def listplaylist(frontend, name):
file: relative/path/to/file3.mp3
"""
try:
return ['file: %s' % t.uri
for t in frontend.backend.stored_playlists.get(name=name).tracks]
playlist = frontend.backend.stored_playlists.get(name=name).get()
return ['file: %s' % t.uri for t in playlist.tracks]
except LookupError:
raise MpdNoExistError(u'No such playlist', command=u'listplaylist')
@ -39,7 +39,8 @@ def listplaylistinfo(frontend, name):
Album, Artist, Track
"""
try:
return frontend.backend.stored_playlists.get(name=name).mpd_format()
playlist = frontend.backend.stored_playlists.get(name=name).get()
return playlist.mpd_format()
except LookupError:
raise MpdNoExistError(
u'No such playlist', command=u'listplaylistinfo')
@ -66,7 +67,7 @@ def listplaylists(frontend):
Last-Modified: 2010-02-06T02:11:08Z
"""
result = []
for playlist in frontend.backend.stored_playlists.playlists:
for playlist in frontend.backend.stored_playlists.playlists.get():
result.append((u'playlist', playlist.name))
last_modified = (playlist.last_modified or
dt.datetime.now()).isoformat()
@ -92,7 +93,7 @@ def load(frontend, name):
- ``load`` appends the given playlist to the current playlist.
"""
try:
playlist = frontend.backend.stored_playlists.get(name=name)
playlist = frontend.backend.stored_playlists.get(name=name).get()
frontend.backend.current_playlist.append(playlist.tracks)
except LookupError:
raise MpdNoExistError(u'No such playlist', command=u'load')

View File

@ -15,9 +15,8 @@ class MpdServer(asyncore.dispatcher):
for each client connection.
"""
def __init__(self, core_queue):
def __init__(self):
asyncore.dispatcher.__init__(self)
self.core_queue = core_queue
def start(self):
"""Start MPD server."""
@ -47,8 +46,7 @@ class MpdServer(asyncore.dispatcher):
(client_socket, client_socket_address) = self.accept()
logger.info(u'MPD client connection from [%s]:%s',
client_socket_address[0], client_socket_address[1])
MpdSession(self, client_socket, client_socket_address,
self.core_queue).start()
MpdSession(self, client_socket, client_socket_address).start()
def handle_close(self):
"""Handle end of client connection."""

View File

@ -1,28 +1,28 @@
import asynchat
import logging
import multiprocessing
from mopidy import settings
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION
from mopidy.utils.log import indent
from mopidy.utils.process import pickle_connection
logger = logging.getLogger('mopidy.frontends.mpd.session')
class MpdSession(asynchat.async_chat):
"""
The MPD client session. Keeps track of a single client and passes its
MPD requests to the dispatcher.
The MPD client session. Keeps track of a single client session. Any
requests from the client is passed on to the MPD request dispatcher.
"""
def __init__(self, server, client_socket, client_socket_address,
core_queue):
def __init__(self, server, client_socket, client_socket_address):
asynchat.async_chat.__init__(self, sock=client_socket)
self.server = server
self.client_address = client_socket_address[0]
self.client_port = client_socket_address[1]
self.core_queue = core_queue
self.input_buffer = []
self.authenticated = False
self.set_terminator(LINE_TERMINATOR.encode(ENCODING))
self.dispatcher = MpdDispatcher()
def start(self):
"""Start a new client session."""
@ -46,15 +46,12 @@ class MpdSession(asynchat.async_chat):
def handle_request(self, request):
"""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),
})
my_end.poll(None)
response = my_end.recv()
if not self.authenticated:
(self.authenticated, response) = self.check_password(request)
if response is not None:
self.send_response(response)
return
response = self.dispatcher.handle_request(request)
if response is not None:
self.handle_response(response)
@ -69,3 +66,26 @@ class MpdSession(asynchat.async_chat):
output = u'%s%s' % (output, LINE_TERMINATOR)
data = output.encode(ENCODING)
self.push(data)
def check_password(self, request):
"""
Takes any request and tries to authenticate the client using it.
:rtype: a two-tuple containing (is_authenticated, response_message). If
the response_message is :class:`None`, normal processing should
continue, even though the client may not be authenticated.
"""
if settings.MPD_SERVER_PASSWORD is None:
return (True, None)
command = request.split(' ')[0]
if command == 'password':
if request == 'password "%s"' % settings.MPD_SERVER_PASSWORD:
return (True, u'OK')
else:
return (False, u'ACK [3@0] {password} incorrect password')
if command in ('close', 'commands', 'notcommands', 'ping'):
return (False, None)
else:
return (False,
u'ACK [4@0] {%(c)s} you don\'t have permission for "%(c)s"' %
{'c': command})

View File

@ -1,18 +0,0 @@
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()

View File

@ -84,8 +84,9 @@ def artists_to_mpd_format(artists):
:type track: array of :class:`mopidy.models.Artist`
:rtype: string
"""
artists = list(artists)
artists.sort(key=lambda a: a.name)
return u', '.join([a.name for a in artists])
return u', '.join([a.name for a in artists if a.name])
def tracks_to_mpd_format(tracks, start=0, end=None, cpids=None):
"""

View File

@ -1,12 +1,14 @@
import alsaaudio
import logging
from pykka.actor import ThreadingActor
from mopidy import settings
from mopidy.mixers.base import BaseMixer
logger = logging.getLogger('mopidy.mixers.alsa')
class AlsaMixer(BaseMixer):
class AlsaMixer(ThreadingActor, BaseMixer):
"""
Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control
volume.
@ -20,8 +22,10 @@ class AlsaMixer(BaseMixer):
- :attr:`mopidy.settings.MIXER_ALSA_CONTROL`
"""
def __init__(self, *args, **kwargs):
super(AlsaMixer, self).__init__(*args, **kwargs)
def __init__(self):
self._mixer = None
def on_start(self):
self._mixer = alsaaudio.Mixer(self._get_mixer_control())
assert self._mixer is not None

View File

@ -2,17 +2,12 @@ from mopidy import settings
class BaseMixer(object):
"""
:param backend: a backend instance
:type backend: :class:`mopidy.backends.base.Backend`
**Settings:**
- :attr:`mopidy.settings.MIXER_MAX_VOLUME`
"""
def __init__(self, backend, *args, **kwargs):
self.backend = backend
self.amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
@property
def volume(self):
@ -35,9 +30,6 @@ class BaseMixer(object):
volume = 100
self._set_volume(volume)
def destroy(self):
pass
def _get_volume(self):
"""
Return volume as integer in range [0, 100]. :class:`None` if unknown.

View File

@ -1,12 +1,13 @@
import logging
from threading import Lock
from pykka.actor import ThreadingActor
from mopidy import settings
from mopidy.mixers.base import BaseMixer
logger = logging.getLogger(u'mopidy.mixers.denon')
class DenonMixer(BaseMixer):
class DenonMixer(ThreadingActor, BaseMixer):
"""
Mixer for controlling Denon amplifiers and receivers using the RS-232
protocol.
@ -25,27 +26,19 @@ class DenonMixer(BaseMixer):
"""
def __init__(self, *args, **kwargs):
"""
Connects using the serial specifications from Denon's RS-232 Protocol
specification: 9600bps 8N1.
"""
super(DenonMixer, self).__init__(*args, **kwargs)
device = kwargs.get('device', None)
if device:
self._device = device
else:
from serial import Serial
self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2)
self._device = kwargs.get('device', None)
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
self._volume = 0
self._lock = Lock()
def on_start(self):
if self._device is None:
from serial import Serial
self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2)
def _get_volume(self):
self._lock.acquire()
self.ensure_open_device()
self._ensure_open_device()
self._device.write('MV?\r')
vol = str(self._device.readline()[2:4])
self._lock.release()
logger.debug(u'_get_volume() = %s' % vol)
return self._levels.index(vol)
@ -53,14 +46,12 @@ class DenonMixer(BaseMixer):
# Clamp according to Denon-spec
if volume > 99:
volume = 99
self._lock.acquire()
self.ensure_open_device()
self._ensure_open_device()
self._device.write('MV%s\r'% self._levels[volume])
vol = self._device.readline()[2:4]
self._lock.release()
self._volume = self._levels.index(vol)
def ensure_open_device(self):
def _ensure_open_device(self):
if not self._device.isOpen():
logger.debug(u'(re)connecting to Denon device')
self._device.open()

View File

@ -1,10 +1,11 @@
from pykka.actor import ThreadingActor
from mopidy.mixers.base import BaseMixer
class DummyMixer(BaseMixer):
class DummyMixer(ThreadingActor, BaseMixer):
"""Mixer which just stores and reports the chosen volume."""
def __init__(self, *args, **kwargs):
super(DummyMixer, self).__init__(*args, **kwargs)
def __init__(self):
self._volume = None
def _get_volume(self):

View File

@ -1,13 +1,22 @@
from mopidy.mixers.base import BaseMixer
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
class GStreamerSoftwareMixer(BaseMixer):
from mopidy.mixers.base import BaseMixer
from mopidy.outputs.base import BaseOutput
class GStreamerSoftwareMixer(ThreadingActor, BaseMixer):
"""Mixer which uses GStreamer to control volume in software."""
def __init__(self, *args, **kwargs):
super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs)
def __init__(self):
self.output = None
def on_start(self):
output_refs = ActorRegistry.get_by_class(BaseOutput)
assert len(output_refs) == 1, 'Expected exactly one running output.'
self.output = output_refs[0].proxy()
def _get_volume(self):
return self.backend.output.get_volume()
return self.output.get_volume().get()
def _set_volume(self, volume):
self.backend.output.set_volume(volume)
self.output.set_volume(volume).get()

View File

@ -1,14 +1,14 @@
import logging
from serial import Serial
from multiprocessing import Pipe
import serial
from pykka.actor import ThreadingActor
from mopidy import settings
from mopidy.mixers.base import BaseMixer
from mopidy.utils.process import BaseThread
logger = logging.getLogger('mopidy.mixers.nad')
class NadMixer(BaseMixer):
class NadMixer(ThreadingActor, BaseMixer):
"""
Mixer for controlling NAD amplifiers and receivers using the NAD RS-232
protocol.
@ -36,21 +36,19 @@ class NadMixer(BaseMixer):
"""
def __init__(self, *args, **kwargs):
super(NadMixer, self).__init__(*args, **kwargs)
self._volume = None
self._pipe, other_end = Pipe()
NadTalker(self.backend.core_queue, pipe=other_end).start()
def __init__(self):
self._volume_cache = None
self._nad_talker = NadTalker.start().proxy()
def _get_volume(self):
return self._volume
return self._volume_cache
def _set_volume(self, volume):
self._volume = volume
self._pipe.send({'command': 'set_volume', 'volume': volume})
self._volume_cache = volume
self._nad_talker.set_volume(volume)
class NadTalker(BaseThread):
class NadTalker(ThreadingActor):
"""
Independent process which does the communication with the NAD device.
@ -72,29 +70,20 @@ class NadTalker(BaseThread):
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
_nad_volume = None
def __init__(self, core_queue, pipe=None):
super(NadTalker, self).__init__(core_queue)
self.name = u'NadTalker'
self.pipe = pipe
def __init__(self):
self._device = None
def run_inside_try(self):
def on_start(self):
self._open_connection()
self._set_device_to_known_state()
while self.pipe.poll(None):
message = self.pipe.recv()
if message['command'] == 'set_volume':
self._set_volume(message['volume'])
elif message['command'] == 'reset_device':
self._set_device_to_known_state()
def _open_connection(self):
# Opens serial connection to the device.
# Communication settings: 115200 bps 8N1
logger.info(u'Connecting to serial device "%s"',
settings.MIXER_EXT_PORT)
self._device = Serial(port=settings.MIXER_EXT_PORT, baudrate=115200,
timeout=self.TIMEOUT)
self._device = serial.Serial(port=settings.MIXER_EXT_PORT,
baudrate=115200, timeout=self.TIMEOUT)
self._get_device_model()
def _set_device_to_known_state(self):
@ -147,6 +136,8 @@ class NadTalker(BaseThread):
return self._readline().replace('%s=' % key, '')
def _command_device(self, key, value):
if type(value) == unicode:
value = value.encode('utf-8')
self._write('%s=%s' % (key, value))
self._readline()
@ -162,7 +153,7 @@ class NadTalker(BaseThread):
self._nad_volume = 0
logger.info(u'Done calibrating NAD amplifier')
def _set_volume(self, volume):
def set_volume(self, volume):
# Increase or decrease the amplifier volume until it matches the given
# target volume.
logger.debug(u'Setting volume to %d' % volume)

View File

@ -1,9 +1,11 @@
from subprocess import Popen, PIPE
import time
from pykka.actor import ThreadingActor
from mopidy.mixers.base import BaseMixer
class OsaMixer(BaseMixer):
class OsaMixer(ThreadingActor, BaseMixer):
"""
Mixer which uses ``osascript`` on OS X to control volume.
@ -14,7 +16,6 @@ class OsaMixer(BaseMixer):
**Settings:**
- None
"""
CACHE_TTL = 30

View File

@ -1,5 +1,3 @@
from copy import copy
from mopidy.frontends.mpd import translator
class ImmutableObject(object):
@ -23,6 +21,17 @@ class ImmutableObject(object):
return super(ImmutableObject, self).__setattr__(name, value)
raise AttributeError('Object is immutable.')
def __repr__(self):
kwarg_pairs = []
for (key, value) in sorted(self.__dict__.items()):
if isinstance(value, (frozenset, tuple)):
value = list(value)
kwarg_pairs.append('%s=%s' % (key, repr(value)))
return '%(classname)s(%(kwargs)s)' % {
'classname': self.__class__.__name__,
'kwargs': ', '.join(kwarg_pairs),
}
def __hash__(self):
hash_sum = 0
for key, value in self.__dict__.items():
@ -65,6 +74,7 @@ class ImmutableObject(object):
% key)
return self.__class__(**data)
class Artist(ImmutableObject):
"""
:param uri: artist URI
@ -105,6 +115,9 @@ class Album(ImmutableObject):
#: The album name. Read-only.
name = None
#: A set of album artists. Read-only.
artists = frozenset()
#: The number of tracks in the album. Read-only.
num_tracks = 0
@ -112,14 +125,9 @@ class Album(ImmutableObject):
musicbrainz_id = None
def __init__(self, *args, **kwargs):
self._artists = frozenset(kwargs.pop('artists', []))
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
super(Album, self).__init__(*args, **kwargs)
@property
def artists(self):
"""List of :class:`Artist` elements. Read-only."""
return list(self._artists)
class Track(ImmutableObject):
"""
@ -149,6 +157,9 @@ class Track(ImmutableObject):
#: The track name. Read-only.
name = None
#: A set of track artists. Read-only.
artists = frozenset()
#: The track :class:`Album`. Read-only.
album = None
@ -168,14 +179,9 @@ class Track(ImmutableObject):
musicbrainz_id = None
def __init__(self, *args, **kwargs):
self._artists = frozenset(kwargs.pop('artists', []))
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
super(Track, self).__init__(*args, **kwargs)
@property
def artists(self):
"""List of :class:`Artist`. Read-only."""
return list(self._artists)
def mpd_format(self, *args, **kwargs):
return translator.track_to_mpd_format(self, *args, **kwargs)
@ -198,24 +204,22 @@ class Playlist(ImmutableObject):
#: The playlist name. Read-only.
name = None
#: The playlist's tracks. Read-only.
tracks = tuple()
#: The playlist modification time. Read-only.
#:
#: :class:`datetime.datetime`, or :class:`None` if unknown.
last_modified = None
def __init__(self, *args, **kwargs):
self._tracks = kwargs.pop('tracks', [])
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', []))
super(Playlist, self).__init__(*args, **kwargs)
@property
def tracks(self):
"""List of :class:`Track` elements. Read-only."""
return copy(self._tracks)
@property
def length(self):
"""The number of tracks in the playlist. Read-only."""
return len(self._tracks)
return len(self.tracks)
def mpd_format(self, *args, **kwargs):
return translator.playlist_to_mpd_format(self, *args, **kwargs)

View File

@ -3,33 +3,6 @@ class BaseOutput(object):
Base class for audio outputs.
"""
def __init__(self, core_queue):
self.core_queue = core_queue
def start(self):
"""
Start the output.
*MAY be implemented by subclasses.*
"""
pass
def destroy(self):
"""
Destroy the output.
*MAY be implemented by subclasses.*
"""
pass
def process_message(self, message):
"""
Process messages with the output as destination.
*MUST be implemented by subclass.*
"""
raise NotImplementedError
def play_uri(self, uri):
"""
Play URI.

View File

@ -1,6 +1,8 @@
from pykka.actor import ThreadingActor
from mopidy.outputs.base import BaseOutput
class DummyOutput(BaseOutput):
class DummyOutput(ThreadingActor, BaseOutput):
"""
Audio output used for testing.
"""
@ -8,15 +10,6 @@ class DummyOutput(BaseOutput):
# pylint: disable = R0902
# Too many instance attributes (9/7)
#: 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
@ -40,15 +33,6 @@ class DummyOutput(BaseOutput):
#: 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

View File

@ -3,113 +3,39 @@ pygst.require('0.10')
import gst
import logging
import multiprocessing
from pykka.actor import ThreadingActor
from pykka.registry import ActorRegistry
from mopidy import settings
from mopidy.backends.base import Backend
from mopidy.outputs.base import BaseOutput
from mopidy.utils.process import (BaseThread, pickle_connection,
unpickle_connection)
logger = logging.getLogger('mopidy.outputs.gstreamer')
class GStreamerOutput(BaseOutput):
class GStreamerOutput(ThreadingActor, BaseOutput):
"""
Audio output through GStreamer.
Starts :class:`GStreamerMessagesThread` and :class:`GStreamerPlayerThread`.
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
**Settings:**
- :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
"""
def __init__(self, *args, **kwargs):
super(GStreamerOutput, self).__init__(*args, **kwargs)
self.output_queue = multiprocessing.Queue()
self.player_thread = GStreamerPlayerThread(self.core_queue,
self.output_queue)
def start(self):
self.player_thread.start()
def destroy(self):
self.player_thread.destroy()
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 GStreamerPlayerThread(BaseThread):
"""
A process for all work related to GStreamer.
The main loop processes events from both Mopidy and GStreamer.
This thread requires :class:`mopidy.utils.process.GObjectEventThread` to be
running too. This is not enforced in any way by the code.
Make sure this subprocess is started by the MainThread in the top-most
parent process, and not some other thread. If not, we can get into the
problems described at
http://jameswestby.net/weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html.
"""
def __init__(self, core_queue, output_queue):
super(GStreamerPlayerThread, self).__init__(core_queue)
self.name = u'GStreamerPlayerThread'
self.output_queue = output_queue
def __init__(self):
self.gst_pipeline = None
def run_inside_try(self):
self.setup()
while True:
message = self.output_queue.get()
self.process_mopidy_message(message)
def on_start(self):
self._setup_gstreamer()
def _setup_gstreamer(self):
"""
**Warning:** :class:`GStreamerOutput` requires
:class:`mopidy.utils.process.GObjectEventThread` to be running. This is
not enforced by :class:`GStreamerOutput` itself.
"""
def setup(self):
logger.debug(u'Setting up GStreamer pipeline')
self.gst_pipeline = gst.parse_launch(' ! '.join([
@ -122,7 +48,7 @@ class GStreamerPlayerThread(BaseThread):
if settings.BACKENDS[0] == 'mopidy.backends.local.LocalBackend':
uri_bin = gst.element_factory_make('uridecodebin', 'uri')
uri_bin.connect('pad-added', self.process_new_pad, pad)
uri_bin.connect('pad-added', self._process_new_pad, pad)
self.gst_pipeline.add(uri_bin)
else:
app_src = gst.element_factory_make('appsrc', 'appsrc')
@ -141,57 +67,29 @@ class GStreamerPlayerThread(BaseThread):
# Setup bus and message processor
gst_bus = self.gst_pipeline.get_bus()
gst_bus.add_signal_watch()
gst_bus.connect('message', self.process_gst_message)
gst_bus.connect('message', self._process_gstreamer_message)
def process_new_pad(self, source, pad, target_pad):
def _process_new_pad(self, source, pad, target_pad):
pad.link(target_pad)
def process_mopidy_message(self, message):
"""Process messages from the rest of Mopidy."""
if message['command'] == 'play_uri':
response = self.play_uri(message['uri'])
connection = unpickle_connection(message['reply_to'])
connection.send(response)
elif message['command'] == 'deliver_data':
self.deliver_data(message['caps'], message['data'])
elif message['command'] == 'end_of_data_stream':
self.end_of_data_stream()
elif message['command'] == 'set_state':
response = self.set_state(message['state'])
connection = unpickle_connection(message['reply_to'])
connection.send(response)
elif message['command'] == 'get_volume':
volume = self.get_volume()
connection = unpickle_connection(message['reply_to'])
connection.send(volume)
elif message['command'] == 'set_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'])
connection.send(response)
elif message['command'] == 'get_position':
response = self.get_position()
connection = unpickle_connection(message['reply_to'])
connection.send(response)
else:
logger.warning(u'Cannot handle message: %s', message)
def process_gst_message(self, bus, message):
def _process_gstreamer_message(self, bus, message):
"""Process messages from GStreamer."""
if message.type == gst.MESSAGE_EOS:
logger.debug(u'GStreamer signalled end-of-stream. '
'Sending end_of_track to core_queue ...')
self.core_queue.put({'command': 'end_of_track'})
'Telling backend ...')
self._get_backend().playback.on_end_of_track()
elif message.type == gst.MESSAGE_ERROR:
self.set_state('NULL')
error, debug = message.parse_error()
logger.error(u'%s %s', error, debug)
# FIXME Should we send 'stop_playback' to core here? Can we
# FIXME Should we send 'stop_playback' to the backend here? Can we
# differentiate on how serious the error is?
def _get_backend(self):
backend_refs = ActorRegistry.get_by_class(Backend)
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
return backend_refs[0].proxy()
def play_uri(self, uri):
"""Play audio at URI"""
self.set_state('READY')
@ -216,6 +114,21 @@ class GStreamerPlayerThread(BaseThread):
"""
self.gst_pipeline.get_by_name('appsrc').emit('end-of-stream')
def get_position(self):
try:
position = self.gst_pipeline.query_position(gst.FORMAT_TIME)[0]
return position // gst.MSECOND
except gst.QueryError, e:
logger.error('time_position failed: %s', e)
return 0
def set_position(self, position):
self.gst_pipeline.get_state() # block until state changes are done
handeled = self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME),
gst.SEEK_FLAG_FLUSH, position * gst.MSECOND)
self.gst_pipeline.get_state() # block until seek is done
return handeled
def set_state(self, state_name):
"""
Set the GStreamer state. Returns :class:`True` if successful.

View File

@ -164,6 +164,11 @@ OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput'
#: Listens on all interfaces, both IPv4 and IPv6.
MPD_SERVER_HOSTNAME = u'127.0.0.1'
#: The password required for connecting to the MPD server.
#:
#: Default: :class:`None`, which means no password required.
MPD_SERVER_PASSWORD = None
#: Which TCP port Mopidy's MPD server should listen to.
#:
#: Default: 6600

View File

@ -1,13 +1,15 @@
import logging
import logging.handlers
from mopidy import settings
from mopidy import get_version, settings
def setup_logging(verbosity_level, save_debug_log):
setup_root_logger()
setup_console_logging(verbosity_level)
if save_debug_log:
setup_debug_logging_to_file()
logger = logging.getLogger('mopidy.utils.log')
logger.info(u'-- Starting Mopidy %s --', get_version())
def setup_root_logger():
root = logging.getLogger('')

View File

@ -1,8 +1,5 @@
import logging
import multiprocessing
import multiprocessing.dummy
from multiprocessing.reduction import reduce_connection
import pickle
import threading
import gobject
gobject.threads_init()
@ -11,52 +8,10 @@ from mopidy import SettingsError
logger = logging.getLogger('mopidy.utils.process')
def pickle_connection(connection):
return pickle.dumps(reduce_connection(connection))
def unpickle_connection(pickled_connection):
# From http://stackoverflow.com/questions/1446004
(func, args) = pickle.loads(pickled_connection)
return func(*args)
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'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):
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):
class BaseThread(threading.Thread):
def __init__(self):
super(BaseThread, self).__init__()
self.core_queue = core_queue
# No thread should block process from exiting
self.daemon = True
@ -84,8 +39,6 @@ class BaseThread(multiprocessing.dummy.Process):
pass
def exit(self, status=0, reason=None):
self.core_queue.put({'to': 'core', 'command': 'exit',
'status': status, 'reason': reason})
self.destroy()
@ -98,8 +51,8 @@ class GObjectEventThread(BaseThread):
:mod:`mopidy.output.gstreamer`, :mod:`mopidy.frontend.mpris`, etc.
"""
def __init__(self, core_queue):
super(GObjectEventThread, self).__init__(core_queue)
def __init__(self):
super(GObjectEventThread, self).__init__()
self.name = u'GObjectEventThread'
self.loop = None

View File

@ -49,7 +49,7 @@ class SettingsProxy(object):
if attr not in self.current:
raise SettingsError(u'Setting "%s" is not set.' % attr)
value = self.current[attr]
if type(value) != bool and not value:
if isinstance(value, basestring) and len(value) == 0:
raise SettingsError(u'Setting "%s" is empty.' % attr)
if attr.endswith('_PATH') or attr.endswith('_FILE'):
value = os.path.expanduser(value)
@ -141,8 +141,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') and len(value):
value = u'********'
value = mask_value_if_secret(key, value)
lines.append(u'%s:' % key)
lines.append(u' Value: %s' % repr(value))
if value != default_value and default_value is not None:
@ -151,3 +150,9 @@ def list_settings_optparse_callback(*args):
lines.append(u' Error: %s' % errors[key])
print u'Settings: %s' % indent('\n'.join(lines), places=2)
sys.exit(0)
def mask_value_if_secret(key, value):
if key.endswith('PASSWORD') and value:
return u'********'
else:
return value

1
requirements/core.txt Normal file
View File

@ -0,0 +1 @@
Pykka >= 0.12

View File

@ -1 +1 @@
pylast >= 0.5
pylast >= 0.5.7

View File

@ -1,2 +1,4 @@
coverage
mock
nose
tox

View File

@ -69,7 +69,10 @@ for dirpath, dirnames, filenames in os.walk(project_dir):
data_files.append([dirpath,
[os.path.join(dirpath, f) for f in filenames]])
data_files.append(('/usr/local/share/applications', ['data/mopidy.desktop']))
if os.geteuid() == 0:
# Only try to install this file if we are root
data_files.append(
('/usr/local/share/applications', ['data/mopidy.desktop']))
setup(
name='Mopidy',

View File

@ -1,7 +1,9 @@
import os
try: # 2.7
# pylint: disable = E0611,F0401
from unittest.case import SkipTest
# pylint: enable = E0611,F0401
except ImportError:
try: # Nose
from nose.plugins.skip import SkipTest
@ -14,9 +16,9 @@ from mopidy import settings
# Nuke any local settings to ensure same test env all over
settings.local.clear()
def data_folder(name):
folder = os.path.dirname(__file__)
folder = os.path.join(folder, 'data')
folder = os.path.abspath(folder)
return os.path.join(folder, name)
def path_to_data_dir(name):
path = os.path.dirname(__file__)
path = os.path.join(path, 'data')
path = os.path.abspath(path)
return os.path.join(path, name)

View File

@ -1,11 +1,9 @@
import mock
import multiprocessing
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 mopidy.outputs.base import BaseOutput
from tests.backends.base import populate_playlist
@ -13,19 +11,13 @@ class CurrentPlaylistControllerTest(object):
tracks = []
def setUp(self):
self.core_queue = multiprocessing.Queue()
self.output = DummyOutput(self.core_queue)
self.backend = self.backend_class(
self.core_queue, self.output, DummyMixer)
self.backend = self.backend_class()
self.backend.output = mock.Mock(spec=BaseOutput)
self.controller = self.backend.current_playlist
self.playback = self.backend.playback
assert len(self.tracks) == 3, 'Need three tracks to run tests.'
def tearDown(self):
self.backend.destroy()
self.output.destroy()
def test_add(self):
for track in self.tracks:
cp_track = self.controller.add(track)

View File

@ -1,7 +1,6 @@
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist, Track, Album, Artist
from tests import SkipTest, data_folder
from tests import SkipTest, path_to_data_dir
class LibraryControllerTest(object):
artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()]
@ -9,18 +8,15 @@ class LibraryControllerTest(object):
Album(name='album2', artists=artists[1:2]),
Album()]
tracks = [Track(name='track1', length=4000, artists=artists[:1],
album=albums[0], uri='file://' + data_folder('uri1')),
album=albums[0], uri='file://' + path_to_data_dir('uri1')),
Track(name='track2', length=4000, artists=artists[1:2],
album=albums[1], uri='file://' + data_folder('uri2')),
album=albums[1], uri='file://' + path_to_data_dir('uri2')),
Track()]
def setUp(self):
self.backend = self.backend_class(mixer_class=DummyMixer)
self.backend = self.backend_class()
self.library = self.backend.library
def tearDown(self):
self.backend.destroy()
def test_refresh(self):
self.library.refresh()

View File

@ -1,12 +1,10 @@
import mock
import multiprocessing
import random
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 mopidy.outputs.base import BaseOutput
from tests import SkipTest
from tests.backends.base import populate_playlist
@ -17,10 +15,8 @@ class PlaybackControllerTest(object):
tracks = []
def setUp(self):
self.core_queue = multiprocessing.Queue()
self.output = DummyOutput(self.core_queue)
self.backend = self.backend_class(
self.core_queue, self.output, DummyMixer)
self.backend = self.backend_class()
self.backend.output = mock.Mock(spec=BaseOutput)
self.playback = self.backend.playback
self.current_playlist = self.backend.current_playlist
@ -29,10 +25,6 @@ class PlaybackControllerTest(object):
assert self.tracks[0].length >= 2000, \
'First song needs to be at least 2000 miliseconds'
def tearDown(self):
self.backend.destroy()
self.output.destroy()
def test_initial_state_is_stopped(self):
self.assertEqual(self.playback.state, self.playback.STOPPED)
@ -212,7 +204,7 @@ class PlaybackControllerTest(object):
def test_next_until_end_of_playlist_and_play_from_start(self):
self.playback.play()
for track in self.tracks:
for _ in self.tracks:
self.playback.next()
self.assertEqual(self.playback.current_track, None)
@ -258,7 +250,7 @@ class PlaybackControllerTest(object):
@populate_playlist
def test_next_track_at_end_of_playlist(self):
self.playback.play()
for track in self.current_playlist.cp_tracks[1:]:
for _ in self.current_playlist.cp_tracks[1:]:
self.playback.next()
self.assertEqual(self.playback.track_at_next, None)
@ -266,7 +258,7 @@ class PlaybackControllerTest(object):
def test_next_track_at_end_of_playlist_with_repeat(self):
self.playback.repeat = True
self.playback.play()
for track in self.tracks[1:]:
for _ in self.tracks[1:]:
self.playback.next()
self.assertEqual(self.playback.track_at_next, self.tracks[0])
@ -348,7 +340,7 @@ class PlaybackControllerTest(object):
def test_end_of_track_until_end_of_playlist_and_play_from_start(self):
self.playback.play()
for track in self.tracks:
for _ in self.tracks:
self.playback.on_end_of_track()
self.assertEqual(self.playback.current_track, None)
@ -394,7 +386,7 @@ class PlaybackControllerTest(object):
@populate_playlist
def test_end_of_track_track_at_end_of_playlist(self):
self.playback.play()
for track in self.current_playlist.cp_tracks[1:]:
for _ in self.current_playlist.cp_tracks[1:]:
self.playback.on_end_of_track()
self.assertEqual(self.playback.track_at_next, None)
@ -402,7 +394,7 @@ class PlaybackControllerTest(object):
def test_end_of_track_track_at_end_of_playlist_with_repeat(self):
self.playback.repeat = True
self.playback.play()
for track in self.tracks[1:]:
for _ in self.tracks[1:]:
self.playback.on_end_of_track()
self.assertEqual(self.playback.track_at_next, self.tracks[0])
@ -466,7 +458,7 @@ class PlaybackControllerTest(object):
@populate_playlist
def test_previous_track_with_consume(self):
self.playback.consume = True
for track in self.tracks:
for _ in self.tracks:
self.playback.next()
self.assertEqual(self.playback.track_at_previous,
self.playback.current_track)
@ -474,7 +466,7 @@ class PlaybackControllerTest(object):
@populate_playlist
def test_previous_track_with_random(self):
self.playback.random = True
for track in self.tracks:
for _ in self.tracks:
self.playback.next()
self.assertEqual(self.playback.track_at_previous,
self.playback.current_track)
@ -547,7 +539,6 @@ class PlaybackControllerTest(object):
@populate_playlist
def test_on_current_playlist_change_when_stopped(self):
current_track = self.playback.current_track
self.backend.current_playlist.append([self.tracks[2]])
self.assertEqual(self.playback.state, self.playback.STOPPED)
self.assertEqual(self.playback.current_track, None)
@ -677,9 +668,10 @@ class PlaybackControllerTest(object):
self.playback.seek(0)
self.assertEqual(self.playback.state, self.playback.PLAYING)
@SkipTest
@populate_playlist
def test_seek_beyond_end_of_song(self):
raise SkipTest # FIXME need to decide return value
# FIXME need to decide return value
self.playback.play()
result = self.playback.seek(self.tracks[0].length*100)
self.assert_(not result, 'Seek return value was %s' % result)
@ -696,9 +688,10 @@ class PlaybackControllerTest(object):
self.playback.seek(self.current_playlist.tracks[-1].length * 100)
self.assertEqual(self.playback.state, self.playback.STOPPED)
@SkipTest
@populate_playlist
def test_seek_beyond_start_of_song(self):
raise SkipTest # FIXME need to decide return value
# FIXME need to decide return value
self.playback.play()
result = self.playback.seek(-1000)
self.assert_(not result, 'Seek return value was %s' % result)
@ -734,10 +727,18 @@ class PlaybackControllerTest(object):
self.assertEqual(self.playback.stop(), None)
def test_time_position_when_stopped(self):
future = mock.Mock()
future.get = mock.Mock(return_value=0)
self.backend.output.get_position = mock.Mock(return_value=future)
self.assertEqual(self.playback.time_position, 0)
@populate_playlist
def test_time_position_when_stopped_with_playlist(self):
future = mock.Mock()
future.get = mock.Mock(return_value=0)
self.backend.output.get_position = mock.Mock(return_value=future)
self.assertEqual(self.playback.time_position, 0)
@SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput
@ -770,7 +771,7 @@ class PlaybackControllerTest(object):
def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self):
self.playback.consume = True
self.playback.play()
for i in range(len(self.backend.current_playlist.tracks)):
for _ in range(len(self.backend.current_playlist.tracks)):
self.playback.on_end_of_track()
self.assertEqual(len(self.backend.current_playlist.tracks), 0)
@ -824,14 +825,14 @@ class PlaybackControllerTest(object):
def test_random_until_end_of_playlist(self):
self.playback.random = True
self.playback.play()
for track in self.tracks[1:]:
for _ in self.tracks[1:]:
self.playback.next()
self.assertEqual(self.playback.track_at_next, None)
@populate_playlist
def test_random_until_end_of_playlist_and_play_from_start(self):
self.playback.repeat = True
for track in self.tracks:
for _ in self.tracks:
self.playback.next()
self.assertNotEqual(self.playback.track_at_next, None)
self.assertEqual(self.playback.state, self.playback.STOPPED)
@ -843,7 +844,7 @@ class PlaybackControllerTest(object):
self.playback.repeat = True
self.playback.random = True
self.playback.play()
for track in self.tracks:
for _ in self.tracks:
self.playback.next()
self.assertNotEqual(self.playback.track_at_next, None)
@ -852,7 +853,7 @@ class PlaybackControllerTest(object):
self.playback.random = True
self.playback.play()
played = []
for track in self.tracks:
for _ in self.tracks:
self.assert_(self.playback.current_track not in played)
played.append(self.playback.current_track)
self.playback.next()

View File

@ -3,23 +3,20 @@ import shutil
import tempfile
from mopidy import settings
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist
from tests import SkipTest, data_folder
from tests import SkipTest, path_to_data_dir
class StoredPlaylistsControllerTest(object):
def setUp(self):
settings.LOCAL_PLAYLIST_PATH = tempfile.mkdtemp()
settings.LOCAL_TAG_CACHE_FILE = data_folder('library_tag_cache')
settings.LOCAL_MUSIC_PATH = data_folder('')
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache')
settings.LOCAL_MUSIC_PATH = path_to_data_dir('')
self.backend = self.backend_class(mixer_class=DummyMixer)
self.backend = self.backend_class()
self.stored = self.backend.stored_playlists
def tearDown(self):
self.backend.destroy()
if os.path.exists(settings.LOCAL_PLAYLIST_PATH):
shutil.rmtree(settings.LOCAL_PLAYLIST_PATH)

View File

@ -1,6 +1,6 @@
from mopidy.utils.path import path_to_uri
from tests import data_folder
from tests import path_to_data_dir
song = data_folder('song%s.wav')
song = path_to_data_dir('song%s.wav')
generate_song = lambda i: path_to_uri(song % i)

View File

@ -9,7 +9,7 @@ if sys.platform == 'win32':
from mopidy import settings
from mopidy.backends.local import LocalBackend
from tests import data_folder
from tests import path_to_data_dir
from tests.backends.base.library import LibraryControllerTest
class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase):
@ -17,8 +17,8 @@ class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase):
backend_class = LocalBackend
def setUp(self):
settings.LOCAL_TAG_CACHE_FILE = data_folder('library_tag_cache')
settings.LOCAL_MUSIC_PATH = data_folder('')
settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache')
settings.LOCAL_MUSIC_PATH = path_to_data_dir('')
super(LocalLibraryControllerTest, self).setUp()

View File

@ -11,7 +11,7 @@ from mopidy.backends.local import LocalBackend
from mopidy.models import Track
from mopidy.utils.path import path_to_uri
from tests import data_folder
from tests import path_to_data_dir
from tests.backends.base.playback import PlaybackControllerTest
from tests.backends.local import generate_song
@ -32,7 +32,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
settings.runtime.clear()
def add_track(self, path):
uri = path_to_uri(data_folder(path))
uri = path_to_uri(path_to_data_dir(path))
track = Track(uri=uri, length=4464)
self.backend.current_playlist.add(track)

View File

@ -14,7 +14,7 @@ from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist, Track
from mopidy.utils.path import path_to_uri
from tests import data_folder
from tests import path_to_data_dir
from tests.backends.base.stored_playlists import \
StoredPlaylistsControllerTest
from tests.backends.local import generate_song
@ -65,13 +65,12 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest,
self.assertEqual(uri, contents.strip())
def test_playlists_are_loaded_at_startup(self):
track = Track(uri=path_to_uri(data_folder('uri2')))
track = Track(uri=path_to_uri(path_to_data_dir('uri2')))
playlist = Playlist(tracks=[track], name='test')
self.stored.save(playlist)
self.backend.destroy()
self.backend = self.backend_class(mixer_class=DummyMixer)
self.backend = self.backend_class()
self.stored = self.backend.stored_playlists
self.assert_(self.stored.playlists)

View File

@ -8,26 +8,26 @@ from mopidy.utils.path import path_to_uri
from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache
from mopidy.models import Track, Artist, Album
from tests import SkipTest, data_folder
from tests import SkipTest, path_to_data_dir
song1_path = data_folder('song1.mp3')
song2_path = data_folder('song2.mp3')
encoded_path = data_folder(u'æøå.mp3')
song1_path = path_to_data_dir('song1.mp3')
song2_path = path_to_data_dir('song2.mp3')
encoded_path = path_to_data_dir(u'æøå.mp3')
song1_uri = path_to_uri(song1_path)
song2_uri = path_to_uri(song2_path)
encoded_uri = path_to_uri(encoded_path)
class M3UToUriTest(unittest.TestCase):
def test_empty_file(self):
uris = parse_m3u(data_folder('empty.m3u'))
uris = parse_m3u(path_to_data_dir('empty.m3u'))
self.assertEqual([], uris)
def test_basic_file(self):
uris = parse_m3u(data_folder('one.m3u'))
uris = parse_m3u(path_to_data_dir('one.m3u'))
self.assertEqual([song1_uri], uris)
def test_file_with_comment(self):
uris = parse_m3u(data_folder('comment.m3u'))
uris = parse_m3u(path_to_data_dir('comment.m3u'))
self.assertEqual([song1_uri], uris)
def test_file_with_absolute_files(self):
@ -64,11 +64,11 @@ class M3UToUriTest(unittest.TestCase):
os.remove(tmp.name)
def test_encoding_is_latin1(self):
uris = parse_m3u(data_folder('encoding.m3u'))
uris = parse_m3u(path_to_data_dir('encoding.m3u'))
self.assertEqual([encoded_uri], uris)
def test_open_missing_file(self):
uris = parse_m3u(data_folder('non-existant.m3u'))
uris = parse_m3u(path_to_data_dir('non-existant.m3u'))
self.assertEqual([], uris)
@ -81,7 +81,7 @@ expected_albums = [Album(name='albumname', artists=expected_artists,
expected_tracks = []
def generate_track(path, ident):
uri = path_to_uri(data_folder(path))
uri = path_to_uri(path_to_data_dir(path))
track = Track(name='trackname', artists=expected_artists, track_no=1,
album=expected_albums[0], length=4000, uri=uri)
expected_tracks.append(track)
@ -98,28 +98,28 @@ generate_track('subdir1/subsubdir/song9.mp3', 1)
class MPDTagCacheToTracksTest(unittest.TestCase):
def test_emtpy_cache(self):
tracks = parse_mpd_tag_cache(data_folder('empty_tag_cache'),
data_folder(''))
tracks = parse_mpd_tag_cache(path_to_data_dir('empty_tag_cache'),
path_to_data_dir(''))
self.assertEqual(set(), tracks)
def test_simple_cache(self):
tracks = parse_mpd_tag_cache(data_folder('simple_tag_cache'),
data_folder(''))
uri = path_to_uri(data_folder('song1.mp3'))
tracks = parse_mpd_tag_cache(path_to_data_dir('simple_tag_cache'),
path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
track = Track(name='trackname', artists=expected_artists, track_no=1,
album=expected_albums[0], length=4000, uri=uri)
self.assertEqual(set([track]), tracks)
def test_advanced_cache(self):
tracks = parse_mpd_tag_cache(data_folder('advanced_tag_cache'),
data_folder(''))
tracks = parse_mpd_tag_cache(path_to_data_dir('advanced_tag_cache'),
path_to_data_dir(''))
self.assertEqual(set(expected_tracks), tracks)
def test_unicode_cache(self):
tracks = parse_mpd_tag_cache(data_folder('utf8_tag_cache'),
data_folder(''))
tracks = parse_mpd_tag_cache(path_to_data_dir('utf8_tag_cache'),
path_to_data_dir(''))
uri = path_to_uri(data_folder('song1.mp3'))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
artists = [Artist(name=u'æøå')]
album = Album(name=u'æøå', artists=artists)
track = Track(uri=uri, name=u'æøå', artists=artists,
@ -132,14 +132,14 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
raise SkipTest
def test_cache_with_blank_track_info(self):
tracks = parse_mpd_tag_cache(data_folder('blank_tag_cache'),
data_folder(''))
uri = path_to_uri(data_folder('song1.mp3'))
tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'),
path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
self.assertEqual(set([Track(uri=uri, length=4000)]), tracks)
def test_musicbrainz_tagcache(self):
tracks = parse_mpd_tag_cache(data_folder('musicbrainz_tag_cache'),
data_folder(''))
tracks = parse_mpd_tag_cache(path_to_data_dir('musicbrainz_tag_cache'),
path_to_data_dir(''))
artist = list(expected_tracks[0].artists)[0].copy(
musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897')
albumartist = list(expected_tracks[0].artists)[0].copy(
@ -153,9 +153,9 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
self.assertEqual(track, list(tracks)[0])
def test_albumartist_tag_cache(self):
tracks = parse_mpd_tag_cache(data_folder('albumartist_tag_cache'),
data_folder(''))
uri = path_to_uri(data_folder('song1.mp3'))
tracks = parse_mpd_tag_cache(path_to_data_dir('albumartist_tag_cache'),
path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
artist = Artist(name='albumartistname')
album = expected_albums[0].copy(artists=[artist])
track = Track(name='trackname', artists=expected_artists, track_no=1,

View File

@ -1 +0,0 @@
../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../../../sample.mp3

View File

@ -1 +0,0 @@
../../../sample.mp3

View File

@ -1 +0,0 @@
../../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
../sample.mp3

Binary file not shown.

View File

@ -1 +0,0 @@
blank.flac

BIN
tests/data/song1.flac Normal file

Binary file not shown.

View File

@ -1 +0,0 @@
blank.mp3

Some files were not shown because too many files have changed in this diff Show More