diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..15d8f359 --- /dev/null +++ b/.mailmap @@ -0,0 +1,4 @@ +Kristian Klette +Johannes Knutsen +Johannes Knutsen +John Bäckstrand diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..a57f7474 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python + +install: + - "wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" + - "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" + - "sudo apt-get update" + - "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')" + +before_script: + - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" + +script: nosetests diff --git a/README.rst b/README.rst index 13ab0f92..0b0f6965 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,8 @@ Mopidy ****** +.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop + Mopidy is a music server which can play music from `Spotify `_ or from your local hard drive. To search for music in Spotify's vast archive, manage playlists, and play music, you can use most diff --git a/bin/mopidy b/bin/mopidy index aabf21d3..0472518e 100755 --- a/bin/mopidy +++ b/bin/mopidy @@ -1,5 +1,5 @@ #! /usr/bin/env python if __name__ == '__main__': - from mopidy.core import main + from mopidy.__main__ import main main() diff --git a/docs/api/audio.rst b/docs/api/audio.rst new file mode 100644 index 00000000..d5fb5dd9 --- /dev/null +++ b/docs/api/audio.rst @@ -0,0 +1,19 @@ +.. _audio-api: + +********* +Audio API +********* + +The audio API is the interface we have built around GStreamer to support our +specific use cases. Most backends should be able to get by with simply setting +the URI of the resource they want to play, for these cases the default playback +provider should be used. + +For more advanced cases such as when the raw audio data is delivered outside of +GStreamer or the backend needs to add metadata to the currently playing resource, +developers should sub-class the base playback provider and implement the extra +behaviour that is needed through the following API: + + +.. autoclass:: mopidy.audio.Audio + :members: diff --git a/docs/api/backends/providers.rst b/docs/api/backends.rst similarity index 56% rename from docs/api/backends/providers.rst rename to docs/api/backends.rst index 61e5f68a..781723d6 100644 --- a/docs/api/backends/providers.rst +++ b/docs/api/backends.rst @@ -1,12 +1,12 @@ -.. _backend-provider-api: +.. _backend-api: -******************** -Backend provider API -******************** +*********** +Backend API +*********** -The backend provider API is the interface that must be implemented when you -create a backend. If you are working on a frontend and need to access the -backend, see the :ref:`backend-controller-api`. +The backend API is the interface that must be implemented when you create a +backend. If you are working on a frontend and need to access the backend, see +the :ref:`core-api`. Playback provider @@ -30,8 +30,8 @@ Library provider :members: -Backend provider implementations -================================ +Backend implementations +======================= * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.spotify` diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst deleted file mode 100644 index 20dc2d61..00000000 --- a/docs/api/backends/controllers.rst +++ /dev/null @@ -1,60 +0,0 @@ -.. _backend-controller-api: - -********************** -Backend controller API -********************** - - -The backend controller API is the interface that is used by frontends like -:mod:`mopidy.frontends.mpd`. If you want to implement your own backend, see the -:ref:`backend-provider-api`. - - -The backend -=========== - -.. autoclass:: mopidy.backends.base.Backend - :members: - - -Playback controller -=================== - -Manages playback, with actions like play, pause, stop, next, previous, and -seek. - -.. autoclass:: mopidy.backends.base.PlaybackController - :members: - - -Mixer controller -================ - -Manages volume. See :class:`mopidy.mixers.base.BaseMixer`. - - -Current playlist controller -=========================== - -Manages everything related to the currently loaded playlist. - -.. autoclass:: mopidy.backends.base.CurrentPlaylistController - :members: - - -Stored playlists controller -=========================== - -Manages stored playlist. - -.. autoclass:: mopidy.backends.base.StoredPlaylistsController - :members: - - -Library controller -================== - -Manages the music library, e.g. searching for tracks to be added to a playlist. - -.. autoclass:: mopidy.backends.base.LibraryController - :members: diff --git a/docs/api/backends/concepts.rst b/docs/api/concepts.rst similarity index 87% rename from docs/api/backends/concepts.rst rename to docs/api/concepts.rst index 0d476213..ae959237 100644 --- a/docs/api/backends/concepts.rst +++ b/docs/api/concepts.rst @@ -1,4 +1,4 @@ -.. _backend-concepts: +.. _concepts: ********************************************** The backend, controller, and provider concepts @@ -12,11 +12,11 @@ Controllers: functionality. Most, but not all, controllers delegates some work to one or more providers. The controllers are responsible for choosing the right provider for any given task based upon i.e. the track's URI. See - :ref:`backend-controller-api` for more details. + :ref:`core-api` for more details. Providers: Anything specific to i.e. Spotify integration or local storage is contained in the providers. To integrate with new music sources, you just add new - providers. See :ref:`backend-provider-api` for more details. + providers. See :ref:`backend-api` for more details. .. digraph:: backend_relations @@ -27,4 +27,3 @@ Providers: "Playback\ncontroller" -> "Playback\nproviders" Backend -> "Stored\nplaylists\ncontroller" "Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" - Backend -> Mixer diff --git a/docs/api/core.rst b/docs/api/core.rst new file mode 100644 index 00000000..e74d9f45 --- /dev/null +++ b/docs/api/core.rst @@ -0,0 +1,50 @@ +.. _core-api: + +******** +Core API +******** + + +The core API is the interface that is used by frontends like +:mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the +backends. + + +Playback controller +=================== + +Manages playback, with actions like play, pause, stop, next, previous, +seek, and volume control. + +.. autoclass:: mopidy.core.PlaybackState + :members: + +.. autoclass:: mopidy.core.PlaybackController + :members: + + +Current playlist controller +=========================== + +Manages everything related to the currently loaded playlist. + +.. autoclass:: mopidy.core.CurrentPlaylistController + :members: + + +Stored playlists controller +=========================== + +Manages stored playlist. + +.. autoclass:: mopidy.core.StoredPlaylistsController + :members: + + +Library controller +================== + +Manages the music library, e.g. searching for tracks to be added to a playlist. + +.. autoclass:: mopidy.core.LibraryController + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 1f37e9ff..618096ee 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -5,7 +5,10 @@ API reference .. toctree:: :glob: - backends/concepts - backends/controllers - backends/providers - * + concepts + models + backends + core + audio + frontends + listeners diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst deleted file mode 100644 index 2459db8c..00000000 --- a/docs/api/mixers.rst +++ /dev/null @@ -1,43 +0,0 @@ -********* -Mixer API -********* - -Mixers are responsible for controlling volume. Clients of the mixers will -simply instantiate a mixer and read/write to the ``volume`` attribute:: - - >>> from mopidy.mixers.alsa import AlsaMixer - >>> mixer = AlsaMixer() - >>> mixer.volume - 100 - >>> mixer.volume = 80 - >>> mixer.volume - 80 - -Most users will use one of the internal mixers which controls the volume on the -computer running Mopidy. If you do not specify which mixer you want to use in -the settings, Mopidy will choose one for you based upon what OS you run. See -:attr:`mopidy.settings.MIXER` for the defaults. - -Mopidy also supports controlling volume on other hardware devices instead of on -the computer running Mopidy through the use of custom mixer implementations. To -enable one of the hardware device mixers, you must the set -:attr:`mopidy.settings.MIXER` setting to point to one of the classes found -below, and possibly add some extra settings required by the mixer you choose. - -All mixers should subclass :class:`mopidy.mixers.base.BaseMixer` and override -methods as described below. - -.. automodule:: mopidy.mixers.base - :synopsis: Mixer API - :members: - - -Mixer implementations -===================== - -* :mod:`mopidy.mixers.alsa` -* :mod:`mopidy.mixers.denon` -* :mod:`mopidy.mixers.dummy` -* :mod:`mopidy.mixers.gstreamer_software` -* :mod:`mopidy.mixers.osa` -* :mod:`mopidy.mixers.nad` diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst deleted file mode 100644 index 7f487881..00000000 --- a/docs/api/outputs.rst +++ /dev/null @@ -1,18 +0,0 @@ -.. _output-api: - -********** -Output API -********** - -Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way. - -.. autoclass:: mopidy.outputs.BaseOutput - :members: - - -Output implementations -====================== - -* :class:`mopidy.outputs.custom.CustomOutput` -* :class:`mopidy.outputs.local.LocalOutput` -* :class:`mopidy.outputs.shoutcast.ShoutcastOutput` diff --git a/docs/authors.rst b/docs/authors.rst index af84f842..822abc15 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -9,6 +9,9 @@ Contributors to Mopidy in the order of appearance: - Thomas Adamcik - Kristian Klette +A complete list of persons with commits accepted into the Mopidy repo can be +found at `GitHub `_. + Showing your appreciation ========================= @@ -17,13 +20,3 @@ If you already enjoy Mopidy, or don't enjoy it and want to help us making Mopidy better, the best way to do so is to contribute back to the community. You can contribute code, documentation, tests, bug reports, or help other users, spreading the word, etc. - -If you want to show your appreciation in a less time consuming way, you can -`flattr us `_, or `donate money -`_ to Mopidy's development. - -We promise that any money donated -- to Pledgie, not Flattr, due to the size of -the amounts -- will be used to cover costs related to Mopidy development, like -service subscriptions (Spotify, Last.fm, etc.) and hardware devices like an -used iPod Touch for testing Mopidy with MPod. - diff --git a/docs/changes.rst b/docs/changes.rst index a4aae058..bd90111e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,109 @@ Changes This change log is used to track all major changes to Mopidy. + +v0.8.0 (2012-09-20) +=================== + +This release does not include any major new features. We've done a major +cleanup of how audio outputs and audio mixers work, and on the way we've +resolved a bunch of related issues. + +**Audio output and mixer changes** + +- Removed multiple outputs support. Having this feature currently seems to be + more trouble than what it is worth. The :attr:`mopidy.settings.OUTPUTS` + setting is no longer supported, and has been replaced with + :attr:`mopidy.settings.OUTPUT` which is a GStreamer bin description string in + the same format as ``gst-launch`` expects. Default value is + ``autoaudiosink``. (Fixes: :issue:`81`, :issue:`115`, :issue:`121`, + :issue:`159`) + +- Switch to pure GStreamer based mixing. This implies that users setup a + GStreamer bin with a mixer in it in :attr:`mopidy.settings.MIXER`. The + default value is ``autoaudiomixer``, a custom mixer that attempts to find a + mixer that will work on your system. If this picks the wrong mixer you can of + course override it. Setting the mixer to :class:`None` is also supported. MPD + protocol support for volume has also been updated to return -1 when we have + no mixer set. ``software`` can be used to force software mixing. + +- Removed the Denon hardware mixer, as it is not maintained. + +- Updated the NAD hardware mixer to work in the new GStreamer based mixing + regime. Settings are now passed as GStreamer element properties. In practice + that means that the following old-style config:: + + MIXER = u'mopidy.mixers.nad.NadMixer' + MIXER_EXT_PORT = u'/dev/ttyUSB0' + MIXER_EXT_SOURCE = u'Aux' + MIXER_EXT_SPEAKERS_A = u'On' + MIXER_EXT_SPEAKERS_B = u'Off' + + Now is reduced to simply:: + + MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off' + + The ``port`` property defaults to ``/dev/ttyUSB0``, and the rest of the + properties may be left out if you don't want the mixer to adjust the settings + on your NAD amplifier when Mopidy is started. + +**Changes** + +- When unknown settings are encountered, we now check if it's similar to a + known setting, and suggests to the user what we think the setting should have + been. + +- Added :option:`--list-deps` option to the ``mopidy`` command that lists + required and optional dependencies, their current versions, and some other + information useful for debugging. (Fixes: :issue:`74`) + +- Added ``tools/debug-proxy.py`` to tee client requests to two backends and + diff responses. Intended as a developer tool for checking for MPD protocol + changes and various client support. Requires gevent, which currently is not a + dependency of Mopidy. + +- Support tracks with only release year, and not a full release date, like e.g. + Spotify tracks. + +- Default value of ``LOCAL_MUSIC_PATH`` has been updated to be + ``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of + local backend that relied on the old default ``~/music`` need to update their + settings. Note that the code responsible for finding this music now also + ignores UNIX hidden files and folders. + +- File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and + ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated + to use this instead of hidden away defaults. + +- Playback is now done using ``playbin2`` from GStreamer instead of rolling our + own. This is the first step towards resolving :issue:`171`. + +**Bug fixes** + +- :issue:`72`: Created a Spotify track proxy that will switch to using loaded + data as soon as it becomes available. + +- :issue:`150`: Fix bug which caused some clients to block Mopidy completely. + The bug was caused by some clients sending ``close`` and then shutting down + the connection right away. This trigged a situation in which the connection + cleanup code would wait for an response that would never come inside the + event loop, blocking everything else. + +- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a + track position. Track position and CPID was intermixed, so it would cause a + crash if a CPID matching the track position didn't exist. + +- Fixed crash on lookup of unknown path when using local backend. + +- :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has + been updated so all of the code now uses the correct value. + +- Fixed incorrect track URIs generated by M3U playlist parsing code. Generated + tracks are now relative to ``LOCAL_MUSIC_PATH``. + +- :issue:`203`: Re-add support for software mixing. + + v0.7.3 (2012-08-11) =================== @@ -543,9 +646,9 @@ to this problem. :class:`mopidy.models.Album`, and :class:`mopidy.models.Track`. - Prepare for multi-backend support (see :issue:`40`) by introducing the - :ref:`provider concept `. Split the backend API into a - :ref:`backend controller API ` (for frontend use) - and a :ref:`backend provider API ` (for backend + :ref:`provider concept `. Split the backend API into a + :ref:`backend controller API ` (for frontend use) + and a :ref:`backend provider API ` (for backend implementation use), which includes the following changes: - Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`. @@ -787,8 +890,8 @@ In the last two months, Mopidy's MPD frontend has gotten lots of stability fixes and error handling improvements, proper support for having the same track multiple times in a playlist, and support for IPv6. We have also fixed the choppy playback on the libspotify backend. For the road ahead of us, we got an -updated :doc:`release roadmap ` with our goals for the 0.1 -to 0.3 releases. +updated :doc:`release roadmap ` with our goals for the 0.1 to 0.3 +releases. Enjoy the best alpha relase of Mopidy ever :-) @@ -881,7 +984,7 @@ Since the previous release Mopidy has seen about 300 commits, more than 200 new tests, a libspotify release, and major feature additions to Spotify. The new releases from Spotify have lead to updates to our dependencies, and also to new bugs in Mopidy. Thus, this is primarily a bugfix release, even though the not -yet finished work on a Gstreamer backend have been merged. +yet finished work on a GStreamer backend have been merged. All users are recommended to upgrade to 0.1.0a1, and should at the same time ensure that they have the latest versions of our dependencies: Despotify r508 @@ -906,7 +1009,7 @@ As always, report problems at our IRC channel or our issue tracker. Thanks! - Several new generic features, like shuffle, consume, and playlist repeat. (Fixes: :issue:`3`) - **[Work in Progress]** A new backend for playing music from a local music - archive using the Gstreamer library. + archive using the GStreamer library. - Made :class:`mopidy.mixers.alsa.AlsaMixer` work on machines without a mixer named "Master". diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 4c789eba..c7dc3799 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -30,33 +30,13 @@ 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: +Search only works in two of the three search modes: - "Match if tag contains search phrase (regexes supported)" -- Does not work. The client tries to fetch all known metadata and do the search client side. - "Match if tag contains searched phrase (no regexes)" -- Works. - "Match only if both values are the same" -- Works. -If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp -from `Launchpad `_. - -Communication mode -^^^^^^^^^^^^^^^^^^ - -In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04, -ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy -did not support before Mopidy 0.6. To workaround this limitation in earlier -versions of Mopidy, edit the ncmpcpp configuration file at -``~/.ncmpcpp/config`` and add the following setting:: - - mpd_communication_mode = "polling" - -If you use Mopidy 0.6 or newer, you don't need to change anything. - Graphical clients ================= @@ -102,8 +82,8 @@ It generally works well with Mopidy. 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: +We've tested all four MPD clients we could find for Android with Mopidy 0.7.3 on +a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure: #. Connect to Mopidy #. Search for ``foo``, with search type "any" if it can be selected @@ -127,157 +107,180 @@ HTC Hero with Android 2.1, using the following test procedure: #. 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: +We found that all four apps crashed on Android 4.1.1. -- 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. +Combining what we managed to find before the apps crashed with our experience +from an older version of this review, using Android 2.1, we can say that: -Our recommendation: +- PMix can be ignored, because it is unmaintained and its fork MPDroid is + better on all fronts. -- 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. +- Droid MPD Client was to buggy to get an impression from. Unclear if the bugs + are due to the app or that it hasn't been updated for Android 4.x. + +- BitMPC is in our experience feature complete, but ugly. + +- MPDroid, now that search is in place, is probably feature complete as well, + and looks nicer than BitMPC. + +In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try +anyway, try BitMPC and MPDroid. BitMPC ------ -We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings, -3.5 stars. +Test date: + 2012-09-12 +Tested version: + 1.0.0 (released 2010-04-12) +Downloads: + 5,000+ +Rating: + 3.7 stars from about 100 ratings -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. +- The user interface lacks some finishing touches. E.g. you can't enter a + hostname for the server. Only IPv4 addresses are allowed. + +- When we last tested the same version of BitMPC using Android 2.1: + + - All features exercised in the test procedure worked. + + - BitMPC lacked support for single mode and consume mode. + + - BitMPC crashed if Mopidy was killed or crashed. + +- When we tried to test using Android 4.1.1, BitMPC started and connected to + Mopidy without problems, but the app crashed as soon as fire off our search, + and continued to crash on startup after that. + +In conclusion, BitMPC is usable if you got an older Android phone and don't +care about looks. For newer Android versions, BitMPC will probably not work as +it hasn't been maintained for 2.5 years. Droid MPD Client ---------------- -We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings, -4 stars. +Test date: + 2012-09-12 +Tested version: + 1.4.0 (released 2011-12-20) +Downloads: + 10,000+ +Rating: + 4.2 stars from 400+ ratings -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". +- No intutive way to ask the app to connect to the server after adding the + server hostname to the settings. -The user interface have some French remnants, like "Rechercher" in the search -field. +- 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". -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 tabs "Artists" and "Albums" did not contain anything, and did not cause + any requests. -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 ;-) +- The tab "Folders" showed a spinner and said "Updating data..." but did not + send any requests. -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. +- Searching for "foo" did nothing. No request was sent to the server. -At one point, I had problems turning off repeat mode. After I adjusted the -volume and tried again, it worked. +- Once, I managed to get a list of stored playlists in the "Search" tab, but I + never managed to reproduce this. Opening the stored playlists doesn't work, + because Mopidy haven't implemented ``lsinfo "Playlist name"`` (see + :issue:`193`). -Droid MPD client does not support single mode or consume mode. 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. +- Droid MPD client does not support single mode or consume mode. -In conclusion, some bugs and caveats, but most of the test procedure was -possible to perform. +- Not able to complete the test procedure, due to the above problems. - -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! +In conclusion, not a client we can recommend. MPDroid ------- -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. +Test date: + 2012-09-12 +Tested version: + 0.7 (released 2011-06-19) +Downloads: + 10,000+ +Rating: + 4.5 stars from ~500 ratings -First of all, MPDroid's user interface looks nice. +- MPDroid started out as a fork of PMix. -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. +- First of all, MPDroid's user interface looks nice. -All in all, MPDroid is a good MPD client without search support. +- Last time we tested MPDroid (v0.6.9), we couldn't find any search + functionality. Now we found it, and it worked. + +- Last time we tested MPDroid (v0.6.9) everything in the test procedure worked + out flawlessly. + +- Like all other Android clients, MPDroid does not support single mode or + consume mode. + +- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to + try to reconnect. + +- When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an + empty current playlist and pressing play. + +Disregarding Android 4.x problems, MPDroid is a good MPD client. PMix ---- -We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings, -4 stars. +Test date: + 2012-09-12 +Tested version: + 0.4.0 (released 2010-03-06) +Downloads: + 10,000+ +Rating: + 3.8 stars from >200 ratings -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. +- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes + as soon as it connects to Mopidy. + +- Last time we tested the same version of PMix using Android 2.1, we found + that: + + - PMix does not support search. + + - I could not find stored playlists. + + - Other than that, I was able to complete the test procedure. + + - PMix crashed once during testing. + + - PMix handled the killing of Mopidy just as nicely as MPDroid. + + - It does not support single mode or consume mode. All in all, PMix works but can do less than MPDroid. Use MPDroid instead. -ThreeMPD --------- - -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 -`_ 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. - +iOS clients +=========== MPod ---- -The `MPoD `_ client can be -installed from the `iTunes Store +Test date: + 2011-01-19 +Tested version: + 1.5.1 + +The `MPoD `_ iPhone/iPod Touch +app can be installed from the `iTunes Store `_. Users have reported varying success in using MPoD together with Mopidy. Thus, @@ -321,3 +324,10 @@ we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d - **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`. + + +MPaD +---- + +The `MPaD `_ iPad app works +with Mopidy. A complete review may appear here in the future. diff --git a/docs/conf.py b/docs/conf.py index a33a8f2d..8129adec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,17 +22,19 @@ class Mock(object): def __call__(self, *args, **kwargs): return Mock() + def __or__(self, other): + return Mock() + @classmethod def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' - elif name[0] == name[0].upper(): + elif name[0] == name[0].upper() and not name.startswith('MIXER_TRACK'): return type(name, (), {}) else: return Mock() MOCK_MODULES = [ - 'alsaaudio', 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', @@ -51,11 +53,6 @@ MOCK_MODULES = [ for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() -def get_version(): - init_py = open('../mopidy/__init__.py').read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) - return metadata['version'] - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -94,6 +91,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors' # built documents. # # The full version, including alpha/beta/rc tags. +from mopidy import get_version release = get_version() # The short X.Y version. version = '.'.join(release.split('.')[:2]) diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 00000000..49d8add5 --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,278 @@ +*********** +Development +*********** + +Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at +``irc.freenode.net`` and through `GitHub `_. + + +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. + + +Feature wishlist +================ + +We maintain our collection of sane or less sane ideas for future Mopidy +features as `issues `_ at GitHub +labeled with `the "wishlist" label +`_. 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. + + +Code style +========== + +- Follow :pep:`8` unless otherwise noted. `pep8.py + `_ can be used to check your code against + the guidelines, however remember that matching the style of the surrounding + code is also important. + +- Use four spaces for indentation, *never* tabs. + +- Use CamelCase with initial caps for class names:: + + ClassNameWithCamelCase + +- Use underscore to split variable, function and method names for + readability. Don't use CamelCase. + + :: + + lower_case_with_underscores + +- Use the fact that empty strings, lists and tuples are :class:`False` and + don't compare boolean values using ``==`` and ``!=``. + +- Follow whitespace rules as described in :pep:`8`. Good examples:: + + spam(ham[1], {eggs: 2}) + spam(1) + dict['key'] = list[index] + +- Limit lines to 80 characters and avoid trailing whitespace. However note that + wrapped lines should be *one* indentation level in from level above, except + for ``if``, ``for``, ``with``, and ``while`` lines which should have two + levels of indentation:: + + if (foo and bar ... + baz and foobar): + a = 1 + + from foobar import (foo, bar, ... + baz) + +- For consistency, prefer ``'`` over ``"`` for strings, unless the string + contains ``'``. + +- Take a look at :pep:`20` for a nice peek into a general mindset useful for + Python coding. + + +Commit guidelines +================= + +- We follow the development process described at http://nvie.com/git-model. + +- Keep commits small and on topic. + +- If a commit looks too big you should be working in a feature branch not a + single commit. + +- Merge feature branches with ``--no-ff`` to keep track of the merge. + + +Running tests +============= + +To run tests, you need a couple of dependencies. They can be installed through +Debian/Ubuntu package management:: + + sudo apt-get install python-coverage python-mock python-nose + +Or, they can be installed using ``pip``:: + + sudo pip install -r requirements/tests.txt + +Then, to run all tests, go to the project directory and run:: + + nosetests + +For example:: + + $ nosetests + ...................................................................... + ...................................................................... + ...................................................................... + ....... + ---------------------------------------------------------------------- + Ran 217 tests in 0.267s + + OK + +To run tests with test coverage statistics:: + + nosetests --with-coverage + +For more documentation on testing, check out the `nose documentation +`_. + + +Continuous integration +====================== + +Mopidy uses the free service `Travis CI `_ +for automatically running the test suite when code is pushed to GitHub. This +works both for the main Mopidy repo, but also for any forks. This way, any +contributions to Mopidy through GitHub will automatically be tested by Travis +CI, and the build status will be visible in the GitHub pull request interface, +making it easier to evaluate the quality of pull requests. + +In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all +test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to +the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't +tested by Jenkins before it is merged into the ``develop`` branch, which is a +bit late, but good enough to get broad testing before new code is released. + +In addition to running tests, the Jenkins 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. + + +Protocol debugging +================== + +Since the main interface provided to Mopidy is through the MPD protocol, it is +crucial that we try and stay in sync with protocol developments. In an attempt +to make it easier to debug differences Mopidy and MPD protocol handling we have +created ``tools/debug-proxy.py``. + +This tool is proxy that sits in front of two MPD protocol aware servers and +sends all requests to both, returning the primary response to the client and +then printing any diff in the two responses. + +Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time +of writing. See ``--help`` for available options. Sample session:: + + [127.0.0.1]:59714 + listallinfo + --- Reference response + +++ Actual response + @@ -1,16 +1,1 @@ + -file: uri1 + -Time: 4 + -Artist: artist1 + -Title: track1 + -Album: album1 + -file: uri2 + -Time: 4 + -Artist: artist2 + -Title: track2 + -Album: album2 + -file: uri3 + -Time: 4 + -Artist: artist3 + -Title: track3 + -Album: album3 + -OK + +ACK [2@0] {listallinfo} incorrect arguments + +To ensure that Mopidy and MPD have comparable state it is suggested you setup +both to use ``tests/data/advanced_tag_cache`` for their tag cache and +``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for +playlists. + + +Writing documentation +===================== + +To write documentation, we use `Sphinx `_. See their +site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX +from the documentation files, you need some additional dependencies. + +You can install them through Debian/Ubuntu package management:: + + sudo apt-get install python-sphinx python-pygraphviz graphviz + +Then, to generate docs:: + + cd docs/ + make # For help on available targets + make html # To generate HTML docs + +The documentation at http://docs.mopidy.com/ is automatically updated when a +documentation update is pushed to ``mopidy/mopidy`` at GitHub. + + +Creating releases +================= + +#. Update changelog and commit it. + +#. Merge the release branch (``develop`` in the example) into master:: + + git checkout master + git merge --no-ff -m "Release v0.2.0" develop + +#. Tag the release:: + + git tag -a -m "Release v0.2.0" v0.2.0 + +#. Push to GitHub:: + + git push + git push --tags + +#. Build package and upload to PyPI:: + + rm MANIFEST # Will be regenerated by setup.py + python setup.py sdist upload + +#. Spread the word. + + +Setting profiles during development +=================================== + +While developing Mopidy switching settings back and forth can become an all too +frequent occurrence. As a quick hack to get around this you can structure your +settings file in the following way:: + + import os + profile = os.environ.get('PROFILE', '').split(',') + + if 'spotify' in profile: + BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) + elif 'local' in profile: + BACKENDS = (u'mopidy.backends.local.LocalBackend',) + LOCAL_MUSIC_PATH = u'~/music' + + if 'shoutcast' in profile: + OUTPUT = u'lame ! shout2send mount="/stream"' + elif 'silent' in profile: + OUTPUT = u'fakesink' + MIXER = None + + SPOTIFY_USERNAME = u'xxxxx' + SPOTIFY_PASSWORD = u'xxxxx' + +Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +if you for instance want to test Spotify without any actual audio output. diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst deleted file mode 100644 index 782d2f20..00000000 --- a/docs/development/contributing.rst +++ /dev/null @@ -1,165 +0,0 @@ -***************** -How to contribute -***************** - -Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at -``irc.freenode.net`` and through `GitHub `_. - - -Code style -========== - -- Follow :pep:`8` unless otherwise noted. `pep8.py - `_ can be used to check your code against - the guidelines, however remember that matching the style of the surrounding - code is also important. - -- Use four spaces for indentation, *never* tabs. - -- Use CamelCase with initial caps for class names:: - - ClassNameWithCamelCase - -- Use underscore to split variable, function and method names for - readability. Don't use CamelCase. - - :: - - lower_case_with_underscores - -- Use the fact that empty strings, lists and tuples are :class:`False` and - don't compare boolean values using ``==`` and ``!=``. - -- Follow whitespace rules as described in :pep:`8`. Good examples:: - - spam(ham[1], {eggs: 2}) - spam(1) - dict['key'] = list[index] - -- Limit lines to 80 characters and avoid trailing whitespace. However note that - wrapped lines should be *one* indentation level in from level above, except - for ``if``, ``for``, ``with``, and ``while`` lines which should have two - levels of indentation:: - - if (foo and bar ... - baz and foobar): - a = 1 - - from foobar import (foo, bar, ... - baz) - -- For consistency, prefer ``'`` over ``"`` for strings, unless the string - contains ``'``. - -- Take a look at :pep:`20` for a nice peek into a general mindset useful for - Python coding. - - -Commit guidelines -================= - -- We follow the development process described at http://nvie.com/git-model. - -- Keep commits small and on topic. - -- If a commit looks too big you should be working in a feature branch not a - single commit. - -- Merge feature branches with ``--no-ff`` to keep track of the merge. - - -Running tests -============= - -To run tests, you need a couple of dependencies. They can be installed through -Debian/Ubuntu package management:: - - sudo apt-get install python-coverage python-mock python-nose - -Or, they can be installed using ``pip``:: - - sudo pip install -r requirements/tests.txt - -Then, to run all tests, go to the project directory and run:: - - nosetests - -For example:: - - $ nosetests - ...................................................................... - ...................................................................... - ...................................................................... - ....... - ---------------------------------------------------------------------- - Ran 217 tests in 0.267s - - OK - -To run tests with test coverage statistics:: - - nosetests --with-coverage - -For more documentation on testing, check out the `nose documentation -`_. - - -Continuous integration server -============================= - -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, 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 -===================== - -To write documentation, we use `Sphinx `_. See their -site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX -from the documentation files, you need some additional dependencies. - -You can install them through Debian/Ubuntu package management:: - - sudo apt-get install python-sphinx python-pygraphviz graphviz - -Then, to generate docs:: - - cd docs/ - make # For help on available targets - make html # To generate HTML docs - -The documentation at http://docs.mopidy.com/ is automatically updated when a -documentation update is pushed to ``mopidy/mopidy`` at GitHub. - - -Creating releases -================= - -#. Update changelog and commit it. - -#. Merge the release branch (``develop`` in the example) into master:: - - git checkout master - git merge --no-ff -m "Release v0.2.0" develop - -#. Tag the release:: - - git tag -a -m "Release v0.2.0" v0.2.0 - -#. Push to GitHub:: - - git push - git push --tags - -#. Build package and upload to PyPI:: - - rm MANIFEST # Will be regenerated by setup.py - python setup.py sdist upload - -#. Spread the word. diff --git a/docs/development/index.rst b/docs/development/index.rst deleted file mode 100644 index 321b3242..00000000 --- a/docs/development/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -*********** -Development -*********** - -.. toctree:: - :maxdepth: 3 - - roadmap - contributing diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst deleted file mode 100644 index 6280762c..00000000 --- a/docs/development/roadmap.rst +++ /dev/null @@ -1,34 +0,0 @@ -******* -Roadmap -******* - - -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. - - -Feature wishlist -================ - -We maintain our collection of sane or less sane ideas for future Mopidy -features as `issues `_ at GitHub -labeled with `the "wishlist" label -`_. 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. diff --git a/docs/index.rst b/docs/index.rst index 7e757de0..0af510d0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,7 +54,7 @@ Development documentation .. toctree:: :maxdepth: 3 - development/index + development Indices and tables ================== diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index d0dc0461..42685ad0 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -2,7 +2,7 @@ GStreamer installation ********************** -To use the Mopidy, you first need to install GStreamer and the GStreamer Python +To use Mopidy, you first need to install GStreamer and the GStreamer Python bindings. @@ -54,15 +54,8 @@ Python bindings on OS X using Homebrew. #. Install `Homebrew `_. -#. Download our Homebrew formulas for ``pycairo``, ``pygobject``, ``pygtk``, - and ``gst-python``:: +#. Download our Homebrew formula for ``gst-python``:: - curl -o $(brew --prefix)/Library/Formula/pycairo.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pycairo.rb - curl -o $(brew --prefix)/Library/Formula/pygobject.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygobject.rb - curl -o $(brew --prefix)/Library/Formula/pygtk.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygtk.rb curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb @@ -77,13 +70,13 @@ Python bindings on OS X using Homebrew. You can either amend your ``PYTHONPATH`` permanently, by adding the following statement to your shell's init file, e.g. ``~/.bashrc``:: - export PYTHONPATH=$(brew --prefix)/lib/python2.6/site-packages:$PYTHONPATH + export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH Or, you can prefix the Mopidy command every time you run it:: - PYTHONPATH=$(brew --prefix)/lib/python2.6/site-packages mopidy + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - Note that you need to replace ``python2.6`` with ``python2.7`` if that's + Note that you need to replace ``python2.7`` with ``python2.6`` if that's the Python version you are using. To find your Python version, run:: python --version @@ -112,12 +105,9 @@ Using a custom audio sink ========================= If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can add ``mopidy.outputs.custom.CustomOutput`` to the -:attr:`mopidy.settings.OUTPUTS` setting, and set the -:attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline -description describing the GStreamer sink you want to use. +``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUT` to a partial +GStreamer pipeline description describing the GStreamer sink you want to use. Example of ``settings.py`` for OSS4:: - OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',) - CUSTOM_OUTPUT = u'oss4sink' + OUTPUT = u'oss4sink' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index fae50a1b..66b920f8 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -46,9 +46,6 @@ dependencies installed. sudo apt-get install python-dbus python-indicate - - Some custom mixers (but not the default one) require additional - dependencies. See the docs for each mixer. - Install latest stable release ============================= @@ -176,7 +173,7 @@ If you want to contribute to Mopidy, you should install Mopidy using Git. For an introduction to ``git``, please visit `git-scm.com `_. Also, please read our :doc:`developer documentation -`. +`. From AUR on ArchLinux diff --git a/docs/modules/gstreamer.rst b/docs/modules/gstreamer.rst deleted file mode 100644 index 205b0a3e..00000000 --- a/docs/modules/gstreamer.rst +++ /dev/null @@ -1,7 +0,0 @@ -******************************************** -:mod:`mopidy.gstreamer` -- GStreamer adapter -******************************************** - -.. automodule:: mopidy.gstreamer - :synopsis: GStreamer adapter - :members: diff --git a/docs/modules/mixers/alsa.rst b/docs/modules/mixers/alsa.rst deleted file mode 100644 index e8b7ed6c..00000000 --- a/docs/modules/mixers/alsa.rst +++ /dev/null @@ -1,7 +0,0 @@ -************************************************* -:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux -************************************************* - -.. automodule:: mopidy.mixers.alsa - :synopsis: ALSA mixer for Linux - :members: diff --git a/docs/modules/mixers/denon.rst b/docs/modules/mixers/denon.rst deleted file mode 100644 index 7fb2d6cc..00000000 --- a/docs/modules/mixers/denon.rst +++ /dev/null @@ -1,7 +0,0 @@ -***************************************************************** -:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers -***************************************************************** - -.. automodule:: mopidy.mixers.denon - :synopsis: Hardware mixer for Denon amplifiers - :members: diff --git a/docs/modules/mixers/dummy.rst b/docs/modules/mixers/dummy.rst deleted file mode 100644 index 8ac18e10..00000000 --- a/docs/modules/mixers/dummy.rst +++ /dev/null @@ -1,7 +0,0 @@ -***************************************************** -:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing -***************************************************** - -.. automodule:: mopidy.mixers.dummy - :synopsis: Dummy mixer for testing - :members: diff --git a/docs/modules/mixers/gstreamer_software.rst b/docs/modules/mixers/gstreamer_software.rst deleted file mode 100644 index 98e09f44..00000000 --- a/docs/modules/mixers/gstreamer_software.rst +++ /dev/null @@ -1,7 +0,0 @@ -*************************************************************************** -:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms -*************************************************************************** - -.. automodule:: mopidy.mixers.gstreamer_software - :synopsis: Software mixer for all platforms - :members: diff --git a/docs/modules/mixers/nad.rst b/docs/modules/mixers/nad.rst deleted file mode 100644 index 56291cbb..00000000 --- a/docs/modules/mixers/nad.rst +++ /dev/null @@ -1,7 +0,0 @@ -************************************************************* -:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers -************************************************************* - -.. automodule:: mopidy.mixers.nad - :synopsis: Hardware mixer for NAD amplifiers - :members: diff --git a/docs/modules/mixers/osa.rst b/docs/modules/mixers/osa.rst deleted file mode 100644 index a4363cb4..00000000 --- a/docs/modules/mixers/osa.rst +++ /dev/null @@ -1,7 +0,0 @@ -********************************************** -:mod:`mopidy.mixers.osa` -- Osa mixer for OS X -********************************************** - -.. automodule:: mopidy.mixers.osa - :synopsis: Osa mixer for OS X - :members: diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst deleted file mode 100644 index f80c16e3..00000000 --- a/docs/modules/outputs.rst +++ /dev/null @@ -1,11 +0,0 @@ -************************************************ -:mod:`mopidy.outputs` -- GStreamer audio outputs -************************************************ - -The following GStreamer audio outputs implements the :ref:`output-api`. - -.. autoclass:: mopidy.outputs.custom.CustomOutput - -.. autoclass:: mopidy.outputs.local.LocalOutput - -.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput diff --git a/docs/settings.rst b/docs/settings.rst index a6ad3693..a79dfd78 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -56,7 +56,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See Currently, Mopidy supports using Spotify *or* local storage as a music source. We're working on using both sources simultaneously, and will - hopefully have support for this in the 0.6 release. + have support for this in a future release. .. _generating_a_tag_cache: @@ -157,18 +157,18 @@ server simultaneously. To use the SHOUTcast output, do the following: #. Install, configure and start the Icecast server. It can be found in the ``icecast2`` package in Debian/Ubuntu. -#. Add ``mopidy.outputs.shoutcast.ShoutcastOutput`` output to the - :attr:`mopidy.settings.OUTPUTS` setting. +#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send``. An Ogg Vorbis + encoder could be used instead of the lame MP3 encoder. -#. Check the default values for the following settings, and alter them to match - your Icecast setup if needed: +#. You might also need to change the ``shout2send`` default settings, run + ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely + you want to change ``ip``, ``username``, ``password``, and ``mount``. For + example, to set the username and password, use: + ``lame ! shout2send username="foobar" password="s3cret"``. - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` +Other advanced setups are also possible for outputs. Basically, anything you +can use with the ``gst-launch-0.10`` command can be plugged into +:attr:`mopidy.settings.OUTPUT`. Available settings diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 11293446..26e5b904 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -8,7 +8,7 @@ from subprocess import PIPE, Popen import glib -__version__ = '0.7.3' +__version__ = '0.8.0' DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 169c2754..35518874 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,10 +1,152 @@ +import logging +import optparse +import os +import signal +import sys + +import gobject +gobject.threads_init() + + +# Extract any non-GStreamer arguments, and leave the GStreamer arguments for +# processing by GStreamer. This needs to be done before GStreamer is imported, +# so that GStreamer doesn't hijack e.g. ``--help``. +# NOTE This naive fix does not support values like ``bar`` in +# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. + +def is_gst_arg(argument): + return argument.startswith('--gst') or argument == '--help-gst' + +gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] +mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] +sys.argv[1:] = gstreamer_args + + # 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 import (get_version, settings, OptionalDependencyError, + SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) +from mopidy.audio import Audio +from mopidy.utils import get_class +from mopidy.utils.deps import list_deps_optparse_callback +from mopidy.utils.log import setup_logging +from mopidy.utils.path import get_or_create_folder, get_or_create_file +from mopidy.utils.process import (exit_handler, stop_remaining_actors, + stop_actors_by_class) +from mopidy.utils.settings import list_settings_optparse_callback + + +logger = logging.getLogger('mopidy.main') + + +def main(): + signal.signal(signal.SIGTERM, exit_handler) + loop = gobject.MainLoop() + options = parse_options() + try: + setup_logging(options.verbosity_level, options.save_debug_log) + check_old_folders() + setup_settings(options.interactive) + setup_audio() + setup_backend() + setup_frontends() + loop.run() + except SettingsError as e: + logger.error(e.message) + except KeyboardInterrupt: + logger.info(u'Interrupted. Exiting...') + except Exception as e: + logger.exception(e) + finally: + loop.quit() + stop_frontends() + stop_backend() + stop_audio() + stop_remaining_actors() + + +def parse_options(): + parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) + parser.add_option('--help-gst', + action='store_true', dest='help_gst', + help='show GStreamer help options') + parser.add_option('-i', '--interactive', + action='store_true', dest='interactive', + help='ask interactively for required settings which are missing') + parser.add_option('-q', '--quiet', + action='store_const', const=0, dest='verbosity_level', + help='less output (warning level)') + parser.add_option('-v', '--verbose', + action='count', default=1, 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') + parser.add_option('--list-deps', + action='callback', callback=list_deps_optparse_callback, + help='list dependencies and their versions') + return parser.parse_args(args=mopidy_args)[0] + + +def check_old_folders(): + old_settings_folder = os.path.expanduser(u'~/.mopidy') + + if not os.path.isdir(old_settings_folder): + return + + logger.warning(u'Old settings folder found at %s, settings.py should be ' + 'moved to %s, any cache data should be deleted. See release notes ' + 'for further instructions.', old_settings_folder, SETTINGS_PATH) + + +def setup_settings(interactive): + get_or_create_folder(SETTINGS_PATH) + get_or_create_folder(DATA_PATH) + get_or_create_file(SETTINGS_FILE) + try: + settings.validate(interactive) + except SettingsError, e: + logger.error(e.message) + sys.exit(1) + + +def setup_audio(): + Audio.start() + + +def stop_audio(): + stop_actors_by_class(Audio) + +def setup_backend(): + get_class(settings.BACKENDS[0]).start() + + +def stop_backend(): + stop_actors_by_class(get_class(settings.BACKENDS[0])) + + +def setup_frontends(): + for frontend_class_name in settings.FRONTENDS: + try: + get_class(frontend_class_name).start() + except OptionalDependencyError as e: + logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + + +def stop_frontends(): + for frontend_class_name in settings.FRONTENDS: + try: + stop_actors_by_class(get_class(frontend_class_name)) + except OptionalDependencyError: + pass + + if __name__ == '__main__': - from mopidy.core import main main() diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py new file mode 100644 index 00000000..df5efb92 --- /dev/null +++ b/mopidy/audio/__init__.py @@ -0,0 +1,392 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + +import logging + +from pykka.actor import ThreadingActor +from pykka.registry import ActorRegistry + +from mopidy import settings, utils +from mopidy.backends.base import Backend +from mopidy.utils import process + +# Trigger install of gst mixer plugins +from mopidy.audio import mixers + +logger = logging.getLogger('mopidy.audio') + + +class Audio(ThreadingActor): + """ + Audio output through `GStreamer `_. + + **Settings:** + + - :attr:`mopidy.settings.OUTPUT` + - :attr:`mopidy.settings.MIXER` + - :attr:`mopidy.settings.MIXER_TRACK` + + """ + + def __init__(self): + super(Audio, self).__init__() + + self._default_caps = gst.Caps(""" + audio/x-raw-int, + endianness=(int)1234, + channels=(int)2, + width=(int)16, + depth=(int)16, + signed=(boolean)true, + rate=(int)44100""") + + self._playbin = None + self._mixer = None + self._mixer_track = None + self._software_mixing = False + + self._message_processor_set_up = False + + def on_start(self): + try: + self._setup_playbin() + self._setup_output() + self._setup_mixer() + self._setup_message_processor() + except gobject.GError as ex: + logger.exception(ex) + process.exit_process() + + def on_stop(self): + self._teardown_message_processor() + self._teardown_mixer() + self._teardown_playbin() + + def _setup_playbin(self): + self._playbin = gst.element_factory_make('playbin2') + + fakesink = gst.element_factory_make('fakesink') + self._playbin.set_property('video-sink', fakesink) + + def _teardown_playbin(self): + self._playbin.set_state(gst.STATE_NULL) + + def _setup_output(self): + try: + output = gst.parse_bin_from_description( + settings.OUTPUT, ghost_unconnected_pads=True) + self._playbin.set_property('audio-sink', output) + logger.info('Output set to %s', settings.OUTPUT) + except gobject.GError as ex: + logger.error('Failed to create output "%s": %s', + settings.OUTPUT, ex) + process.exit_process() + + def _setup_mixer(self): + if not settings.MIXER: + logger.info('Not setting up mixer.') + return + + if settings.MIXER == 'software': + self._software_mixing = True + logger.info('Mixer set to software mixing.') + return + + try: + mixerbin = gst.parse_bin_from_description(settings.MIXER, + ghost_unconnected_pads=False) + except gobject.GError as ex: + logger.warning('Failed to create mixer "%s": %s', + settings.MIXER, ex) + return + + # We assume that the bin will contain a single mixer. + mixer = mixerbin.get_by_interface('GstMixer') + if not mixer: + logger.warning('Did not find any mixers in %r', settings.MIXER) + return + + if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + logger.warning('Setting mixer %r to READY failed.', settings.MIXER) + return + + track = self._select_mixer_track(mixer, settings.MIXER_TRACK) + if not track: + logger.warning('Could not find usable mixer track.') + return + + self._mixer = mixer + self._mixer_track = track + logger.info('Mixer set to %s using track called %s', + mixer.get_factory().get_name(), track.label) + + def _select_mixer_track(self, mixer, track_label): + # Look for track with label == MIXER_TRACK, otherwise fallback to + # master track which is also an output. + for track in mixer.list_tracks(): + if track_label: + if track.label == track_label: + return track + elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT): + return track + + def _teardown_mixer(self): + if self._mixer is not None: + self._mixer.set_state(gst.STATE_NULL) + + def _setup_message_processor(self): + bus = self._playbin.get_bus() + bus.add_signal_watch() + bus.connect('message', self._on_message) + self._message_processor_set_up = True + + def _teardown_message_processor(self): + if self._message_processor_set_up: + bus = self._playbin.get_bus() + bus.remove_signal_watch() + + def _on_message(self, bus, message): + if message.type == gst.MESSAGE_EOS: + self._notify_backend_of_eos() + elif message.type == gst.MESSAGE_ERROR: + error, debug = message.parse_error() + logger.error(u'%s %s', error, debug) + self.stop_playback() + elif message.type == gst.MESSAGE_WARNING: + error, debug = message.parse_warning() + logger.warning(u'%s %s', error, debug) + + def _notify_backend_of_eos(self): + backend_refs = ActorRegistry.get_by_class(Backend) + assert len(backend_refs) <= 1, 'Expected at most one running backend.' + if backend_refs: + logger.debug(u'Notifying backend of end-of-stream.') + backend_refs[0].proxy().playback.on_end_of_track() + else: + logger.debug(u'No backend to notify of end-of-stream found.') + + def set_uri(self, uri): + """ + Set URI of audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param uri: the URI to play + :type uri: string + """ + self._playbin.set_property('uri', uri) + + def emit_data(self, capabilities, data): + """ + Call this to deliver raw audio data to be played. + + Note that the uri must be set to ``appsrc://`` for this to work. + + :param capabilities: a GStreamer capabilities string + :type capabilities: string + :param data: raw audio data to be played + """ + caps = gst.caps_from_string(capabilities) + buffer_ = gst.Buffer(buffer(data)) + buffer_.set_caps(caps) + + source = self._playbin.get_property('source') + source.set_property('caps', caps) + source.emit('push-buffer', buffer_) + + def emit_end_of_stream(self): + """ + Put an end-of-stream token on the playbin. This is typically used in + combination with :meth:`emit_data`. + + We will get a GStreamer message when the stream playback reaches the + token, and can then do any end-of-stream related tasks. + """ + self._playbin.get_property('source').emit('end-of-stream') + + def get_position(self): + """ + Get position in milliseconds. + + :rtype: int + """ + if self._playbin.get_state()[1] == gst.STATE_NULL: + return 0 + try: + position = self._playbin.query_position(gst.FORMAT_TIME)[0] + return position // gst.MSECOND + except gst.QueryError, e: + logger.error('time_position failed: %s', e) + return 0 + + def set_position(self, position): + """ + Set position in milliseconds. + + :param position: the position in milliseconds + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple(gst.Format(gst.FORMAT_TIME), + gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) + self._playbin.get_state() # block until seek is done + return handeled + + def start_playback(self): + """ + Notify GStreamer that it should start playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PLAYING) + + def pause_playback(self): + """ + Notify GStreamer that it should pause playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PAUSED) + + def prepare_change(self): + """ + Notify GStreamer that we are about to change state of playback. + + This function *MUST* be called before changing URIs or doing + changes like updating data that is being pushed. The reason for this + is that GStreamer will reset all its state when it changes to + :attr:`gst.STATE_READY`. + """ + return self._set_state(gst.STATE_READY) + + def stop_playback(self): + """ + Notify GStreamer that is should stop playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_NULL) + + def _set_state(self, state): + """ + Internal method for setting the raw GStreamer state. + + .. digraph:: gst_state_transitions + + graph [rankdir="LR"]; + node [fontsize=10]; + + "NULL" -> "READY" + "PAUSED" -> "PLAYING" + "PAUSED" -> "READY" + "PLAYING" -> "PAUSED" + "READY" -> "NULL" + "READY" -> "PAUSED" + + :param state: State to set playbin to. One of: `gst.STATE_NULL`, + `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. + :type state: :class:`gst.State` + :rtype: :class:`True` if successfull, else :class:`False` + """ + result = self._playbin.set_state(state) + if result == gst.STATE_CHANGE_FAILURE: + logger.warning('Setting GStreamer state to %s: failed', + state.value_name) + return False + elif result == gst.STATE_CHANGE_ASYNC: + logger.debug('Setting GStreamer state to %s: async', + state.value_name) + return True + else: + logger.debug('Setting GStreamer state to %s: OK', + state.value_name) + return True + + def get_volume(self): + """ + Get volume level of the installed mixer. + + Example values: + + 0: + Muted. + 100: + Max volume for given system. + :class:`None`: + No mixer present, so the volume is unknown. + + :rtype: int in range [0..100] or :class:`None` + """ + if self._software_mixing: + return round(self._playbin.get_property('volume') * 100) + + if self._mixer is None: + return None + + volumes = self._mixer.get_volume(self._mixer_track) + avg_volume = float(sum(volumes)) / len(volumes) + + new_scale = (0, 100) + old_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) + return utils.rescale(avg_volume, old=old_scale, new=new_scale) + + def set_volume(self, volume): + """ + Set volume level of the installed mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if self._software_mixing: + self._playbin.set_property('volume', volume / 100.0) + return True + + if self._mixer is None: + return False + + old_scale = (0, 100) + new_scale = (self._mixer_track.min_volume, self._mixer_track.max_volume) + + volume = utils.rescale(volume, old=old_scale, new=new_scale) + + volumes = (volume,) * self._mixer_track.num_channels + self._mixer.set_volume(self._mixer_track, volumes) + + return self._mixer.get_volume(self._mixer_track) == volumes + + def set_metadata(self, track): + """ + Set track metadata for currently playing song. + + Only needs to be called by sources such as `appsrc` which do not + already inject tags in playbin, e.g. when using :meth:`emit_data` to + deliver raw audio data to GStreamer. + + :param track: the current track + :type track: :class:`mopidy.models.Track` + """ + taglist = gst.TagList() + artists = [a for a in (track.artists or []) if a.name] + + # Default to blank data to trick shoutcast into clearing any previous + # values it might have. + taglist[gst.TAG_ARTIST] = u' ' + taglist[gst.TAG_TITLE] = u' ' + taglist[gst.TAG_ALBUM] = u' ' + + if artists: + taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + + if track.name: + taglist[gst.TAG_TITLE] = track.name + + if track.album and track.album.name: + taglist[gst.TAG_ALBUM] = track.album.name + + event = gst.event_new_tag(taglist) + self._playbin.send_event(event) diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py new file mode 100644 index 00000000..a0247519 --- /dev/null +++ b/mopidy/audio/mixers/__init__.py @@ -0,0 +1,43 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + + +def create_track(label, initial_volume, min_volume, max_volume, + num_channels, flags): + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (initial_volume,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() + + +# Import all mixers so that they are registered with GStreamer. +# +# Keep these imports at the bottom of the file to avoid cyclic import problems +# when mixers use the above code. +from .auto import AutoAudioMixer +from .fake import FakeMixer +from .nad import NadMixer diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py new file mode 100644 index 00000000..1233afa3 --- /dev/null +++ b/mopidy/audio/mixers/auto.py @@ -0,0 +1,72 @@ +import pygst +pygst.require('0.10') +import gobject +import gst + +import logging + +logger = logging.getLogger('mopidy.audio.mixers.auto') + + +# TODO: we might want to add some ranking to the mixers we know about? +class AutoAudioMixer(gst.Bin): + __gstdetails__ = ('AutoAudioMixer', + 'Mixer', + 'Element automatically selects a mixer.', + 'Thomas Adamcik') + + def __init__(self): + gst.Bin.__init__(self) + mixer = self._find_mixer() + if mixer: + self.add(mixer) + logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) + else: + logger.debug('AutoAudioMixer did not find any usable mixers') + + def _find_mixer(self): + registry = gst.registry_get_default() + + factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) + factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) + + for factory in factories: + # Avoid sink/srcs that implement mixing. + if factory.get_klass() != 'Generic/Audio': + continue + # Avoid anything that doesn't implement mixing. + elif not factory.has_interface('GstMixer'): + continue + + if self._test_mixer(factory): + return factory.create() + + return None + + def _test_mixer(self, factory): + element = factory.create() + if not element: + return False + + try: + result = element.set_state(gst.STATE_READY) + if result != gst.STATE_CHANGE_SUCCESS: + return False + + # Trust that the default device is sane and just check tracks. + return self._test_tracks(element) + finally: + element.set_state(gst.STATE_NULL) + + def _test_tracks(self, element): + # Only allow elements that have a least one output track. + flags = gst.interfaces.MIXER_TRACK_OUTPUT + + for track in element.list_tracks(): + if track.flags & flags: + return True + return False + + +gobject.type_register(AutoAudioMixer) +gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py new file mode 100644 index 00000000..c5faa03f --- /dev/null +++ b/mopidy/audio/mixers/fake.py @@ -0,0 +1,48 @@ +import pygst +pygst.require('0.10') +import gobject +import gst + +from mopidy.audio.mixers import create_track + + +class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): + __gstdetails__ = ('FakeMixer', + 'Mixer', + 'Fake mixer for use in tests.', + 'Thomas Adamcik') + + track_label = gobject.property(type=str, default='Master') + track_initial_volume = gobject.property(type=int, default=0) + track_min_volume = gobject.property(type=int, default=0) + track_max_volume = gobject.property(type=int, default=100) + track_num_channels = gobject.property(type=int, default=2) + track_flags = gobject.property(type=int, + default=(gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) + + def __init__(self): + gst.Element.__init__(self) + + def list_tracks(self): + track = create_track( + self.track_label, + self.track_initial_volume, + self.track_min_volume, + self.track_max_volume, + self.track_num_channels, + self.track_flags) + return [track] + + def get_volume(self, track): + return track.volumes + + def set_volume(self, track, volumes): + track.volumes = volumes + + def set_record(self, track, record): + pass + + +gobject.type_register(FakeMixer) +gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py new file mode 100644 index 00000000..667dee53 --- /dev/null +++ b/mopidy/audio/mixers/nad.py @@ -0,0 +1,236 @@ +import logging + +import pygst +pygst.require('0.10') +import gobject +import gst + +try: + import serial +except ImportError: + serial = None + +from pykka.actor import ThreadingActor + +from mopidy.audio.mixers import create_track + + +logger = logging.getLogger('mopidy.audio.mixers.nad') + + +class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): + __gstdetails__ = ('NadMixer', + 'Mixer', + 'Mixer to control NAD amplifiers using a serial link', + 'Stein Magnus Jodal') + + port = gobject.property(type=str, default='/dev/ttyUSB0') + source = gobject.property(type=str) + speakers_a = gobject.property(type=str) + speakers_b = gobject.property(type=str) + + def __init__(self): + gst.Element.__init__(self) + self._volume_cache = 0 + self._nad_talker = None + + def list_tracks(self): + track = create_track( + label='Master', + initial_volume=0, + min_volume=0, + max_volume=100, + num_channels=1, + flags=(gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) + return [track] + + def get_volume(self, track): + return [self._volume_cache] + + def set_volume(self, track, volumes): + if len(volumes): + volume = volumes[0] + self._volume_cache = volume + self._nad_talker.set_volume(volume) + + def set_mute(self, track, mute): + self._nad_talker.mute(mute) + + def do_change_state(self, transition): + if transition == gst.STATE_CHANGE_NULL_TO_READY: + if serial is None: + logger.warning(u'nadmixer dependency python-serial not found') + return gst.STATE_CHANGE_FAILURE + self._start_nad_talker() + return gst.STATE_CHANGE_SUCCESS + + def _start_nad_talker(self): + self._nad_talker = NadTalker.start( + port=self.port, + source=self.source or None, + speakers_a=self.speakers_a or None, + speakers_b=self.speakers_b or None + ).proxy() + + +gobject.type_register(NadMixer) +gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL) + + +class NadTalker(ThreadingActor): + """ + Independent thread which does the communication with the NAD amplifier + + Since the communication is done in an independent thread, Mopidy won't + block other requests while doing rather time consuming work like + calibrating the NAD amplifier's volume. + """ + + # Serial link settings + BAUDRATE = 115200 + BYTESIZE = 8 + PARITY = 'N' + STOPBITS = 1 + + # Timeout in seconds used for read/write operations. + # If you set the timeout too low, the reads will never get complete + # confirmations and calibration will decrease volume forever. If you set + # the timeout too high, stuff takes more time. 0.2s seems like a good value + # for NAD C 355BEE. + TIMEOUT = 0.2 + + # Number of volume levels the amplifier supports. 40 for NAD C 355BEE. + VOLUME_LEVELS = 40 + + def __init__(self, port, source, speakers_a, speakers_b): + super(NadTalker, self).__init__() + + self.port = port + self.source = source + self.speakers_a = speakers_a + self.speakers_b = speakers_b + + # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. + self._nad_volume = None + + self._device = None + + def on_start(self): + self._open_connection() + self._set_device_to_known_state() + + def _open_connection(self): + logger.info(u'NAD amplifier: Connecting through "%s"', + self.port) + self._device = serial.Serial( + port=self.port, + baudrate=self.BAUDRATE, + bytesize=self.BYTESIZE, + parity=self.PARITY, + stopbits=self.STOPBITS, + timeout=self.TIMEOUT) + self._get_device_model() + + def _set_device_to_known_state(self): + self._power_device_on() + self._select_speakers() + self._select_input_source() + self.mute(False) + self._calibrate_volume() + + def _get_device_model(self): + model = self._ask_device('Main.Model') + logger.info(u'NAD amplifier: Connected to model "%s"', model) + return model + + def _power_device_on(self): + self._check_and_set('Main.Power', 'On') + + def _select_speakers(self): + if self.speakers_a is not None: + self._check_and_set('Main.SpeakerA', self.speakers_a.title()) + if self.speakers_b is not None: + self._check_and_set('Main.SpeakerB', self.speakers_b.title()) + + def _select_input_source(self): + if self.source is not None: + self._check_and_set('Main.Source', self.source.title()) + + def mute(self, mute): + if mute: + self._check_and_set('Main.Mute', 'On') + else: + self._check_and_set('Main.Mute', 'Off') + + def _calibrate_volume(self): + # The NAD C 355BEE amplifier has 40 different volume levels. We have no + # way of asking on which level we are. Thus, we must calibrate the + # mixer by decreasing the volume 39 times. + logger.info(u'NAD amplifier: Calibrating by setting volume to 0') + self._nad_volume = self.VOLUME_LEVELS + self.set_volume(0) + logger.info(u'NAD amplifier: Done calibrating') + + 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) + target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) + if self._nad_volume is None: + return # Calibration needed + while target_nad_volume > self._nad_volume: + if self._increase_volume(): + self._nad_volume += 1 + while target_nad_volume < self._nad_volume: + if self._decrease_volume(): + self._nad_volume -= 1 + + def _increase_volume(self): + # Increase volume. Returns :class:`True` if confirmed by device. + self._write('Main.Volume+') + return self._readline() == 'Main.Volume+' + + def _decrease_volume(self): + # Decrease volume. Returns :class:`True` if confirmed by device. + self._write('Main.Volume-') + return self._readline() == 'Main.Volume-' + + def _check_and_set(self, key, value): + for attempt in range(1, 4): + if self._ask_device(key) == value: + return + logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', + key, value, attempt) + self._command_device(key, value) + if self._ask_device(key) != value: + logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"', + key, value) + + def _ask_device(self, key): + self._write('%s?' % key) + 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() + + def _write(self, data): + # Write data to device. Prepends and appends a newline to the data, as + # recommended by the NAD documentation. + if not self._device.isOpen(): + self._device.open() + self._device.write('\n%s\n' % data) + logger.debug('Write: %s', data) + + def _readline(self): + # Read line from device. The result is stripped for leading and + # trailing whitespace. + if not self._device.isOpen(): + self._device.open() + result = self._device.readline().strip() + if result: + logger.debug('Read: %s', result) + return result diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 76c7f078..e6c8b70a 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -1,12 +1,7 @@ -import logging +from .library import BaseLibraryProvider +from .playback import BasePlaybackProvider +from .stored_playlists import BaseStoredPlaylistsProvider -from .current_playlist import CurrentPlaylistController -from .library import LibraryController, BaseLibraryProvider -from .playback import PlaybackController, BasePlaybackProvider -from .stored_playlists import (StoredPlaylistsController, - BaseStoredPlaylistsProvider) - -logger = logging.getLogger('mopidy.backends.base') class Backend(object): #: The current playlist controller. An instance of diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py index 9e3afe9a..837eef49 100644 --- a/mopidy/backends/base/library.py +++ b/mopidy/backends/base/library.py @@ -1,79 +1,3 @@ -import logging - -logger = logging.getLogger('mopidy.backends.base') - -class LibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseLibraryProvider` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - def find_exact(self, **query): - """ - Search the library for tracks where ``field`` is ``values``. - - Examples:: - - # Returns results matching 'a' - find_exact(any=['a']) - # Returns results matching artist 'xyz' - find_exact(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - find_exact(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.find_exact(**query) - - def lookup(self, uri): - """ - Lookup track with given URI. Returns :class:`None` if not found. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` or :class:`None` - """ - return self.provider.lookup(uri) - - def refresh(self, uri=None): - """ - Refresh library. Limit to URI and below if an URI is given. - - :param uri: directory or track URI - :type uri: string - """ - return self.provider.refresh(uri) - - def search(self, **query): - """ - Search the library for tracks where ``field`` contains ``values``. - - Examples:: - - # Returns results matching 'a' - search(any=['a']) - # Returns results matching artist 'xyz' - search(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - search(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.search(**query) - - class BaseLibraryProvider(object): """ :param backend: backend the controller is a part of diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 16ac75d1..ae5a4383 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,545 +1,3 @@ -import logging -import random -import time - -from mopidy.listeners import BackendListener - -logger = logging.getLogger('mopidy.backends.base') - - -def option_wrapper(name, default): - def get_option(self): - return getattr(self, name, default) - def set_option(self, value): - if getattr(self, name, default) != value: - self._trigger_options_changed() - return setattr(self, name, value) - return property(get_option, set_option) - - -class PlaybackController(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BasePlaybackProvider` - """ - - # pylint: disable = R0902 - # Too many instance attributes - - pykka_traversable = True - - #: Constant representing the paused state. - PAUSED = u'paused' - - #: Constant representing the playing state. - PLAYING = u'playing' - - #: Constant representing the stopped state. - STOPPED = u'stopped' - - #: :class:`True` - #: Tracks are removed from the playlist when they have been played. - #: :class:`False` - #: Tracks are not removed from the playlist. - consume = option_wrapper('_consume', False) - - #: The currently playing or selected track. - #: - #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or - #: :class:`None`. - current_cp_track = None - - #: :class:`True` - #: Tracks are selected at random from the playlist. - #: :class:`False` - #: Tracks are played in the order of the playlist. - random = option_wrapper('_random', False) - - #: :class:`True` - #: The current playlist is played repeatedly. To repeat a single track, - #: select both :attr:`repeat` and :attr:`single`. - #: :class:`False` - #: The current playlist is played once. - repeat = option_wrapper('_repeat', False) - - #: :class:`True` - #: Playback is stopped after current song, unless in :attr:`repeat` - #: mode. - #: :class:`False` - #: Playback continues after current song. - single = option_wrapper('_single', False) - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - self._state = self.STOPPED - self._shuffled = [] - self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = None - - def _get_cpid(self, cp_track): - if cp_track is None: - return None - return cp_track.cpid - - def _get_track(self, cp_track): - if cp_track is None: - return None - return cp_track.track - - @property - def current_cpid(self): - """ - The CPID (current playlist ID) of the currently playing or selected - track. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_cpid(self.current_cp_track) - - @property - def current_track(self): - """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_track(self.current_cp_track) - - @property - def current_playlist_position(self): - """ - The position of the current track in the current playlist. - - Read-only. - """ - if self.current_cp_track is None: - return None - try: - return self.backend.current_playlist.cp_tracks.index( - self.current_cp_track) - except ValueError: - return None - - @property - def track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_eot` for convenience. - """ - return self._get_track(self.cp_track_at_eot) - - @property - def cp_track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - Not necessarily the same track as :attr:`cp_track_at_next`. - """ - # pylint: disable = R0911 - # Too many return statements - - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat and self.single: - return cp_tracks[self.current_playlist_position] - - if self.repeat and not self.single: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_next` for convenience. - """ - return self._get_track(self.cp_track_at_next) - - @property - def cp_track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_previous` for convenience. - """ - return self._get_track(self.cp_track_at_previous) - - @property - def cp_track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ - if self.repeat or self.consume or self.random: - return self.current_cp_track - - if self.current_playlist_position in (None, 0): - return None - - return self.backend.current_playlist.cp_tracks[ - self.current_playlist_position - 1] - - @property - def state(self): - """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] - """ - return self._state - - @state.setter - def state(self, new_state): - (old_state, self._state) = (self.state, new_state) - logger.debug(u'Changing state: %s -> %s', old_state, new_state) - - self._trigger_playback_state_changed() - - # 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): - self._play_time_start() - elif old_state == self.PLAYING and new_state == self.PAUSED: - self._play_time_pause() - elif old_state == self.PAUSED and new_state == self.PLAYING: - self._play_time_resume() - - @property - def time_position(self): - """Time position in milliseconds.""" - if self.state == self.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == self.PAUSED: - return self.play_time_accumulated - elif self.state == self.STOPPED: - return 0 - - def _play_time_start(self): - self.play_time_accumulated = 0 - self.play_time_started = self._current_wall_time - - def _play_time_pause(self): - time_since_started = self._current_wall_time - self.play_time_started - self.play_time_accumulated += time_since_started - - def _play_time_resume(self): - self.play_time_started = self._current_wall_time - - @property - def _current_wall_time(self): - return int(time.time() * 1000) - - def change_track(self, cp_track, on_error_step=1): - """ - Change to the given track, keeping the current playback state. - - :param cp_track: track to change to - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - - """ - old_state = self.state - self.stop() - self.current_cp_track = cp_track - if old_state == self.PLAYING: - self.play(on_error_step=on_error_step) - elif old_state == self.PAUSED: - self.pause() - - def on_end_of_track(self): - """ - Tell the playback controller that end of track is reached. - - Typically called by :class:`mopidy.process.CoreProcess` after a message - from a library thread is received. - """ - if self.state == self.STOPPED: - return - - original_cp_track = self.current_cp_track - - if self.cp_track_at_eot: - self._trigger_track_playback_ended() - self.play(self.cp_track_at_eot) - else: - self.stop(clear_current_track=True) - - if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track.cpid) - - def on_current_playlist_change(self): - """ - Tell the playback controller that the current playlist has changed. - - Used by :class:`mopidy.backends.base.CurrentPlaylistController`. - """ - self._first_shuffle = True - self._shuffled = [] - - if (not self.backend.current_playlist.cp_tracks or - self.current_cp_track not in - self.backend.current_playlist.cp_tracks): - self.stop(clear_current_track=True) - - def next(self): - """ - Change to the next track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - if self.cp_track_at_next: - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_next) - else: - self.stop(clear_current_track=True) - - def pause(self): - """Pause playback.""" - if self.provider.pause(): - self.state = self.PAUSED - self._trigger_track_playback_paused() - - def play(self, cp_track=None, on_error_step=1): - """ - Play the given track, or if the given track is :class:`None`, play the - currently active track. - - :param cp_track: track to play - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - """ - - if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks - elif cp_track is None: - if self.state == self.PAUSED: - return self.resume() - elif self.current_cp_track is not None: - cp_track = self.current_cp_track - elif self.current_cp_track is None and on_error_step == 1: - cp_track = self.cp_track_at_next - elif self.current_cp_track is None and on_error_step == -1: - cp_track = self.cp_track_at_previous - - if cp_track is not None: - self.current_cp_track = cp_track - self.state = self.PLAYING - if not self.provider.play(cp_track.track): - # Track is not playable - if self.random and self._shuffled: - self._shuffled.remove(cp_track) - if on_error_step == 1: - self.next() - elif on_error_step == -1: - self.previous() - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) - - self._trigger_track_playback_started() - - def previous(self): - """ - Change to the previous track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_previous, on_error_step=-1) - - def resume(self): - """If paused, resume playing the current track.""" - if self.state == self.PAUSED and self.provider.resume(): - self.state = self.PLAYING - self._trigger_track_playback_resumed() - - def seek(self, time_position): - """ - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if not self.backend.current_playlist.tracks: - return False - - if self.state == self.STOPPED: - self.play() - elif self.state == self.PAUSED: - self.resume() - - if time_position < 0: - time_position = 0 - elif time_position > self.current_track.length: - self.next() - return True - - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - - success = self.provider.seek(time_position) - if success: - self._trigger_seeked() - return success - - def stop(self, clear_current_track=False): - """ - Stop playing. - - :param clear_current_track: whether to clear the current track _after_ - stopping - :type clear_current_track: boolean - """ - if self.state != self.STOPPED: - if self.provider.stop(): - self._trigger_track_playback_ended() - self.state = self.STOPPED - if clear_current_track: - self.current_cp_track = None - - def _trigger_track_playback_paused(self): - logger.debug(u'Triggering track playback paused event') - if self.current_track is None: - return - BackendListener.send('track_playback_paused', - track=self.current_track, - time_position=self.time_position) - - def _trigger_track_playback_resumed(self): - logger.debug(u'Triggering track playback resumed event') - if self.current_track is None: - return - BackendListener.send('track_playback_resumed', - track=self.current_track, - time_position=self.time_position) - - def _trigger_track_playback_started(self): - logger.debug(u'Triggering track playback started event') - if self.current_track is None: - return - BackendListener.send('track_playback_started', - track=self.current_track) - - def _trigger_track_playback_ended(self): - logger.debug(u'Triggering track playback ended event') - if self.current_track is None: - return - BackendListener.send('track_playback_ended', - track=self.current_track, - time_position=self.time_position) - - def _trigger_playback_state_changed(self): - logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') - - def _trigger_options_changed(self): - logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') - - def _trigger_seeked(self): - logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') - - class BasePlaybackProvider(object): """ :param backend: the backend @@ -555,52 +13,75 @@ class BasePlaybackProvider(object): """ Pause playback. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.pause_playback().get() def play(self, track): """ Play given track. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param track: the track to play :type track: :class:`mopidy.models.Track` :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + self.backend.audio.prepare_change() + self.backend.audio.set_uri(track.uri).get() + return self.backend.audio.start_playback().get() def resume(self): """ Resume playback at the same time position playback was paused. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.start_playback().get() def seek(self, time_position): """ Seek to a given time position. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param time_position: time position in milliseconds :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.set_position(time_position).get() def stop(self): """ Stop playback. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.stop_playback().get() + + def get_volume(self): + """ + Get current volume + + *MAY be reimplemented by subclass.* + + :rtype: int [0..100] or :class:`None` + """ + return self.backend.audio.get_volume().get() + + def set_volume(self, volume): + """ + Get current volume + + *MAY be reimplemented by subclass.* + + :param: volume + :type volume: int [0..100] + """ + self.backend.audio.set_volume(volume) diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index 0ce2e196..d1d52c9a 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -1,120 +1,4 @@ from copy import copy -import logging - -logger = logging.getLogger('mopidy.backends.base') - -class StoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseStoredPlaylistsProvider` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return self.provider.playlists - - @playlists.setter - def playlists(self, playlists): - self.provider.playlists = playlists - - def create(self, name): - """ - Create a new playlist. - - :param name: name of the new playlist - :type name: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.create(name) - - def delete(self, playlist): - """ - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.delete(playlist) - - def get(self, **criteria): - """ - Get playlist by given criterias from the set of stored playlists. - - Raises :exc:`LookupError` if a unique match is not found. - - Examples:: - - get(name='a') # Returns track with name 'a' - get(uri='xyz') # Returns track with URI 'xyz' - get(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' - - :param criteria: one or more criteria to match by - :type criteria: dict - :rtype: :class:`mopidy.models.Playlist` - """ - matches = self.playlists - for (key, value) in criteria.iteritems(): - matches = filter(lambda p: getattr(p, key) == value, matches) - if len(matches) == 1: - return matches[0] - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - if len(matches) == 0: - raise LookupError('"%s" match no playlists' % criteria_string) - else: - raise LookupError('"%s" match multiple playlists' % criteria_string) - - def lookup(self, uri): - """ - Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. - - :param uri: playlist URI - :type uri: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.lookup(uri) - - def refresh(self): - """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. - """ - return self.provider.refresh() - - def rename(self, playlist, new_name): - """ - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - """ - return self.provider.rename(playlist, new_name) - - def save(self, playlist): - """ - Save the playlist to the set of stored playlists. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.save(playlist) class BaseStoredPlaylistsProvider(object): diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 70efb028..3ada0052 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,13 +1,11 @@ from pykka.actor import ThreadingActor -from mopidy.backends.base import (Backend, CurrentPlaylistController, - PlaybackController, BasePlaybackProvider, LibraryController, - BaseLibraryProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import core +from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, Backend): +class DummyBackend(ThreadingActor, base.Backend): """ A backend which implements the backend API in the simplest way possible. Used in tests of the frontends. @@ -18,24 +16,24 @@ class DummyBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(DummyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = DummyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = DummyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'dummy'] -class DummyLibraryProvider(BaseLibraryProvider): +class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] @@ -55,7 +53,11 @@ class DummyLibraryProvider(BaseLibraryProvider): return Playlist() -class DummyPlaybackProvider(BasePlaybackProvider): +class DummyPlaybackProvider(base.BasePlaybackProvider): + def __init__(self, *args, **kwargs): + super(DummyPlaybackProvider, self).__init__(*args, **kwargs) + self._volume = None + def pause(self): return True @@ -72,8 +74,14 @@ class DummyPlaybackProvider(BasePlaybackProvider): def stop(self): return True + def get_volume(self): + return self._volume -class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): + def set_volume(self, volume): + self._volume = volume + + +class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): playlist = Playlist(name=name) self._playlists.append(playlist) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index e8638a3a..db86e56f 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,32 +7,19 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, DATA_PATH -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, BaseLibraryProvider, PlaybackController, - BasePlaybackProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import audio, core, settings +from mopidy.backends import base from mopidy.models import Playlist, Track, Album -from mopidy.gstreamer import GStreamer from .translator import parse_m3u, parse_mpd_tag_cache logger = logging.getLogger(u'mopidy.backends.local') -DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists') -DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache') -DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)) -if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): - DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') - - -class LocalBackend(ThreadingActor, Backend): +class LocalBackend(ThreadingActor, base.Backend): """ A backend for playing music from a local music archive. - **Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local - **Dependencies:** - None @@ -47,32 +34,32 @@ class LocalBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(LocalBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = LocalLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) - playback_provider = LocalPlaybackProvider(backend=self) + playback_provider = base.BasePlaybackProvider(backend=self) self.playback = LocalPlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'file'] - self.gstreamer = None + self.audio = None def on_start(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running GStreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + audio_refs = ActorRegistry.get_by_class(audio.Audio) + assert len(audio_refs) == 1, \ + 'Expected exactly one running Audio instance.' + self.audio = audio_refs[0].proxy() -class LocalPlaybackController(PlaybackController): +class LocalPlaybackController(core.PlaybackController): def __init__(self, *args, **kwargs): super(LocalPlaybackController, self).__init__(*args, **kwargs) @@ -81,32 +68,13 @@ class LocalPlaybackController(PlaybackController): @property def time_position(self): - return self.backend.gstreamer.get_position().get() + return self.backend.audio.get_position().get() -class LocalPlaybackProvider(BasePlaybackProvider): - def pause(self): - return self.backend.gstreamer.pause_playback().get() - - def play(self, track): - self.backend.gstreamer.prepare_change() - self.backend.gstreamer.set_uri(track.uri).get() - return self.backend.gstreamer.start_playback().get() - - def resume(self): - return self.backend.gstreamer.start_playback().get() - - def seek(self, time_position): - return self.backend.gstreamer.set_position(time_position).get() - - def stop(self): - return self.backend.gstreamer.stop_playback().get() - - -class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH + self._folder = settings.LOCAL_PLAYLIST_PATH self.refresh() def lookup(self, uri): @@ -118,9 +86,9 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): logger.info('Loading playlists from %s', self._folder) for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:len('.m3u')] + name = os.path.basename(m3u)[:-len('.m3u')] tracks = [] - for uri in parse_m3u(m3u): + for uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): try: tracks.append(self.backend.library.lookup(uri)) except LookupError, e: @@ -176,19 +144,18 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): self._playlists.append(playlist) -class LocalLibraryProvider(BaseLibraryProvider): +class LocalLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(LocalLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} self.refresh() def refresh(self, uri=None): - tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE - music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH + tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE_FILE, + settings.LOCAL_MUSIC_PATH) - tracks = parse_mpd_tag_cache(tag_cache, music_folder) - - logger.info('Loading tracks in %s from %s', music_folder, tag_cache) + logger.info('Loading tracks in %s from %s', settings.LOCAL_MUSIC_PATH, + settings.LOCAL_TAG_CACHE_FILE) for track in tracks: self._uri_mapping[track.uri] = track @@ -197,7 +164,8 @@ class LocalLibraryProvider(BaseLibraryProvider): try: return self._uri_mapping[uri] except KeyError: - raise LookupError('%s not found.' % uri) + logger.debug(u'Failed to lookup "%s"', uri) + return None def find_exact(self, **query): self._validate_query(query) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 3b610a94..1fea555c 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -7,7 +7,7 @@ from mopidy.models import Track, Artist, Album from mopidy.utils import locale_decode from mopidy.utils.path import path_to_uri -def parse_m3u(file_path): +def parse_m3u(file_path, music_folder): """ Convert M3U file list of uris @@ -29,8 +29,6 @@ def parse_m3u(file_path): """ uris = [] - folder = os.path.dirname(file_path) - try: with open(file_path) as m3u: contents = m3u.readlines() @@ -48,7 +46,7 @@ def parse_m3u(file_path): if line.startswith('file://'): uris.append(line) else: - path = path_to_uri(folder, line) + path = path_to_uri(music_folder, line) uris.append(path) return uris diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 56775926..1feb1c65 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -3,16 +3,14 @@ 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.gstreamer import GStreamer +from mopidy import audio, core, settings +from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} -class SpotifyBackend(ThreadingActor, Backend): +class SpotifyBackend(ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ music streaming service. The backend uses the official `libspotify @@ -51,24 +49,24 @@ class SpotifyBackend(ThreadingActor, Backend): super(SpotifyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = SpotifyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = SpotifyStoredPlaylistsProvider( backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'spotify'] - self.gstreamer = None + self.audio = None self.spotify = None # Fail early if settings are not present @@ -76,10 +74,10 @@ class SpotifyBackend(ThreadingActor, Backend): self.password = settings.SPOTIFY_PASSWORD def on_start(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running GStreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + audio_refs = ActorRegistry.get_by_class(audio.Audio) + assert len(audio_refs) == 1, \ + 'Expected exactly one running Audio instance.' + self.audio = audio_refs[0].proxy() logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a080c7bd..18276ecd 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -5,21 +5,55 @@ from spotify import Link, SpotifyError from mopidy.backends.base import BaseLibraryProvider from mopidy.backends.spotify.translator import SpotifyTranslator -from mopidy.models import Playlist +from mopidy.models import Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.library') + +class SpotifyTrack(Track): + """Proxy object for unloaded Spotify tracks.""" + def __init__(self, uri): + self._spotify_track = Link.from_string(uri).as_track() + self._unloaded_track = Track(uri=uri, name=u'[loading...]') + self._track = None + + @property + def _proxy(self): + if self._track: + return self._track + elif self._spotify_track.is_loaded(): + self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track) + return self._track + else: + return self._unloaded_track + + def __getattribute__(self, name): + if name.startswith('_'): + return super(SpotifyTrack, self).__getattribute__(name) + return self._proxy.__getattribute__(name) + + def __repr__(self): + return self._proxy.__repr__() + + def __hash__(self): + return hash(self._proxy.uri) + + def __eq__(self, other): + if not isinstance(other, Track): + return False + return self._proxy.uri == other.uri + + def copy(self, **values): + return self._proxy.copy(**values) + + class SpotifyLibraryProvider(BaseLibraryProvider): def find_exact(self, **query): return self.search(**query) def lookup(self, uri): try: - spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - return SpotifyTranslator.to_mopidy_track(spotify_track) + return SpotifyTrack(uri) except SpotifyError as e: logger.debug(u'Failed to lookup "%s": %s', uri, e) return None diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index dc328fc9..1c20da87 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -3,15 +3,13 @@ import logging from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackProvider +from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): - def pause(self): - return self.backend.gstreamer.pause_playback() - def play(self, track): - if self.backend.playback.state == self.backend.playback.PLAYING: + if self.backend.playback.state == PlaybackState.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: return False @@ -19,10 +17,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.gstreamer.prepare_change() - self.backend.gstreamer.set_uri('appsrc://') - self.backend.gstreamer.start_playback() - self.backend.gstreamer.set_metadata(track) + self.backend.audio.prepare_change() + self.backend.audio.set_uri('appsrc://') + self.backend.audio.start_playback() + self.backend.audio.set_metadata(track) return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) @@ -32,12 +30,11 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.gstreamer.prepare_change() + self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.gstreamer.start_playback() + self.backend.audio.start_playback() return True def stop(self): - result = self.backend.gstreamer.stop_playback() self.backend.spotify.session.play(0) - return result + return super(SpotifyPlaybackProvider, self).stop() diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 481f7a94..ce1226d8 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,14 +6,13 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import get_version, settings, CACHE_PATH +from mopidy import audio, get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify import BITRATES from mopidy.backends.spotify.container_manager import SpotifyContainerManager from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist -from mopidy.gstreamer import GStreamer from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -23,8 +22,7 @@ logger = logging.getLogger('mopidy.backends.spotify.session_manager') class SpotifySessionManager(BaseThread, PyspotifySessionManager): - cache_location = (settings.SPOTIFY_CACHE_PATH - or os.path.join(CACHE_PATH, 'spotify')) + cache_location = settings.SPOTIFY_CACHE_PATH settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % get_version() @@ -34,7 +32,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): BaseThread.__init__(self) self.name = 'SpotifyThread' - self.gstreamer = None + self.audio = None self.backend = None self.connected = threading.Event() @@ -50,10 +48,10 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connect() def setup(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running gstreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + audio_refs = ActorRegistry.get_by_class(audio.Audio) + assert len(audio_refs) == 1, \ + 'Expected exactly one running Audio instance.' + self.audio = audio_refs[0].proxy() backend_refs = ActorRegistry.get_by_class(Backend) assert len(backend_refs) == 1, 'Expected exactly one running backend.' @@ -117,7 +115,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.gstreamer.emit_data(capabilites, bytes(frames)) + self.audio.emit_data(capabilites, bytes(frames)) return num_frames def play_token_lost(self, session): @@ -143,7 +141,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug(u'End of data stream reached') - self.gstreamer.emit_end_of_stream() + self.audio.emit_end_of_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data @@ -155,7 +153,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists - logger.debug(u'Refreshed %d stored playlist(s)', len(playlists)) + logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): """Search method used by Mopidy backend""" diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 2f47a42b..1a8f048d 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,4 +1,3 @@ -import datetime as dt import logging from spotify import Link, SpotifyError @@ -31,9 +30,8 @@ class SpotifyTranslator(object): if not spotify_track.is_loaded(): return Track(uri=uri, name=u'[loading...]') spotify_album = spotify_track.album() - if (spotify_album is not None and spotify_album.is_loaded() - and dt.MINYEAR <= int(spotify_album.year()) <= dt.MAXYEAR): - date = dt.date(spotify_album.year(), 1, 1) + if spotify_album is not None and spotify_album.is_loaded(): + date = spotify_album.year() else: date = None return Track( diff --git a/mopidy/core.py b/mopidy/core.py deleted file mode 100644 index 596e0fe5..00000000 --- a/mopidy/core.py +++ /dev/null @@ -1,132 +0,0 @@ -import logging -import optparse -import os -import signal -import sys - -import gobject -gobject.threads_init() - -# Extract any non-GStreamer arguments, and leave the GStreamer arguments for -# processing by GStreamer. This needs to be done before GStreamer is imported, -# so that GStreamer doesn't hijack e.g. ``--help``. -# NOTE This naive fix does not support values like ``bar`` in -# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. -def is_gst_arg(argument): - return argument.startswith('--gst') or argument == '--help-gst' -gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] -mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] -sys.argv[1:] = gstreamer_args - -from mopidy import (get_version, settings, OptionalDependencyError, - SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer -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 (exit_handler, stop_remaining_actors, - stop_actors_by_class) -from mopidy.utils.settings import list_settings_optparse_callback - -logger = logging.getLogger('mopidy.core') - -def main(): - signal.signal(signal.SIGTERM, exit_handler) - loop = gobject.MainLoop() - try: - options = parse_options() - setup_logging(options.verbosity_level, options.save_debug_log) - check_old_folders() - setup_settings(options.interactive) - setup_gstreamer() - setup_mixer() - setup_backend() - setup_frontends() - loop.run() - except SettingsError as e: - logger.error(e.message) - except KeyboardInterrupt: - logger.info(u'Interrupted. Exiting...') - except Exception as e: - logger.exception(e) - finally: - loop.quit() - stop_frontends() - stop_backend() - stop_mixer() - stop_gstreamer() - stop_remaining_actors() - -def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) - parser.add_option('--help-gst', - action='store_true', dest='help_gst', - help='show GStreamer help options') - parser.add_option('-i', '--interactive', - action='store_true', dest='interactive', - help='ask interactively for required settings which are missing') - parser.add_option('-q', '--quiet', - action='store_const', const=0, dest='verbosity_level', - help='less output (warning level)') - parser.add_option('-v', '--verbose', - action='count', default=1, 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(args=mopidy_args)[0] - -def check_old_folders(): - old_settings_folder = os.path.expanduser(u'~/.mopidy') - - if not os.path.isdir(old_settings_folder): - return - - logger.warning(u'Old settings folder found at %s, settings.py should be ' - 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, SETTINGS_PATH) - -def setup_settings(interactive): - get_or_create_folder(SETTINGS_PATH) - get_or_create_folder(DATA_PATH) - get_or_create_file(SETTINGS_FILE) - try: - settings.validate(interactive) - except SettingsError, e: - logger.error(e.message) - sys.exit(1) - -def setup_gstreamer(): - GStreamer.start() - -def stop_gstreamer(): - stop_actors_by_class(GStreamer) - -def setup_mixer(): - get_class(settings.MIXER).start() - -def stop_mixer(): - stop_actors_by_class(get_class(settings.MIXER)) - -def setup_backend(): - get_class(settings.BACKENDS[0]).start() - -def stop_backend(): - stop_actors_by_class(get_class(settings.BACKENDS[0])) - -def setup_frontends(): - for frontend_class_name in settings.FRONTENDS: - try: - get_class(frontend_class_name).start() - except OptionalDependencyError as e: - logger.info(u'Disabled: %s (%s)', frontend_class_name, e) - -def stop_frontends(): - for frontend_class_name in settings.FRONTENDS: - try: - stop_actors_by_class(get_class(frontend_class_name)) - except OptionalDependencyError: - pass diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py new file mode 100644 index 00000000..87df96c9 --- /dev/null +++ b/mopidy/core/__init__.py @@ -0,0 +1,4 @@ +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController, PlaybackState +from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/core/current_playlist.py similarity index 99% rename from mopidy/backends/base/current_playlist.py rename to mopidy/core/current_playlist.py index d7e6c331..af06e05e 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -5,7 +5,9 @@ import random from mopidy.listeners import BackendListener from mopidy.models import CpTrack -logger = logging.getLogger('mopidy.backends.base') + +logger = logging.getLogger('mopidy.core') + class CurrentPlaylistController(object): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py new file mode 100644 index 00000000..fc55aaeb --- /dev/null +++ b/mopidy/core/library.py @@ -0,0 +1,70 @@ +class LibraryController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BaseLibraryProvider` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + def find_exact(self, **query): + """ + Search the library for tracks where ``field`` is ``values``. + + Examples:: + + # Returns results matching 'a' + find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.find_exact(**query) + + def lookup(self, uri): + """ + Lookup track with given URI. Returns :class:`None` if not found. + + :param uri: track URI + :type uri: string + :rtype: :class:`mopidy.models.Track` or :class:`None` + """ + return self.provider.lookup(uri) + + def refresh(self, uri=None): + """ + Refresh library. Limit to URI and below if an URI is given. + + :param uri: directory or track URI + :type uri: string + """ + return self.provider.refresh(uri) + + def search(self, **query): + """ + Search the library for tracks where ``field`` contains ``values``. + + Examples:: + + # Returns results matching 'a' + search(any=['a']) + # Returns results matching artist 'xyz' + search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.search(**query) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py new file mode 100644 index 00000000..31a1acc5 --- /dev/null +++ b/mopidy/core/playback.py @@ -0,0 +1,557 @@ +import logging +import random +import time + +from mopidy.listeners import BackendListener + + +logger = logging.getLogger('mopidy.backends.base') + + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + + return property(get_option, set_option) + + + +class PlaybackState(object): + """ + Enum of playback states. + """ + + #: Constant representing the paused state. + PAUSED = u'paused' + + #: Constant representing the playing state. + PLAYING = u'playing' + + #: Constant representing the stopped state. + STOPPED = u'stopped' + + +class PlaybackController(object): + """ + :param backend: the backend + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BasePlaybackProvider` + """ + + # pylint: disable = R0902 + # Too many instance attributes + + pykka_traversable = True + + #: :class:`True` + #: Tracks are removed from the playlist when they have been played. + #: :class:`False` + #: Tracks are not removed from the playlist. + consume = option_wrapper('_consume', False) + + #: The currently playing or selected track. + #: + #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or + #: :class:`None`. + current_cp_track = None + + #: :class:`True` + #: Tracks are selected at random from the playlist. + #: :class:`False` + #: Tracks are played in the order of the playlist. + random = option_wrapper('_random', False) + + #: :class:`True` + #: The current playlist is played repeatedly. To repeat a single track, + #: select both :attr:`repeat` and :attr:`single`. + #: :class:`False` + #: The current playlist is played once. + repeat = option_wrapper('_repeat', False) + + #: :class:`True` + #: Playback is stopped after current song, unless in :attr:`repeat` + #: mode. + #: :class:`False` + #: Playback continues after current song. + single = option_wrapper('_single', False) + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + self._state = PlaybackState.STOPPED + self._shuffled = [] + self._first_shuffle = True + self.play_time_accumulated = 0 + self.play_time_started = 0 + + def _get_cpid(self, cp_track): + if cp_track is None: + return None + return cp_track.cpid + + def _get_track(self, cp_track): + if cp_track is None: + return None + return cp_track.track + + @property + def current_cpid(self): + """ + The CPID (current playlist ID) of the currently playing or selected + track. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_cpid(self.current_cp_track) + + @property + def current_track(self): + """ + The currently playing or selected :class:`mopidy.models.Track`. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_track(self.current_cp_track) + + @property + def current_playlist_position(self): + """ + The position of the current track in the current playlist. + + Read-only. + """ + if self.current_cp_track is None: + return None + try: + return self.backend.current_playlist.cp_tracks.index( + self.current_cp_track) + except ValueError: + return None + + @property + def track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_eot` for convenience. + """ + return self._get_track(self.cp_track_at_eot) + + @property + def cp_track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + Not necessarily the same track as :attr:`cp_track_at_next`. + """ + # pylint: disable = R0911 + # Too many return statements + + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat and self.single: + return cp_tracks[self.current_playlist_position] + + if self.repeat and not self.single: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_next` for convenience. + """ + return self._get_track(self.cp_track_at_next) + + @property + def cp_track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_previous` for convenience. + """ + return self._get_track(self.cp_track_at_previous) + + @property + def cp_track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ + if self.repeat or self.consume or self.random: + return self.current_cp_track + + if self.current_playlist_position in (None, 0): + return None + + return self.backend.current_playlist.cp_tracks[ + self.current_playlist_position - 1] + + @property + def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + return self._state + + @state.setter + def state(self, new_state): + (old_state, self._state) = (self.state, new_state) + logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed() + + # FIXME play_time stuff assumes backend does not have a better way of + # handeling this stuff :/ + if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED) + and new_state == PlaybackState.PLAYING): + self._play_time_start() + elif (old_state == PlaybackState.PLAYING + and new_state == PlaybackState.PAUSED): + self._play_time_pause() + elif (old_state == PlaybackState.PAUSED + and new_state == PlaybackState.PLAYING): + self._play_time_resume() + + @property + def time_position(self): + """Time position in milliseconds.""" + if self.state == PlaybackState.PLAYING: + time_since_started = (self._current_wall_time - + self.play_time_started) + return self.play_time_accumulated + time_since_started + elif self.state == PlaybackState.PAUSED: + return self.play_time_accumulated + elif self.state == PlaybackState.STOPPED: + return 0 + + def _play_time_start(self): + self.play_time_accumulated = 0 + self.play_time_started = self._current_wall_time + + def _play_time_pause(self): + time_since_started = self._current_wall_time - self.play_time_started + self.play_time_accumulated += time_since_started + + def _play_time_resume(self): + self.play_time_started = self._current_wall_time + + @property + def _current_wall_time(self): + return int(time.time() * 1000) + + @property + def volume(self): + return self.provider.get_volume() + + @volume.setter + def volume(self, volume): + self.provider.set_volume(volume) + + def change_track(self, cp_track, on_error_step=1): + """ + Change to the given track, keeping the current playback state. + + :param cp_track: track to change to + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + + """ + old_state = self.state + self.stop() + self.current_cp_track = cp_track + if old_state == PlaybackState.PLAYING: + self.play(on_error_step=on_error_step) + elif old_state == PlaybackState.PAUSED: + self.pause() + + def on_end_of_track(self): + """ + Tell the playback controller that end of track is reached. + """ + if self.state == PlaybackState.STOPPED: + return + + original_cp_track = self.current_cp_track + + if self.cp_track_at_eot: + self._trigger_track_playback_ended() + self.play(self.cp_track_at_eot) + else: + self.stop(clear_current_track=True) + + if self.consume: + self.backend.current_playlist.remove(cpid=original_cp_track.cpid) + + def on_current_playlist_change(self): + """ + Tell the playback controller that the current playlist has changed. + + Used by :class:`mopidy.backends.base.CurrentPlaylistController`. + """ + self._first_shuffle = True + self._shuffled = [] + + if (not self.backend.current_playlist.cp_tracks or + self.current_cp_track not in + self.backend.current_playlist.cp_tracks): + self.stop(clear_current_track=True) + + def next(self): + """ + Change to the next track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + if self.cp_track_at_next: + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_next) + else: + self.stop(clear_current_track=True) + + def pause(self): + """Pause playback.""" + if self.provider.pause(): + self.state = PlaybackState.PAUSED + self._trigger_track_playback_paused() + + def play(self, cp_track=None, on_error_step=1): + """ + Play the given track, or if the given track is :class:`None`, play the + currently active track. + + :param cp_track: track to play + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + """ + + if cp_track is not None: + assert cp_track in self.backend.current_playlist.cp_tracks + elif cp_track is None: + if self.state == PlaybackState.PAUSED: + return self.resume() + elif self.current_cp_track is not None: + cp_track = self.current_cp_track + elif self.current_cp_track is None and on_error_step == 1: + cp_track = self.cp_track_at_next + elif self.current_cp_track is None and on_error_step == -1: + cp_track = self.cp_track_at_previous + + if cp_track is not None: + self.current_cp_track = cp_track + self.state = PlaybackState.PLAYING + if not self.provider.play(cp_track.track): + # Track is not playable + if self.random and self._shuffled: + self._shuffled.remove(cp_track) + if on_error_step == 1: + self.next() + elif on_error_step == -1: + self.previous() + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) + + self._trigger_track_playback_started() + + def previous(self): + """ + Change to the previous track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_previous, on_error_step=-1) + + def resume(self): + """If paused, resume playing the current track.""" + if self.state == PlaybackState.PAUSED and self.provider.resume(): + self.state = PlaybackState.PLAYING + self._trigger_track_playback_resumed() + + def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if not self.backend.current_playlist.tracks: + return False + + if self.state == PlaybackState.STOPPED: + self.play() + elif self.state == PlaybackState.PAUSED: + self.resume() + + if time_position < 0: + time_position = 0 + elif time_position > self.current_track.length: + self.next() + return True + + self.play_time_started = self._current_wall_time + self.play_time_accumulated = time_position + + success = self.provider.seek(time_position) + if success: + self._trigger_seeked() + return success + + def stop(self, clear_current_track=False): + """ + Stop playing. + + :param clear_current_track: whether to clear the current track _after_ + stopping + :type clear_current_track: boolean + """ + if self.state != PlaybackState.STOPPED: + if self.provider.stop(): + self._trigger_track_playback_ended() + self.state = PlaybackState.STOPPED + if clear_current_track: + self.current_cp_track = None + + def _trigger_track_playback_paused(self): + logger.debug(u'Triggering track playback paused event') + if self.current_track is None: + return + BackendListener.send('track_playback_paused', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_resumed(self): + logger.debug(u'Triggering track playback resumed event') + if self.current_track is None: + return + BackendListener.send('track_playback_resumed', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_started(self): + logger.debug(u'Triggering track playback started event') + if self.current_track is None: + return + BackendListener.send('track_playback_started', + track=self.current_track) + + def _trigger_track_playback_ended(self): + logger.debug(u'Triggering track playback ended event') + if self.current_track is None: + return + BackendListener.send('track_playback_ended', + track=self.current_track, + time_position=self.time_position) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') + + def _trigger_seeked(self): + logger.debug(u'Triggering seeked event') + BackendListener.send('seeked') diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py new file mode 100644 index 00000000..a29e34fc --- /dev/null +++ b/mopidy/core/stored_playlists.py @@ -0,0 +1,113 @@ +class StoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BaseStoredPlaylistsProvider` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return self.provider.playlists + + @playlists.setter + def playlists(self, playlists): + self.provider.playlists = playlists + + def create(self, name): + """ + Create a new playlist. + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.create(name) + + def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.delete(playlist) + + def get(self, **criteria): + """ + Get playlist by given criterias from the set of stored playlists. + + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + get(name='a') # Returns track with name 'a' + get(uri='xyz') # Returns track with URI 'xyz' + get(name='a', uri='xyz') # Returns track with name 'a' and URI + # 'xyz' + + :param criteria: one or more criteria to match by + :type criteria: dict + :rtype: :class:`mopidy.models.Playlist` + """ + matches = self.playlists + for (key, value) in criteria.iteritems(): + matches = filter(lambda p: getattr(p, key) == value, matches) + if len(matches) == 1: + return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError('"%s" match no playlists' % criteria_string) + else: + raise LookupError('"%s" match multiple playlists' + % criteria_string) + + def lookup(self, uri): + """ + Lookup playlist with given URI in both the set of stored playlists and + in any other playlist sources. + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.lookup(uri) + + def refresh(self): + """ + Refresh the stored playlists in + :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. + """ + return self.provider.refresh() + + def rename(self, playlist, new_name): + """ + Rename playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + :param new_name: the new name + :type new_name: string + """ + return self.provider.rename(playlist, new_name) + + def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.save(playlist) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 2b012c7c..94ac6bf9 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -15,7 +15,6 @@ 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 logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') @@ -235,7 +234,6 @@ class MpdContext(object): self.events = set() self.subscriptions = set() self._backend = None - self._mixer = None @property def backend(self): @@ -248,14 +246,3 @@ class MpdContext(object): 'Expected exactly one running backend.' self._backend = backend_refs[0].proxy() return self._backend - - @property - def mixer(self): - """ - The mixer. An instance of :class:`mopidy.mixers.base.BaseMixer`. - """ - if self._mixer is None: - mixer_refs = ActorRegistry.get_by_class(BaseMixer) - assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - self._mixer = mixer_refs[0].proxy() - return self._mixer diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 0d61c887..c60cbc4a 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -55,8 +55,7 @@ def addid(context, uri, songpos=None): track = context.backend.library.lookup(uri).get() if track is None: raise MpdNoExistError(u'No such song', command=u'addid') - if songpos and songpos > len( - context.backend.current_playlist.tracks.get()): + if songpos and songpos > context.backend.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') cp_track = context.backend.current_playlist.add(track, at_position=songpos).get() @@ -132,7 +131,7 @@ def move_range(context, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = len(context.backend.current_playlist.tracks.get()) + end = context.backend.current_playlist.length.get() start = int(start) end = int(end) to = int(to) @@ -244,7 +243,7 @@ def playlistinfo(context, songpos=None, """ if songpos is not None: songpos = int(songpos) - cp_track = context.backend.current_playlist.get(cpid=songpos).get() + cp_track = context.backend.current_playlist.cp_tracks.get()[songpos] return track_to_mpd_format(cp_track, position=songpos) else: if start is None: diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index cde2754a..d0128a1e 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -193,10 +193,10 @@ def _list_build_query(field, mpd_query): # shlex does not seem to be friends with unicode objects tokens = shlex.split(mpd_query.encode('utf-8')) except ValueError as error: - if error.message == 'No closing quotation': + if str(error) == 'No closing quotation': raise MpdArgError(u'Invalid unquoted character', command=u'list') else: - raise error + raise tokens = [t.decode('utf-8') for t in tokens] if len(tokens) == 1: if field == u'album': @@ -242,7 +242,7 @@ def _list_date(context, query): playlist = context.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'))) + dates.add((u'Date', track.date)) return dates @handle_request(r'^listall "(?P[^"]+)"') diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 948083a8..4152f11e 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from mopidy.backends.base import PlaybackController +from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -104,11 +104,9 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (context.backend.playback.state.get() == - PlaybackController.PLAYING): + if (context.backend.playback.state.get() == PlaybackState.PLAYING): context.backend.playback.pause() - elif (context.backend.playback.state.get() == - PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == PlaybackState.PAUSED): context.backend.playback.resume() elif int(state): context.backend.playback.pause() @@ -123,8 +121,8 @@ def play(context): """ return context.backend.playback.play().get() -@handle_request(r'^playid "(?P\d+)"$') -@handle_request(r'^playid "(?P-1)"$') +@handle_request(r'^playid (?P-?\d+)$') +@handle_request(r'^playid "(?P-?\d+)"$') def playid(context, cpid): """ *musicpd.org, playback section:* @@ -163,11 +161,11 @@ def playpos(context, songpos): *Clarifications:* - - ``playid "-1"`` when playing is ignored. - - ``playid "-1"`` when paused resumes playback. - - ``playid "-1"`` when stopped with a current track starts playback at the + - ``play "-1"`` when playing is ignored. + - ``play "-1"`` when paused resumes playback. + - ``play "-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 + - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* @@ -185,9 +183,9 @@ def playpos(context, songpos): raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackController.PLAYING): + if (context.backend.playback.state.get() == PlaybackState.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == PlaybackState.PAUSED): return context.backend.playback.resume().get() elif context.backend.playback.current_cp_track.get() is not None: cp_track = context.backend.playback.current_cp_track.get() @@ -353,7 +351,7 @@ def setvol(context, volume): volume = 0 if volume > 100: volume = 100 - context.mixer.volume = volume + context.backend.playback.volume = volume @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index f32c46c8..fc24e1e1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,6 +1,6 @@ import pykka.future -from mopidy.backends.base import PlaybackController +from mopidy.core import PlaybackState from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format @@ -137,7 +137,7 @@ def status(context): Reports the current status of the player and the volume level. - - ``volume``: 0-100 + - ``volume``: 0-100 or -1 - ``repeat``: 0 or 1 - ``single``: 0 or 1 - ``consume``: 0 or 1 @@ -168,7 +168,7 @@ def status(context): futures = { 'current_playlist.length': context.backend.current_playlist.length, 'current_playlist.version': context.backend.current_playlist.version, - 'mixer.volume': context.mixer.volume, + 'playback.volume': context.backend.playback.volume, 'playback.consume': context.backend.playback.consume, 'playback.random': context.backend.playback.random, 'playback.repeat': context.backend.playback.repeat, @@ -194,8 +194,8 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (PlaybackController.PLAYING, - PlaybackController.PAUSED): + if futures['playback.state'].get() in (PlaybackState.PLAYING, + PlaybackState.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) @@ -239,11 +239,11 @@ def _status_songpos(futures): def _status_state(futures): state = futures['playback.state'].get() - if state == PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: return u'play' - elif state == PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: return u'stop' - elif state == PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: return u'pause' def _status_time(futures): @@ -263,11 +263,11 @@ def _status_time_total(futures): return current_cp_track.track.length def _status_volume(futures): - volume = futures['mixer.volume'].get() + volume = futures['playback.volume'].get() if volume is not None: return volume else: - return 0 + return -1 def _status_xfade(futures): return 0 # Not supported diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 9ed1fe2c..93669977 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -16,8 +16,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend -from mopidy.backends.base.playback import PlaybackController -from mopidy.mixers.base import BaseMixer +from mopidy.core import PlaybackState from mopidy.utils.process import exit_process # Must be done before dbus.SessionBus() is called @@ -37,7 +36,6 @@ class MprisObject(dbus.service.Object): def __init__(self): self._backend = None - self._mixer = None self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), @@ -95,14 +93,6 @@ class MprisObject(dbus.service.Object): self._backend = backend_refs[0].proxy() return self._backend - @property - def mixer(self): - if self._mixer is None: - mixer_refs = ActorRegistry.get_by_class(BaseMixer) - assert len(mixer_refs) == 1, 'Expected exactly one running mixer.' - self._mixer = mixer_refs[0].proxy() - return self._mixer - def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid @@ -208,11 +198,11 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: self.backend.playback.pause().get() - elif state == PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: self.backend.playback.resume().get() - elif state == PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: self.backend.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -230,7 +220,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PAUSED: + if state == PlaybackState.PAUSED: self.backend.playback.resume().get() else: self.backend.playback.play().get() @@ -297,11 +287,11 @@ class MprisObject(dbus.service.Object): def get_PlaybackStatus(self): state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: return 'Playing' - elif state == PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: return 'Paused' - elif state == PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: return 'Stopped' def get_LoopStatus(self): @@ -380,9 +370,10 @@ class MprisObject(dbus.service.Object): return dbus.Dictionary(metadata, signature='sv') def get_Volume(self): - volume = self.mixer.volume.get() - if volume is not None: - return volume / 100.0 + volume = self.backend.playback.volume.get() + if volume is None: + return 0 + return volume / 100.0 def set_Volume(self, value): if not self.get_CanControl(): @@ -391,11 +382,11 @@ class MprisObject(dbus.service.Object): if value is None: return elif value < 0: - self.mixer.volume = 0 + self.backend.playback.volume = 0 elif value > 1: - self.mixer.volume = 100 + self.backend.playback.volume = 100 elif 0 <= value <= 1: - self.mixer.volume = int(value * 100) + self.backend.playback.volume = int(value * 100) def get_Position(self): return self.backend.playback.time_position.get() * 1000 diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py deleted file mode 100644 index c33dbe03..00000000 --- a/mopidy/gstreamer.py +++ /dev/null @@ -1,396 +0,0 @@ -import pygst -pygst.require('0.10') -import gst - -import logging - -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry - -from mopidy import settings -from mopidy.utils import get_class -from mopidy.backends.base import Backend - -logger = logging.getLogger('mopidy.gstreamer') - - -class GStreamer(ThreadingActor): - """ - Audio output through `GStreamer `_. - - **Settings:** - - - :attr:`mopidy.settings.OUTPUTS` - - """ - - def __init__(self): - super(GStreamer, self).__init__() - self._default_caps = gst.Caps(""" - audio/x-raw-int, - endianness=(int)1234, - channels=(int)2, - width=(int)16, - depth=(int)16, - signed=(boolean)true, - rate=(int)44100""") - self._pipeline = None - self._source = None - self._tee = None - self._uridecodebin = None - self._volume = None - self._outputs = [] - self._handlers = {} - - def on_start(self): - self._setup_pipeline() - self._setup_outputs() - self._setup_message_processor() - - def _setup_pipeline(self): - description = ' ! '.join([ - 'uridecodebin name=uri', - 'audioconvert name=convert', - 'volume name=volume', - 'tee name=tee']) - - logger.debug(u'Setting up base GStreamer pipeline: %s', description) - - self._pipeline = gst.parse_launch(description) - self._tee = self._pipeline.get_by_name('tee') - self._volume = self._pipeline.get_by_name('volume') - self._uridecodebin = self._pipeline.get_by_name('uri') - - self._uridecodebin.connect('notify::source', self._on_new_source) - self._uridecodebin.connect('pad-added', self._on_new_pad, - self._pipeline.get_by_name('convert').get_pad('sink')) - - def _setup_outputs(self): - for output in settings.OUTPUTS: - get_class(output)(self).connect() - - def _setup_message_processor(self): - bus = self._pipeline.get_bus() - bus.add_signal_watch() - bus.connect('message', self._on_message) - - def _on_new_source(self, element, pad): - self._source = element.get_property('source') - try: - self._source.set_property('caps', self._default_caps) - except TypeError: - pass - - def _on_new_pad(self, source, pad, target_pad): - if not pad.is_linked(): - if target_pad.is_linked(): - target_pad.get_peer().unlink(target_pad) - pad.link(target_pad) - - def _on_message(self, bus, message): - if message.src in self._handlers: - if self._handlers[message.src](message): - return # Message was handeled by output - - if message.type == gst.MESSAGE_EOS: - logger.debug(u'GStreamer signalled end-of-stream. ' - 'Telling backend ...') - self._get_backend().playback.on_end_of_track() - elif message.type == gst.MESSAGE_ERROR: - error, debug = message.parse_error() - logger.error(u'%s %s', error, debug) - self.stop_playback() - elif message.type == gst.MESSAGE_WARNING: - error, debug = message.parse_warning() - logger.warning(u'%s %s', error, debug) - - def _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 set_uri(self, uri): - """ - Set URI of audio to be played. - - You *MUST* call :meth:`prepare_change` before calling this method. - - :param uri: the URI to play - :type uri: string - """ - self._uridecodebin.set_property('uri', uri) - - def emit_data(self, capabilities, data): - """ - Call this to deliver raw audio data to be played. - - :param capabilities: a GStreamer capabilities string - :type capabilities: string - :param data: raw audio data to be played - """ - caps = gst.caps_from_string(capabilities) - buffer_ = gst.Buffer(buffer(data)) - buffer_.set_caps(caps) - self._source.set_property('caps', caps) - self._source.emit('push-buffer', buffer_) - - def emit_end_of_stream(self): - """ - Put an end-of-stream token on the pipeline. This is typically used in - combination with :meth:`emit_data`. - - We will get a GStreamer message when the stream playback reaches the - token, and can then do any end-of-stream related tasks. - """ - self._source.emit('end-of-stream') - - def get_position(self): - """ - Get position in milliseconds. - - :rtype: int - """ - if self._pipeline.get_state()[1] == gst.STATE_NULL: - return 0 - try: - position = self._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): - """ - Set position in milliseconds. - - :param position: the position in milliseconds - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._pipeline.get_state() # block until state changes are done - handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), - gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._pipeline.get_state() # block until seek is done - return handeled - - def start_playback(self): - """ - Notify GStreamer that it should start playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PLAYING) - - def pause_playback(self): - """ - Notify GStreamer that it should pause playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PAUSED) - - def prepare_change(self): - """ - Notify GStreamer that we are about to change state of playback. - - This function *MUST* be called before changing URIs or doing - changes like updating data that is being pushed. The reason for this - is that GStreamer will reset all its state when it changes to - :attr:`gst.STATE_READY`. - """ - return self._set_state(gst.STATE_READY) - - def stop_playback(self): - """ - Notify GStreamer that is should stop playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_NULL) - - def _set_state(self, state): - """ - Internal method for setting the raw GStreamer state. - - .. digraph:: gst_state_transitions - - graph [rankdir="LR"]; - node [fontsize=10]; - - "NULL" -> "READY" - "PAUSED" -> "PLAYING" - "PAUSED" -> "READY" - "PLAYING" -> "PAUSED" - "READY" -> "NULL" - "READY" -> "PAUSED" - - :param state: State to set pipeline to. One of: `gst.STATE_NULL`, - `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. - :type state: :class:`gst.State` - :rtype: :class:`True` if successfull, else :class:`False` - """ - result = self._pipeline.set_state(state) - if result == gst.STATE_CHANGE_FAILURE: - logger.warning('Setting GStreamer state to %s: failed', - state.value_name) - return False - elif result == gst.STATE_CHANGE_ASYNC: - logger.debug('Setting GStreamer state to %s: async', - state.value_name) - return True - else: - logger.debug('Setting GStreamer state to %s: OK', - state.value_name) - return True - - def get_volume(self): - """ - Get volume level of the GStreamer software mixer. - - :rtype: int in range [0..100] - """ - return int(self._volume.get_property('volume') * 100) - - def set_volume(self, volume): - """ - Set volume level of the GStreamer software mixer. - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._volume.set_property('volume', volume / 100.0) - return True - - def set_metadata(self, track): - """ - Set track metadata for currently playing song. - - Only needs to be called by sources such as `appsrc` which do not - already inject tags in pipeline, e.g. when using :meth:`emit_data` to - deliver raw audio data to GStreamer. - - :param track: the current track - :type track: :class:`mopidy.modes.Track` - """ - taglist = gst.TagList() - artists = [a for a in (track.artists or []) if a.name] - - # Default to blank data to trick shoutcast into clearing any previous - # values it might have. - taglist[gst.TAG_ARTIST] = u' ' - taglist[gst.TAG_TITLE] = u' ' - taglist[gst.TAG_ALBUM] = u' ' - - if artists: - taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) - - if track.name: - taglist[gst.TAG_TITLE] = track.name - - if track.album and track.album.name: - taglist[gst.TAG_ALBUM] = track.album.name - - event = gst.event_new_tag(taglist) - self._pipeline.send_event(event) - - def connect_output(self, output): - """ - Connect output to pipeline. - - :param output: output to connect to the pipeline - :type output: :class:`gst.Bin` - """ - self._pipeline.add(output) - output.sync_state_with_parent() # Required to add to running pipe - gst.element_link_many(self._tee, output) - self._outputs.append(output) - logger.debug('GStreamer added %s', output.get_name()) - - def list_outputs(self): - """ - Get list with the name of all active outputs. - - :rtype: list of strings - """ - return [output.get_name() for output in self._outputs] - - def remove_output(self, output): - """ - Remove output from our pipeline. - - :param output: output to remove from the pipeline - :type output: :class:`gst.Bin` - """ - if output not in self._outputs: - raise LookupError('Ouput %s not present in pipeline' - % output.get_name) - teesrc = output.get_pad('sink').get_peer() - handler = teesrc.add_event_probe(self._handle_event_probe) - - struct = gst.Structure('mopidy-unlink-tee') - struct.set_value('handler', handler) - - event = gst.event_new_custom(gst.EVENT_CUSTOM_DOWNSTREAM, struct) - self._tee.send_event(event) - - def _handle_event_probe(self, teesrc, event): - if (event.type == gst.EVENT_CUSTOM_DOWNSTREAM - and event.has_name('mopidy-unlink-tee')): - data = self._get_structure_data(event.get_structure()) - - output = teesrc.get_peer().get_parent() - - teesrc.unlink(teesrc.get_peer()) - teesrc.remove_event_probe(data['handler']) - - output.set_state(gst.STATE_NULL) - self._pipeline.remove(output) - - logger.warning('Removed %s', output.get_name()) - return False - return True - - def _get_structure_data(self, struct): - # Ugly hack to get around missing get_value in pygst bindings :/ - data = {} - def get_data(key, value): - data[key] = value - struct.foreach(get_data) - return data - - def connect_message_handler(self, element, handler): - """ - Attach custom message handler for given element. - - Hook to allow outputs (or other code) to register custom message - handlers for all messages coming from the element in question. - - In the case of outputs, :meth:`mopidy.outputs.BaseOutput.on_connect` - should be used to attach such handlers and care should be taken to - remove them in :meth:`mopidy.outputs.BaseOutput.on_remove` using - :meth:`remove_message_handler`. - - The handler callback will only be given the message in question, and - is free to ignore the message. However, if the handler wants to prevent - the default handling of the message it should return :class:`True` - indicating that the message has been handled. - - Note that there can only be one handler per element. - - :param element: element to watch messages from - :type element: :class:`gst.Element` - :param handler: callable that takes :class:`gst.Message` and returns - :class:`True` if the message has been handeled - :type handler: callable - """ - self._handlers[element] = handler - - def remove_message_handler(self, element): - """ - Remove custom message handler. - - :param element: element to remove message handling from. - :type element: :class:`gst.Element` - """ - self._handlers.pop(element, None) diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py deleted file mode 100644 index acb12e66..00000000 --- a/mopidy/mixers/alsa.py +++ /dev/null @@ -1,60 +0,0 @@ -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(ThreadingActor, BaseMixer): - """ - Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control - volume. - - **Dependencies:** - - - pyalsaaudio >= 0.2 (python-alsaaudio on Debian/Ubuntu) - - **Settings:** - - - :attr:`mopidy.settings.MIXER_ALSA_CONTROL` - """ - - def __init__(self): - super(AlsaMixer, self).__init__() - self._mixer = None - - def on_start(self): - self._mixer = alsaaudio.Mixer(self._get_mixer_control()) - assert self._mixer is not None - - def _get_mixer_control(self): - """Returns the first mixer control candidate that is known to ALSA""" - candidates = self._get_mixer_control_candidates() - for control in candidates: - if control in alsaaudio.mixers(): - logger.info(u'Mixer control in use: %s', control) - return control - else: - logger.debug(u'Mixer control not found, skipping: %s', control) - logger.warning(u'No working mixer controls found. Tried: %s', - candidates) - - def _get_mixer_control_candidates(self): - """ - A mixer named 'Master' does not always exist, so we fall back to using - 'PCM'. If this does not work for you, you may set - :attr:`mopidy.settings.MIXER_ALSA_CONTROL`. - """ - if settings.MIXER_ALSA_CONTROL: - return [settings.MIXER_ALSA_CONTROL] - return [u'Master', u'PCM'] - - def get_volume(self): - # FIXME does not seem to see external volume changes. - return self._mixer.getvolume()[0] - - def set_volume(self, volume): - self._mixer.setvolume(volume) diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py deleted file mode 100644 index 82783be1..00000000 --- a/mopidy/mixers/base.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging - -from mopidy import listeners, settings - -logger = logging.getLogger('mopidy.mixers') - -class BaseMixer(object): - """ - **Settings:** - - - :attr:`mopidy.settings.MIXER_MAX_VOLUME` - """ - - amplification_factor = settings.MIXER_MAX_VOLUME / 100.0 - - @property - def volume(self): - """ - The audio volume - - Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is - equal to 0. Values above 100 is equal to 100. - """ - if not hasattr(self, '_user_volume'): - self._user_volume = 0 - volume = self.get_volume() - if volume is None or not self.amplification_factor < 1: - return volume - else: - user_volume = int(volume / self.amplification_factor) - if (user_volume - 1) <= self._user_volume <= (user_volume + 1): - return self._user_volume - else: - return user_volume - - @volume.setter - def volume(self, volume): - if not hasattr(self, '_user_volume'): - self._user_volume = 0 - volume = int(volume) - if volume < 0: - volume = 0 - elif volume > 100: - volume = 100 - self._user_volume = volume - real_volume = int(volume * self.amplification_factor) - self.set_volume(real_volume) - self._trigger_volume_changed() - - def get_volume(self): - """ - Return volume as integer in range [0, 100]. :class:`None` if unknown. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def set_volume(self, volume): - """ - Set volume as integer in range [0, 100]. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def _trigger_volume_changed(self): - logger.debug(u'Triggering volume changed event') - listeners.BackendListener.send('volume_changed') diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py deleted file mode 100644 index b0abbdb9..00000000 --- a/mopidy/mixers/denon.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.mixers.base import BaseMixer - -logger = logging.getLogger(u'mopidy.mixers.denon') - -class DenonMixer(ThreadingActor, BaseMixer): - """ - Mixer for controlling Denon amplifiers and receivers using the RS-232 - protocol. - - The external mixer is the authoritative source for the current volume. - This allows the user to use his remote control the volume without Mopidy - cancelling the volume setting. - - **Dependencies** - - - pyserial (python-serial on Debian/Ubuntu) - - **Settings** - - - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` - """ - - def __init__(self, device=None): - super(DenonMixer, self).__init__() - self._device = device - self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] - self._volume = 0 - - 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._ensure_open_device() - self._device.write('MV?\r') - vol = str(self._device.readline()[2:4]) - logger.debug(u'_get_volume() = %s' % vol) - return self._levels.index(vol) - - def set_volume(self, volume): - # Clamp according to Denon-spec - if volume > 99: - volume = 99 - self._ensure_open_device() - self._device.write('MV%s\r'% self._levels[volume]) - vol = self._device.readline()[2:4] - self._volume = self._levels.index(vol) - - def _ensure_open_device(self): - if not self._device.isOpen(): - logger.debug(u'(re)connecting to Denon device') - self._device.open() diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py deleted file mode 100644 index 7262e83c..00000000 --- a/mopidy/mixers/dummy.py +++ /dev/null @@ -1,16 +0,0 @@ -from pykka.actor import ThreadingActor - -from mopidy.mixers.base import BaseMixer - -class DummyMixer(ThreadingActor, BaseMixer): - """Mixer which just stores and reports the chosen volume.""" - - def __init__(self): - super(DummyMixer, self).__init__() - self._volume = None - - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py deleted file mode 100644 index a38692db..00000000 --- a/mopidy/mixers/gstreamer_software.py +++ /dev/null @@ -1,23 +0,0 @@ -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry - -from mopidy.mixers.base import BaseMixer -from mopidy.gstreamer import GStreamer - -class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): - """Mixer which uses GStreamer to control volume in software.""" - - def __init__(self): - super(GStreamerSoftwareMixer, self).__init__() - self.output = None - - def on_start(self): - output_refs = ActorRegistry.get_by_class(GStreamer) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() - - def get_volume(self): - return self.output.get_volume().get() - - def set_volume(self, volume): - self.output.set_volume(volume).get() diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py deleted file mode 100644 index 78473308..00000000 --- a/mopidy/mixers/nad.py +++ /dev/null @@ -1,198 +0,0 @@ -import logging -import serial - -from pykka.actor import ThreadingActor - -from mopidy import settings -from mopidy.mixers.base import BaseMixer - -logger = logging.getLogger('mopidy.mixers.nad') - -class NadMixer(ThreadingActor, BaseMixer): - """ - Mixer for controlling NAD amplifiers and receivers using the NAD RS-232 - protocol. - - The NAD mixer was created using a NAD C 355BEE amplifier, but should also - work with other NAD amplifiers supporting the same RS-232 protocol (v2.x). - The C 355BEE does not give you access to the current volume. It only - supports increasing or decreasing the volume one step at the time. Other - NAD amplifiers may support more advanced volume adjustment than what is - currently used by this mixer. - - Sadly, this means that if you use the remote control to change the volume - on the amplifier, Mopidy will no longer report the correct volume. - - **Dependencies** - - - pyserial (python-serial on Debian/Ubuntu) - - **Settings** - - - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` - - :attr:`mopidy.settings.MIXER_EXT_SOURCE` -- Example: ``Aux`` - - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_A` -- Example: ``On`` - - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_B` -- Example: ``Off`` - - """ - - def __init__(self): - super(NadMixer, self).__init__() - self._volume_cache = None - self._nad_talker = NadTalker.start().proxy() - - def get_volume(self): - return self._volume_cache - - def set_volume(self, volume): - self._volume_cache = volume - self._nad_talker.set_volume(volume) - - -class NadTalker(ThreadingActor): - """ - Independent process which does the communication with the NAD device. - - Since the communication is done in an independent process, Mopidy won't - block other requests while doing rather time consuming work like - calibrating the NAD device's volume. - """ - - # Timeout in seconds used for read/write operations. - # If you set the timeout too low, the reads will never get complete - # confirmations and calibration will decrease volume forever. If you set - # the timeout too high, stuff takes more time. 0.2s seems like a good value - # for NAD C 355BEE. - TIMEOUT = 0.2 - - # Number of volume levels the device supports. 40 for NAD C 355BEE. - VOLUME_LEVELS = 40 - - # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. - _nad_volume = None - - def __init__(self): - super(NadTalker, self).__init__() - self._device = None - - def on_start(self): - self._open_connection() - 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.Serial(port=settings.MIXER_EXT_PORT, - baudrate=115200, timeout=self.TIMEOUT) - self._get_device_model() - - def _set_device_to_known_state(self): - self._power_device_on() - self._select_speakers() - self._select_input_source() - self._unmute() - self._calibrate_volume() - - def _get_device_model(self): - model = self._ask_device('Main.Model') - logger.info(u'Connected to device of model "%s"', model) - return model - - def _power_device_on(self): - while self._ask_device('Main.Power') != 'On': - logger.info(u'Powering device on') - self._command_device('Main.Power', 'On') - - def _select_speakers(self): - if settings.MIXER_EXT_SPEAKERS_A is not None: - while (self._ask_device('Main.SpeakerA') - != settings.MIXER_EXT_SPEAKERS_A): - logger.info(u'Setting speakers A "%s"', - settings.MIXER_EXT_SPEAKERS_A) - self._command_device('Main.SpeakerA', - settings.MIXER_EXT_SPEAKERS_A) - if settings.MIXER_EXT_SPEAKERS_B is not None: - while (self._ask_device('Main.SpeakerB') != - settings.MIXER_EXT_SPEAKERS_B): - logger.info(u'Setting speakers B "%s"', - settings.MIXER_EXT_SPEAKERS_B) - self._command_device('Main.SpeakerB', - settings.MIXER_EXT_SPEAKERS_B) - - def _select_input_source(self): - if settings.MIXER_EXT_SOURCE is not None: - while self._ask_device('Main.Source') != settings.MIXER_EXT_SOURCE: - logger.info(u'Selecting input source "%s"', - settings.MIXER_EXT_SOURCE) - self._command_device('Main.Source', settings.MIXER_EXT_SOURCE) - - def _unmute(self): - while self._ask_device('Main.Mute') != 'Off': - logger.info(u'Unmuting device') - self._command_device('Main.Mute', 'Off') - - def _ask_device(self, key): - self._write('%s?' % key) - 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() - - def _calibrate_volume(self): - # The NAD C 355BEE amplifier has 40 different volume levels. We have no - # way of asking on which level we are. Thus, we must calibrate the - # mixer by decreasing the volume 39 times. - logger.info(u'Calibrating NAD amplifier') - steps_left = self.VOLUME_LEVELS - 1 - while steps_left: - if self._decrease_volume(): - steps_left -= 1 - self._nad_volume = 0 - logger.info(u'Done calibrating NAD amplifier') - - 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) - target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) - if self._nad_volume is None: - return # Calibration needed - while target_nad_volume > self._nad_volume: - if self._increase_volume(): - self._nad_volume += 1 - while target_nad_volume < self._nad_volume: - if self._decrease_volume(): - self._nad_volume -= 1 - - def _increase_volume(self): - # Increase volume. Returns :class:`True` if confirmed by device. - self._write('Main.Volume+') - return self._readline() == 'Main.Volume+' - - def _decrease_volume(self): - # Decrease volume. Returns :class:`True` if confirmed by device. - self._write('Main.Volume-') - return self._readline() == 'Main.Volume-' - - def _write(self, data): - # Write data to device. Prepends and appends a newline to the data, as - # recommended by the NAD documentation. - if not self._device.isOpen(): - self._device.open() - self._device.write('\n%s\n' % data) - logger.debug('Write: %s', data) - - def _readline(self): - # Read line from device. The result is stripped for leading and - # trailing whitespace. - if not self._device.isOpen(): - self._device.open() - result = self._device.readline().strip() - if result: - logger.debug('Read: %s', result) - return result diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py deleted file mode 100644 index bd97d790..00000000 --- a/mopidy/mixers/osa.py +++ /dev/null @@ -1,46 +0,0 @@ -from subprocess import Popen, PIPE -import time - -from pykka.actor import ThreadingActor - -from mopidy.mixers.base import BaseMixer - -class OsaMixer(ThreadingActor, BaseMixer): - """ - Mixer which uses ``osascript`` on OS X to control volume. - - **Dependencies:** - - - None - - **Settings:** - - - None - """ - - CACHE_TTL = 30 - - _cache = None - _last_update = None - - def _valid_cache(self): - return (self._cache is not None - and self._last_update is not None - and (int(time.time() - self._last_update) < self.CACHE_TTL)) - - def get_volume(self): - if not self._valid_cache(): - try: - self._cache = int(Popen( - ['osascript', '-e', - 'output volume of (get volume settings)'], - stdout=PIPE).communicate()[0]) - except ValueError: - self._cache = None - self._last_update = int(time.time()) - return self._cache - - def set_volume(self, volume): - Popen(['osascript', '-e', 'set volume output volume %d' % volume]) - self._cache = volume - self._last_update = int(time.time()) diff --git a/mopidy/models.py b/mopidy/models.py index 9a508ba7..507ca088 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,5 +1,6 @@ from collections import namedtuple + class ImmutableObject(object): """ Superclass for immutable objects whose fields can only be modified via the @@ -74,6 +75,19 @@ class ImmutableObject(object): % key) return self.__class__(**data) + def serialize(self): + data = {} + for key in self.__dict__.keys(): + public_key = key.lstrip('_') + value = self.__dict__[key] + if isinstance(value, (set, frozenset, list, tuple)): + value = [o.serialize() for o in value] + elif isinstance(value, ImmutableObject): + value = value.serialize() + if value: + data[public_key] = value + return data + class Artist(ImmutableObject): """ @@ -144,8 +158,8 @@ class Track(ImmutableObject): :type album: :class:`Album` :param track_no: track number in album :type track_no: integer - :param date: track release date - :type date: :class:`datetime.date` + :param date: track release date (YYYY or YYYY-MM-DD) + :type date: string :param length: track length in milliseconds :type length: integer :param bitrate: bitrate in kbit/s @@ -216,6 +230,8 @@ class Playlist(ImmutableObject): self.__dict__['tracks'] = tuple(kwargs.pop('tracks', [])) super(Playlist, self).__init__(*args, **kwargs) + # TODO: def insert(self, pos, track): ... ? + @property def length(self): """The number of tracks in the playlist. Read-only.""" diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py deleted file mode 100644 index ba242c4b..00000000 --- a/mopidy/outputs/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -import pygst -pygst.require('0.10') -import gst - -import logging - -logger = logging.getLogger('mopidy.outputs') - -class BaseOutput(object): - """Base class for pluggable audio outputs.""" - - MESSAGE_EOS = gst.MESSAGE_EOS - MESSAGE_ERROR = gst.MESSAGE_ERROR - MESSAGE_WARNING = gst.MESSAGE_WARNING - - def __init__(self, gstreamer): - self.gstreamer = gstreamer - self.bin = self._build_bin() - self.bin.set_name(self.get_name()) - - self.modify_bin() - - def _build_bin(self): - description = 'queue ! %s' % self.describe_bin() - logger.debug('Creating new output: %s', description) - return gst.parse_bin_from_description(description, True) - - def connect(self): - """Attach output to GStreamer pipeline.""" - self.gstreamer.connect_output(self.bin) - self.on_connect() - - def on_connect(self): - """ - Called after output has been connected to GStreamer pipeline. - - *MAY be implemented by subclass.* - """ - pass - - def remove(self): - """Remove output from GStreamer pipeline.""" - self.gstreamer.remove_output(self.bin) - self.on_remove() - - def on_remove(self): - """ - Called after output has been removed from GStreamer pipeline. - - *MAY be implemented by subclass.* - """ - pass - - def get_name(self): - """ - Get name of the output. Defaults to the output's class name. - - *MAY be implemented by subclass.* - - :rtype: string - """ - return self.__class__.__name__ - - def modify_bin(self): - """ - Modifies ``self.bin`` before it is installed if needed. - - Overriding this method allows for outputs to modify the constructed bin - before it is installed. This can for instance be a good place to call - `set_properties` on elements that need to be configured. - - *MAY be implemented by subclass.* - """ - pass - - def describe_bin(self): - """ - Return string describing the output bin in :command:`gst-launch` - format. - - For simple cases this can just be a sink such as ``autoaudiosink``, - or it can be a chain like ``element1 ! element2 ! sink``. See the - manpage of :command:`gst-launch` for details on the format. - - *MUST be implemented by subclass.* - - :rtype: string - """ - raise NotImplementedError - - def set_properties(self, element, properties): - """ - Helper method for setting of properties on elements. - - Will call :meth:`gst.Element.set_property` on ``element`` for each key - in ``properties`` that has a value that is not :class:`None`. - - :param element: element to set properties on - :type element: :class:`gst.Element` - :param properties: properties to set on element - :type properties: dict - """ - for key, value in properties.items(): - if value is not None: - element.set_property(key, value) diff --git a/mopidy/outputs/custom.py b/mopidy/outputs/custom.py deleted file mode 100644 index 09239a44..00000000 --- a/mopidy/outputs/custom.py +++ /dev/null @@ -1,34 +0,0 @@ -from mopidy import settings -from mopidy.outputs import BaseOutput - -class CustomOutput(BaseOutput): - """ - Custom output for using alternate setups. - - This output is intended to handle two main cases: - - 1. Simple things like switching which sink to use. Say :class:`LocalOutput` - doesn't work for you and you want to switch to ALSA, simple. Set - :attr:`mopidy.settings.CUSTOM_OUTPUT` to ``alsasink`` and you are good - to go. Some possible sinks include: - - - alsasink - - osssink - - pulsesink - - ...and many more - - 2. Advanced setups that require complete control of the output bin. For - these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a - :command:`gst-launch` compatible string describing the target setup. - - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.CUSTOM_OUTPUT` - """ - - def describe_bin(self): - return settings.CUSTOM_OUTPUT diff --git a/mopidy/outputs/local.py b/mopidy/outputs/local.py deleted file mode 100644 index 8101e026..00000000 --- a/mopidy/outputs/local.py +++ /dev/null @@ -1,20 +0,0 @@ -from mopidy.outputs import BaseOutput - -class LocalOutput(BaseOutput): - """ - Basic output to local audio sink. - - This output will normally tell GStreamer to choose whatever it thinks is - best for your system. In other words this is usually a sane choice. - - **Dependencies:** - - - None - - **Settings:** - - - None - """ - - def describe_bin(self): - return 'autoaudiosink' diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py deleted file mode 100644 index ffe09aae..00000000 --- a/mopidy/outputs/shoutcast.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging - -from mopidy import settings -from mopidy.outputs import BaseOutput - -logger = logging.getLogger('mopidy.outputs.shoutcast') - -class ShoutcastOutput(BaseOutput): - """ - Shoutcast streaming output. - - This output allows for streaming to an icecast server or anything else that - supports Shoutcast. The output supports setting for: server address, port, - mount point, user, password and encoder to use. Please see - :class:`mopidy.settings` for details about settings. - - **Dependencies:** - - - A SHOUTcast/Icecast server - - **Settings:** - - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT` - - :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER` - """ - - def describe_bin(self): - return 'audioconvert ! %s ! shout2send name=shoutcast' \ - % settings.SHOUTCAST_OUTPUT_ENCODER - - def modify_bin(self): - self.set_properties(self.bin.get_by_name('shoutcast'), { - u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME, - u'port': settings.SHOUTCAST_OUTPUT_PORT, - u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, - u'username': settings.SHOUTCAST_OUTPUT_USERNAME, - u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, - }) - - def on_connect(self): - self.gstreamer.connect_message_handler( - self.bin.get_by_name('shoutcast'), self.message_handler) - - def on_remove(self): - self.gstreamer.remove_message_handler( - self.bin.get_by_name('shoutcast')) - - def message_handler(self, message): - if message.type != self.MESSAGE_ERROR: - return False - error, debug = message.parse_error() - logger.warning('%s (%s)', error, debug) - self.remove() - return True diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 3bcf03d9..29511c80 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -52,7 +52,7 @@ def translator(data): class Scanner(object): def __init__(self, folder, data_callback, error_callback=None): - self.uris = [path_to_uri(f) for f in find_files(folder)] + self.files = find_files(folder) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() @@ -114,18 +114,19 @@ class Scanner(object): return None def next_uri(self): - if not self.uris: - return self.stop() - + try: + uri = path_to_uri(self.files.next()) + except StopIteration: + self.stop() + return False self.pipe.set_state(gst.STATE_NULL) - self.uribin.set_property('uri', self.uris.pop()) + self.uribin.set_property('uri', uri) self.pipe.set_state(gst.STATE_PAUSED) + return True def start(self): - if not self.uris: - return - self.next_uri() - self.loop.run() + if self.next_uri(): + self.loop.run() def stop(self): self.pipe.set_state(gst.STATE_NULL) diff --git a/mopidy/settings.py b/mopidy/settings.py index ccbf8457..98f7e05e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -1,5 +1,5 @@ """ -Available settings and their default values. +All available settings and their default values. .. warning:: @@ -14,6 +14,10 @@ Available settings and their default values. #: #: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) #: +#: Other typical values:: +#: +#: BACKENDS = (u'mopidy.backends.local.LocalBackend',) +#: #: .. note:: #: Currently only the first backend in the list is used. BACKENDS = ( @@ -26,14 +30,6 @@ BACKENDS = ( #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' -#: Which GStreamer bin description to use in -#: :class:`mopidy.outputs.custom.CustomOutput`. -#: -#: Default:: -#: -#: CUSTOM_OUTPUT = u'fakesink' -CUSTOM_OUTPUT = u'fakesink' - #: The log format used for debug logging. #: #: See http://docs.python.org/library/logging.html#formatter-objects for @@ -89,9 +85,8 @@ LASTFM_PASSWORD = u'' #: #: Default:: #: -#: # Defaults to asking glib where music is stored, fallback is ~/music -#: LOCAL_MUSIC_PATH = None -LOCAL_MUSIC_PATH = None +#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' +LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' #: Path to playlist folder with m3u files for local music. #: @@ -99,8 +94,8 @@ LOCAL_MUSIC_PATH = None #: #: Default:: #: -#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists -LOCAL_PLAYLIST_PATH = None +#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' +LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: Path to tag cache for local music. #: @@ -108,57 +103,38 @@ LOCAL_PLAYLIST_PATH = None #: #: Default:: #: -#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache -LOCAL_TAG_CACHE_FILE = None +#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' +LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' -#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. +#: Sound mixer to use. +#: +#: Expects a GStreamer mixer to use, typical values are: +#: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. +#: +#: Setting this to :class:`None` turns off volume control. ``software`` +#: can be used to force software mixing in the application. #: #: Default:: #: -#: MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer' -MIXER = u'mopidy.mixers.gstreamer_software.GStreamerSoftwareMixer' +#: MIXER = u'autoaudiomixer' +MIXER = u'autoaudiomixer' -#: ALSA mixer only. What mixer control to use. If set to :class:`False`, first -#: ``Master`` and then ``PCM`` will be tried. +#: Sound mixer track to use. #: -#: Example: ``Master Front``. Default: :class:`False` -MIXER_ALSA_CONTROL = False - -#: External mixers only. Which port the mixer is connected to. -#: -#: This must point to the device port like ``/dev/ttyUSB0``. -#: -#: Default: :class:`None` -MIXER_EXT_PORT = None - -#: External mixers only. What input source the external mixer should use. -#: -#: Example: ``Aux``. Default: :class:`None` -MIXER_EXT_SOURCE = None - -#: External mixers only. What state Speakers A should be in. -#: -#: Default: :class:`None`. -MIXER_EXT_SPEAKERS_A = None - -#: External mixers only. What state Speakers B should be in. -#: -#: Default: :class:`None`. -MIXER_EXT_SPEAKERS_B = None - -#: The maximum volume. Integer in the range 0 to 100. -#: -#: If this settings is set to 80, the mixer will set the actual volume to 80 -#: when asked to set it to 100. +#: Name of the mixer track to use. If this is not set we will try to find the +#: master output track. As an example, using ``alsamixer`` you would +#: typically set this to ``Master`` or ``PCM``. #: #: Default:: #: -#: MIXER_MAX_VOLUME = 100 -MIXER_MAX_VOLUME = 100 +#: MIXER_TRACK = None +MIXER_TRACK = None #: Which address Mopidy's MPD server should bind to. #: -#:Examples: +#: Used by :mod:`mopidy.frontends.mpd`. +#: +#: Examples: #: #: ``127.0.0.1`` #: Listens only on the IPv4 loopback interface. Default. @@ -172,89 +148,40 @@ MPD_SERVER_HOSTNAME = u'127.0.0.1' #: Which TCP port Mopidy's MPD server should listen to. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: 6600 MPD_SERVER_PORT = 6600 #: The password required for connecting to the MPD server. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: :class:`None`, which means no password required. MPD_SERVER_PASSWORD = None #: The maximum number of concurrent connections the MPD server will accept. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 -#: List of outputs to use. See :mod:`mopidy.outputs` for all available -#: backends +#: Output to use. See :mod:`mopidy.outputs` for all available backends #: #: Default:: #: -#: OUTPUTS = ( -#: u'mopidy.outputs.local.LocalOutput', -#: ) -OUTPUTS = ( - u'mopidy.outputs.local.LocalOutput', -) - -#: Hostname of the SHOUTcast server which Mopidy should stream audio to. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' -SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' - -#: Port of the SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_PORT = 8000 -SHOUTCAST_OUTPUT_PORT = 8000 - -#: User to authenticate as against SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_USERNAME = u'source' -SHOUTCAST_OUTPUT_USERNAME = u'source' - -#: Password to authenticate with against SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme' -SHOUTCAST_OUTPUT_PASSWORD = u'hackme' - -#: Mountpoint to use for the stream on the SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_MOUNT = u'/stream' -SHOUTCAST_OUTPUT_MOUNT = u'/stream' - -#: Encoder to use to process audio data before streaming to SHOUTcast server. -#: -#: Used by :mod:`mopidy.outputs.shoutcast`. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' -SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' +#: OUTPUT = u'autoaudiosink' +OUTPUT = u'autoaudiosink' #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_CACHE_PATH = None +#: +#: Default:: +#: +#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' +SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' #: Your Spotify Premium username. #: @@ -271,7 +198,7 @@ SPOTIFY_PASSWORD = u'' #: Available values are 96, 160, and 320. #: #: Used by :mod:`mopidy.backends.spotify`. -# +#: #: Default:: #: #: SPOTIFY_BITRATE = 160 diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index 00129cdd..aacc2e85 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1,3 +1,5 @@ +from __future__ import division + import locale import logging import os @@ -5,6 +7,8 @@ import sys logger = logging.getLogger('mopidy.utils') + +# TODO: use itertools.chain.from_iterable(the_list)? def flatten(the_list): result = [] for element in the_list: @@ -14,22 +18,33 @@ def flatten(the_list): result.append(element) return result + +def rescale(v, old=None, new=None): + """Convert value between scales.""" + new_min, new_max = new + old_min, old_max = old + scaling = float(new_max - new_min) / (old_max - old_min) + return round(scaling * (v - old_min) + new_min) + + def import_module(name): __import__(name) return sys.modules[name] + def get_class(name): logger.debug('Loading: %s', name) if '.' not in name: raise ImportError("Couldn't load: %s" % name) module_name = name[:name.rindex('.')] - class_name = name[name.rindex('.') + 1:] + cls_name = name[name.rindex('.') + 1:] try: module = import_module(module_name) - class_object = getattr(module, class_name) + cls = getattr(module, cls_name) except (ImportError, AttributeError): raise ImportError("Couldn't load: %s" % name) - return class_object + return cls + def locale_decode(bytestr): try: diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py new file mode 100644 index 00000000..2c68e429 --- /dev/null +++ b/mopidy/utils/deps.py @@ -0,0 +1,193 @@ +import os +import platform +import sys + +import pygst +pygst.require('0.10') +import gst + +import pykka + +from mopidy.utils.log import indent + + +def list_deps_optparse_callback(*args): + """ + Prints a list of all dependencies. + + Called by optparse when Mopidy is run with the :option:`--list-deps` + option. + """ + print format_dependency_list() + sys.exit(0) + + +def format_dependency_list(adapters=None): + if adapters is None: + adapters = [ + platform_info, + python_info, + gstreamer_info, + pykka_info, + pyspotify_info, + pylast_info, + dbus_info, + serial_info, + ] + + lines = [] + for adapter in adapters: + dep_info = adapter() + lines.append('%(name)s: %(version)s' % { + 'name': dep_info['name'], + 'version': dep_info.get('version', 'not found'), + }) + if 'path' in dep_info: + lines.append(' Imported from: %s' % ( + os.path.dirname(dep_info['path']))) + if 'other' in dep_info: + lines.append(' Other: %s' % ( + indent(dep_info['other'])),) + return '\n'.join(lines) + + +def platform_info(): + return { + 'name': 'Platform', + 'version': platform.platform(), + } + + +def python_info(): + return { + 'name': 'Python', + 'version': '%s %s' % (platform.python_implementation(), + platform.python_version()), + 'path': platform.__file__, + } + + +def gstreamer_info(): + other = [] + other.append('Python wrapper: gst-python %s' % ( + '.'.join(map(str, gst.get_pygst_version())))) + other.append('Relevant elements:') + for name, status in _gstreamer_check_elements(): + other.append(' %s: %s' % (name, 'OK' if status else 'not found')) + return { + 'name': 'GStreamer', + 'version': '.'.join(map(str, gst.get_gst_version())), + 'path': gst.__file__, + 'other': '\n'.join(other), + } + + +def _gstreamer_check_elements(): + elements_to_check = [ + # Core playback + 'uridecodebin', + + # External HTTP streams + 'souphttpsrc', + + # Spotify + 'appsrc', + + # Mixers and sinks + 'alsamixer', + 'alsasink', + 'ossmixer', + 'osssink', + 'oss4mixer', + 'oss4sink', + 'pulsemixer', + 'pulsesink', + + # MP3 encoding and decoding + 'mp3parse', + 'mad', + 'id3demux', + 'id3v2mux', + 'lame', + + # Ogg Vorbis encoding and decoding + 'vorbisdec', + 'vorbisenc', + 'vorbisparse', + 'oggdemux', + 'oggmux', + 'oggparse', + + # Flac decoding + 'flacdec', + 'flacparse', + + # Shoutcast output + 'shout2send', + ] + known_elements = [factory.get_name() for factory in + gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] + return [(element, element in known_elements) for element in elements_to_check] + + +def pykka_info(): + if hasattr(pykka, '__version__'): + # Pykka >= 0.14 + version = pykka.__version__ + else: + # Pykka < 0.14 + version = pykka.get_version() + return { + 'name': 'Pykka', + 'version': version, + 'path': pykka.__file__, + } + + +def pyspotify_info(): + dep_info = {'name': 'pyspotify'} + try: + import spotify + if hasattr(spotify, '__version__'): + dep_info['version'] = spotify.__version__ + else: + dep_info['version'] = '< 1.3' + dep_info['path'] = spotify.__file__ + dep_info['other'] = 'Built for libspotify API version %d' % ( + spotify.api_version,) + except ImportError: + pass + return dep_info + + +def pylast_info(): + dep_info = {'name': 'pylast'} + try: + import pylast + dep_info['version'] = pylast.__version__ + dep_info['path'] = pylast.__file__ + except ImportError: + pass + return dep_info + + +def dbus_info(): + dep_info = {'name': 'dbus-python'} + try: + import dbus + dep_info['version'] = dbus.__version__ + dep_info['path'] = dbus.__file__ + except ImportError: + pass + return dep_info + + +def serial_info(): + dep_info = {'name': 'pyserial'} + try: + import serial + dep_info['version'] = serial.VERSION + dep_info['path'] = serial.__file__ + except ImportError: + pass + return dep_info diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 0e5dfc29..191efa2f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -9,8 +9,9 @@ def setup_logging(verbosity_level, save_debug_log): if save_debug_log: setup_debug_logging_to_file() logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s on %s %s', - get_version(), get_platform(), get_python()) + logger.info(u'Starting Mopidy %s', get_version()) + logger.info(u'Platform: %s', get_platform()) + logger.info(u'Python: %s', get_python()) def setup_root_logger(): root = logging.getLogger('') diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 4b8a9ac9..7d97daf8 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -150,7 +150,7 @@ class Connection(object): logger.log(level, reason) try: - self.actor_ref.stop() + self.actor_ref.stop(block=False) except ActorDeadError: pass diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 5d99ac12..7f1b9233 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,11 +1,20 @@ +import glib import logging import os -import sys import re +import string +import sys import urllib logger = logging.getLogger('mopidy.utils.path') +XDG_DIRS = { + 'XDG_CACHE_DIR': glib.get_user_cache_dir(), + 'XDG_DATA_DIR': glib.get_user_data_dir(), + 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), +} + + def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): @@ -16,6 +25,7 @@ def get_or_create_folder(folder): os.makedirs(folder, 0755) return folder + def get_or_create_file(filename): filename = os.path.expanduser(filename) if not os.path.isfile(filename): @@ -23,6 +33,7 @@ def get_or_create_file(filename): open(filename, 'w') return filename + def path_to_uri(*paths): path = os.path.join(*paths) path = path.encode('utf-8') @@ -30,6 +41,7 @@ def path_to_uri(*paths): return 'file:' + urllib.pathname2url(path) return 'file://' + urllib.pathname2url(path) + def uri_to_path(uri): if sys.platform == 'win32': path = urllib.url2pathname(re.sub('^file:', '', uri)) @@ -37,6 +49,7 @@ def uri_to_path(uri): path = urllib.url2pathname(re.sub('^file://', '', uri)) return path.encode('latin1').decode('utf-8') # Undo double encoding + def split_path(path): parts = [] while True: @@ -47,21 +60,40 @@ def split_path(path): break return parts -# pylint: disable = W0612 -# Unused variable 'dirnames' + +def expand_path(path): + path = string.Template(path).safe_substitute(XDG_DIRS) + path = os.path.expanduser(path) + path = os.path.abspath(path) + return path + + def find_files(path): if os.path.isfile(path): if not isinstance(path, unicode): path = path.decode('utf-8') - yield path + if not os.path.basename(path).startswith('.'): + yield path else: for dirpath, dirnames, filenames in os.walk(path): + # Filter out hidden folders by modifying dirnames in place. + for dirname in dirnames: + if dirname.startswith('.'): + dirnames.remove(dirname) + for filename in filenames: + # Skip hidden files. + if filename.startswith('.'): + continue + filename = os.path.join(dirpath, filename) if not isinstance(filename, unicode): - filename = filename.decode('utf-8') + try: + filename = filename.decode('utf-8') + except UnicodeDecodeError: + filename = filename.decode('latin1') yield filename -# pylint: enable = W0612 + # FIXME replace with mock usage in tests. class Mtime(object): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index ff449a61..5468b9bf 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,17 +1,20 @@ # Absolute import needed to import ~/.mopidy/settings.py and not ourselves from __future__ import absolute_import -from copy import copy + +import copy import getpass import logging import os -from pprint import pformat +import pprint import sys from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE -from mopidy.utils.log import indent +from mopidy.utils import log +from mopidy.utils import path logger = logging.getLogger('mopidy.utils.settings') + class SettingsProxy(object): def __init__(self, default_settings_module): self.default = self._get_settings_dict_from_module( @@ -38,7 +41,7 @@ class SettingsProxy(object): @property def current(self): - current = copy(self.default) + current = copy.copy(self.default) current.update(self.local) current.update(self.runtime) return current @@ -46,16 +49,18 @@ class SettingsProxy(object): def __getattr__(self, attr): if not self._is_setting(attr): return - if attr not in self.current: + + current = self.current # bind locally to avoid copying+updates + if attr not in current: raise SettingsError(u'Setting "%s" is not set.' % attr) - value = self.current[attr] + + value = current[attr] if isinstance(value, basestring) and len(value) == 0: raise SettingsError(u'Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = os.path.expanduser(value) - value = os.path.abspath(value) + value = path.expand_path(value) return value def __setattr__(self, attr, value): @@ -69,7 +74,7 @@ class SettingsProxy(object): self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): logger.error(u'Settings validation errors: %s', - indent(self.get_errors_as_string())) + log.indent(self.get_errors_as_string())) raise SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): @@ -101,7 +106,7 @@ def validate_settings(defaults, settings): Checks the settings for both errors like misspellings and against a set of rules for renamed settings, etc. - Returns of setting names with associated errors. + Returns mapping from setting names to associated errors. :param defaults: Mopidy's default settings :type defaults: dict @@ -112,15 +117,20 @@ def validate_settings(defaults, settings): errors = {} changed = { + 'CUSTOM_OUTPUT': 'OUTPUT', 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', - 'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT', + 'GSTREAMER_AUDIO_SINK': 'OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', - 'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT', + 'LOCAL_OUTPUT_OVERRIDE': 'OUTPUT', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', - 'OUTPUT': None, + 'MIXER_ALSA_CONTROL': None, + 'MIXER_EXT_PORT': None, + 'MIXER_EXT_SPEAKERS_A': None, + 'MIXER_EXT_SPEAKERS_B': None, + 'MIXER_MAX_VOLUME': None, 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', @@ -136,26 +146,40 @@ def validate_settings(defaults, settings): else: errors[setting] = u'Deprecated setting. Use %s.' % ( changed[setting],) - continue - if setting == 'BACKENDS': + elif setting == 'BACKENDS': if 'mopidy.backends.despotify.DespotifyBackend' in value: - errors[setting] = (u'Deprecated setting value. ' + - '"mopidy.backends.despotify.DespotifyBackend" is no ' + - 'longer available.') - continue + errors[setting] = ( + u'Deprecated setting value. ' + u'"mopidy.backends.despotify.DespotifyBackend" is no ' + u'longer available.') - if setting == 'SPOTIFY_BITRATE': + elif setting == 'OUTPUTS': + errors[setting] = ( + u'Deprecated setting, please change to OUTPUT. OUTPUT expects ' + u'a GStreamer bin description string for your desired output.') + + elif setting == 'SPOTIFY_BITRATE': if value not in (96, 160, 320): - errors[setting] = (u'Unavailable Spotify bitrate. ' + - u'Available bitrates are 96, 160, and 320.') + errors[setting] = ( + u'Unavailable Spotify bitrate. Available bitrates are 96, ' + u'160, and 320.') - if setting not in defaults: - errors[setting] = u'Unknown setting. Is it misspelled?' - continue + elif setting.startswith('SHOUTCAST_OUTPUT_'): + errors[setting] = ( + u'Deprecated setting, please set the value via the GStreamer ' + u'bin in OUTPUT.') + + elif setting not in defaults: + errors[setting] = u'Unknown setting.' + suggestion = did_you_mean(setting, defaults) + + if suggestion: + errors[setting] += u' Did you mean %s?' % suggestion return errors + def list_settings_optparse_callback(*args): """ Prints a list of all settings. @@ -167,22 +191,57 @@ def list_settings_optparse_callback(*args): print format_settings_list(settings) sys.exit(0) + def format_settings_list(settings): errors = settings.get_errors() lines = [] for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent(pformat(masked_value), places=2))) + lines.append(u'%s: %s' % ( + key, log.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: lines.append(u' Default: %s' % - indent(pformat(default_value), places=4)) + log.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) + def mask_value_if_secret(key, value): if key.endswith('PASSWORD') and value: return u'********' else: return value + + +def did_you_mean(setting, defaults): + """Suggest most likely setting based on levenshtein.""" + if not defaults: + return None + + setting = setting.upper() + candidates = [(levenshtein(setting, d), d) for d in defaults] + candidates.sort() + + if candidates[0][0] <= 3: + return candidates[0][1] + return None + + +def levenshtein(a, b, max=3): + """Calculates the Levenshtein distance between a and b.""" + n, m = len(a), len(b) + if n > m: + return levenshtein(b, a) + + current = xrange(n+1) + for i in xrange(1, m+1): + previous, current = current, [i] + [0] * n + for j in xrange(1, n+1): + add, delete = previous[j] + 1, current[j-1] + 1 + change = previous[j-1] + if a[j-1] != b[i-1]: + change += 1 + current[j] = min(add, delete, change) + return current[n] diff --git a/tests/audio_test.py b/tests/audio_test.py new file mode 100644 index 00000000..fcafa75f --- /dev/null +++ b/tests/audio_test.py @@ -0,0 +1,67 @@ +import sys + +from mopidy import audio, settings +from mopidy.utils.path import path_to_uri + +from tests import unittest, path_to_data_dir + + +@unittest.skipIf(sys.platform == 'win32', + 'Our Windows build server does not support GStreamer yet') +class AudioTest(unittest.TestCase): + def setUp(self): + settings.MIXER = 'fakemixer track_max_volume=65536' + settings.OUTPUT = 'fakesink' + self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) + self.audio = audio.Audio.start().proxy() + + def tearDown(self): + self.audio.stop() + settings.runtime.clear() + + def prepare_uri(self, uri): + self.audio.prepare_change() + self.audio.set_uri(uri) + + def test_start_playback_existing_file(self): + self.prepare_uri(self.song_uri) + self.assertTrue(self.audio.start_playback().get()) + + def test_start_playback_non_existing_file(self): + self.prepare_uri(self.song_uri + 'bogus') + self.assertFalse(self.audio.start_playback().get()) + + def test_pause_playback_while_playing(self): + self.prepare_uri(self.song_uri) + self.audio.start_playback() + self.assertTrue(self.audio.pause_playback().get()) + + def test_stop_playback_while_playing(self): + self.prepare_uri(self.song_uri) + self.audio.start_playback() + self.assertTrue(self.audio.stop_playback().get()) + + @unittest.SkipTest + def test_deliver_data(self): + pass # TODO + + @unittest.SkipTest + def test_end_of_data_stream(self): + pass # TODO + + def test_set_volume(self): + for value in range(0, 101): + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) + + @unittest.SkipTest + def test_set_state_encapsulation(self): + pass # TODO + + @unittest.SkipTest + def test_set_position(self): + pass # TODO + + @unittest.SkipTest + def test_invalid_output_raises_error(self): + pass # TODO diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index e99cd56c..430e4c40 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,8 +1,9 @@ import mock import random +from mopidy import audio +from mopidy.core import PlaybackState from mopidy.models import CpTrack, Playlist, Track -from mopidy.gstreamer import GStreamer from tests.backends.base import populate_playlist @@ -12,7 +13,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.gstreamer = mock.Mock(spec=GStreamer) + self.backend.audio = mock.Mock(spec=audio.Audio) self.controller = self.backend.current_playlist self.playback = self.backend.playback @@ -71,9 +72,9 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_clear_when_playing(self): self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.controller.clear() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_get_by_uri_returns_unique_match(self): track = Track(uri='a') @@ -134,13 +135,13 @@ class CurrentPlaylistControllerTest(object): self.playback.play() track = self.playback.current_track self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) @populate_playlist def test_append_preserves_stopped_state(self): self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) def test_index_returns_index_of_track(self): @@ -205,7 +206,7 @@ class CurrentPlaylistControllerTest(object): version = self.controller.version self.controller.remove(uri=track1.uri) self.assert_(version < self.controller.version) - self.assert_(track1 not in self.controller.tracks) + self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @populate_playlist diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4b3ef5c0..f76d9d75 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -34,8 +34,8 @@ class LibraryControllerTest(object): self.assertEqual(track, self.tracks[0]) def test_lookup_unknown_track(self): - test = lambda: self.library.lookup('fake uri') - self.assertRaises(LookupError, test) + track = self.library.lookup('fake uri') + self.assertEquals(track, None) def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 40c49709..1e434e35 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -2,8 +2,9 @@ import mock import random import time +from mopidy import audio +from mopidy.core import PlaybackState from mopidy.models import Track -from mopidy.gstreamer import GStreamer from tests import unittest from tests.backends.base import populate_playlist @@ -16,7 +17,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.gstreamer = mock.Mock(spec=GStreamer) + self.backend.audio = mock.Mock(spec=audio.Audio) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -26,21 +27,21 @@ class PlaybackControllerTest(object): 'First song needs to be at least 2000 miliseconds' def test_initial_state_is_stopped(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_play_with_empty_playlist(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_play_with_empty_playlist_return_value(self): self.assertEqual(self.playback.play(), None) @populate_playlist def test_play_state(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_return_value(self): @@ -48,9 +49,9 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_track_state(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play(self.current_playlist.cp_tracks[-1]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_track_return_value(self): @@ -70,7 +71,7 @@ class PlaybackControllerTest(object): track = self.playback.current_track self.playback.pause() self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) @populate_playlist @@ -81,7 +82,7 @@ class PlaybackControllerTest(object): track = self.playback.current_track self.playback.pause() self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) @populate_playlist @@ -106,12 +107,12 @@ class PlaybackControllerTest(object): def test_current_track_after_completed_playlist(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -141,17 +142,17 @@ class PlaybackControllerTest(object): self.playback.next() self.playback.stop() self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_previous_at_start_of_playlist(self): self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) def test_previous_for_empty_playlist(self): self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -185,20 +186,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_does_not_trigger_playback(self): self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) self.assertEqual(self.playback.current_playlist_position, i) self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_until_end_of_playlist_and_play_from_start(self): @@ -208,15 +209,15 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) def test_next_for_empty_playlist(self): self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_skips_to_next_track_on_failure(self): @@ -273,7 +274,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assert_(self.tracks[0] in self.backend.current_playlist.tracks) + self.assertIn(self.tracks[0], self.backend.current_playlist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -321,20 +322,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_does_not_trigger_playback(self): self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) self.assertEqual(self.playback.current_playlist_position, i) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): @@ -344,15 +345,15 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) def test_end_of_track_for_empty_playlist(self): self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): @@ -410,7 +411,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assert_(self.tracks[0] not in self.backend.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -534,13 +535,13 @@ class PlaybackControllerTest(object): self.playback.play() current_track = self.playback.current_track self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_on_current_playlist_change_when_stopped(self): self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -549,26 +550,26 @@ class PlaybackControllerTest(object): self.playback.pause() current_track = self.playback.current_track self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.backend.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_pause_when_stopped(self): self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_when_playing(self): self.playback.play() self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_when_paused(self): self.playback.play() self.playback.pause() self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_return_value(self): @@ -578,20 +579,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_resume_when_stopped(self): self.playback.resume() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_resume_when_playing(self): self.playback.play() self.playback.resume() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_resume_when_paused(self): self.playback.play() self.playback.pause() self.playback.resume() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_resume_return_value(self): @@ -624,12 +625,12 @@ class PlaybackControllerTest(object): def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_seek_when_stopped_triggers_play(self): self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_seek_when_playing(self): @@ -666,7 +667,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @unittest.SkipTest @populate_playlist @@ -686,7 +687,7 @@ class PlaybackControllerTest(object): def test_seek_beyond_end_of_song_for_last_track(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.seek(self.current_playlist.tracks[-1].length * 100) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @unittest.SkipTest @populate_playlist @@ -702,25 +703,25 @@ class PlaybackControllerTest(object): self.playback.seek(-1000) position = self.playback.time_position self.assert_(position >= 0, position) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_stop_when_stopped(self): self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_stop_when_playing(self): self.playback.play() self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_stop_when_paused(self): self.playback.play() self.playback.pause() self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_stop_return_value(self): self.playback.play() @@ -729,7 +730,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.gstreamer.get_position = mock.Mock(return_value=future) + self.backend.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -737,7 +738,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.gstreamer.get_position = mock.Mock(return_value=future) + self.backend.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -810,7 +811,7 @@ class PlaybackControllerTest(object): def test_end_of_playlist_stops(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): self.assertEqual(self.playback.repeat, False) @@ -835,9 +836,9 @@ class PlaybackControllerTest(object): for _ in self.tracks: self.playback.next() self.assertNotEqual(self.playback.track_at_next, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_random_until_end_of_playlist_with_repeat(self): @@ -854,7 +855,7 @@ class PlaybackControllerTest(object): self.playback.play() played = [] for _ in self.tracks: - self.assert_(self.playback.current_track not in played) + self.assertNotIn(self.playback.current_track, played) played.append(self.playback.current_track) self.playback.next() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 54315e62..1e575b9e 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -30,7 +30,7 @@ class StoredPlaylistsControllerTest(object): def test_create_in_playlists(self): playlist = self.stored.create('test') self.assert_(self.stored.playlists) - self.assert_(playlist in self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) def test_playlists_empty_to_start_with(self): self.assert_(not self.stored.playlists) @@ -101,7 +101,7 @@ class StoredPlaylistsControllerTest(object): # FIXME should we handle playlists without names? playlist = Playlist(name='test') self.stored.save(playlist) - self.assert_(playlist in self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 788fe33c..c167fbcc 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -2,6 +2,7 @@ import sys from mopidy import settings from mopidy.backends.local import LocalBackend +from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -34,19 +35,19 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): self.backend.current_playlist.add(track) def test_uri_scheme(self): - self.assert_('file' in self.backend.uri_schemes) + self.assertIn('file', self.backend.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_ogg(self): self.add_track('blank.ogg') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_flac(self): self.add_track('blank.flac') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 1dceb737..08f29c1b 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -9,6 +9,7 @@ from mopidy.models import Track, Artist, Album from tests import unittest, path_to_data_dir +data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir(u'æøå.mp3') @@ -21,22 +22,32 @@ encoded_uri = path_to_uri(encoded_path) class M3UToUriTest(unittest.TestCase): def test_empty_file(self): - uris = parse_m3u(path_to_data_dir('empty.m3u')) + uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) self.assertEqual([], uris) def test_basic_file(self): - uris = parse_m3u(path_to_data_dir('one.m3u')) + uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir) self.assertEqual([song1_uri], uris) def test_file_with_comment(self): - uris = parse_m3u(path_to_data_dir('comment.m3u')) + uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) self.assertEqual([song1_uri], uris) + def test_file_is_relative_to_correct_folder(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write('song1.mp3') + try: + uris = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_uri], uris) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + def test_file_with_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): @@ -48,29 +59,28 @@ class M3UToUriTest(unittest.TestCase): tmp.write('# comment \n') tmp.write(song2_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri, song2_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) - def test_file_with_uri(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - uris = parse_m3u(path_to_data_dir('encoding.m3u')) + uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) self.assertEqual([encoded_uri], uris) def test_open_missing_file(self): - uris = parse_m3u(path_to_data_dir('non-existant.m3u')) + uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) self.assertEqual([], uris) diff --git a/mopidy/mixers/__init__.py b/tests/core/__init__.py similarity index 100% rename from mopidy/mixers/__init__.py rename to tests/core/__init__.py diff --git a/tests/data/.blank.mp3 b/tests/data/.blank.mp3 new file mode 100644 index 00000000..ef159a70 Binary files /dev/null and b/tests/data/.blank.mp3 differ diff --git a/tests/mixers/__init__.py b/tests/data/.hidden/.gitignore similarity index 100% rename from tests/mixers/__init__.py rename to tests/data/.hidden/.gitignore diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index bfa7c548..9f05d7dd 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -2,7 +2,6 @@ from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request -from mopidy.mixers.dummy import DummyMixer from tests import unittest @@ -10,12 +9,10 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): self.backend = DummyBackend.start().proxy() - self.mixer = DummyMixer.start().proxy() self.dispatcher = MpdDispatcher() def tearDown(self): self.backend.stop().get() - self.mixer.stop().get() def test_register_same_pattern_twice_fails(self): func = lambda: None @@ -40,7 +37,7 @@ class MpdDispatcherTest(unittest.TestCase): expected_handler (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) - self.assert_('arg1' in kwargs) + self.assertIn('arg1', kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') def test_handling_unknown_request_yields_error(self): @@ -51,5 +48,5 @@ class MpdDispatcherTest(unittest.TestCase): expected = 'magic' request_handlers['known request'] = lambda x: expected result = self.dispatcher.handle_request('known request') - self.assert_(u'OK' in result) - self.assert_(expected in result) + self.assertIn(u'OK', result) + self.assertIn(expected, result) diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index b54906be..3b8fbe33 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -3,7 +3,6 @@ import mock from mopidy import settings from mopidy.backends import dummy as backend from mopidy.frontends import mpd -from mopidy.mixers import dummy as mixer from tests import unittest @@ -23,7 +22,6 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): self.backend = backend.DummyBackend.start().proxy() - self.mixer = mixer.DummyMixer.start().proxy() self.connection = MockConnection() self.session = mpd.MpdSession(self.connection) @@ -32,7 +30,6 @@ class BaseTestCase(unittest.TestCase): def tearDown(self): self.backend.stop().get() - self.mixer.stop().get() settings.runtime.clear() def sendRequest(self, request): @@ -45,7 +42,7 @@ class BaseTestCase(unittest.TestCase): self.assertEqual([], self.connection.response) def assertInResponse(self, value): - self.assert_(value in self.connection.response, u'Did not find %s ' + self.assertIn(value, self.connection.response, u'Did not find %s ' 'in %s' % (repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): @@ -54,7 +51,7 @@ class BaseTestCase(unittest.TestCase): (repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): - self.assert_(value not in self.connection.response, u'Found %s in %s' % + self.assertNotIn(value, self.connection.response, u'Found %s in %s' % (repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py index 20422f5b..0f0d9c86 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -38,7 +38,6 @@ class AuthenticationTest(protocol.BaseTestCase): self.sendRequest(u'close') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index a81725ad..65b051d3 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -21,7 +21,7 @@ class CommandListsTest(protocol.BaseTestCase): self.assertEqual([], self.dispatcher.command_list) self.assertEqual(False, self.dispatcher.command_list_ok) self.sendRequest(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) + self.assertIn(u'ping', self.dispatcher.command_list) self.sendRequest(u'command_list_end') self.assertInResponse(u'OK') self.assertEqual(False, self.dispatcher.command_list) @@ -42,7 +42,7 @@ class CommandListsTest(protocol.BaseTestCase): self.assertEqual([], self.dispatcher.command_list) self.assertEqual(True, self.dispatcher.command_list_ok) self.sendRequest(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) + self.assertIn(u'ping', self.dispatcher.command_list) self.sendRequest(u'command_list_end') self.assertInResponse(u'list_OK') self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 321fc6ee..21889e82 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -285,6 +285,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistinfo_with_songpos(self): + # Make the track's CPID not match the playlist position + self.backend.current_playlist.cp_id = 17 self.backend.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 01658f6d..4f8f7430 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,12 +1,13 @@ -from mopidy.backends import base as backend +from mopidy.core import PlaybackState from mopidy.models import Track from tests import unittest from tests.frontends.mpd import protocol -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED + +PAUSED = PlaybackState.PAUSED +PLAYING = PlaybackState.PLAYING +STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): @@ -76,37 +77,37 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_setvol_below_min(self): self.sendRequest(u'setvol "-10"') - self.assertEqual(0, self.mixer.volume.get()) + self.assertEqual(0, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_min(self): self.sendRequest(u'setvol "0"') - self.assertEqual(0, self.mixer.volume.get()) + self.assertEqual(0, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_middle(self): self.sendRequest(u'setvol "50"') - self.assertEqual(50, self.mixer.volume.get()) + self.assertEqual(50, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_max(self): self.sendRequest(u'setvol "100"') - self.assertEqual(100, self.mixer.volume.get()) + self.assertEqual(100, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_above_max(self): self.sendRequest(u'setvol "110"') - self.assertEqual(100, self.mixer.volume.get()) + self.assertEqual(100, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): self.sendRequest(u'setvol "+10"') - self.assertEqual(10, self.mixer.volume.get()) + self.assertEqual(10, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_without_quotes(self): self.sendRequest(u'setvol 50') - self.assertEqual(50, self.mixer.volume.get()) + self.assertEqual(50, self.backend.playback.volume.get()) self.assertInResponse(u'OK') def test_single_off(self): @@ -286,6 +287,13 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertEqual(PLAYING, self.backend.playback.state.get()) self.assertInResponse(u'OK') + def test_playid_without_quotes(self): + self.backend.current_playlist.append([Track()]) + + self.sendRequest(u'playid 0') + self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertInResponse(u'OK') + def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): self.assertEqual(self.backend.playback.current_track.get(), None) self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index a20abaed..e6cd80e2 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -31,66 +31,66 @@ class TrackMpdFormatTest(unittest.TestCase): def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format(Track()) - self.assert_(('file', '') in result) - self.assert_(('Time', 0) in result) - self.assert_(('Artist', '') in result) - self.assert_(('Title', '') in result) - self.assert_(('Album', '') in result) - self.assert_(('Track', 0) in result) - self.assert_(('Date', '') in result) + self.assertIn(('file', ''), result) + self.assertIn(('Time', 0), result) + self.assertIn(('Artist', ''), result) + self.assertIn(('Title', ''), result) + self.assertIn(('Album', ''), result) + self.assertIn(('Track', 0), result) + self.assertIn(('Date', ''), result) self.assertEqual(len(result), 7) def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format(Track(), position=1) - self.assert_(('Pos', 1) not in result) + self.assertNotIn(('Pos', 1), result) def test_track_to_mpd_format_with_cpid(self): result = translator.track_to_mpd_format(CpTrack(1, Track())) - self.assert_(('Id', 1) not in result) + self.assertNotIn(('Id', 1), result) def test_track_to_mpd_format_with_position_and_cpid(self): result = translator.track_to_mpd_format(CpTrack(2, Track()), position=1) - self.assert_(('Pos', 1) in result) - self.assert_(('Id', 2) in result) + self.assertIn(('Pos', 1), result) + self.assertIn(('Id', 2), result) def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( CpTrack(122, self.track), position=9) - self.assert_(('file', 'a uri') in result) - self.assert_(('Time', 137) in result) - self.assert_(('Artist', 'an artist') in result) - self.assert_(('Title', 'a name') in result) - self.assert_(('Album', 'an album') in result) - self.assert_(('AlbumArtist', 'an other artist') in result) - self.assert_(('Track', '7/13') in result) - self.assert_(('Date', datetime.date(1977, 1, 1)) in result) - self.assert_(('Pos', 9) in result) - self.assert_(('Id', 122) in result) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertIn(('Artist', 'an artist'), result) + self.assertIn(('Title', 'a name'), result) + self.assertIn(('Album', 'an album'), result) + self.assertIn(('AlbumArtist', 'an other artist'), result) + self.assertIn(('Track', '7/13'), result) + self.assertIn(('Date', datetime.date(1977, 1, 1)), result) + self.assertIn(('Pos', 9), result) + self.assertIn(('Id', 122), result) self.assertEqual(len(result), 10) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.copy(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_TRACKID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_TRACKID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): album = self.track.album.copy(musicbrainz_id='foo') track = self.track.copy(album=album) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ALBUMID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') album = self.track.album.copy(artists=[artist]) track = self.track.copy(album=album) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ALBUMARTISTID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ALBUMARTISTID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_artistid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') track = self.track.copy(artists=[artist]) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ARTISTID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) def test_artists_to_mpd_format(self): artists = [Artist(name=u'ABBA'), Artist(name=u'Beatles')] diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index bdd2dab8..2bc3488b 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,14 +1,15 @@ -from mopidy.backends import dummy as backend +from mopidy.backends import dummy +from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status -from mopidy.mixers import dummy as mixer from mopidy.models import Track from tests import unittest -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED + +PAUSED = PlaybackState.PAUSED +PLAYING = PlaybackState.PLAYING +STOPPED = PlaybackState.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? @@ -16,134 +17,132 @@ STOPPED = backend.PlaybackController.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() - self.mixer = mixer.DummyMixer.start().proxy() + self.backend = dummy.DummyBackend.start().proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context def tearDown(self): self.backend.stop().get() - self.mixer.stop().get() def test_stats_method(self): result = status.stats(self.context) - self.assert_('artists' in result) + self.assertIn('artists', result) self.assert_(int(result['artists']) >= 0) - self.assert_('albums' in result) + self.assertIn('albums', result) self.assert_(int(result['albums']) >= 0) - self.assert_('songs' in result) + self.assertIn('songs', result) self.assert_(int(result['songs']) >= 0) - self.assert_('uptime' in result) + self.assertIn('uptime', result) self.assert_(int(result['uptime']) >= 0) - self.assert_('db_playtime' in result) + self.assertIn('db_playtime', result) self.assert_(int(result['db_playtime']) >= 0) - self.assert_('db_update' in result) + self.assertIn('db_update', result) self.assert_(int(result['db_update']) >= 0) - self.assert_('playtime' in result) + self.assertIn('playtime', result) self.assert_(int(result['playtime']) >= 0) - def test_status_method_contains_volume_which_defaults_to_0(self): + def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) - self.assert_('volume' in result) - self.assertEqual(int(result['volume']), 0) + self.assertIn('volume', result) + self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.mixer.volume = 17 + self.backend.playback.volume = 17 result = dict(status.status(self.context)) - self.assert_('volume' in result) + self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): result = dict(status.status(self.context)) - self.assert_('repeat' in result) + self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.backend.playback.repeat = 1 result = dict(status.status(self.context)) - self.assert_('repeat' in result) + self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): result = dict(status.status(self.context)) - self.assert_('random' in result) + self.assertIn('random', result) self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.backend.playback.random = 1 result = dict(status.status(self.context)) - self.assert_('random' in result) + self.assertIn('random', result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): result = dict(status.status(self.context)) - self.assert_('single' in result) - self.assert_(int(result['single']) in (0, 1)) + self.assertIn('single', result) + self.assertIn(int(result['single']), (0, 1)) def test_status_method_contains_consume_is_0(self): result = dict(status.status(self.context)) - self.assert_('consume' in result) + self.assertIn('consume', result) self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.backend.playback.consume = 1 result = dict(status.status(self.context)) - self.assert_('consume' in result) + self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) - self.assert_('playlist' in result) - self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) + self.assertIn('playlist', result) + self.assertIn(int(result['playlist']), xrange(0, 2**31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) - self.assert_('playlistlength' in result) + self.assertIn('playlistlength', result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) - self.assert_('xfade' in result) + self.assertIn('xfade', result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.backend.playback.state = PLAYING result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.backend.playback.state = STOPPED result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.backend.playback.state = PLAYING self.backend.playback.state = PAUSED result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): self.backend.current_playlist.append([Track()]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('song' in result) + self.assertIn('song', result) self.assert_(int(result['song']) >= 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): self.backend.current_playlist.append([Track()]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('songid' in result) + self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): self.backend.current_playlist.append([Track(length=None)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('time' in result) + self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) @@ -153,7 +152,7 @@ class StatusHandlerTest(unittest.TestCase): self.backend.current_playlist.append([Track(length=10000)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('time' in result) + self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) @@ -163,19 +162,19 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.state = PAUSED self.backend.playback.play_time_accumulated = 59123 result = dict(status.status(self.context)) - self.assert_('elapsed' in result) + self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): self.backend.playback.state = PAUSED self.backend.playback.play_time_accumulated = 123 # Less than 1000ms result = dict(status.status(self.context)) - self.assert_('elapsed' in result) + self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '0.123') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('bitrate' in result) + self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 24c426fb..db7f9265 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -4,8 +4,7 @@ import mock from mopidy import OptionalDependencyError from mopidy.backends.dummy import DummyBackend -from mopidy.backends.base.playback import PlaybackController -from mopidy.mixers.dummy import DummyMixer +from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track try: @@ -15,23 +14,21 @@ except OptionalDependencyError: from tests import unittest -PLAYING = PlaybackController.PLAYING -PAUSED = PlaybackController.PAUSED -STOPPED = PlaybackController.STOPPED +PLAYING = PlaybackState.PLAYING +PAUSED = PlaybackState.PAUSED +STOPPED = PlaybackState.STOPPED @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.mixer = DummyMixer.start().proxy() self.backend = DummyBackend.start().proxy() self.mpris = objects.MprisObject() self.mpris._backend = self.backend def tearDown(self): self.backend.stop() - self.mixer.stop() def test_get_playback_status_is_playing_when_playing(self): self.backend.playback.state = PLAYING @@ -144,7 +141,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assert_('mpris:trackid' in result.keys()) + self.assertIn('mpris:trackid', result.keys()) self.assertEquals(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): @@ -208,36 +205,40 @@ class PlayerInterfaceTest(unittest.TestCase): self.assertEquals(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): - self.mixer.volume = 0 + self.backend.playback.volume = None result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0) - self.mixer.volume = 50 + self.backend.playback.volume = 0 + result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') + self.assertEquals(result, 0) + + self.backend.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 0.5) - self.mixer.volume = 100 + self.backend.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') self.assertEquals(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.mixer.volume = 0 + self.backend.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.mixer.volume.get(), 0) + self.assertEquals(self.backend.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.mixer.volume.get(), 100) + self.assertEquals(self.backend.playback.volume.get(), 100) def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.mixer.volume.get(), 100) + self.assertEquals(self.backend.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.mixer.volume = 10 + self.backend.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.mixer.volume.get(), 10) + self.assertEquals(self.backend.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): self.backend.current_playlist.append([Track(uri='a', length=40000)]) diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py deleted file mode 100644 index 012c9002..00000000 --- a/tests/gstreamer_test.py +++ /dev/null @@ -1,73 +0,0 @@ -import sys - -from mopidy import settings -from mopidy.gstreamer import GStreamer -from mopidy.utils.path import path_to_uri - -from tests import unittest, path_to_data_dir - - -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') -class GStreamerTest(unittest.TestCase): - def setUp(self): - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer() - self.gstreamer.on_start() - - def prepare_uri(self, uri): - self.gstreamer.prepare_change() - self.gstreamer.set_uri(uri) - - def tearDown(self): - settings.runtime.clear() - - def test_start_playback_existing_file(self): - self.prepare_uri(self.song_uri) - self.assertTrue(self.gstreamer.start_playback()) - - def test_start_playback_non_existing_file(self): - self.prepare_uri(self.song_uri + 'bogus') - self.assertFalse(self.gstreamer.start_playback()) - - def test_pause_playback_while_playing(self): - self.prepare_uri(self.song_uri) - self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.pause_playback()) - - def test_stop_playback_while_playing(self): - self.prepare_uri(self.song_uri) - self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.stop_playback()) - - @unittest.SkipTest - def test_deliver_data(self): - pass # TODO - - @unittest.SkipTest - def test_end_of_data_stream(self): - pass # TODO - - def test_default_get_volume_result(self): - self.assertEqual(100, self.gstreamer.get_volume()) - - def test_set_volume(self): - self.assertTrue(self.gstreamer.set_volume(50)) - self.assertEqual(50, self.gstreamer.get_volume()) - - def test_set_volume_to_zero(self): - self.assertTrue(self.gstreamer.set_volume(0)) - self.assertEqual(0, self.gstreamer.get_volume()) - - def test_set_volume_to_one_hundred(self): - self.assertTrue(self.gstreamer.set_volume(100)) - self.assertEqual(100, self.gstreamer.get_volume()) - - @unittest.SkipTest - def test_set_state_encapsulation(self): - pass # TODO - - @unittest.SkipTest - def test_set_position(self): - pass # TODO diff --git a/tests/help_test.py b/tests/help_test.py index 1fa22c2f..a2803b72 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -13,18 +13,18 @@ class HelpTest(unittest.TestCase): args = [sys.executable, mopidy_dir, '--help'] process = subprocess.Popen(args, stdout=subprocess.PIPE) output = process.communicate()[0] - self.assert_('--version' in output) - self.assert_('--help' in output) - self.assert_('--help-gst' in output) - self.assert_('--interactive' in output) - self.assert_('--quiet' in output) - self.assert_('--verbose' in output) - self.assert_('--save-debug-log' in output) - self.assert_('--list-settings' in output) + self.assertIn('--version', output) + self.assertIn('--help', output) + self.assertIn('--help-gst', output) + self.assertIn('--interactive', output) + self.assertIn('--quiet', output) + self.assertIn('--verbose', output) + self.assertIn('--save-debug-log', output) + self.assertIn('--list-settings', output) def test_help_gst_has_gstreamer_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help-gst'] process = subprocess.Popen(args, stdout=subprocess.PIPE) output = process.communicate()[0] - self.assert_('--gst-version' in output) + self.assertIn('--gst-version', output) diff --git a/tests/mixers/base_test.py b/tests/mixers/base_test.py deleted file mode 100644 index 54cd8773..00000000 --- a/tests/mixers/base_test.py +++ /dev/null @@ -1,38 +0,0 @@ -class BaseMixerTest(object): - MIN = 0 - MAX = 100 - ACTUAL_MIN = MIN - ACTUAL_MAX = MAX - INITIAL = None - - mixer_class = None - - def setUp(self): - assert self.mixer_class is not None, \ - "mixer_class must be set in subclass" - # pylint: disable = E1102 - self.mixer = self.mixer_class() - # pylint: enable = E1102 - - def test_initial_volume(self): - self.assertEqual(self.mixer.volume, self.INITIAL) - - def test_volume_set_to_min(self): - self.mixer.volume = self.MIN - self.assertEqual(self.mixer.volume, self.ACTUAL_MIN) - - def test_volume_set_to_max(self): - self.mixer.volume = self.MAX - self.assertEqual(self.mixer.volume, self.ACTUAL_MAX) - - def test_volume_set_to_below_min_results_in_min(self): - self.mixer.volume = -10 - self.assertEqual(self.mixer.volume, self.ACTUAL_MIN) - - def test_volume_set_to_above_max_results_in_max(self): - self.mixer.volume = self.MAX + 10 - self.assertEqual(self.mixer.volume, self.ACTUAL_MAX) - - def test_volume_is_not_float(self): - self.mixer.volume = 1.0 / 3 * 100 - self.assertEqual(self.mixer.volume, 33) diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py deleted file mode 100644 index cdfe0772..00000000 --- a/tests/mixers/denon_test.py +++ /dev/null @@ -1,42 +0,0 @@ -from mopidy.mixers.denon import DenonMixer -from tests.mixers.base_test import BaseMixerTest - -from tests import unittest - - -class DenonMixerDeviceMock(object): - def __init__(self): - self._open = True - self.ret_val = bytes('MV00\r') - - def write(self, x): - if x[2] != '?': - self.ret_val = bytes(x) - - def read(self, x): - return self.ret_val - - def readline(self): - return self.ret_val - - def isOpen(self): - return self._open - - def open(self): - self._open = True - - -class DenonMixerTest(BaseMixerTest, unittest.TestCase): - ACTUAL_MAX = 99 - INITIAL = 1 - - mixer_class = DenonMixer - - def setUp(self): - self.device = DenonMixerDeviceMock() - self.mixer = DenonMixer(device=self.device) - - def test_reopen_device(self): - self.device._open = False - self.mixer.volume = 10 - self.assertTrue(self.device.isOpen()) diff --git a/tests/mixers/dummy_test.py b/tests/mixers/dummy_test.py deleted file mode 100644 index f9418d7a..00000000 --- a/tests/mixers/dummy_test.py +++ /dev/null @@ -1,23 +0,0 @@ -from mopidy.mixers.dummy import DummyMixer - -from tests import unittest -from tests.mixers.base_test import BaseMixerTest - - -class DummyMixerTest(BaseMixerTest, unittest.TestCase): - mixer_class = DummyMixer - - def test_set_volume_is_capped(self): - self.mixer.amplification_factor = 0.5 - self.mixer.volume = 100 - self.assertEquals(self.mixer._volume, 50) - - def test_get_volume_does_not_show_that_the_volume_is_capped(self): - self.mixer.amplification_factor = 0.5 - self.mixer._volume = 50 - self.assertEquals(self.mixer.volume, 100) - - def test_get_volume_get_the_same_number_as_was_set(self): - self.mixer.amplification_factor = 0.5 - self.mixer.volume = 13 - self.assertEquals(self.mixer.volume, 13) diff --git a/tests/models_test.py b/tests/models_test.py index 978f35b6..779d1a4b 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -43,7 +43,7 @@ class GenericCopyTets(unittest.TestCase): artist2 = Artist(name='bar') track = Track(artists=[artist1]) copy = track.copy(artists=[artist2]) - self.assert_(artist2 in copy.artists) + self.assertIn(artist2, copy.artists) def test_copying_track_with_invalid_key(self): test = lambda: Track().copy(invalid_key=True) @@ -79,6 +79,11 @@ class ArtistTest(unittest.TestCase): "Artist(name='name', uri='uri')", repr(Artist(uri='uri', name='name'))) + def test_serialize(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Artist(uri='uri', name='name').serialize()) + def test_eq_name(self): artist1 = Artist(name=u'name') artist2 = Artist(name=u'name') @@ -150,7 +155,7 @@ class AlbumTest(unittest.TestCase): def test_artists(self): artist = Artist() album = Album(artists=[artist]) - self.assert_(artist in album.artists) + self.assertIn(artist, album.artists) self.assertRaises(AttributeError, setattr, album, 'artists', None) def test_num_tracks(self): @@ -180,6 +185,17 @@ class AlbumTest(unittest.TestCase): "Album(artists=[Artist(name='foo')], name='name', uri='uri')", repr(Album(uri='uri', name='name', artists=[Artist(name='foo')]))) + def test_serialize_without_artists(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Album(uri='uri', name='name').serialize()) + + def test_serialize_with_artists(self): + artist = Artist(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, + Album(uri='uri', name='name', artists=[artist]).serialize()) + def test_eq_name(self): album1 = Album(name=u'name') album2 = Album(name=u'name') @@ -322,7 +338,7 @@ class TrackTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, track, 'track_no', None) def test_date(self): - date = datetime.date(1977, 1, 1) + date = '1977-01-01' track = Track(date=date) self.assertEqual(track.date, date) self.assertRaises(AttributeError, setattr, track, 'date', None) @@ -360,6 +376,23 @@ class TrackTest(unittest.TestCase): "Track(artists=[Artist(name='foo')], name='name', uri='uri')", repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) + def test_serialize_without_artists(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Track(uri='uri', name='name').serialize()) + + def test_serialize_with_artists(self): + artist = Artist(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'artists': [artist.serialize()]}, + Track(uri='uri', name='name', artists=[artist]).serialize()) + + def test_serialize_with_album(self): + album = Album(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'album': album.serialize()}, + Track(uri='uri', name='name', album=album).serialize()) + def test_eq_uri(self): track1 = Track(uri=u'uri1') track2 = Track(uri=u'uri1') @@ -401,7 +434,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_date(self): - date = datetime.date.today() + date = '1977-01-01' track1 = Track(date=date) track2 = Track(date=date) self.assertEqual(track1, track2) @@ -426,7 +459,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq(self): - date = datetime.date.today() + date = '1977-01-01' artists = [Artist()] album = Album() track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, @@ -475,8 +508,8 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne_date(self): - track1 = Track(date=datetime.date.today()) - track2 = Track(date=datetime.date.today()-datetime.timedelta(days=1)) + track1 = Track(date='1977-01-01') + track2 = Track(date='1977-01-02') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -501,12 +534,12 @@ class TrackTest(unittest.TestCase): def test_ne(self): track1 = Track(uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date=datetime.date.today(), length=100, bitrate=100, + track_no=1, date='1977-01-01', length=100, bitrate=100, musicbrainz_id='id1') track2 = Track(uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date=datetime.date.today()-datetime.timedelta(days=1), - length=200, bitrate=200, musicbrainz_id='id2') + track_no=2, date='1977-01-02', length=200, bitrate=200, + musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -603,6 +636,17 @@ class PlaylistTest(unittest.TestCase): "uri='uri')", repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) + def test_serialize_without_tracks(self): + self.assertDictEqual( + {'uri': 'uri', 'name': 'name'}, + Playlist(uri='uri', name='name').serialize()) + + def test_serialize_with_tracks(self): + track = Track(name='foo') + self.assertDictEqual( + {'uri': 'uri', 'name': 'name', 'tracks': [track.serialize()]}, + Playlist(uri='uri', name='name', tracks=[track]).serialize()) + def test_eq_name(self): playlist1 = Playlist(name=u'name') playlist2 = Playlist(name=u'name') diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py new file mode 100644 index 00000000..f5aa0b1e --- /dev/null +++ b/tests/utils/deps_test.py @@ -0,0 +1,113 @@ +import platform + +import pygst +pygst.require('0.10') +import gst +import pykka + +try: + import dbus +except ImportError: + dbus = False + +try: + import pylast +except ImportError: + pylast = False + +try: + import serial +except ImportError: + serial = False + +try: + import spotify +except ImportError: + spotify = False + +from mopidy.utils import deps + +from tests import unittest + + +class DepsTest(unittest.TestCase): + def test_format_dependency_list(self): + adapters = [ + lambda: dict(name='Python', version='FooPython 2.7.3'), + lambda: dict(name='Platform', version='Loonix 4.0.1'), + lambda: dict(name='Pykka', path='/foo/bar/baz.py', other='Quux') + ] + + result = deps.format_dependency_list(adapters) + + self.assertIn('Python: FooPython 2.7.3', result) + self.assertIn('Platform: Loonix 4.0.1', result) + self.assertIn('Pykka: not found', result) + self.assertIn('Imported from: /foo/bar', result) + self.assertNotIn('/baz.py', result) + self.assertIn('Quux', result) + + def test_platform_info(self): + result = deps.platform_info() + + self.assertEquals('Platform', result['name']) + self.assertIn(platform.platform(), result['version']) + + def test_python_info(self): + result = deps.python_info() + + self.assertEquals('Python', result['name']) + self.assertIn(platform.python_implementation(), result['version']) + self.assertIn(platform.python_version(), result['version']) + self.assertIn('python', result['path']) + + def test_gstreamer_info(self): + result = deps.gstreamer_info() + + self.assertEquals('GStreamer', result['name']) + self.assertEquals('.'.join(map(str, gst.get_gst_version())), result['version']) + self.assertIn('gst', result['path']) + self.assertIn('Python wrapper: gst-python', result['other']) + self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) + self.assertIn('Relevant elements:', result['other']) + + def test_pykka_info(self): + result = deps.pykka_info() + + self.assertEquals('Pykka', result['name']) + self.assertEquals(pykka.__version__, result['version']) + self.assertIn('pykka', result['path']) + + @unittest.skipUnless(spotify, 'pyspotify not found') + def test_pyspotify_info(self): + result = deps.pyspotify_info() + + self.assertEquals('pyspotify', result['name']) + self.assertEquals(spotify.__version__, result['version']) + self.assertIn('spotify', result['path']) + self.assertIn('Built for libspotify API version', result['other']) + self.assertIn(str(spotify.api_version), result['other']) + + @unittest.skipUnless(pylast, 'pylast not found') + def test_pylast_info(self): + result = deps.pylast_info() + + self.assertEquals('pylast', result['name']) + self.assertEquals(pylast.__version__, result['version']) + self.assertIn('pylast', result['path']) + + @unittest.skipUnless(dbus, 'dbus not found') + def test_dbus_info(self): + result = deps.dbus_info() + + self.assertEquals('dbus-python', result['name']) + self.assertEquals(dbus.__version__, result['version']) + self.assertIn('dbus', result['path']) + + @unittest.skipUnless(serial, 'serial not found') + def test_serial_info(self): + result = deps.serial_info() + + self.assertEquals('pyserial', result['name']) + self.assertEquals(serial.VERSION, result['version']) + self.assertIn('serial', result['path']) diff --git a/tests/utils/init_test.py b/tests/utils/init_test.py index 2097e3e6..bdd0adc5 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/init_test.py @@ -1,24 +1,27 @@ -from mopidy.utils import get_class +from mopidy import utils from tests import unittest class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): - self.assertRaises(ImportError, get_class, 'foo.bar.Baz') + with self.assertRaises(ImportError): + utils.get_class('foo.bar.Baz') def test_loading_class_that_does_not_exist(self): - self.assertRaises(ImportError, get_class, 'unittest.FooBarBaz') + with self.assertRaises(ImportError): + utils.get_class('unittest.FooBarBaz') def test_loading_incorrect_class_path(self): - self.assertRaises(ImportError, get_class, 'foobarbaz') + with self.assertRaises(ImportError): + utils.get_class('foobarbaz') def test_import_error_message_contains_complete_class_path(self): try: - get_class('foo.bar.Baz') + utils.get_class('foo.bar.Baz') except ImportError as e: - self.assert_('foo.bar.Baz' in str(e)) + self.assertIn('foo.bar.Baz', str(e)) def test_loading_existing_class(self): - cls = get_class('unittest.TestCase') + cls = utils.get_class('unittest.TestCase') self.assertEqual(cls.__name__, 'TestCase') diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index aa1be2b6..96ddb833 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -91,7 +91,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) - self.mock.actor_ref.stop.assert_called_once_with() + self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_handles_actor_already_being_stopped(self): self.mock.stopping = False @@ -100,7 +100,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) network.Connection.stop(self.mock, sentinel.reason) - self.mock.actor_ref.stop.assert_called_once_with() + self.mock.actor_ref.stop.assert_called_once_with(block=False) def test_stop_sets_stopping_to_true(self): self.mock.stopping = False diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 19bae375..d782aa15 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -1,12 +1,12 @@ # encoding: utf-8 +import glib import os import shutil import sys import tempfile -from mopidy.utils.path import (get_or_create_folder, mtime, - path_to_uri, uri_to_path, split_path, find_files) +from mopidy.utils import path from tests import unittest, path_to_data_dir @@ -23,7 +23,7 @@ class GetOrCreateFolderTest(unittest.TestCase): folder = os.path.join(self.parent, 'test') self.assert_(not os.path.exists(folder)) self.assert_(not os.path.isdir(folder)) - created = get_or_create_folder(folder) + created = path.get_or_create_folder(folder) self.assert_(os.path.exists(folder)) self.assert_(os.path.isdir(folder)) self.assertEqual(created, folder) @@ -35,7 +35,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assert_(not os.path.isdir(level2_folder)) self.assert_(not os.path.exists(level3_folder)) self.assert_(not os.path.isdir(level3_folder)) - created = get_or_create_folder(level3_folder) + created = path.get_or_create_folder(level3_folder) self.assert_(os.path.exists(level2_folder)) self.assert_(os.path.isdir(level2_folder)) self.assert_(os.path.exists(level3_folder)) @@ -43,7 +43,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assertEqual(created, level3_folder) def test_creating_existing_folder(self): - created = get_or_create_folder(self.parent) + created = path.get_or_create_folder(self.parent) self.assert_(os.path.exists(self.parent)) self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) @@ -52,92 +52,116 @@ class GetOrCreateFolderTest(unittest.TestCase): conflicting_file = os.path.join(self.parent, 'test') open(conflicting_file, 'w').close() folder = os.path.join(self.parent, 'test') - self.assertRaises(OSError, get_or_create_folder, folder) + self.assertRaises(OSError, path.get_or_create_folder, folder) class PathToFileURITest(unittest.TestCase): def test_simple_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc/fstab') + result = path.path_to_uri(u'/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') def test_folder_and_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/', u'clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/', u'clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc', u'fstab') + result = path.path_to_uri(u'/etc', u'fstab') self.assertEqual(result, u'file:///etc/fstab') def test_space_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/test this') + result = path.path_to_uri(u'C:/test this') self.assertEqual(result, 'file:///C://test%20this') else: - result = path_to_uri(u'/tmp/test this') + result = path.path_to_uri(u'/tmp/test this') self.assertEqual(result, u'file:///tmp/test%20this') def test_unicode_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/æøå') + result = path.path_to_uri(u'C:/æøå') self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') else: - result = path_to_uri(u'/tmp/æøå') + result = path.path_to_uri(u'/tmp/æøå') self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://WINDOWS/clock.avi') + result = path.uri_to_path('file:///C://WINDOWS/clock.avi') self.assertEqual(result, u'C:/WINDOWS/clock.avi') else: - result = uri_to_path('file:///etc/fstab') + result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, u'/etc/fstab') def test_space_in_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://test%20this') + result = path.uri_to_path('file:///C://test%20this') self.assertEqual(result, u'C:/test this') else: - result = uri_to_path(u'file:///tmp/test%20this') + result = path.uri_to_path(u'file:///tmp/test%20this') self.assertEqual(result, u'/tmp/test this') def test_unicode_in_uri(self): if sys.platform == 'win32': - result = uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'C:/æøå') else: - result = uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'/tmp/æøå') class SplitPathTest(unittest.TestCase): def test_empty_path(self): - self.assertEqual([], split_path('')) + self.assertEqual([], path.split_path('')) def test_single_folder(self): - self.assertEqual(['foo'], split_path('foo')) + self.assertEqual(['foo'], path.split_path('foo')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_initial_slash_is_ignored(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('/foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): - self.assertEqual([], split_path('/')) + self.assertEqual([], path.split_path('/')) + + +class ExpandPathTest(unittest.TestCase): + # TODO: test via mocks? + + def test_empty_path(self): + self.assertEqual(os.path.abspath('.'), path.expand_path('')) + + def test_absolute_path(self): + self.assertEqual('/tmp/foo', path.expand_path('/tmp/foo')) + + def test_home_dir_expansion(self): + self.assertEqual(os.path.expanduser('~/foo'), path.expand_path('~/foo')) + + def test_abspath(self): + self.assertEqual(os.path.abspath('./foo'), path.expand_path('./foo')) + + def test_xdg_subsititution(self): + self.assertEqual(glib.get_user_data_dir() + '/foo', + path.expand_path('$XDG_DATA_DIR/foo')) + + def test_xdg_subsititution_unknown(self): + self.assertEqual('/tmp/$XDG_INVALID_DIR/foo', + path.expand_path('/tmp/$XDG_INVALID_DIR/foo')) class FindFilesTest(unittest.TestCase): - def find(self, path): - return list(find_files(path_to_data_dir(path))) + def find(self, value): + return list(path.find_files(path_to_data_dir(value))) def test_basic_folder(self): self.assert_(self.find('')) @@ -156,15 +180,21 @@ class FindFilesTest(unittest.TestCase): self.assert_(is_unicode(name), '%s is not unicode object' % repr(name)) + def test_ignores_hidden_folders(self): + self.assertEqual(self.find('.hidden'), []) + + def test_ignores_hidden_files(self): + self.assertEqual(self.find('.blank.mp3'), []) + class MtimeTest(unittest.TestCase): def tearDown(self): - mtime.undo_fake() + path.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat('.').st_mtime) - self.assertEqual(mtime_dir, mtime('.')) + self.assertEqual(mtime_dir, path.mtime('.')) def test_fake_time_is_returned(self): - mtime.set_fake_time(123456) - self.assertEqual(mtime('.'), 123456) + path.mtime.set_fake_time(123456) + self.assertEqual(path.mtime('.'), 123456) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 55e1156b..cf476c24 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,8 +1,7 @@ import os -from mopidy import settings as default_settings_module, SettingsError -from mopidy.utils.settings import (format_settings_list, mask_value_if_secret, - SettingsProxy, validate_settings) +import mopidy +from mopidy.utils import settings as setting_utils from tests import unittest @@ -16,29 +15,29 @@ class ValidateSettingsTest(unittest.TestCase): } def test_no_errors_yields_empty_dict(self): - result = validate_settings(self.defaults, {}) + result = setting_utils.validate_settings(self.defaults, {}) self.assertEqual(result, {}) def test_unknown_setting_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) self.assertEqual(result['MPD_SERVER_HOSTNMAE'], - u'Unknown setting. Is it misspelled?') + u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) self.assertEqual(result['SERVER_HOSTNAME'], u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') def test_unneeded_settings_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) self.assertEqual(result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') def test_deprecated_setting_value_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) self.assertEqual(result['BACKENDS'], u'Deprecated setting value. ' + @@ -46,33 +45,33 @@ class ValidateSettingsTest(unittest.TestCase): 'available.') def test_unavailable_bitrate_setting_returns_error(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'SPOTIFY_BITRATE': 50}) self.assertEqual(result['SPOTIFY_BITRATE'], u'Unavailable Spotify bitrate. ' + u'Available bitrates are 96, 160, and 320.') def test_two_errors_are_both_reported(self): - result = validate_settings(self.defaults, + result = setting_utils.validate_settings(self.defaults, {'FOO': '', 'BAR': ''}) self.assertEqual(len(result), 2) def test_masks_value_if_secret(self): - secret = mask_value_if_secret('SPOTIFY_PASSWORD', 'bar') + secret = setting_utils.mask_value_if_secret('SPOTIFY_PASSWORD', 'bar') self.assertEqual(u'********', secret) def test_does_not_mask_value_if_not_secret(self): - not_secret = mask_value_if_secret('SPOTIFY_USERNAME', 'foo') + not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', 'foo') self.assertEqual('foo', not_secret) def test_does_not_mask_value_if_none(self): - not_secret = mask_value_if_secret('SPOTIFY_USERNAME', None) + not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) class SettingsProxyTest(unittest.TestCase): def setUp(self): - self.settings = SettingsProxy(default_settings_module) + self.settings = setting_utils.SettingsProxy(mopidy.settings) self.settings.local.clear() def test_set_and_get_attr(self): @@ -83,7 +82,7 @@ class SettingsProxyTest(unittest.TestCase): try: _ = self.settings.TEST self.fail(u'Should raise exception') - except SettingsError as e: + except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) def test_getattr_raises_error_on_empty_setting(self): @@ -91,7 +90,7 @@ class SettingsProxyTest(unittest.TestCase): try: _ = self.settings.TEST self.fail(u'Should raise exception') - except SettingsError as e: + except mopidy.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) def test_getattr_does_not_raise_error_if_setting_is_false(self): @@ -108,7 +107,7 @@ class SettingsProxyTest(unittest.TestCase): def test_setattr_updates_runtime_settings(self): self.settings.TEST = 'test' - self.assert_('TEST' in self.settings.runtime) + self.assertIn('TEST', self.settings.runtime) def test_setattr_updates_runtime_with_value(self): self.settings.TEST = 'test' @@ -177,44 +176,67 @@ class SettingsProxyTest(unittest.TestCase): class FormatSettingListTest(unittest.TestCase): def setUp(self): - self.settings = SettingsProxy(default_settings_module) + self.settings = setting_utils.SettingsProxy(mopidy.settings) def test_contains_the_setting_name(self): self.settings.TEST = u'test' - result = format_settings_list(self.settings) - self.assert_('TEST:' in result, result) + result = setting_utils.format_settings_list(self.settings) + self.assertIn('TEST:', result, result) def test_repr_of_a_string_value(self): self.settings.TEST = u'test' - result = format_settings_list(self.settings) - self.assert_("TEST: u'test'" in result, result) + result = setting_utils.format_settings_list(self.settings) + self.assertIn("TEST: u'test'", result, result) def test_repr_of_an_int_value(self): self.settings.TEST = 123 - result = format_settings_list(self.settings) - self.assert_("TEST: 123" in result, result) + result = setting_utils.format_settings_list(self.settings) + self.assertIn("TEST: 123", result, result) def test_repr_of_a_tuple_value(self): self.settings.TEST = (123, u'abc') - result = format_settings_list(self.settings) - self.assert_("TEST: (123, u'abc')" in result, result) + result = setting_utils.format_settings_list(self.settings) + self.assertIn("TEST: (123, u'abc')", result, result) def test_passwords_are_masked(self): self.settings.TEST_PASSWORD = u'secret' - result = format_settings_list(self.settings) - self.assert_("TEST_PASSWORD: u'secret'" not in result, result) - self.assert_("TEST_PASSWORD: u'********'" in result, result) + result = setting_utils.format_settings_list(self.settings) + self.assertNotIn("TEST_PASSWORD: u'secret'", result, result) + self.assertIn("TEST_PASSWORD: u'********'", result, result) def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) - result = format_settings_list(self.settings) - self.assert_("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, - result) + result = setting_utils.format_settings_list(self.settings) + self.assertIn("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) def test_long_values_are_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend') - result = format_settings_list(self.settings) + result = setting_utils.format_settings_list(self.settings) self.assert_("""FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) + + +class DidYouMeanTest(unittest.TestCase): + def testSuggestoins(self): + defaults = { + 'MPD_SERVER_HOSTNAME': '::', + 'MPD_SERVER_PORT': 6600, + 'SPOTIFY_BITRATE': 160, + } + + suggestion = setting_utils.did_you_mean('spotify_bitrate', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPOTIFY_BITROTE', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPITIFY_BITROT', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPTIFY_BITROT', defaults) + self.assertEqual(suggestion, 'SPOTIFY_BITRATE') + + suggestion = setting_utils.did_you_mean('SPTIFY_BITRO', defaults) + self.assertEqual(suggestion, None) diff --git a/tests/version_test.py b/tests/version_test.py index 26045ac1..c3eb00c1 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -27,14 +27,15 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.6.1') < SV('0.7.0')) self.assert_(SV('0.7.0') < SV('0.7.1')) self.assert_(SV('0.7.1') < SV('0.7.2')) - self.assert_(SV('0.7.2') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.0')) + self.assert_(SV('0.7.2') < SV('0.7.3')) + self.assert_(SV('0.7.3') < SV(__version__)) + self.assert_(SV(__version__) < SV('0.8.1')) def test_get_platform_contains_platform(self): - self.assert_(platform.platform() in get_platform()) + self.assertIn(platform.platform(), get_platform()) def test_get_python_contains_python_implementation(self): - self.assert_(platform.python_implementation() in get_python()) + self.assertIn(platform.python_implementation(), get_python()) def test_get_python_contains_python_version(self): - self.assert_(platform.python_version() in get_python()) + self.assertIn(platform.python_version(), get_python()) diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py new file mode 100755 index 00000000..2f54ea36 --- /dev/null +++ b/tools/debug-proxy.py @@ -0,0 +1,190 @@ +#! /usr/bin/env python + +import argparse +import difflib +import sys + +from gevent import select, server, socket + +COLORS = ['\033[1;%dm' % (30+i) for i in range(8)] +BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS +RESET = "\033[0m" +BOLD = "\033[1m" + + +def proxy(client, address, reference_address, actual_address): + """Main handler code that gets called for each connection.""" + client.setblocking(False) + + reference = connect(reference_address) + actual = connect(actual_address) + + if reference and actual: + loop(client, address, reference, actual) + else: + print 'Could not connect to one of the backends.' + + for sock in (client, reference, actual): + close(sock) + + +def connect(address): + """Connect to given address and set socket non blocking.""" + try: + sock = socket.socket() + sock.connect(address) + sock.setblocking(False) + except socket.error: + return None + return sock + + +def close(sock): + """Shutdown and close our sockets.""" + try: + sock.shutdown(socket.SHUT_WR) + sock.close() + except socket.error: + pass + + +def loop(client, address, reference, actual): + """Loop that handles one MPD reqeust/response pair per iteration.""" + + # Consume banners from backends + responses = dict() + disconnected = read([reference, actual], responses, find_response_end_token) + diff(address, '', responses[reference], responses[actual]) + + # We lost a backend, might as well give up. + if disconnected: + return + + client.sendall(responses[reference]) + + while True: + responses = dict() + + # Get the command from the client. Not sure how an if this will handle + # client sending multiple commands currently :/ + disconnected = read([client], responses, find_request_end_token) + + # We lost the client, might as well give up. + if disconnected: + return + + # Send the entire command to both backends. + reference.sendall(responses[client]) + actual.sendall(responses[client]) + + # Get the entire resonse from both backends. + disconnected = read([reference, actual], responses, find_response_end_token) + + # Send the client the complete reference response + client.sendall(responses[reference]) + + # Compare our responses + diff(address, responses[client], responses[reference], responses[actual]) + + # Give up if we lost a backend. + if disconnected: + return + + +def read(sockets, responses, find_end_token): + """Keep reading from sockets until they disconnet or we find our token.""" + + # This function doesn't go to well with idle when backends are out of sync. + disconnected = False + + for sock in sockets: + responses.setdefault(sock, '') + + while sockets: + for sock in select.select(sockets, [], [])[0]: + data = sock.recv(4096) + responses[sock] += data + + if find_end_token(responses[sock]): + sockets.remove(sock) + + if not data: + sockets.remove(sock) + disconnected = True + + return disconnected + + +def find_response_end_token(data): + """Find token that indicates the response is over.""" + for line in data.splitlines(True): + if line.startswith(('OK', 'ACK')) and line.endswith('\n'): + return True + return False + + +def find_request_end_token(data): + """Find token that indicates that request is over.""" + lines = data.splitlines(True) + if not lines: + return False + elif 'command_list_ok_begin' == lines[0].strip(): + return 'command_list_end' == lines[-1].strip() + else: + return lines[0].endswith('\n') + + +def diff(address, command, reference_response, actual_response): + """Print command from client and a unified diff of the responses.""" + sys.stdout.write('[%s]:%s\n%s' % (address[0], address[1], command)) + for line in difflib.unified_diff(reference_response.splitlines(True), + actual_response.splitlines(True), + fromfile='Reference response', + tofile='Actual response'): + + if line.startswith('+') and not line.startswith('+++'): + sys.stdout.write(GREEN) + elif line.startswith('-') and not line.startswith('---'): + sys.stdout.write(RED) + elif line.startswith('@@'): + sys.stdout.write(CYAN) + + sys.stdout.write(line) + sys.stdout.write(RESET) + + sys.stdout.flush() + + +def parse_args(): + """Handle flag parsing.""" + parser = argparse.ArgumentParser( + description='Proxy and compare MPD protocol interactions.') + parser.add_argument('--listen', default=':6600', type=parse_address, + help='address:port to listen on.') + parser.add_argument('--reference', default=':6601', type=parse_address, + help='address:port for the reference backend.') + parser.add_argument('--actual', default=':6602', type=parse_address, + help='address:port for the actual backend.') + + return parser.parse_args() + + +def parse_address(address): + """Convert host:port or port to address to pass to connect.""" + if ':' not in address: + return ('', int(address)) + host, port = address.rsplit(':', 1) + return (host, int(port)) + + +if __name__ == '__main__': + args = parse_args() + + def handle(client, address): + """Wrapper that adds reference and actual backends to proxy calls.""" + return proxy(client, address, args.reference, args.actual) + + try: + server.StreamServer(args.listen, handle).serve_forever() + except (KeyboardInterrupt, SystemExit): + pass