Merge branch 'develop' into feature/mpris-frontend

This commit is contained in:
Stein Magnus Jodal 2011-05-11 17:18:26 +02:00
commit 55f13123a3
28 changed files with 750 additions and 490 deletions

View File

@ -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`

View File

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

View File

@ -0,0 +1,9 @@
********************************************
:mod:`mopidy.gstreamer` -- GStreamer adapter
********************************************
.. inheritance-diagram:: mopidy.gstreamer
.. automodule:: mopidy.gstreamer
:synopsis: GStreamer adapter
:members:

15
docs/modules/outputs.rst Normal file
View File

@ -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

View File

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

View File

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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

264
mopidy/gstreamer.py Normal file
View File

@ -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 <http://gstreamer.freedesktop.org/>`_.
**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)

View File

@ -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()

View File

@ -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

View File

@ -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,
})

View File

@ -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

View File

@ -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

View File

@ -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 <http://gstreamer.freedesktop.org/>`_.
**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

View File

@ -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)]

View File

@ -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 <http://www.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`.

View File

@ -1,6 +1,5 @@
import logging
import logging.handlers
import platform
from mopidy import get_version, get_platform, get_python, settings

View File

@ -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',

View File

@ -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

View File

@ -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()

80
tests/gstreamer_test.py Normal file
View File

@ -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

27
tests/help_test.py Normal file
View File

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

View File

@ -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

View File

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