diff --git a/MANIFEST.in b/MANIFEST.in index 033c51f2..f3723ecd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,10 @@ -include LICENSE pylintrc *.rst *.ini data/mopidy.desktop +include *.ini +include *.rst +include LICENSE +include MANIFEST.in +include data/mopidy.desktop include mopidy/backends/spotify/spotify_appkey.key +include pylintrc recursive-include docs * prune docs/_build recursive-include requirements * diff --git a/bin/mopidy-scan b/bin/mopidy-scan index 84cfee57..869aa662 100755 --- a/bin/mopidy-scan +++ b/bin/mopidy-scan @@ -1,31 +1,38 @@ #!/usr/bin/env python -if __name__ == '__main__': - import sys +import sys +import logging - from mopidy import settings - from mopidy.scanner import Scanner, translator - from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format +from mopidy import settings +from mopidy.utils.log import setup_console_logging, setup_root_logger +from mopidy.scanner import Scanner, translator +from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format - tracks = [] +setup_root_logger() +setup_console_logging(2) - def store(data): - track = translator(data) - tracks.append(track) - print >> sys.stderr, 'Added %s' % track.uri +tracks = [] - def debug(uri, error): - print >> sys.stderr, 'Failed %s: %s' % (uri, error) +def store(data): + track = translator(data) + tracks.append(track) + logging.debug(u'Added %s', track.uri) - print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH +def debug(uri, error, debug): + logging.error(u'Failed %s: %s - %s', uri, error, debug) - scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) +logging.info(u'Scanning %s', settings.LOCAL_MUSIC_PATH) + +scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) +try: scanner.start() +except KeyboardInterrupt: + scanner.stop() - print >> sys.stderr, 'Done' +logging.info(u'Done') - for a in tracks_to_tag_cache_format(tracks): - if len(a) == 1: - print (u'%s' % a).encode('utf-8') - else: - print (u'%s: %s' % a).encode('utf-8') +for a in tracks_to_tag_cache_format(tracks): + if len(a) == 1: + print (u'%s' % a).encode('utf-8') + else: + print (u'%s: %s' % a).encode('utf-8') diff --git a/docs/_static/thread_communication.png b/docs/_static/thread_communication.png deleted file mode 100644 index 95bf1892..00000000 Binary files a/docs/_static/thread_communication.png and /dev/null differ diff --git a/docs/authors.rst b/docs/authors.rst index 01e810e4..af84f842 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -5,7 +5,7 @@ Authors Contributors to Mopidy in the order of appearance: - Stein Magnus Jodal -- Johannes Knutsen +- Johannes Knutsen - Thomas Adamcik - Kristian Klette diff --git a/docs/changes.rst b/docs/changes.rst index b4d56711..c93e0ee8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,26 +8,46 @@ This change log is used to track all major changes to Mopidy. v0.5.0 (in development) ======================= -No description yet. +Since last time we've added support for audio streaming to SHOUTcast servers +and fixed the longstanding playlist loading issue in the Spotify backend. As +always the release has a bunch of bug fixes. + +Please note that 0.5.0 requires some updated dependencies, as listed under +*Important changes* below. **Important changes** -- Mopidy now supports running with 1-n outputs at the same time. This feature - was mainly added to facilitate Shoutcast support, which Mopidy has also - gained. In its current state outputs can not be toggled during runtime. +- If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and + pyspotify 1.3. If you install from APT, libspotify and pyspotify will + automatically be upgraded. If you are not installing from APT, follow the + instructions at :doc:`/installation/libspotify/`. + +- If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` + setting, you must update your settings file. The new setting is named + :attr:`mopidy.settings.SPOTIFY_BITRATE` and accepts the integer values 96, + 160, and 320. + +- Mopidy now supports running with 1 to N outputs at the same time. This + feature was mainly added to facilitate SHOUTcast support, which Mopidy has + also gained. In its current state outputs can not be toggled during runtime. **Changes** -- Fix local backend time query errors that where coming from stopped pipeline. - (Fixes: :issue:`87`) +- Local backend: -- Support passing options to GStreamer. See :option:`--help-gst` for a list of - available options. (Fixes: :issue:`95`) + - Fix local backend time query errors that where coming from stopped + pipeline. (Fixes: :issue:`87`) -- Improve :option:`--list-settings` output. (Fixes: :issue:`91`) +- Spotify backend: -- Replace not decodable characters returned from Spotify instead of throwing an - exception, as we won't try to figure out the encoding of non-UTF-8-data. + - Thanks to Antoine Pierlot-Garcin's recent work on updating and improving + pyspotify, stored playlists will again load when Mopidy starts. The + workaround of searching and reconnecting to make the playlists appear are + no longer necessary. (Fixes: :issue:`59`) + + - Track's that are no longer available in Spotify's archives are now + "autolinked" to corresponding tracks in other albums, just like the + official Spotify clients do. (Fixes: :issue:`34`) - MPD frontend: @@ -44,6 +64,26 @@ No description yet. authentication is turned on, but the connected user has not been authenticated yet. +- Command line usage: + + - Support passing options to GStreamer. See :option:`--help-gst` for a list + of available options. (Fixes: :issue:`95`) + + - Improve :option:`--list-settings` output. (Fixes: :issue:`91`) + + - Added :option:`--interactive` for reading missing local settings from + ``stdin``. (Fixes: :issue:`96`) + +- Tag cache generator: + + - Made it possible to abort :command:`mopidy-scan` with CTRL+C. + + - Fixed bug regarding handling of bad dates. + + - Use :mod:`logging` instead of ``print`` statements. + + - Found and worked around strange WMA metadata behaviour. + v0.4.1 (2011-05-06) =================== diff --git a/docs/development/index.rst b/docs/development/index.rst index 14c49dbd..321b3242 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -7,4 +7,3 @@ Development roadmap contributing - internals diff --git a/docs/development/internals.rst b/docs/development/internals.rst deleted file mode 100644 index 4b4d3b14..00000000 --- a/docs/development/internals.rst +++ /dev/null @@ -1,113 +0,0 @@ -********* -Internals -********* - -Some of the following notes and details will hopefully be useful when you start -developing on Mopidy, while some may only be useful when you get deeper into -specific parts of Mopidy. - -In addition to what you'll find here, don't forget the :doc:`/api/index`. - - -Class instantiation and usage -============================= - -The following diagram shows how Mopidy is wired together with the MPD client, -the Spotify service, and the speakers. - -**Legend** - -- Filled red boxes are the key external systems. -- Gray boxes are external dependencies. -- Blue circles lives in the ``main`` process, also known as ``CoreProcess``. - It is processing messages put on the core queue. -- Purple circles lives in a process named ``MpdProcess``, running an - :mod:`asyncore` loop. -- Green circles lives in a process named ``GStreamerProcess``. -- Brown circle is a thread living in the ``CoreProcess``. - -.. digraph:: class_instantiation_and_usage - - "main" [ color="blue" ] - "CoreProcess" [ color="blue" ] - - # Frontend - "MPD client" [ color="red", style="filled", shape="box" ] - "MpdFrontend" [ color="blue" ] - "MpdProcess" [ color="purple" ] - "MpdServer" [ color="purple" ] - "MpdSession" [ color="purple" ] - "MpdDispatcher" [ color="blue" ] - - # Backend - "Libspotify\nBackend" [ color="blue" ] - "Libspotify\nSessionManager" [ color="brown" ] - "pyspotify" [ color="gray", shape="box" ] - "libspotify" [ color="gray", shape="box" ] - "Spotify" [ color="red", style="filled", shape="box" ] - - # Output/mixer - "GStreamer\nOutput" [ color="blue" ] - "GStreamer\nSoftwareMixer" [ color="blue" ] - "GStreamer\nProcess" [ color="green" ] - "GStreamer" [ color="gray", shape="box" ] - "Speakers" [ color="red", style="filled", shape="box" ] - - "main" -> "CoreProcess" [ label="create" ] - - # Frontend - "CoreProcess" -> "MpdFrontend" [ label="create" ] - "MpdFrontend" -> "MpdProcess" [ label="create" ] - "MpdFrontend" -> "MpdDispatcher" [ label="create" ] - "MpdProcess" -> "MpdServer" [ label="create" ] - "MpdServer" -> "MpdSession" [ label="create one\nper client" ] - "MpdSession" -> "MpdDispatcher" [ - label="pass requests\nvia core_queue" ] - "MpdDispatcher" -> "MpdSession" [ - label="pass response\nvia reply_to pipe" ] - "MpdDispatcher" -> "Libspotify\nBackend" [ label="use backend API" ] - "MPD client" -> "MpdServer" [ label="connect" ] - "MPD client" -> "MpdSession" [ label="request" ] - "MpdSession" -> "MPD client" [ label="response" ] - - # Backend - "CoreProcess" -> "Libspotify\nBackend" [ label="create" ] - "Libspotify\nBackend" -> "Libspotify\nSessionManager" [ - label="creates and uses" ] - "Libspotify\nSessionManager" -> "Libspotify\nBackend" [ - label="pass commands\nvia core_queue" ] - "Libspotify\nSessionManager" -> "pyspotify" [ label="use Python\nwrapper" ] - "pyspotify" -> "Libspotify\nSessionManager" [ label="use callbacks" ] - "pyspotify" -> "libspotify" [ label="use C library" ] - "libspotify" -> "Spotify" [ label="use service" ] - "Libspotify\nSessionManager" -> "GStreamer\nProcess" [ - label="pass commands\nand audio data\nvia output_queue" ] - - # Output/mixer - "Libspotify\nBackend" -> "GStreamer\nSoftwareMixer" [ - label="create and\nuse mixer API" ] - "GStreamer\nSoftwareMixer" -> "GStreamer\nProcess" [ - label="pass commands\nvia output_queue" ] - "CoreProcess" -> "GStreamer\nOutput" [ label="create" ] - "GStreamer\nOutput" -> "GStreamer\nProcess" [ label="create" ] - "GStreamer\nProcess" -> "GStreamer" [ label="use library" ] - "GStreamer" -> "Speakers" [ label="play audio" ] - - -Thread/process communication -============================ - -.. warning:: - This section is currently outdated. - -- Everything starts with ``Main``. -- ``Main`` creates a ``Core`` process which runs the frontend, backend, and - mixer. -- Mixers *may* create an additional process for communication with external - devices, like ``NadTalker`` in this example. -- Backend libraries *may* have threads of their own, like ``despotify`` here - which has additional threads in the ``Core`` process. -- ``Server`` part currently runs in the same process and thread as ``Main``. -- ``Client`` is some external client talking to ``Server`` over a socket. - -.. image:: /_static/thread_communication.png diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index cec8e9c7..6280762c 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -26,7 +26,7 @@ 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 +`_. 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 diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 72d55908..08e16378 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -73,5 +73,12 @@ Using a custom audio sink ========================= If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can change :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` -in your ``settings.py`` file. +``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. + +Example of ``settings.py`` for OSS4:: + + OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',) + CUSTOM_OUTPUT = u'oss4sink' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 1f497e3a..5101cc84 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -9,8 +9,8 @@ setup and whether you want to use stable releases or less stable development versions. -Install dependencies -==================== +Requirements +============ .. toctree:: :hidden: diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index ca0ad87d..2728be94 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -4,8 +4,8 @@ libspotify installation Mopidy uses `libspotify `_ for playing music from -the Spotify music service. To use :mod:`mopidy.backends.libspotify` you must -install libspotify and `pyspotify `_. +the Spotify music service. To use :mod:`mopidy.backends.spotify` you must +install libspotify and `pyspotify `_. .. note:: @@ -30,7 +30,7 @@ If you run a Debian based Linux distribution, like Ubuntu, see http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source on your installation. Then, simply run:: - sudo apt-get install libspotify7 + sudo apt-get install libspotify8 When libspotify has been installed, continue with :ref:`pyspotify_installation`. @@ -39,14 +39,14 @@ When libspotify has been installed, continue with On Linux from source -------------------- -Download and install libspotify 0.0.7 for your OS and CPU architecture from +Download and install libspotify 0.0.8 for your OS and CPU architecture from https://developer.spotify.com/en/libspotify/. For 64-bit Linux the process is as follows:: - wget http://developer.spotify.com/download/libspotify/libspotify-0.0.7-linux6-x86_64.tar.gz - tar zxfv libspotify-0.0.7-linux6-x86_64.tar.gz - cd libspotify-0.0.7-linux6-x86_64/ + wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz + tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz + cd libspotify-0.0.8-linux6-x86_64/ sudo make install prefix=/usr/local sudo ldconfig @@ -103,14 +103,10 @@ Debian/Ubuntu systems run:: On OS X no additional dependencies are needed. -Get the pyspotify code, and install it:: +Then get, build, and install the latest releast of pyspotify using ``pip``:: - wget --no-check-certificate -O pyspotify.tar.gz https://github.com/mopidy/pyspotify/tarball/mopidy - tar zxfv pyspotify.tar.gz - cd pyspotify/ - sudo python setup.py install + sudo pip install -U pyspotify -It is important that you install pyspotify from the ``mopidy`` branch of the -``mopidy/pyspotify`` repository, as the upstream repository at -``winjer/pyspotify`` is not updated with changes needed to support e.g. -libspotify 0.0.7 and high bitrate audio. +Or using the older ``easy_install``:: + + sudo easy_install pyspotify diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst index 37ff0390..87d23dab 100644 --- a/docs/modules/outputs.rst +++ b/docs/modules/outputs.rst @@ -4,10 +4,11 @@ The following GStreamer audio outputs implements the :ref:`output-api`. -.. inheritance-diagram:: mopidy.outputs - +.. inheritance-diagram:: mopidy.outputs.custom .. autoclass:: mopidy.outputs.custom.CustomOutput +.. inheritance-diagram:: mopidy.outputs.local .. autoclass:: mopidy.outputs.local.LocalOutput +.. inheritance-diagram:: mopidy.outputs.shoutcast .. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput diff --git a/docs/settings.rst b/docs/settings.rst index 1d4a4972..f0888670 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.3 release. + hopefully have support for this in the 0.6 release. .. _generating_a_tag_cache: @@ -92,6 +92,7 @@ To make a ``tag_cache`` of your local music available for Mopidy: .. _use_mpd_on_a_network: + Connecting from other machines on the network ============================================= @@ -119,6 +120,31 @@ file:: LASTFM_PASSWORD = u'mysecret' +Streaming audio through a SHOUTcast/Icecast server +================================================== + +If you want to play the audio on another computer than the one running Mopidy, +you can stream the audio from Mopidy through an SHOUTcast or Icecast audio +streaming server. Multiple media players can then be connected to the streaming +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. + +#. Check the default values for the following settings, and alter them to match + your Icecast setup if needed: + + - :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` + + Available settings ================== diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 4ea7a13f..530c4840 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -263,6 +263,7 @@ class PlaybackController(object): .. digraph:: state_transitions "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] "PLAYING" -> "STOPPED" [ label="stop" ] "PLAYING" -> "PAUSED" [ label="pause" ] "PLAYING" -> "PLAYING" [ label="play" ] diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index cc039ce0..93cf3534 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -22,7 +22,11 @@ class LocalBackend(ThreadingActor, Backend): """ A backend for playing music from a local music archive. - **Issues:** http://github.com/mopidy/mopidy/issues/labels/backend-local + **Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local + + **Dependencies:** + + - None **Settings:** @@ -174,7 +178,7 @@ class LocalLibraryProvider(BaseLibraryProvider): tracks = parse_mpd_tag_cache(tag_cache, music_folder) - logger.info('Loading songs in %s from %s', music_folder, tag_cache) + logger.info('Loading tracks in %s from %s', music_folder, tag_cache) for track in tracks: self._uri_mapping[track.uri] = track diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 9dababc0..66bcffd4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -11,6 +11,7 @@ from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') ENCODING = 'utf-8' +BITRATES = {96: 2, 160: 0, 320: 1} class SpotifyBackend(ThreadingActor, Backend): """ @@ -27,7 +28,12 @@ class SpotifyBackend(ThreadingActor, Backend): trade mark of the Spotify Group. **Issues:** - http://github.com/mopidy/mopidy/issues/labels/backend-spotify + https://github.com/mopidy/mopidy/issues?labels=backend-spotify + + **Dependencies:** + + - libspotify == 0.0.8 (libspotify8 package from apt.mopidy.com) + - pyspotify == 1.3 (python-spotify package from apt.mopidy.com) **Settings:** @@ -66,19 +72,22 @@ class SpotifyBackend(ThreadingActor, Backend): self.gstreamer = None self.spotify = None + # Fail early if settings are not present + self.username = settings.SPOTIFY_USERNAME + 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() + logger.info(u'Mopidy uses SPOTIFY(R) CORE') self.spotify = self._connect() def _connect(self): from .session_manager import SpotifySessionManager - logger.info(u'Mopidy uses SPOTIFY(R) CORE') logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager( - settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD) + spotify = SpotifySessionManager(self.username, self.password) spotify.start() return spotify diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py new file mode 100644 index 00000000..520cfb68 --- /dev/null +++ b/mopidy/backends/spotify/container_manager.py @@ -0,0 +1,46 @@ +import logging + +from spotify.manager import SpotifyContainerManager as \ + PyspotifyContainerManager + +logger = logging.getLogger('mopidy.backends.spotify.container_manager') + +class SpotifyContainerManager(PyspotifyContainerManager): + def __init__(self, session_manager): + PyspotifyContainerManager.__init__(self) + self.session_manager = session_manager + + def container_loaded(self, container, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: playlist container loaded') + self.session_manager.refresh_stored_playlists() + + playlist_container = self.session_manager.session.playlist_container() + for playlist in playlist_container: + self.session_manager.playlist_manager.watch(playlist) + logger.debug(u'Watching %d playlist(s) for changes', + len(playlist_container)) + + def playlist_added(self, container, playlist, position, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: playlist added at position %d', + position) + # container_loaded() is called after this callback, so we do not need + # to handle this callback. + + def playlist_moved(self, container, playlist, old_position, new_position, + userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: playlist "%s" moved from position %d to %d', + playlist.name(), old_position, new_position) + # container_loaded() is called after this callback, so we do not need + # to handle this callback. + + def playlist_removed(self, container, playlist, position, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: playlist "%s" removed from position %d', + playlist.name(), position) + # container_loaded() is called after this callback, so we do not need + # to handle this callback. diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py new file mode 100644 index 00000000..f72ac4ca --- /dev/null +++ b/mopidy/backends/spotify/playlist_manager.py @@ -0,0 +1,93 @@ +import datetime +import logging + +from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager + +logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') + +class SpotifyPlaylistManager(PyspotifyPlaylistManager): + def __init__(self, session_manager): + PyspotifyPlaylistManager.__init__(self) + self.session_manager = session_manager + + def tracks_added(self, playlist, tracks, position, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: ' + u'%d track(s) added to position %d in playlist "%s"', + len(tracks), position, playlist.name()) + self.session_manager.refresh_stored_playlists() + + def tracks_moved(self, playlist, tracks, new_position, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: ' + u'%d track(s) moved to position %d in playlist "%s"', + len(tracks), new_position, playlist.name()) + self.session_manager.refresh_stored_playlists() + + def tracks_removed(self, playlist, tracks, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: ' + u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) + self.session_manager.refresh_stored_playlists() + + def playlist_renamed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: Playlist renamed to "%s"', + playlist.name()) + self.session_manager.refresh_stored_playlists() + + def playlist_state_changed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: The state of playlist "%s" changed', + playlist.name()) + + def playlist_update_in_progress(self, playlist, done, userdata): + """Callback used by pyspotify""" + if done: + logger.debug(u'Callback called: ' + u'Update of playlist "%s" done', playlist.name()) + else: + logger.debug(u'Callback called: ' + u'Update of playlist "%s" in progress', playlist.name()) + + def playlist_metadata_updated(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: Metadata updated for playlist "%s"', + playlist.name()) + + def track_created_changed(self, playlist, position, user, when, userdata): + """Callback used by pyspotify""" + when = datetime.datetime.fromtimestamp(when) + logger.debug( + u'Callback called: Created by/when for track %d in playlist ' + u'"%s" changed to user "N/A" and time "%s"', + position, playlist.name(), when) + + def track_message_changed(self, playlist, position, message, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Message for track %d in playlist ' + u'"%s" changed to "%s"', position, playlist.name(), message) + + def track_seen_changed(self, playlist, position, seen, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Seen attribute for track %d in playlist ' + u'"%s" changed to "%s"', position, playlist.name(), seen) + + def description_changed(self, playlist, description, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Description changed for playlist "%s" to "%s"', + playlist.name(), description) + + def subscribers_changed(self, playlist, userdata): + """Callback used by pyspotify""" + logger.debug( + u'Callback called: Subscribers changed for playlist "%s"', + playlist.name()) + + def image_changed(self, playlist, image, userdata): + """Callback used by pyspotify""" + logger.debug(u'Callback called: Image changed for playlist "%s"', + playlist.name()) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index f34283c6..fd71d861 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -8,6 +8,9 @@ from pykka.registry import ActorRegistry from mopidy import 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 @@ -27,7 +30,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def __init__(self, username, password): PyspotifySessionManager.__init__(self, username, password) BaseThread.__init__(self) - self.name = 'SpotifySMThread' + self.name = 'SpotifyThread' self.gstreamer = None self.backend = None @@ -35,13 +38,17 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connected = threading.Event() self.session = None + self.container_manager = None + self.playlist_manager = None + def run_inside_try(self): self.setup() self.connect() def setup(self): gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + assert len(gstreamer_refs) == 1, \ + 'Expected exactly one running gstreamer.' self.gstreamer = gstreamer_refs[0].proxy() backend_refs = ActorRegistry.get_by_class(Backend) @@ -53,14 +60,19 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): if error: logger.error(u'Spotify login error: %s', error) return + logger.info(u'Connected to Spotify') self.session = session - if settings.SPOTIFY_HIGH_BITRATE: - logger.debug(u'Preferring high bitrate from Spotify') - self.session.set_preferred_bitrate(1) - else: - logger.debug(u'Preferring normal bitrate from Spotify') - self.session.set_preferred_bitrate(0) + + logger.debug(u'Preferred Spotify bitrate is %s kbps', + settings.SPOTIFY_BITRATE) + self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) + + self.container_manager = SpotifyContainerManager(self) + self.playlist_manager = SpotifyPlaylistManager(self) + + self.container_manager.watch(self.session.playlist_container()) + self.connected.set() def logged_out(self, session): @@ -69,13 +81,12 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def metadata_updated(self, session): """Callback used by pyspotify""" - logger.debug(u'Metadata updated') - self.refresh_stored_playlists() + logger.debug(u'Callback called: Metadata updated') def connection_error(self, session, error): """Callback used by pyspotify""" if error is None: - logger.info(u'Spotify connection error resolved') + logger.info(u'Spotify connection OK') else: logger.error(u'Spotify connection error: %s', error) self.backend.playback.pause() diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 21abdf78..1bf7e5aa 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -4,7 +4,7 @@ import logging from spotify import Link, SpotifyError from mopidy import settings -from mopidy.backends.spotify import ENCODING +from mopidy.backends.spotify import ENCODING, BITRATES from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') @@ -16,7 +16,7 @@ class SpotifyTranslator(object): return Artist(name=u'[loading...]') return Artist( uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name().decode(ENCODING, 'replace'), + name=spotify_artist.name() ) @classmethod @@ -24,7 +24,7 @@ class SpotifyTranslator(object): if spotify_album is None or not spotify_album.is_loaded(): return Album(name=u'[loading...]') # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name().decode(ENCODING, 'replace')) + return Album(name=spotify_album.name()) @classmethod def to_mopidy_track(cls, spotify_track): @@ -38,13 +38,13 @@ class SpotifyTranslator(object): date = None return Track( uri=uri, - name=spotify_track.name().decode(ENCODING, 'replace'), + name=spotify_track.name(), artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], album=cls.to_mopidy_album(spotify_track.album()), track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=(settings.SPOTIFY_HIGH_BITRATE and 320 or 160), + bitrate=BITRATES[settings.SPOTIFY_BITRATE], ) @classmethod @@ -57,7 +57,7 @@ class SpotifyTranslator(object): try: return Playlist( uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name().decode(ENCODING, 'replace'), + name=spotify_playlist.name(), # FIXME if check on link is a hackish workaround for is_local tracks=[cls.to_mopidy_track(t) for t in spotify_playlist if str(Link.from_track(t, 0))], diff --git a/mopidy/core.py b/mopidy/core.py index e510b698..65472a29 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,5 +1,6 @@ import logging import optparse +import signal import sys import time @@ -16,38 +17,48 @@ sys.argv[1:] = gstreamer_args from pykka.registry import ActorRegistry -from mopidy import get_version, settings, OptionalDependencyError +from mopidy import (get_version, settings, OptionalDependencyError, + SettingsError) 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 GObjectEventThread +from mopidy.utils.process import (GObjectEventThread, exit_handler, + stop_all_actors) from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') def main(): - options = parse_options() - setup_logging(options.verbosity_level, options.save_debug_log) - setup_settings() - setup_gobject_loop() - setup_gstreamer() - setup_mixer() - setup_backend() - setup_frontends() + signal.signal(signal.SIGTERM, exit_handler) try: - while ActorRegistry.get_all(): + options = parse_options() + setup_logging(options.verbosity_level, options.save_debug_log) + setup_settings(options.interactive) + setup_gobject_loop() + setup_gstreamer() + setup_mixer() + setup_backend() + setup_frontends() + while True: time.sleep(1) - logger.info(u'No actors left. Exiting...') + except SettingsError as e: + logger.error(e.message) except KeyboardInterrupt: - logger.info(u'User interrupt. Exiting...') - ActorRegistry.stop_all() + logger.info(u'Interrupted. Exiting...') + except Exception as e: + logger.exception(e) + finally: + stop_all_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 is missing') parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') @@ -62,10 +73,14 @@ def parse_options(): help='list current settings') return parser.parse_args(args=mopidy_args)[0] -def setup_settings(): +def setup_settings(interactive): get_or_create_folder('~/.mopidy/') get_or_create_file('~/.mopidy/settings.py') - settings.validate() + try: + settings.validate(interactive) + except SettingsError, e: + logger.error(e.message) + sys.exit(1) def setup_gobject_loop(): GObjectEventThread().start() diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 24c21c38..175aa0ee 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -13,11 +13,15 @@ class MpdFrontend(ThreadingActor, BaseFrontend): """ The MPD frontend. + **Dependencies:** + + - None + **Settings:** - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - :attr:`mopidy.settings.MPD_SERVER_PORT` + - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` """ def __init__(self): diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index b3aa0481..62e443fb 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -14,13 +14,11 @@ class MpdServer(asyncore.dispatcher): for each client connection. """ - def __init__(self): - asyncore.dispatcher.__init__(self) - def start(self): """Start MPD server.""" try: - self.socket = network.create_socket() + self.set_socket(network.create_socket()) + self.set_reuse_addr() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT logger.debug(u'MPD server is binding to [%s]:%s', hostname, port) @@ -38,7 +36,3 @@ class MpdServer(asyncore.dispatcher): logger.info(u'MPD client connection from [%s]:%s', client_socket_address[0], client_socket_address[1]) MpdSession(self, client_socket, client_socket_address) - - def handle_close(self): - """Called by asyncore when the socket is closed.""" - self.close() diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 53f4cab7..ce5d3be7 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -49,7 +49,7 @@ class MpdSession(asynchat.async_chat): Format a response from the MPD command handlers and send it to the client. """ - if response is not None: + if response: response = LINE_TERMINATOR.join(response) logger.debug(u'Response to [%s]:%s: %s', self.client_address, self.client_port, indent(response)) diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py index f52292d2..166c487e 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/gstreamer.py @@ -298,7 +298,7 @@ class GStreamer(ThreadingActor): output.sync_state_with_parent() # Required to add to running pipe gst.element_link_many(self._tee, output) self._outputs.append(output) - logger.info('Added %s', output.get_name()) + logger.debug('GStreamer added %s', output.get_name()) def list_outputs(self): """ diff --git a/mopidy/outputs/custom.py b/mopidy/outputs/custom.py index c5ca30bb..09239a44 100644 --- a/mopidy/outputs/custom.py +++ b/mopidy/outputs/custom.py @@ -21,6 +21,14 @@ class CustomOutput(BaseOutput): 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 index e004a076..8101e026 100644 --- a/mopidy/outputs/local.py +++ b/mopidy/outputs/local.py @@ -6,6 +6,14 @@ class LocalOutput(BaseOutput): 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): diff --git a/mopidy/outputs/shoutcast.py b/mopidy/outputs/shoutcast.py index 4298bba5..ffe09aae 100644 --- a/mopidy/outputs/shoutcast.py +++ b/mopidy/outputs/shoutcast.py @@ -13,6 +13,19 @@ class ShoutcastOutput(BaseOutput): 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): @@ -21,9 +34,9 @@ class ShoutcastOutput(BaseOutput): def modify_bin(self): self.set_properties(self.bin.get_by_name('shoutcast'), { - u'ip': settings.SHOUTCAST_OUTPUT_SERVER, - u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, + 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, }) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index c603c578..3bcf03d9 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -24,7 +24,7 @@ def translator(data): _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) - if gst.TAG_DATE in data: + if gst.TAG_DATE in data and data[gst.TAG_DATE]: date = data[gst.TAG_DATE] date = datetime.date(date.year, date.month, date.day) track_kwargs['date'] = date @@ -57,17 +57,16 @@ class Scanner(object): self.error_callback = error_callback self.loop = gobject.MainLoop() - caps = gst.Caps('audio/x-raw-int') fakesink = gst.element_factory_make('fakesink') - pad = fakesink.get_pad('sink') self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.connect('pad-added', self.process_new_pad, pad) - self.uribin.set_property('caps', caps) + self.uribin.set_property('caps', gst.Caps('audio/x-raw-int')) + self.uribin.connect('pad-added', self.process_new_pad, + fakesink.get_pad('sink')) self.pipe = gst.element_factory_make('pipeline') - self.pipe.add(fakesink) self.pipe.add(self.uribin) + self.pipe.add(fakesink) bus = self.pipe.get_bus() bus.add_signal_watch() @@ -78,22 +77,36 @@ class Scanner(object): pad.link(target_pad) def process_tags(self, bus, message): - data = message.parse_tag() - data = dict([(k, data[k]) for k in data.keys()]) - data['uri'] = unicode(self.uribin.get_property('uri')) - data['duration'] = self.get_duration() - self.data_callback(data) - self.next_uri() + taglist = message.parse_tag() + data = { + 'uri': unicode(self.uribin.get_property('uri')), + gst.TAG_DURATION: self.get_duration(), + } + + for key in taglist.keys(): + # XXX: For some crazy reason some wma files spit out lists here, + # not sure if this is due to better data in headers or wma being + # stupid. So ugly hack for now :/ + if type(taglist[key]) is list: + data[key] = taglist[key][0] + else: + data[key] = taglist[key] + + try: + self.data_callback(data) + self.next_uri() + except KeyboardInterrupt: + self.stop() def process_error(self, bus, message): if self.error_callback: uri = self.uribin.get_property('uri') - errors = message.parse_error() - self.error_callback(uri, errors) + error, debug = message.parse_error() + self.error_callback(uri, error, debug) self.next_uri() def get_duration(self): - self.pipe.get_state() + self.pipe.get_state() # Block until state change is done. try: return self.pipe.query_duration( gst.FORMAT_TIME, None)[0] // gst.MSECOND diff --git a/mopidy/settings.py b/mopidy/settings.py index 414c79ee..9ac63719 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -157,16 +157,16 @@ MIXER_MAX_VOLUME = 100 #: Listens on all interfaces, both IPv4 and IPv6. MPD_SERVER_HOSTNAME = u'127.0.0.1' -#: The password required for connecting to the MPD server. -#: -#: Default: :class:`None`, which means no password required. -MPD_SERVER_PASSWORD = None - #: Which TCP port Mopidy's MPD server should listen to. #: #: Default: 6600 MPD_SERVER_PORT = 6600 +#: The password required for connecting to the MPD server. +#: +#: Default: :class:`None`, which means no password required. +MPD_SERVER_PASSWORD = None + #: List of outputs to use. See :mod:`mopidy.outputs` for all available #: backends #: @@ -179,42 +179,54 @@ OUTPUTS = ( u'mopidy.outputs.local.LocalOutput', ) -#: Servar that runs Shoutcast server to send stream to. +#: Hostname of the SHOUTcast server which Mopidy should stream audio to. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: -#: SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' -SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' +#: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' +SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1' -#: User to authenticate as against Shoutcast server. +#: Port of the SHOUTcast server. #: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_USERNAME = u'source' -SHOUTCAST_OUTPUT_USERNAME = u'source' - -#: Password to authenticate with against Shoutcast server. -#: -#: Default:: -#: -#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme' -SHOUTCAST_OUTPUT_PASSWORD = u'hackme' - -#: Port to use for streaming to Shoutcast server. +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: #: SHOUTCAST_OUTPUT_PORT = 8000 SHOUTCAST_OUTPUT_PORT = 8000 -#: Mountpoint to use for the stream on the Shoutcast server. +#: 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. +#: Encoder to use to process audio data before streaming to SHOUTcast server. +#: +#: Used by :mod:`mopidy.outputs.shoutcast`. #: #: Default:: #: @@ -236,11 +248,13 @@ SPOTIFY_USERNAME = u'' #: Used by :mod:`mopidy.backends.spotify`. SPOTIFY_PASSWORD = u'' -#: Do you prefer high bitrate (320k)? +#: Spotify preferred bitrate. +#: +#: Available values are 96, 160, and 320. #: #: Used by :mod:`mopidy.backends.spotify`. # #: Default:: #: -#: SPOTIFY_HIGH_BITRATE = False # 160k -SPOTIFY_HIGH_BITRATE = False +#: SPOTIFY_BITRATE = 160 +SPOTIFY_BITRATE = 160 diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 7f6cf664..c1d1c9f5 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,15 +1,40 @@ import logging +import signal +import thread import threading import gobject gobject.threads_init() from pykka import ActorDeadError +from pykka.registry import ActorRegistry from mopidy import SettingsError logger = logging.getLogger('mopidy.utils.process') +def exit_process(): + logger.debug(u'Interrupting main...') + thread.interrupt_main() + logger.debug(u'Interrupted main') + +def exit_handler(signum, frame): + """A :mod:`signal` handler which will exit the program on signal.""" + signals = dict((k, v) for v, k in signal.__dict__.iteritems() + if v.startswith('SIG') and not v.startswith('SIG_')) + logger.info(u'Got %s signal', signals[signum]) + exit_process() + +def stop_all_actors(): + num_actors = len(ActorRegistry.get_all()) + while num_actors: + logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', + num_actors, threading.active_count() - num_actors, + ', '.join([t.name for t in threading.enumerate()])) + logger.debug(u'Stopping %d actor(s)...', num_actors) + ActorRegistry.stop_all() + num_actors = len(ActorRegistry.get_all()) + logger.debug(u'All actors stopped.') class BaseThread(threading.Thread): def __init__(self): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 2bd6e6f3..500477e2 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,6 +1,7 @@ # Absolute import needed to import ~/.mopidy/settings.py and not ourselves from __future__ import absolute_import from copy import copy +import getpass import logging import os from pprint import pformat @@ -63,12 +64,28 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) - def validate(self): + def validate(self, interactive): + if interactive: + 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())) raise SettingsError(u'Settings validation failed.') + def _read_missing_settings_from_stdin(self, current, runtime): + for setting, value in current.iteritems(): + if isinstance(value, basestring) and len(value) == 0: + runtime[setting] = self._read_from_stdin(setting + u': ') + + def _read_from_stdin(self, prompt): + if u'_PASSWORD' in prompt: + return (getpass.getpass(prompt) + .decode(sys.stdin.encoding, 'ignore')) + else: + sys.stdout.write(prompt) + return (sys.stdin.readline().strip() + .decode(sys.stdin.encoding, 'ignore')) + def get_errors(self): return validate_settings(self.default, self.local) @@ -107,6 +124,7 @@ def validate_settings(defaults, settings): 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', + 'SPOTIFY_HIGH_BITRATE': 'SPOTIFY_BITRATE', 'SPOTIFY_LIB_APPKEY': None, 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } @@ -127,6 +145,11 @@ def validate_settings(defaults, settings): 'longer available.') continue + if setting == 'SPOTIFY_BITRATE': + if value not in (96, 160, 320): + errors[setting] = (u'Unavailable Spotify bitrate. ' + + u'Available bitrates are 96, 160, and 320.') + if setting not in defaults: errors[setting] = u'Unknown setting. Is it misspelled?' continue diff --git a/setup.py b/setup.py index 3d9d4fdf..a8cf8ed1 100644 --- a/setup.py +++ b/setup.py @@ -69,11 +69,6 @@ for dirpath, dirnames, filenames in os.walk(project_dir): data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) -if os.geteuid() == 0: - # Only try to install this file if we are root - data_files.append( - ('/usr/local/share/applications', ['data/mopidy.desktop'])) - setup( name='Mopidy', version=get_version(), diff --git a/tests/help_test.py b/tests/help_test.py index dccccc9c..25f534c2 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -14,6 +14,7 @@ class HelpTest(unittest.TestCase): 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) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index b98c5aa9..f403a221 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -4,7 +4,7 @@ from datetime import date from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album -from tests import path_to_data_dir +from tests import path_to_data_dir, SkipTest class FakeGstDate(object): def __init__(self, year, month, day): @@ -144,9 +144,9 @@ class ScannerTest(unittest.TestCase): uri = data['uri'][len('file://'):] self.data[uri] = data - def error_callback(self, uri, errors): + def error_callback(self, uri, error, debug): uri = uri[len('file://'):] - self.errors[uri] = errors + self.errors[uri] = (error, debug) def test_data_is_set(self): self.scan('scanner/simple') @@ -184,3 +184,7 @@ class ScannerTest(unittest.TestCase): def test_other_media_is_ignored(self): self.scan('scanner/image') self.assert_(self.errors) + + @SkipTest + def test_song_without_time_is_handeled(self): + pass diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 1ffff9a6..973c2280 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -10,6 +10,7 @@ class ValidateSettingsTest(unittest.TestCase): self.defaults = { 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, + 'SPOTIFY_BITRATE': 160, } def test_no_errors_yields_empty_dict(self): @@ -42,6 +43,13 @@ class ValidateSettingsTest(unittest.TestCase): '"mopidy.backends.despotify.DespotifyBackend" is no longer ' + 'available.') + def test_unavailable_bitrate_setting_returns_error(self): + result = 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, {'FOO': '', 'BAR': ''}) @@ -63,6 +71,7 @@ class ValidateSettingsTest(unittest.TestCase): class SettingsProxyTest(unittest.TestCase): def setUp(self): self.settings = SettingsProxy(default_settings_module) + self.settings.local.clear() def test_set_and_get_attr(self): self.settings.TEST = 'test' @@ -141,6 +150,20 @@ class SettingsProxyTest(unittest.TestCase): actual = self.settings.TEST self.assertEqual(actual, './test') + def test_interactive_input_of_missing_defaults(self): + self.settings.default['TEST'] = '' + interactive_input = 'input' + self.settings._read_from_stdin = lambda _: interactive_input + self.settings.validate(interactive=True) + self.assertEqual(interactive_input, self.settings.TEST) + + def test_interactive_input_not_needed_when_setting_is_set_locally(self): + self.settings.default['TEST'] = '' + self.settings.local['TEST'] = 'test' + self.settings._read_from_stdin = lambda _: self.fail( + 'Should not read from stdin') + self.settings.validate(interactive=True) + class FormatSettingListTest(unittest.TestCase): def setUp(self):