diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst index 5ef1606d..062eabdd 100644 --- a/docs/api/outputs.rst +++ b/docs/api/outputs.rst @@ -1,20 +1,19 @@ +.. _output-api: + ********** Output API ********** -Outputs are responsible for playing audio. +Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way. -.. warning:: - - A stable output API is not available yet, as we've only implemented a - single output module. - -.. automodule:: mopidy.outputs.base - :synopsis: Base class for outputs +.. autoclass:: mopidy.outputs.BaseOutput :members: Output implementations ====================== -* :mod:`mopidy.outputs.gstreamer` +* :class:`mopidy.outputs.CustomOutput` +* :class:`mopidy.outputs.LocalOutput` +* :class:`mopidy.outputs.NullOutput` +* :class:`mopidy.outputs.ShoutcastOutput` diff --git a/docs/changes.rst b/docs/changes.rst index 12da4e6d..f8f01129 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,9 +10,49 @@ This change log is used to track all major changes to Mopidy. No description yet. +**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. + **Changes** -No changes yet. +- Fix local backend time query errors that where coming from stopped pipeline. + (Fixes: :issue:`87`) + +- Support passing options to GStreamer. See :option:`--help-gst` for a list of + available options. (Fixes: :issue:`95`) + + +0.4.1 (2011-05-06) +================== + +This is a bug fix release fixing audio problems on older GStreamer and some +minor bugs. + + +**Bugfixes** + +- Fix broken audio on at least GStreamer 0.10.30, which affects Ubuntu 10.10. + The GStreamer `appsrc` bin wasn't being linked due to lack of default caps. + (Fixes: :issue:`85`) + +- Fix crash in :mod:`mopidy.mixers.nad` that occures at startup when the + :mod:`io` module is available. We used an `eol` keyword argument which is + supported by :meth:`serial.FileLike.readline`, but not by + :meth:`io.RawBaseIO.readline`. When the :mod:`io` module is available, it is + used by PySerial instead of the `FileLike` implementation. + +- Fix UnicodeDecodeError in MPD frontend on non-english locale. Thanks to + Antoine Pierlot-Garcin for the patch. (Fixes: :issue:`88`) + +- Do not create Pykka proxies that are not going to be used in + :mod:`mopidy.core`. The underlying actor may already intentionally be dead, + and thus the program may crash on creating a proxy it doesn't need. Combined + with the Pykka 0.12.2 release this fixes a crash in the Last.fm frontend + which may occur when all dependencies are installed, but the frontend isn't + configured. (Fixes: :issue:`84`) 0.4.0 (2011-04-27) diff --git a/docs/modules/gstreamer.rst b/docs/modules/gstreamer.rst new file mode 100644 index 00000000..adbf5fda --- /dev/null +++ b/docs/modules/gstreamer.rst @@ -0,0 +1,9 @@ +******************************************** +:mod:`mopidy.gstreamer` -- GStreamer adapter +******************************************** + +.. inheritance-diagram:: mopidy.gstreamer + +.. automodule:: mopidy.gstreamer + :synopsis: GStreamer adapter + :members: diff --git a/docs/modules/outputs.rst b/docs/modules/outputs.rst new file mode 100644 index 00000000..7da29fbc --- /dev/null +++ b/docs/modules/outputs.rst @@ -0,0 +1,15 @@ +************************************************ +:mod:`mopidy.outputs` -- GStreamer audio outputs +************************************************ + +The following GStreamer audio outputs implements the :ref:`output-api`. + +.. inheritance-diagram:: mopidy.outputs + +.. autoclass:: mopidy.outputs.CustomOutput + +.. autoclass:: mopidy.outputs.LocalOutput + +.. autoclass:: mopidy.outputs.NullOutput + +.. autoclass:: mopidy.outputs.ShoutcastOutput diff --git a/docs/modules/outputs/gstreamer.rst b/docs/modules/outputs/gstreamer.rst deleted file mode 100644 index 69c77dad..00000000 --- a/docs/modules/outputs/gstreamer.rst +++ /dev/null @@ -1,9 +0,0 @@ -********************************************************************* -:mod:`mopidy.outputs.gstreamer` -- GStreamer output for all platforms -********************************************************************* - -.. inheritance-diagram:: mopidy.outputs.gstreamer - -.. automodule:: mopidy.outputs.gstreamer - :synopsis: GStreamer output for all platforms - :members: diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 2fa96dab..cc039ce0 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -12,7 +12,7 @@ from mopidy.backends.base import (Backend, CurrentPlaylistController, BasePlaybackProvider, StoredPlaylistsController, BaseStoredPlaylistsProvider) from mopidy.models import Playlist, Track, Album -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer from .translator import parse_m3u, parse_mpd_tag_cache @@ -50,12 +50,12 @@ class LocalBackend(ThreadingActor, Backend): self.uri_handlers = [u'file://'] - self.output = None + self.gstreamer = None def on_start(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() + gstreamer_refs = ActorRegistry.get_by_class(GStreamer) + assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + self.gstreamer = gstreamer_refs[0].proxy() class LocalPlaybackController(PlaybackController): @@ -67,24 +67,26 @@ class LocalPlaybackController(PlaybackController): @property def time_position(self): - return self.backend.output.get_position().get() + return self.backend.gstreamer.get_position().get() class LocalPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.output.set_state('PAUSED').get() + return self.backend.gstreamer.pause_playback().get() def play(self, track): - return self.backend.output.play_uri(track.uri).get() + 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.output.set_state('PLAYING').get() + return self.backend.gstreamer.start_playback().get() def seek(self, time_position): - return self.backend.output.set_position(time_position).get() + return self.backend.gstreamer.set_position(time_position).get() def stop(self): - return self.backend.output.set_state('READY').get() + return self.backend.gstreamer.stop_playback().get() class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 1ac5f0be..9dababc0 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -6,7 +6,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import (Backend, CurrentPlaylistController, LibraryController, PlaybackController, StoredPlaylistsController) -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') @@ -63,13 +63,13 @@ class SpotifyBackend(ThreadingActor, Backend): self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] - self.output = None + self.gstreamer = None self.spotify = None def on_start(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() + gstreamer_refs = ActorRegistry.get_by_class(GStreamer) + assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + self.gstreamer = gstreamer_refs[0].proxy() self.spotify = self._connect() diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 3721fe9c..dc328fc9 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -8,10 +8,9 @@ logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): def pause(self): - return self.backend.output.set_state('PAUSED') + return self.backend.gstreamer.pause_playback() def play(self, track): - self.backend.output.set_state('READY') if self.backend.playback.state == self.backend.playback.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: @@ -20,7 +19,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.output.play_uri('appsrc://') + self.backend.gstreamer.prepare_change() + self.backend.gstreamer.set_uri('appsrc://') + self.backend.gstreamer.start_playback() + self.backend.gstreamer.set_metadata(track) return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) @@ -30,12 +32,12 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.output.set_state('READY') + self.backend.gstreamer.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.output.set_state('PLAYING') + self.backend.gstreamer.start_playback() return True def stop(self): - result = self.backend.output.set_state('READY') + result = self.backend.gstreamer.stop_playback() self.backend.spotify.session.play(0) return result diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 395f3f28..09064db2 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -10,7 +10,7 @@ from mopidy import get_version, settings from mopidy.backends.base import Backend from mopidy.backends.spotify.translator import SpotifyTranslator from mopidy.models import Playlist -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -29,7 +29,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): BaseThread.__init__(self) self.name = 'SpotifySMThread' - self.output = None + self.gstreamer = None self.backend = None self.connected = threading.Event() @@ -40,9 +40,9 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connect() def setup(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) - assert len(output_refs) == 1, 'Expected exactly one running output.' - self.output = output_refs[0].proxy() + gstreamer_refs = ActorRegistry.get_by_class(GStreamer) + assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.' + self.gstreamer = gstreamer_refs[0].proxy() backend_refs = ActorRegistry.get_by_class(Backend) assert len(backend_refs) == 1, 'Expected exactly one running backend.' @@ -106,7 +106,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.output.deliver_data(capabilites, bytes(frames)) + self.gstreamer.deliver_data(capabilites, bytes(frames)) def play_token_lost(self, session): """Callback used by pyspotify""" @@ -120,7 +120,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug(u'End of data stream reached') - self.output.end_of_data_stream() + self.gstreamer.end_of_data_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data diff --git a/mopidy/core.py b/mopidy/core.py index f1a9dc36..e510b698 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -1,10 +1,23 @@ import logging import optparse +import sys import time +# 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(arg): + return arg.startswith('--gst') or arg == '--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 pykka.registry import ActorRegistry from mopidy import get_version, settings, OptionalDependencyError +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 @@ -18,7 +31,7 @@ def main(): setup_logging(options.verbosity_level, options.save_debug_log) setup_settings() setup_gobject_loop() - setup_output() + setup_gstreamer() setup_mixer() setup_backend() setup_frontends() @@ -32,6 +45,9 @@ def main(): 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('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') @@ -44,7 +60,7 @@ def parse_options(): parser.add_option('--list-settings', action='callback', callback=list_settings_optparse_callback, help='list current settings') - return parser.parse_args()[0] + return parser.parse_args(args=mopidy_args)[0] def setup_settings(): get_or_create_folder('~/.mopidy/') @@ -52,25 +68,20 @@ def setup_settings(): settings.validate() def setup_gobject_loop(): - gobject_loop = GObjectEventThread() - gobject_loop.start() - return gobject_loop + GObjectEventThread().start() -def setup_output(): - return get_class(settings.OUTPUT).start().proxy() +def setup_gstreamer(): + GStreamer.start() def setup_mixer(): - return get_class(settings.MIXER).start().proxy() + get_class(settings.MIXER).start() def setup_backend(): - return get_class(settings.BACKENDS[0]).start().proxy() + get_class(settings.BACKENDS[0]).start() def setup_frontends(): - frontends = [] for frontend_class_name in settings.FRONTENDS: try: - frontend = get_class(frontend_class_name).start().proxy() - frontends.append(frontend) + get_class(frontend_class_name).start() except OptionalDependencyError as e: logger.info(u'Disabled: %s (%s)', frontend_class_name, e) - return frontends diff --git a/mopidy/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 8507e266..1be46ef4 100644 --- a/mopidy/frontends/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -52,7 +52,7 @@ class MpdServer(asyncore.dispatcher): self._format_hostname(settings.MPD_SERVER_HOSTNAME), settings.MPD_SERVER_PORT) except IOError, e: - logger.error('MPD server startup failed: %s' % e) + logger.error(u'MPD server startup failed: %s' % str(e).decode('utf-8')) sys.exit(1) def handle_accept(self): diff --git a/mopidy/gstreamer.py b/mopidy/gstreamer.py new file mode 100644 index 00000000..3c8941fa --- /dev/null +++ b/mopidy/gstreamer.py @@ -0,0 +1,264 @@ +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') + +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""") + + +class GStreamer(ThreadingActor): + """ + Audio output through `GStreamer `_. + + **Settings:** + + - :attr:`mopidy.settings.OUTPUTS` + + """ + + def __init__(self): + self._pipeline = None + self._source = None + self._taginject = None + self._tee = None + self._uridecodebin = None + self._volume = None + + def on_start(self): + self._setup_gstreamer() + + def _setup_gstreamer(self): + """ + **Warning:** :class:`GStreamer` requires + :class:`mopidy.utils.process.GObjectEventThread` to be running. This is + not enforced by :class:`GStreamer` itself. + """ + description = ' ! '.join([ + 'uridecodebin name=uri', + 'audioconvert name=convert', + 'volume name=volume', + 'taginject name=inject', + 'tee name=tee']) + + logger.debug(u'Setting up base GStreamer pipeline: %s', description) + + self._pipeline = gst.parse_launch(description) + self._taginject = self._pipeline.get_by_name('inject') + 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._process_new_source) + self._uridecodebin.connect('pad-added', self._process_new_pad, + self._pipeline.get_by_name('convert').get_pad('sink')) + + for output in settings.OUTPUTS: + output_cls = get_class(output)() + output_cls.connect_bin(self._pipeline, self._tee) + + # Setup bus and message processor + bus = self._pipeline.get_bus() + bus.add_signal_watch() + bus.connect('message', self._process_gstreamer_message) + + def _process_new_source(self, element, pad): + self._source = element.get_by_name('source') + try: + self._source.set_property('caps', default_caps) + except TypeError: + pass + + def _process_new_pad(self, source, pad, target_pad): + if not pad.is_linked(): + pad.link(target_pad) + + def _process_gstreamer_message(self, bus, message): + """Process messages from GStreamer.""" + if message.type == gst.MESSAGE_EOS: + logger.debug(u'GStreamer signalled end-of-stream. ' + 'Telling backend ...') + self._get_backend().playback.on_end_of_track() + elif message.type == gst.MESSAGE_ERROR: + self.stop_playback() + error, debug = message.parse_error() + logger.error(u'%s %s', error, debug) + # FIXME Should we send 'stop_playback' to the backend here? Can we + # differentiate on how serious the error is? + 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): + """ + Change internal uridecodebin's URI + + :param uri: the URI to play + :type uri: string + """ + self._uridecodebin.set_property('uri', uri) + + def deliver_data(self, capabilities, data): + """ + Deliver 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 end_of_data_stream(self): + """ + Add end-of-stream token to source. + + 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""" + return self._set_state(gst.STATE_PLAYING) + + def pause_playback(self): + """Notify GStreamer that it should pause playback""" + return self._set_state(gst.STATE_PAUSED) + + def prepare_change(self): + """ + Notify GStreamer that we are about to change state of playback. + + This function always needs to 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""" + return self._set_state(gst.STATE_NULL) + + def _set_state(self, state): + """ + Set the GStreamer state. Returns :class:`True` if successful. + + .. digraph:: gst_state_transitions + + "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` or :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 for software mixer. + + :rtype: int in range [0..100] + """ + return int(self._volume.get_property('volume') * 100) + + def set_volume(self, volume): + """ + Set volume level for software mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + 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 don't already + inject tags in pipeline. + + :param track: Track containing metadata for current song. + :type track: :class:`mopidy.modes.Track` + """ + # FIXME what if we want to unset taginject tags? + tags = u'artist="%(artist)s",title="%(title)s"' % { + 'artist': u', '.join([a.name for a in track.artists]), + 'title': track.name, + } + logger.debug('Setting tags to: %s', tags) + self._taginject.set_property('tags', tags) diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py index d6365b4b..87602772 100644 --- a/mopidy/mixers/gstreamer_software.py +++ b/mopidy/mixers/gstreamer_software.py @@ -2,7 +2,7 @@ from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry from mopidy.mixers.base import BaseMixer -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): """Mixer which uses GStreamer to control volume in software.""" @@ -11,7 +11,7 @@ class GStreamerSoftwareMixer(ThreadingActor, BaseMixer): self.output = None def on_start(self): - output_refs = ActorRegistry.get_by_class(BaseOutput) + output_refs = ActorRegistry.get_by_class(GStreamer) assert len(output_refs) == 1, 'Expected exactly one running output.' self.output = output_refs[0].proxy() diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index bd53376e..62f38bb7 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -190,7 +190,7 @@ class NadTalker(ThreadingActor): # trailing whitespace. if not self._device.isOpen(): self._device.open() - result = self._device.readline(eol='\n').strip() + result = self._device.readline().strip() if result: logger.debug('Read: %s', result) return result diff --git a/mopidy/outputs/__init__.py b/mopidy/outputs/__init__.py index e69de29b..d1512617 100644 --- a/mopidy/outputs/__init__.py +++ b/mopidy/outputs/__init__.py @@ -0,0 +1,153 @@ +import logging + +import pygst +pygst.require('0.10') +import gst + +from mopidy import settings + +logger = logging.getLogger('mopidy.outputs') + + +class BaseOutput(object): + """Base class for providing support for multiple pluggable outputs.""" + + def connect_bin(self, pipeline, element): + """ + Connect output bin to pipeline and given element. + + In normal cases the element will probably be a `tee`, + thus allowing us to connect any number of outputs. This + however is why each bin is forced to have its own `queue` + after the `tee`. + + :param pipeline: gst.Pipeline to add output to. + :type pipeline: :class:`gst.Pipeline` + :param element: gst.Element in pipeline to connect output to. + :type element: :class:`gst.Element` + """ + description = 'queue ! %s' % self.describe_bin() + logger.debug('Adding new output to tee: %s', description) + + output = gst.parse_bin_from_description(description, True) + self.modify_bin(output) + + pipeline.add(output) + output.sync_state_with_parent() # Required to add to running pipe + gst.element_link_many(element, output) + + def modify_bin(self, output): + """ + Modifies 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. + + :param output: gst.Bin to modify in some way. + :type output: :class:`gst.Bin` + """ + pass + + def describe_bin(self): + """ + Return text string describing bin in gst-launch format. + + For simple cases this can just be a plain sink such as `autoaudiosink` + or it can be a chain `element1 ! element2 ! sink`. See `man + gst-launch0.10` for details on format. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def set_properties(self, element, properties): + """ + Helper to allow for simple setting of properties on elements. + + Will call `set_property` on the element for each key that has a value + that is not None. + + :param element: gst.Element to set properties on. + :type element: :class:`gst.Element` + :param properties: Dictionary of properties to set on element. + :type properties: dict + """ + for key, value in properties.items(): + if value is not None: + element.set_property(key, value) + + +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. + + """ + def describe_bin(self): + return settings.CUSTOM_OUTPUT + + +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. + """ + + def describe_bin(self): + return 'autoaudiosink' + + +class NullOutput(BaseOutput): + """ + Fall-back null output. + + This output will not output anything. It is intended as a fall-back for + when setup of all other outputs have failed and should not be used by end + users. Inserting this output in such a case ensures that the pipeline does + not crash. + """ + + def describe_bin(self): + return 'fakesink' + + +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. + """ + + def describe_bin(self): + return 'audioconvert ! %s ! shout2send name=shoutcast' \ + % settings.SHOUTCAST_OUTPUT_ENCODER + + def modify_bin(self, output): + self.set_properties(output.get_by_name('shoutcast'), { + u'ip': settings.SHOUTCAST_OUTPUT_SERVER, + u'mount': settings.SHOUTCAST_OUTPUT_MOUNT, + u'port': settings.SHOUTCAST_OUTPUT_PORT, + u'username': settings.SHOUTCAST_OUTPUT_USERNAME, + u'password': settings.SHOUTCAST_OUTPUT_PASSWORD, + }) diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py deleted file mode 100644 index fbc86688..00000000 --- a/mopidy/outputs/base.py +++ /dev/null @@ -1,91 +0,0 @@ -class BaseOutput(object): - """ - Base class for audio outputs. - """ - - def play_uri(self, uri): - """ - Play URI. - - *MUST be implemented by subclass.* - - :param uri: the URI to play - :type uri: string - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def deliver_data(self, capabilities, data): - """ - Deliver audio data to be played. - - *MUST be implemented by subclass.* - - :param capabilities: a GStreamer capabilities string - :type capabilities: string - """ - raise NotImplementedError - - def end_of_data_stream(self): - """ - Signal that the last audio data has been delivered. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def get_position(self): - """ - Get position in milliseconds. - - *MUST be implemented by subclass.* - - :rtype: int - """ - raise NotImplementedError - - def set_position(self, position): - """ - Set position in milliseconds. - - *MUST be implemented by subclass.* - - :param position: the position in milliseconds - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def set_state(self, state): - """ - Set playback state. - - *MUST be implemented by subclass.* - - :param state: the state - :type state: string - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError - - def get_volume(self): - """ - Get volume level for software mixer. - - *MUST be implemented by subclass.* - - :rtype: int in range [0..100] - """ - raise NotImplementedError - - def set_volume(self, volume): - """ - Set volume level for software mixer. - - *MUST be implemented by subclass.* - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - raise NotImplementedError diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py deleted file mode 100644 index f09965f7..00000000 --- a/mopidy/outputs/dummy.py +++ /dev/null @@ -1,63 +0,0 @@ -from pykka.actor import ThreadingActor - -from mopidy.outputs.base import BaseOutput - -class DummyOutput(ThreadingActor, BaseOutput): - """ - Audio output used for testing. - """ - - # pylint: disable = R0902 - # Too many instance attributes (9/7) - - #: For testing. Contains the last URI passed to :meth:`play_uri`. - uri = None - - #: For testing. Contains the last capabilities passed to - #: :meth:`deliver_data`. - capabilities = None - - #: For testing. Contains the last data passed to :meth:`deliver_data`. - data = None - - #: For testing. :class:`True` if :meth:`end_of_data_stream` has been - #: called. - end_of_data_stream_called = False - - #: For testing. Contains the current position. - position = 0 - - #: For testing. Contains the current state. - state = 'NULL' - - #: For testing. Contains the current volume. - volume = 100 - - def play_uri(self, uri): - self.uri = uri - return True - - def deliver_data(self, capabilities, data): - self.capabilities = capabilities - self.data = data - - def end_of_data_stream(self): - self.end_of_data_stream_called = True - - def get_position(self): - return self.position - - def set_position(self, position): - self.position = position - return True - - def set_state(self, state): - self.state = state - return True - - def get_volume(self): - return self.volume - - def set_volume(self, volume): - self.volume = volume - return True diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py deleted file mode 100644 index a6d1e9dd..00000000 --- a/mopidy/outputs/gstreamer.py +++ /dev/null @@ -1,153 +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.backends.base import Backend -from mopidy.outputs.base import BaseOutput - -logger = logging.getLogger('mopidy.outputs.gstreamer') - -class GStreamerOutput(ThreadingActor, BaseOutput): - """ - Audio output through `GStreamer `_. - - **Settings:** - - - :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK` - - """ - - def __init__(self): - self.gst_pipeline = None - - def on_start(self): - self._setup_gstreamer() - - def _setup_gstreamer(self): - """ - **Warning:** :class:`GStreamerOutput` requires - :class:`mopidy.utils.process.GObjectEventThread` to be running. This is - not enforced by :class:`GStreamerOutput` itself. - """ - - logger.debug(u'Setting up GStreamer pipeline') - - self.gst_pipeline = gst.parse_launch(' ! '.join([ - 'audioconvert name=convert', - 'volume name=volume', - settings.GSTREAMER_AUDIO_SINK, - ])) - - pad = self.gst_pipeline.get_by_name('convert').get_pad('sink') - - uridecodebin = gst.element_factory_make('uridecodebin', 'uri') - uridecodebin.connect('pad-added', self._process_new_pad, pad) - self.gst_pipeline.add(uridecodebin) - - # Setup bus and message processor - gst_bus = self.gst_pipeline.get_bus() - gst_bus.add_signal_watch() - gst_bus.connect('message', self._process_gstreamer_message) - - def _process_new_pad(self, source, pad, target_pad): - pad.link(target_pad) - - def _process_gstreamer_message(self, bus, message): - """Process messages from GStreamer.""" - if message.type == gst.MESSAGE_EOS: - logger.debug(u'GStreamer signalled end-of-stream. ' - 'Telling backend ...') - self._get_backend().playback.on_end_of_track() - elif message.type == gst.MESSAGE_ERROR: - self.set_state('NULL') - error, debug = message.parse_error() - logger.error(u'%s %s', error, debug) - # FIXME Should we send 'stop_playback' to the backend here? Can we - # differentiate on how serious the error is? - - def _get_backend(self): - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - return backend_refs[0].proxy() - - def play_uri(self, uri): - """Play audio at URI""" - self.set_state('READY') - self.gst_pipeline.get_by_name('uri').set_property('uri', uri) - return self.set_state('PLAYING') - - def deliver_data(self, caps_string, data): - """Deliver audio data to be played""" - source = self.gst_pipeline.get_by_name('source') - caps = gst.caps_from_string(caps_string) - buffer_ = gst.Buffer(buffer(data)) - buffer_.set_caps(caps) - source.set_property('caps', caps) - source.emit('push-buffer', buffer_) - - def end_of_data_stream(self): - """ - Add end-of-stream token to source. - - We will get a GStreamer message when the stream playback reaches the - token, and can then do any end-of-stream related tasks. - """ - self.gst_pipeline.get_by_name('source').emit('end-of-stream') - - def get_position(self): - try: - position = self.gst_pipeline.query_position(gst.FORMAT_TIME)[0] - return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) - return 0 - - def set_position(self, position): - self.gst_pipeline.get_state() # block until state changes are done - handeled = self.gst_pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), - gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self.gst_pipeline.get_state() # block until seek is done - return handeled - - def set_state(self, state_name): - """ - Set the GStreamer state. Returns :class:`True` if successful. - - .. digraph:: gst_state_transitions - - "NULL" -> "READY" - "PAUSED" -> "PLAYING" - "PAUSED" -> "READY" - "PLAYING" -> "PAUSED" - "READY" -> "NULL" - "READY" -> "PAUSED" - - :param state_name: NULL, READY, PAUSED, or PLAYING - :type state_name: string - :rtype: :class:`True` or :class:`False` - """ - result = self.gst_pipeline.set_state( - getattr(gst, 'STATE_' + state_name)) - if result == gst.STATE_CHANGE_FAILURE: - logger.warning('Setting GStreamer state to %s: failed', state_name) - return False - else: - logger.debug('Setting GStreamer state to %s: OK', state_name) - return True - - def get_volume(self): - """Get volume in range [0..100]""" - gst_volume = self.gst_pipeline.get_by_name('volume') - return int(gst_volume.get_property('volume') * 100) - - def set_volume(self, volume): - """Set volume in range [0..100]""" - gst_volume = self.gst_pipeline.get_by_name('volume') - gst_volume.set_property('volume', volume / 100.0) - return True diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 93224331..c603c578 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -16,48 +16,34 @@ def translator(data): artist_kwargs = {} track_kwargs = {} - # FIXME replace with data.get('foo', None) ? + def _retrieve(source_key, target_key, target): + if source_key in data: + target[target_key] = data[source_key] - if 'album' in data: - album_kwargs['name'] = data['album'] + _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) + _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) + _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) - if 'track-count' in data: - album_kwargs['num_tracks'] = data['track-count'] - - if 'artist' in data: - artist_kwargs['name'] = data['artist'] - - if 'date' in data: - date = data['date'] + if gst.TAG_DATE in data: + date = data[gst.TAG_DATE] date = datetime.date(date.year, date.month, date.day) track_kwargs['date'] = date - if 'title' in data: - track_kwargs['name'] = data['title'] + _retrieve(gst.TAG_TITLE, 'name', track_kwargs) + _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) - if 'track-number' in data: - track_kwargs['track_no'] = data['track-number'] - - if 'album-artist' in data: - albumartist_kwargs['name'] = data['album-artist'] - - if 'musicbrainz-trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz-trackid'] - - if 'musicbrainz-artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz-artistid'] - - if 'musicbrainz-albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz-albumid'] - - if 'musicbrainz-albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = data['musicbrainz-albumartistid'] + # Following keys don't seem to have TAG_* constant. + _retrieve('album-artist', 'name', albumartist_kwargs) + _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) + _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) + _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) + _retrieve('musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] track_kwargs['uri'] = data['uri'] - track_kwargs['length'] = data['duration'] + track_kwargs['length'] = data[gst.TAG_DURATION] track_kwargs['album'] = Album(**album_kwargs) track_kwargs['artists'] = [Artist(**artist_kwargs)] diff --git a/mopidy/settings.py b/mopidy/settings.py index 9080f331..3b5a3bc8 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -26,6 +26,13 @@ BACKENDS = ( #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' +#: Which GStreamer bin description to use in :class:`mopidy.outputs.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 @@ -55,13 +62,6 @@ FRONTENDS = ( u'mopidy.frontends.mpris.MprisFrontend', ) -#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`. -#: -#: Default:: -#: -#: GSTREAMER_AUDIO_SINK = u'autoaudiosink' -GSTREAMER_AUDIO_SINK = u'autoaudiosink' - #: Your `Last.fm `_ username. #: #: Used by :mod:`mopidy.frontends.lastfm`. @@ -144,13 +144,6 @@ MIXER_EXT_SPEAKERS_B = None #: MIXER_MAX_VOLUME = 100 MIXER_MAX_VOLUME = 100 -#: Audio output handler to use. -#: -#: Default:: -#: -#: OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' -OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput' - #: Which address Mopidy's MPD server should bind to. #: #:Examples: @@ -175,6 +168,60 @@ MPD_SERVER_PASSWORD = None #: Default: 6600 MPD_SERVER_PORT = 6600 +#: List of outputs to use. See :mod:`mopidy.outputs` for all available +#: backends +#: +#: Default:: +#: +#: OUTPUTS = ( +#: u'mopidy.outputs.LocalOutput', +#: ) +OUTPUTS = ( + u'mopidy.outputs.LocalOutput', +) + +#: Servar that runs Shoutcast server to send stream to. +#: +#: Default:: +#: +#: SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' +SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1' + +#: User to authenticate as against 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. +#: +#: Default:: +#: +#: SHOUTCAST_OUTPUT_PORT = 8000 +SHOUTCAST_OUTPUT_PORT = 8000 + +#: Mountpoint to use for the stream on the Shoutcast server. +#: +#: Default:: +#: +#: SHOUTCAST_OUTPUT_MOUNT = u'/stream' +SHOUTCAST_OUTPUT_MOUNT = u'/stream' + +#: Encoder to use to process audio data before streaming. +#: +#: Default:: +#: +#: SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' +SHOUTCAST_OUTPUT_ENCODER = u'lame mode=stereo bitrate=320' + #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 531b68b6..03b85b48 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,6 +1,5 @@ import logging import logging.handlers -import platform from mopidy import get_version, get_platform, get_python, settings diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 529c6fb1..7f541c21 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -97,9 +97,12 @@ def validate_settings(defaults, settings): 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', 'FRONTEND': 'FRONTENDS', + 'GSTREAMER_AUDIO_SINK': 'CUSTOM_OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', + 'LOCAL_OUTPUT_OVERRIDE': 'CUSTOM_OUTPUT', 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', + 'OUTPUT': None, 'SERVER': None, 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', 'SERVER_PORT': 'MPD_SERVER_PORT', diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index ee5e1111..427ce76d 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -3,7 +3,7 @@ import multiprocessing import random from mopidy.models import Playlist, Track -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer from tests.backends.base import populate_playlist @@ -12,7 +12,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=BaseOutput) + self.backend.gstreamer = mock.Mock(spec=GStreamer) self.controller = self.backend.current_playlist self.playback = self.backend.playback diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 8ea48a3a..2d455225 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -4,7 +4,7 @@ import random import time from mopidy.models import Track -from mopidy.outputs.base import BaseOutput +from mopidy.gstreamer import GStreamer from tests import SkipTest from tests.backends.base import populate_playlist @@ -16,7 +16,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.output = mock.Mock(spec=BaseOutput) + self.backend.gstreamer = mock.Mock(spec=GStreamer) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -520,7 +520,7 @@ class PlaybackControllerTest(object): self.assert_(wrapper.called) - @SkipTest # Blocks for 10ms and does not work with DummyOutput + @SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -599,7 +599,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @SkipTest # Uses sleep and does not work with DummyOutput+LocalBackend + @SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -729,7 +729,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.output.get_position = mock.Mock(return_value=future) + self.backend.gstreamer.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -737,11 +737,11 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.output.get_position = mock.Mock(return_value=future) + self.backend.gstreamer.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) - @SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput + @SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() diff --git a/tests/gstreamer_test.py b/tests/gstreamer_test.py new file mode 100644 index 00000000..0b9a559e --- /dev/null +++ b/tests/gstreamer_test.py @@ -0,0 +1,80 @@ +import multiprocessing +import unittest + +from tests import SkipTest + +# FIXME Our Windows build server does not support GStreamer yet +import sys +if sys.platform == 'win32': + raise SkipTest + +from mopidy import settings +from mopidy.gstreamer import GStreamer +from mopidy.utils.path import path_to_uri + +from tests import path_to_data_dir + +# TODO BaseOutputTest? + +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()) + + @SkipTest + def test_deliver_data(self): + pass # TODO + + @SkipTest + def test_end_of_data_stream(self): + pass # TODO + + def test_default_get_volume_result(self): + 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()) + + @SkipTest + def test_set_state_encapsulation(self): + pass # TODO + + @SkipTest + def test_set_position(self): + pass # TODO diff --git a/tests/help_test.py b/tests/help_test.py new file mode 100644 index 00000000..dccccc9c --- /dev/null +++ b/tests/help_test.py @@ -0,0 +1,27 @@ +import os +import subprocess +import sys +import unittest + +import mopidy + +class HelpTest(unittest.TestCase): + def test_help_has_mopidy_options(self): + mopidy_dir = os.path.dirname(mopidy.__file__) + 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_('--quiet' in output) + self.assert_('--verbose' in output) + self.assert_('--save-debug-log' in output) + self.assert_('--list-settings' in 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) diff --git a/tests/outputs/gstreamer_test.py b/tests/outputs/gstreamer_test.py deleted file mode 100644 index 31a16756..00000000 --- a/tests/outputs/gstreamer_test.py +++ /dev/null @@ -1,62 +0,0 @@ -import multiprocessing -import unittest - -from tests import SkipTest - -# FIXME Our Windows build server does not support GStreamer yet -import sys -if sys.platform == 'win32': - raise SkipTest - -from mopidy import settings -from mopidy.outputs.gstreamer import GStreamerOutput -from mopidy.utils.path import path_to_uri - -from tests import path_to_data_dir - -class GStreamerOutputTest(unittest.TestCase): - def setUp(self): - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.output = GStreamerOutput() - self.output.on_start() - - def tearDown(self): - settings.runtime.clear() - - def test_play_uri_existing_file(self): - self.assertTrue(self.output.play_uri(self.song_uri)) - - def test_play_uri_non_existing_file(self): - self.assertFalse(self.output.play_uri(self.song_uri + 'bogus')) - - @SkipTest - def test_deliver_data(self): - pass # TODO - - @SkipTest - def test_end_of_data_stream(self): - pass # TODO - - def test_default_get_volume_result(self): - self.assertEqual(100, self.output.get_volume()) - - def test_set_volume(self): - self.assertTrue(self.output.set_volume(50)) - self.assertEqual(50, self.output.get_volume()) - - def test_set_volume_to_zero(self): - self.assertTrue(self.output.set_volume(0)) - self.assertEqual(0, self.output.get_volume()) - - def test_set_volume_to_one_hundred(self): - self.assertTrue(self.output.set_volume(100)) - self.assertEqual(100, self.output.get_volume()) - - @SkipTest - def test_set_state(self): - pass # TODO - - @SkipTest - def test_set_position(self): - pass # TODO diff --git a/tests/version_test.py b/tests/version_test.py index b060a9c6..7bfb540e 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -18,7 +18,8 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.2.0') < SV('0.3.0')) self.assert_(SV('0.3.0') < SV('0.3.1')) self.assert_(SV('0.3.1') < SV('0.4.0')) - self.assert_(SV('0.4.0') < SV(get_plain_version())) + self.assert_(SV('0.4.0') < SV('0.4.1')) + self.assert_(SV('0.4.1') < SV(get_plain_version())) self.assert_(SV(get_plain_version()) < SV('0.5.1')) def test_get_platform_contains_platform(self):