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 e4769683..375d2da1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,7 +8,9 @@ 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. @@ -20,22 +22,21 @@ Please note that 0.5.0 requires some updated dependencies, as listed under automatically be upgraded. If you are not installing from APT, follow the instructions at :doc:`/installation/libspotify/`. -- 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 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`) - -- Improve :option:`--list-settings` output. (Fixes: :issue:`91`) - -- 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. + - Fix local backend time query errors that where coming from stopped + pipeline. (Fixes: :issue:`87`) - Spotify backend: @@ -44,6 +45,9 @@ Please note that 0.5.0 requires some updated dependencies, as listed under workaround of searching and reconnecting to make the playlists appear are no longer necessary. (Fixes: :issue:`59`) + - 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. + - MPD frontend: - Refactoring and cleanup. Most notably, all request handlers now get an @@ -59,7 +63,27 @@ Please note that 0.5.0 requires some updated dependencies, as listed under authentication is turned on, but the connected user has not been authenticated yet. -- Backends: +- 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. + +- Backend API: - Calling on :meth:`mopidy.backends.base.playback.PlaybackController.next` and :meth:`mopidy.backends.base.playback.PlaybackController.previous` no 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/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 eeb4b2f3..29188f55 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..87997059 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.2 (python-spotify package from apt.mopidy.com) **Settings:** diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 388b29c3..4b6abe85 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -8,6 +8,7 @@ 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.translator import SpotifyTranslator from mopidy.models import Playlist from mopidy.gstreamer import GStreamer @@ -58,12 +59,10 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 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.container_manager.watch(self.session.playlist_container()) self.connected.set() diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 21abdf78..91a2a9ae 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') @@ -44,7 +44,7 @@ class SpotifyTranslator(object): 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 diff --git a/mopidy/core.py b/mopidy/core.py index e510b698..b89a5456 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,44 @@ 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: + 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 ActorRegistry.get_all(): time.sleep(1) logger.info(u'No actors left. Exiting...') except KeyboardInterrupt: logger.info(u'User interrupt. Exiting...') - ActorRegistry.stop_all() + 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 +69,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 927e2a00..62e443fb 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -14,13 +14,10 @@ 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 @@ -39,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/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 0ac74c82..ddf622c4 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -158,16 +158,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 #: @@ -180,42 +180,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:: #: @@ -237,11 +249,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..5b09148d 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -1,15 +1,30 @@ import logging +import signal 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_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. Exiting...', signals[signum]) + stop_all_actors() + +def stop_all_actors(): + num_actors = len(ActorRegistry.get_all()) + while num_actors: + logger.debug(u'Stopping %d actor(s)...', num_actors) + ActorRegistry.stop_all() + num_actors = len(ActorRegistry.get_all()) 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):