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):