Merge branch 'develop' into feature/glib-loop

Conflicts:
	mopidy/frontends/mpd/server.py
This commit is contained in:
Thomas Adamcik 2011-06-14 01:12:38 +02:00
commit 49f39977ec
36 changed files with 546 additions and 274 deletions

View File

@ -1,5 +1,10 @@
include LICENSE pylintrc *.rst *.ini data/mopidy.desktop
include *.ini
include *.rst
include LICENSE
include MANIFEST.in
include data/mopidy.desktop
include mopidy/backends/spotify/spotify_appkey.key
include pylintrc
recursive-include docs *
prune docs/_build
recursive-include requirements *

View File

@ -1,31 +1,38 @@
#!/usr/bin/env python
if __name__ == '__main__':
import sys
import sys
import logging
from mopidy import settings
from mopidy.scanner import Scanner, translator
from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format
from mopidy import settings
from mopidy.utils.log import setup_console_logging, setup_root_logger
from mopidy.scanner import Scanner, translator
from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format
tracks = []
setup_root_logger()
setup_console_logging(2)
def store(data):
track = translator(data)
tracks.append(track)
print >> sys.stderr, 'Added %s' % track.uri
tracks = []
def debug(uri, error):
print >> sys.stderr, 'Failed %s: %s' % (uri, error)
def store(data):
track = translator(data)
tracks.append(track)
logging.debug(u'Added %s', track.uri)
print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH
def debug(uri, error, debug):
logging.error(u'Failed %s: %s - %s', uri, error, debug)
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
logging.info(u'Scanning %s', settings.LOCAL_MUSIC_PATH)
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
try:
scanner.start()
except KeyboardInterrupt:
scanner.stop()
print >> sys.stderr, 'Done'
logging.info(u'Done')
for a in tracks_to_tag_cache_format(tracks):
if len(a) == 1:
print (u'%s' % a).encode('utf-8')
else:
print (u'%s: %s' % a).encode('utf-8')
for a in tracks_to_tag_cache_format(tracks):
if len(a) == 1:
print (u'%s' % a).encode('utf-8')
else:
print (u'%s: %s' % a).encode('utf-8')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -5,7 +5,7 @@ Authors
Contributors to Mopidy in the order of appearance:
- Stein Magnus Jodal <stein.magnus@jodal.no>
- Johannes Knutsen <johannes@knutseninfo.no>
- Johannes Knutsen <johannes@knutsen.me>
- Thomas Adamcik <adamcik@samfundet.no>
- Kristian Klette <klette@klette.us>

View File

@ -8,26 +8,46 @@ This change log is used to track all major changes to Mopidy.
v0.5.0 (in development)
=======================
No description yet.
Since last time we've added support for audio streaming to SHOUTcast servers
and fixed the longstanding playlist loading issue in the Spotify backend. As
always the release has a bunch of bug fixes.
Please note that 0.5.0 requires some updated dependencies, as listed under
*Important changes* below.
**Important changes**
- Mopidy now supports running with 1-n outputs at the same time. This feature
was mainly added to facilitate Shoutcast support, which Mopidy has also
gained. In its current state outputs can not be toggled during runtime.
- If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and
pyspotify 1.3. If you install from APT, libspotify and pyspotify will
automatically be upgraded. If you are not installing from APT, follow the
instructions at :doc:`/installation/libspotify/`.
- If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE`
setting, you must update your settings file. The new setting is named
:attr:`mopidy.settings.SPOTIFY_BITRATE` and accepts the integer values 96,
160, and 320.
- Mopidy now supports running with 1 to N outputs at the same time. This
feature was mainly added to facilitate SHOUTcast support, which Mopidy has
also gained. In its current state outputs can not be toggled during runtime.
**Changes**
- Fix local backend time query errors that where coming from stopped pipeline.
(Fixes: :issue:`87`)
- Local backend:
- Support passing options to GStreamer. See :option:`--help-gst` for a list of
available options. (Fixes: :issue:`95`)
- Fix local backend time query errors that where coming from stopped
pipeline. (Fixes: :issue:`87`)
- Improve :option:`--list-settings` output. (Fixes: :issue:`91`)
- Spotify backend:
- Replace not decodable characters returned from Spotify instead of throwing an
exception, as we won't try to figure out the encoding of non-UTF-8-data.
- Thanks to Antoine Pierlot-Garcin's recent work on updating and improving
pyspotify, stored playlists will again load when Mopidy starts. The
workaround of searching and reconnecting to make the playlists appear are
no longer necessary. (Fixes: :issue:`59`)
- Track's that are no longer available in Spotify's archives are now
"autolinked" to corresponding tracks in other albums, just like the
official Spotify clients do. (Fixes: :issue:`34`)
- MPD frontend:
@ -44,6 +64,26 @@ No description yet.
authentication is turned on, but the connected user has not been
authenticated yet.
- Command line usage:
- Support passing options to GStreamer. See :option:`--help-gst` for a list
of available options. (Fixes: :issue:`95`)
- Improve :option:`--list-settings` output. (Fixes: :issue:`91`)
- Added :option:`--interactive` for reading missing local settings from
``stdin``. (Fixes: :issue:`96`)
- Tag cache generator:
- Made it possible to abort :command:`mopidy-scan` with CTRL+C.
- Fixed bug regarding handling of bad dates.
- Use :mod:`logging` instead of ``print`` statements.
- Found and worked around strange WMA metadata behaviour.
v0.4.1 (2011-05-06)
===================

View File

@ -7,4 +7,3 @@ Development
roadmap
contributing
internals

View File

@ -1,113 +0,0 @@
*********
Internals
*********
Some of the following notes and details will hopefully be useful when you start
developing on Mopidy, while some may only be useful when you get deeper into
specific parts of Mopidy.
In addition to what you'll find here, don't forget the :doc:`/api/index`.
Class instantiation and usage
=============================
The following diagram shows how Mopidy is wired together with the MPD client,
the Spotify service, and the speakers.
**Legend**
- Filled red boxes are the key external systems.
- Gray boxes are external dependencies.
- Blue circles lives in the ``main`` process, also known as ``CoreProcess``.
It is processing messages put on the core queue.
- Purple circles lives in a process named ``MpdProcess``, running an
:mod:`asyncore` loop.
- Green circles lives in a process named ``GStreamerProcess``.
- Brown circle is a thread living in the ``CoreProcess``.
.. digraph:: class_instantiation_and_usage
"main" [ color="blue" ]
"CoreProcess" [ color="blue" ]
# Frontend
"MPD client" [ color="red", style="filled", shape="box" ]
"MpdFrontend" [ color="blue" ]
"MpdProcess" [ color="purple" ]
"MpdServer" [ color="purple" ]
"MpdSession" [ color="purple" ]
"MpdDispatcher" [ color="blue" ]
# Backend
"Libspotify\nBackend" [ color="blue" ]
"Libspotify\nSessionManager" [ color="brown" ]
"pyspotify" [ color="gray", shape="box" ]
"libspotify" [ color="gray", shape="box" ]
"Spotify" [ color="red", style="filled", shape="box" ]
# Output/mixer
"GStreamer\nOutput" [ color="blue" ]
"GStreamer\nSoftwareMixer" [ color="blue" ]
"GStreamer\nProcess" [ color="green" ]
"GStreamer" [ color="gray", shape="box" ]
"Speakers" [ color="red", style="filled", shape="box" ]
"main" -> "CoreProcess" [ label="create" ]
# Frontend
"CoreProcess" -> "MpdFrontend" [ label="create" ]
"MpdFrontend" -> "MpdProcess" [ label="create" ]
"MpdFrontend" -> "MpdDispatcher" [ label="create" ]
"MpdProcess" -> "MpdServer" [ label="create" ]
"MpdServer" -> "MpdSession" [ label="create one\nper client" ]
"MpdSession" -> "MpdDispatcher" [
label="pass requests\nvia core_queue" ]
"MpdDispatcher" -> "MpdSession" [
label="pass response\nvia reply_to pipe" ]
"MpdDispatcher" -> "Libspotify\nBackend" [ label="use backend API" ]
"MPD client" -> "MpdServer" [ label="connect" ]
"MPD client" -> "MpdSession" [ label="request" ]
"MpdSession" -> "MPD client" [ label="response" ]
# Backend
"CoreProcess" -> "Libspotify\nBackend" [ label="create" ]
"Libspotify\nBackend" -> "Libspotify\nSessionManager" [
label="creates and uses" ]
"Libspotify\nSessionManager" -> "Libspotify\nBackend" [
label="pass commands\nvia core_queue" ]
"Libspotify\nSessionManager" -> "pyspotify" [ label="use Python\nwrapper" ]
"pyspotify" -> "Libspotify\nSessionManager" [ label="use callbacks" ]
"pyspotify" -> "libspotify" [ label="use C library" ]
"libspotify" -> "Spotify" [ label="use service" ]
"Libspotify\nSessionManager" -> "GStreamer\nProcess" [
label="pass commands\nand audio data\nvia output_queue" ]
# Output/mixer
"Libspotify\nBackend" -> "GStreamer\nSoftwareMixer" [
label="create and\nuse mixer API" ]
"GStreamer\nSoftwareMixer" -> "GStreamer\nProcess" [
label="pass commands\nvia output_queue" ]
"CoreProcess" -> "GStreamer\nOutput" [ label="create" ]
"GStreamer\nOutput" -> "GStreamer\nProcess" [ label="create" ]
"GStreamer\nProcess" -> "GStreamer" [ label="use library" ]
"GStreamer" -> "Speakers" [ label="play audio" ]
Thread/process communication
============================
.. warning::
This section is currently outdated.
- Everything starts with ``Main``.
- ``Main`` creates a ``Core`` process which runs the frontend, backend, and
mixer.
- Mixers *may* create an additional process for communication with external
devices, like ``NadTalker`` in this example.
- Backend libraries *may* have threads of their own, like ``despotify`` here
which has additional threads in the ``Core`` process.
- ``Server`` part currently runs in the same process and thread as ``Main``.
- ``Client`` is some external client talking to ``Server`` over a socket.
.. image:: /_static/thread_communication.png

View File

@ -26,7 +26,7 @@ Feature wishlist
We maintain our collection of sane or less sane ideas for future Mopidy
features as `issues <https://github.com/mopidy/mopidy/issues>`_ at GitHub
labeled with `the "wishlist" label
<https://github.com/mopidy/mopidy/issues/labels/wishlist>`_. Feel free to vote
<https://github.com/mopidy/mopidy/issues?labels=wishlist>`_. Feel free to vote
up any feature you would love to see in Mopidy, but please refrain from adding
a comment just to say "I want this too!". You are of course free to add
comments if you have suggestions for how the feature should work or be

View File

@ -73,5 +73,12 @@ Using a custom audio sink
=========================
If you for some reason want to use some other GStreamer audio sink than
``autoaudiosink``, you can change :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
in your ``settings.py`` file.
``autoaudiosink``, you can add ``mopidy.outputs.custom.CustomOutput`` to the
:attr:`mopidy.settings.OUTPUTS` setting, and set the
:attr:`mopidy.settings.CUSTOM_OUTPUT` setting to a partial GStreamer pipeline
description describing the GStreamer sink you want to use.
Example of ``settings.py`` for OSS4::
OUTPUTS = (u'mopidy.outputs.custom.CustomOutput',)
CUSTOM_OUTPUT = u'oss4sink'

View File

@ -9,8 +9,8 @@ setup and whether you want to use stable releases or less stable development
versions.
Install dependencies
====================
Requirements
============
.. toctree::
:hidden:

View File

@ -4,8 +4,8 @@ libspotify installation
Mopidy uses `libspotify
<http://developer.spotify.com/en/libspotify/overview/>`_ for playing music from
the Spotify music service. To use :mod:`mopidy.backends.libspotify` you must
install libspotify and `pyspotify <http://github.com/mopidy/pyspotify>`_.
the Spotify music service. To use :mod:`mopidy.backends.spotify` you must
install libspotify and `pyspotify <http://pyspotify.mopidy.com/>`_.
.. note::
@ -30,7 +30,7 @@ If you run a Debian based Linux distribution, like Ubuntu, see
http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source
on your installation. Then, simply run::
sudo apt-get install libspotify7
sudo apt-get install libspotify8
When libspotify has been installed, continue with
:ref:`pyspotify_installation`.
@ -39,14 +39,14 @@ When libspotify has been installed, continue with
On Linux from source
--------------------
Download and install libspotify 0.0.7 for your OS and CPU architecture from
Download and install libspotify 0.0.8 for your OS and CPU architecture from
https://developer.spotify.com/en/libspotify/.
For 64-bit Linux the process is as follows::
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.7-linux6-x86_64.tar.gz
tar zxfv libspotify-0.0.7-linux6-x86_64.tar.gz
cd libspotify-0.0.7-linux6-x86_64/
wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz
tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz
cd libspotify-0.0.8-linux6-x86_64/
sudo make install prefix=/usr/local
sudo ldconfig
@ -103,14 +103,10 @@ Debian/Ubuntu systems run::
On OS X no additional dependencies are needed.
Get the pyspotify code, and install it::
Then get, build, and install the latest releast of pyspotify using ``pip``::
wget --no-check-certificate -O pyspotify.tar.gz https://github.com/mopidy/pyspotify/tarball/mopidy
tar zxfv pyspotify.tar.gz
cd pyspotify/
sudo python setup.py install
sudo pip install -U pyspotify
It is important that you install pyspotify from the ``mopidy`` branch of the
``mopidy/pyspotify`` repository, as the upstream repository at
``winjer/pyspotify`` is not updated with changes needed to support e.g.
libspotify 0.0.7 and high bitrate audio.
Or using the older ``easy_install``::
sudo easy_install pyspotify

View File

@ -4,10 +4,11 @@
The following GStreamer audio outputs implements the :ref:`output-api`.
.. inheritance-diagram:: mopidy.outputs
.. inheritance-diagram:: mopidy.outputs.custom
.. autoclass:: mopidy.outputs.custom.CustomOutput
.. inheritance-diagram:: mopidy.outputs.local
.. autoclass:: mopidy.outputs.local.LocalOutput
.. inheritance-diagram:: mopidy.outputs.shoutcast
.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput

View File

@ -56,7 +56,7 @@ You may also want to change some of the ``LOCAL_*`` settings. See
Currently, Mopidy supports using Spotify *or* local storage as a music
source. We're working on using both sources simultaneously, and will
hopefully have support for this in the 0.3 release.
hopefully have support for this in the 0.6 release.
.. _generating_a_tag_cache:
@ -92,6 +92,7 @@ To make a ``tag_cache`` of your local music available for Mopidy:
.. _use_mpd_on_a_network:
Connecting from other machines on the network
=============================================
@ -119,6 +120,31 @@ file::
LASTFM_PASSWORD = u'mysecret'
Streaming audio through a SHOUTcast/Icecast server
==================================================
If you want to play the audio on another computer than the one running Mopidy,
you can stream the audio from Mopidy through an SHOUTcast or Icecast audio
streaming server. Multiple media players can then be connected to the streaming
server simultaneously. To use the SHOUTcast output, do the following:
#. Install, configure and start the Icecast server. It can be found in the
``icecast2`` package in Debian/Ubuntu.
#. Add ``mopidy.outputs.shoutcast.ShoutcastOutput`` output to the
:attr:`mopidy.settings.OUTPUTS` setting.
#. Check the default values for the following settings, and alter them to match
your Icecast setup if needed:
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER`
Available settings
==================

View File

@ -263,6 +263,7 @@ class PlaybackController(object):
.. digraph:: state_transitions
"STOPPED" -> "PLAYING" [ label="play" ]
"STOPPED" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "STOPPED" [ label="stop" ]
"PLAYING" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "PLAYING" [ label="play" ]

View File

@ -22,7 +22,11 @@ class LocalBackend(ThreadingActor, Backend):
"""
A backend for playing music from a local music archive.
**Issues:** http://github.com/mopidy/mopidy/issues/labels/backend-local
**Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local
**Dependencies:**
- None
**Settings:**
@ -174,7 +178,7 @@ class LocalLibraryProvider(BaseLibraryProvider):
tracks = parse_mpd_tag_cache(tag_cache, music_folder)
logger.info('Loading songs in %s from %s', music_folder, tag_cache)
logger.info('Loading tracks in %s from %s', music_folder, tag_cache)
for track in tracks:
self._uri_mapping[track.uri] = track

View File

@ -11,6 +11,7 @@ from mopidy.gstreamer import GStreamer
logger = logging.getLogger('mopidy.backends.spotify')
ENCODING = 'utf-8'
BITRATES = {96: 2, 160: 0, 320: 1}
class SpotifyBackend(ThreadingActor, Backend):
"""
@ -27,7 +28,12 @@ class SpotifyBackend(ThreadingActor, Backend):
trade mark of the Spotify Group.
**Issues:**
http://github.com/mopidy/mopidy/issues/labels/backend-spotify
https://github.com/mopidy/mopidy/issues?labels=backend-spotify
**Dependencies:**
- libspotify == 0.0.8 (libspotify8 package from apt.mopidy.com)
- pyspotify == 1.3 (python-spotify package from apt.mopidy.com)
**Settings:**
@ -66,19 +72,22 @@ class SpotifyBackend(ThreadingActor, Backend):
self.gstreamer = None
self.spotify = None
# Fail early if settings are not present
self.username = settings.SPOTIFY_USERNAME
self.password = settings.SPOTIFY_PASSWORD
def on_start(self):
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.'
self.gstreamer = gstreamer_refs[0].proxy()
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
self.spotify = self._connect()
def _connect(self):
from .session_manager import SpotifySessionManager
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
logger.debug(u'Connecting to Spotify')
spotify = SpotifySessionManager(
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD)
spotify = SpotifySessionManager(self.username, self.password)
spotify.start()
return spotify

View File

@ -0,0 +1,46 @@
import logging
from spotify.manager import SpotifyContainerManager as \
PyspotifyContainerManager
logger = logging.getLogger('mopidy.backends.spotify.container_manager')
class SpotifyContainerManager(PyspotifyContainerManager):
def __init__(self, session_manager):
PyspotifyContainerManager.__init__(self)
self.session_manager = session_manager
def container_loaded(self, container, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: playlist container loaded')
self.session_manager.refresh_stored_playlists()
playlist_container = self.session_manager.session.playlist_container()
for playlist in playlist_container:
self.session_manager.playlist_manager.watch(playlist)
logger.debug(u'Watching %d playlist(s) for changes',
len(playlist_container))
def playlist_added(self, container, playlist, position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: playlist added at position %d',
position)
# container_loaded() is called after this callback, so we do not need
# to handle this callback.
def playlist_moved(self, container, playlist, old_position, new_position,
userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: playlist "%s" moved from position %d to %d',
playlist.name(), old_position, new_position)
# container_loaded() is called after this callback, so we do not need
# to handle this callback.
def playlist_removed(self, container, playlist, position, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: playlist "%s" removed from position %d',
playlist.name(), position)
# container_loaded() is called after this callback, so we do not need
# to handle this callback.

View File

@ -0,0 +1,93 @@
import datetime
import logging
from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager
logger = logging.getLogger('mopidy.backends.spotify.playlist_manager')
class SpotifyPlaylistManager(PyspotifyPlaylistManager):
def __init__(self, session_manager):
PyspotifyPlaylistManager.__init__(self)
self.session_manager = session_manager
def tracks_added(self, playlist, tracks, position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
u'%d track(s) added to position %d in playlist "%s"',
len(tracks), position, playlist.name())
self.session_manager.refresh_stored_playlists()
def tracks_moved(self, playlist, tracks, new_position, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
u'%d track(s) moved to position %d in playlist "%s"',
len(tracks), new_position, playlist.name())
self.session_manager.refresh_stored_playlists()
def tracks_removed(self, playlist, tracks, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: '
u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name())
self.session_manager.refresh_stored_playlists()
def playlist_renamed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Playlist renamed to "%s"',
playlist.name())
self.session_manager.refresh_stored_playlists()
def playlist_state_changed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: The state of playlist "%s" changed',
playlist.name())
def playlist_update_in_progress(self, playlist, done, userdata):
"""Callback used by pyspotify"""
if done:
logger.debug(u'Callback called: '
u'Update of playlist "%s" done', playlist.name())
else:
logger.debug(u'Callback called: '
u'Update of playlist "%s" in progress', playlist.name())
def playlist_metadata_updated(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Metadata updated for playlist "%s"',
playlist.name())
def track_created_changed(self, playlist, position, user, when, userdata):
"""Callback used by pyspotify"""
when = datetime.datetime.fromtimestamp(when)
logger.debug(
u'Callback called: Created by/when for track %d in playlist '
u'"%s" changed to user "N/A" and time "%s"',
position, playlist.name(), when)
def track_message_changed(self, playlist, position, message, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Message for track %d in playlist '
u'"%s" changed to "%s"', position, playlist.name(), message)
def track_seen_changed(self, playlist, position, seen, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Seen attribute for track %d in playlist '
u'"%s" changed to "%s"', position, playlist.name(), seen)
def description_changed(self, playlist, description, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Description changed for playlist "%s" to "%s"',
playlist.name(), description)
def subscribers_changed(self, playlist, userdata):
"""Callback used by pyspotify"""
logger.debug(
u'Callback called: Subscribers changed for playlist "%s"',
playlist.name())
def image_changed(self, playlist, image, userdata):
"""Callback used by pyspotify"""
logger.debug(u'Callback called: Image changed for playlist "%s"',
playlist.name())

View File

@ -8,6 +8,9 @@ from pykka.registry import ActorRegistry
from mopidy import get_version, settings
from mopidy.backends.base import Backend
from mopidy.backends.spotify import BITRATES
from mopidy.backends.spotify.container_manager import SpotifyContainerManager
from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager
from mopidy.backends.spotify.translator import SpotifyTranslator
from mopidy.models import Playlist
from mopidy.gstreamer import GStreamer
@ -27,7 +30,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def __init__(self, username, password):
PyspotifySessionManager.__init__(self, username, password)
BaseThread.__init__(self)
self.name = 'SpotifySMThread'
self.name = 'SpotifyThread'
self.gstreamer = None
self.backend = None
@ -35,13 +38,17 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
self.connected = threading.Event()
self.session = None
self.container_manager = None
self.playlist_manager = None
def run_inside_try(self):
self.setup()
self.connect()
def setup(self):
gstreamer_refs = ActorRegistry.get_by_class(GStreamer)
assert len(gstreamer_refs) == 1, 'Expected exactly one running gstreamer.'
assert len(gstreamer_refs) == 1, \
'Expected exactly one running gstreamer.'
self.gstreamer = gstreamer_refs[0].proxy()
backend_refs = ActorRegistry.get_by_class(Backend)
@ -53,14 +60,19 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
if error:
logger.error(u'Spotify login error: %s', error)
return
logger.info(u'Connected to Spotify')
self.session = session
if settings.SPOTIFY_HIGH_BITRATE:
logger.debug(u'Preferring high bitrate from Spotify')
self.session.set_preferred_bitrate(1)
else:
logger.debug(u'Preferring normal bitrate from Spotify')
self.session.set_preferred_bitrate(0)
logger.debug(u'Preferred Spotify bitrate is %s kbps',
settings.SPOTIFY_BITRATE)
self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE])
self.container_manager = SpotifyContainerManager(self)
self.playlist_manager = SpotifyPlaylistManager(self)
self.container_manager.watch(self.session.playlist_container())
self.connected.set()
def logged_out(self, session):
@ -69,13 +81,12 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager):
def metadata_updated(self, session):
"""Callback used by pyspotify"""
logger.debug(u'Metadata updated')
self.refresh_stored_playlists()
logger.debug(u'Callback called: Metadata updated')
def connection_error(self, session, error):
"""Callback used by pyspotify"""
if error is None:
logger.info(u'Spotify connection error resolved')
logger.info(u'Spotify connection OK')
else:
logger.error(u'Spotify connection error: %s', error)
self.backend.playback.pause()

View File

@ -4,7 +4,7 @@ import logging
from spotify import Link, SpotifyError
from mopidy import settings
from mopidy.backends.spotify import ENCODING
from mopidy.backends.spotify import ENCODING, BITRATES
from mopidy.models import Artist, Album, Track, Playlist
logger = logging.getLogger('mopidy.backends.spotify.translator')
@ -16,7 +16,7 @@ class SpotifyTranslator(object):
return Artist(name=u'[loading...]')
return Artist(
uri=str(Link.from_artist(spotify_artist)),
name=spotify_artist.name().decode(ENCODING, 'replace'),
name=spotify_artist.name()
)
@classmethod
@ -24,7 +24,7 @@ class SpotifyTranslator(object):
if spotify_album is None or not spotify_album.is_loaded():
return Album(name=u'[loading...]')
# TODO pyspotify got much more data on albums than this
return Album(name=spotify_album.name().decode(ENCODING, 'replace'))
return Album(name=spotify_album.name())
@classmethod
def to_mopidy_track(cls, spotify_track):
@ -38,13 +38,13 @@ class SpotifyTranslator(object):
date = None
return Track(
uri=uri,
name=spotify_track.name().decode(ENCODING, 'replace'),
name=spotify_track.name(),
artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()],
album=cls.to_mopidy_album(spotify_track.album()),
track_no=spotify_track.index(),
date=date,
length=spotify_track.duration(),
bitrate=(settings.SPOTIFY_HIGH_BITRATE and 320 or 160),
bitrate=BITRATES[settings.SPOTIFY_BITRATE],
)
@classmethod
@ -57,7 +57,7 @@ class SpotifyTranslator(object):
try:
return Playlist(
uri=str(Link.from_playlist(spotify_playlist)),
name=spotify_playlist.name().decode(ENCODING, 'replace'),
name=spotify_playlist.name(),
# FIXME if check on link is a hackish workaround for is_local
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist
if str(Link.from_track(t, 0))],

View File

@ -1,5 +1,6 @@
import logging
import optparse
import signal
import sys
import time
@ -16,38 +17,48 @@ sys.argv[1:] = gstreamer_args
from pykka.registry import ActorRegistry
from mopidy import get_version, settings, OptionalDependencyError
from mopidy import (get_version, settings, OptionalDependencyError,
SettingsError)
from mopidy.gstreamer import GStreamer
from mopidy.utils import get_class
from mopidy.utils.log import setup_logging
from mopidy.utils.path import get_or_create_folder, get_or_create_file
from mopidy.utils.process import GObjectEventThread
from mopidy.utils.process import (GObjectEventThread, exit_handler,
stop_all_actors)
from mopidy.utils.settings import list_settings_optparse_callback
logger = logging.getLogger('mopidy.core')
def main():
options = parse_options()
setup_logging(options.verbosity_level, options.save_debug_log)
setup_settings()
setup_gobject_loop()
setup_gstreamer()
setup_mixer()
setup_backend()
setup_frontends()
signal.signal(signal.SIGTERM, exit_handler)
try:
while ActorRegistry.get_all():
options = parse_options()
setup_logging(options.verbosity_level, options.save_debug_log)
setup_settings(options.interactive)
setup_gobject_loop()
setup_gstreamer()
setup_mixer()
setup_backend()
setup_frontends()
while True:
time.sleep(1)
logger.info(u'No actors left. Exiting...')
except SettingsError as e:
logger.error(e.message)
except KeyboardInterrupt:
logger.info(u'User interrupt. Exiting...')
ActorRegistry.stop_all()
logger.info(u'Interrupted. Exiting...')
except Exception as e:
logger.exception(e)
finally:
stop_all_actors()
def parse_options():
parser = optparse.OptionParser(version=u'Mopidy %s' % get_version())
parser.add_option('--help-gst',
action='store_true', dest='help_gst',
help='show GStreamer help options')
parser.add_option('-i', '--interactive',
action='store_true', dest='interactive',
help='ask interactively for required settings which is missing')
parser.add_option('-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
@ -62,10 +73,14 @@ def parse_options():
help='list current settings')
return parser.parse_args(args=mopidy_args)[0]
def setup_settings():
def setup_settings(interactive):
get_or_create_folder('~/.mopidy/')
get_or_create_file('~/.mopidy/settings.py')
settings.validate()
try:
settings.validate(interactive)
except SettingsError, e:
logger.error(e.message)
sys.exit(1)
def setup_gobject_loop():
GObjectEventThread().start()

View File

@ -13,11 +13,15 @@ class MpdFrontend(ThreadingActor, BaseFrontend):
"""
The MPD frontend.
**Dependencies:**
- None
**Settings:**
- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
- :attr:`mopidy.settings.MPD_SERVER_PORT`
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
"""
def __init__(self):

View File

@ -14,13 +14,11 @@ class MpdServer(asyncore.dispatcher):
for each client connection.
"""
def __init__(self):
asyncore.dispatcher.__init__(self)
def start(self):
"""Start MPD server."""
try:
self.socket = network.create_socket()
self.set_socket(network.create_socket())
self.set_reuse_addr()
hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME)
port = settings.MPD_SERVER_PORT
logger.debug(u'MPD server is binding to [%s]:%s', hostname, port)
@ -38,7 +36,3 @@ class MpdServer(asyncore.dispatcher):
logger.info(u'MPD client connection from [%s]:%s',
client_socket_address[0], client_socket_address[1])
MpdSession(self, client_socket, client_socket_address)
def handle_close(self):
"""Called by asyncore when the socket is closed."""
self.close()

View File

@ -49,7 +49,7 @@ class MpdSession(asynchat.async_chat):
Format a response from the MPD command handlers and send it to the
client.
"""
if response is not None:
if response:
response = LINE_TERMINATOR.join(response)
logger.debug(u'Response to [%s]:%s: %s', self.client_address,
self.client_port, indent(response))

View File

@ -298,7 +298,7 @@ class GStreamer(ThreadingActor):
output.sync_state_with_parent() # Required to add to running pipe
gst.element_link_many(self._tee, output)
self._outputs.append(output)
logger.info('Added %s', output.get_name())
logger.debug('GStreamer added %s', output.get_name())
def list_outputs(self):
"""

View File

@ -21,6 +21,14 @@ class CustomOutput(BaseOutput):
these cases setup :attr:`mopidy.settings.CUSTOM_OUTPUT` with a
:command:`gst-launch` compatible string describing the target setup.
**Dependencies:**
- None
**Settings:**
- :attr:`mopidy.settings.CUSTOM_OUTPUT`
"""
def describe_bin(self):
return settings.CUSTOM_OUTPUT

View File

@ -6,6 +6,14 @@ class LocalOutput(BaseOutput):
This output will normally tell GStreamer to choose whatever it thinks is
best for your system. In other words this is usually a sane choice.
**Dependencies:**
- None
**Settings:**
- None
"""
def describe_bin(self):

View File

@ -13,6 +13,19 @@ class ShoutcastOutput(BaseOutput):
supports Shoutcast. The output supports setting for: server address, port,
mount point, user, password and encoder to use. Please see
:class:`mopidy.settings` for details about settings.
**Dependencies:**
- A SHOUTcast/Icecast server
**Settings:**
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_HOSTNAME`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PORT`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_USERNAME`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_PASSWORD`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_MOUNT`
- :attr:`mopidy.settings.SHOUTCAST_OUTPUT_ENCODER`
"""
def describe_bin(self):
@ -21,9 +34,9 @@ class ShoutcastOutput(BaseOutput):
def modify_bin(self):
self.set_properties(self.bin.get_by_name('shoutcast'), {
u'ip': settings.SHOUTCAST_OUTPUT_SERVER,
u'mount': settings.SHOUTCAST_OUTPUT_MOUNT,
u'ip': settings.SHOUTCAST_OUTPUT_HOSTNAME,
u'port': settings.SHOUTCAST_OUTPUT_PORT,
u'mount': settings.SHOUTCAST_OUTPUT_MOUNT,
u'username': settings.SHOUTCAST_OUTPUT_USERNAME,
u'password': settings.SHOUTCAST_OUTPUT_PASSWORD,
})

View File

@ -24,7 +24,7 @@ def translator(data):
_retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs)
_retrieve(gst.TAG_ARTIST, 'name', artist_kwargs)
if gst.TAG_DATE in data:
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
date = data[gst.TAG_DATE]
date = datetime.date(date.year, date.month, date.day)
track_kwargs['date'] = date
@ -57,17 +57,16 @@ class Scanner(object):
self.error_callback = error_callback
self.loop = gobject.MainLoop()
caps = gst.Caps('audio/x-raw-int')
fakesink = gst.element_factory_make('fakesink')
pad = fakesink.get_pad('sink')
self.uribin = gst.element_factory_make('uridecodebin')
self.uribin.connect('pad-added', self.process_new_pad, pad)
self.uribin.set_property('caps', caps)
self.uribin.set_property('caps', gst.Caps('audio/x-raw-int'))
self.uribin.connect('pad-added', self.process_new_pad,
fakesink.get_pad('sink'))
self.pipe = gst.element_factory_make('pipeline')
self.pipe.add(fakesink)
self.pipe.add(self.uribin)
self.pipe.add(fakesink)
bus = self.pipe.get_bus()
bus.add_signal_watch()
@ -78,22 +77,36 @@ class Scanner(object):
pad.link(target_pad)
def process_tags(self, bus, message):
data = message.parse_tag()
data = dict([(k, data[k]) for k in data.keys()])
data['uri'] = unicode(self.uribin.get_property('uri'))
data['duration'] = self.get_duration()
self.data_callback(data)
self.next_uri()
taglist = message.parse_tag()
data = {
'uri': unicode(self.uribin.get_property('uri')),
gst.TAG_DURATION: self.get_duration(),
}
for key in taglist.keys():
# XXX: For some crazy reason some wma files spit out lists here,
# not sure if this is due to better data in headers or wma being
# stupid. So ugly hack for now :/
if type(taglist[key]) is list:
data[key] = taglist[key][0]
else:
data[key] = taglist[key]
try:
self.data_callback(data)
self.next_uri()
except KeyboardInterrupt:
self.stop()
def process_error(self, bus, message):
if self.error_callback:
uri = self.uribin.get_property('uri')
errors = message.parse_error()
self.error_callback(uri, errors)
error, debug = message.parse_error()
self.error_callback(uri, error, debug)
self.next_uri()
def get_duration(self):
self.pipe.get_state()
self.pipe.get_state() # Block until state change is done.
try:
return self.pipe.query_duration(
gst.FORMAT_TIME, None)[0] // gst.MSECOND

View File

@ -157,16 +157,16 @@ MIXER_MAX_VOLUME = 100
#: Listens on all interfaces, both IPv4 and IPv6.
MPD_SERVER_HOSTNAME = u'127.0.0.1'
#: The password required for connecting to the MPD server.
#:
#: Default: :class:`None`, which means no password required.
MPD_SERVER_PASSWORD = None
#: Which TCP port Mopidy's MPD server should listen to.
#:
#: Default: 6600
MPD_SERVER_PORT = 6600
#: The password required for connecting to the MPD server.
#:
#: Default: :class:`None`, which means no password required.
MPD_SERVER_PASSWORD = None
#: List of outputs to use. See :mod:`mopidy.outputs` for all available
#: backends
#:
@ -179,42 +179,54 @@ OUTPUTS = (
u'mopidy.outputs.local.LocalOutput',
)
#: Servar that runs Shoutcast server to send stream to.
#: Hostname of the SHOUTcast server which Mopidy should stream audio to.
#:
#: Used by :mod:`mopidy.outputs.shoutcast`.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1'
SHOUTCAST_OUTPUT_SERVER = u'127.0.0.1'
#: SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1'
SHOUTCAST_OUTPUT_HOSTNAME = u'127.0.0.1'
#: User to authenticate as against Shoutcast server.
#: Port of the SHOUTcast server.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_USERNAME = u'source'
SHOUTCAST_OUTPUT_USERNAME = u'source'
#: Password to authenticate with against Shoutcast server.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme'
SHOUTCAST_OUTPUT_PASSWORD = u'hackme'
#: Port to use for streaming to Shoutcast server.
#: Used by :mod:`mopidy.outputs.shoutcast`.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_PORT = 8000
SHOUTCAST_OUTPUT_PORT = 8000
#: Mountpoint to use for the stream on the Shoutcast server.
#: User to authenticate as against SHOUTcast server.
#:
#: Used by :mod:`mopidy.outputs.shoutcast`.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_USERNAME = u'source'
SHOUTCAST_OUTPUT_USERNAME = u'source'
#: Password to authenticate with against SHOUTcast server.
#:
#: Used by :mod:`mopidy.outputs.shoutcast`.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_PASSWORD = u'hackme'
SHOUTCAST_OUTPUT_PASSWORD = u'hackme'
#: Mountpoint to use for the stream on the SHOUTcast server.
#:
#: Used by :mod:`mopidy.outputs.shoutcast`.
#:
#: Default::
#:
#: SHOUTCAST_OUTPUT_MOUNT = u'/stream'
SHOUTCAST_OUTPUT_MOUNT = u'/stream'
#: Encoder to use to process audio data before streaming.
#: Encoder to use to process audio data before streaming to SHOUTcast server.
#:
#: Used by :mod:`mopidy.outputs.shoutcast`.
#:
#: Default::
#:
@ -236,11 +248,13 @@ SPOTIFY_USERNAME = u''
#: Used by :mod:`mopidy.backends.spotify`.
SPOTIFY_PASSWORD = u''
#: Do you prefer high bitrate (320k)?
#: Spotify preferred bitrate.
#:
#: Available values are 96, 160, and 320.
#:
#: Used by :mod:`mopidy.backends.spotify`.
#
#: Default::
#:
#: SPOTIFY_HIGH_BITRATE = False # 160k
SPOTIFY_HIGH_BITRATE = False
#: SPOTIFY_BITRATE = 160
SPOTIFY_BITRATE = 160

View File

@ -1,15 +1,40 @@
import logging
import signal
import thread
import threading
import gobject
gobject.threads_init()
from pykka import ActorDeadError
from pykka.registry import ActorRegistry
from mopidy import SettingsError
logger = logging.getLogger('mopidy.utils.process')
def exit_process():
logger.debug(u'Interrupting main...')
thread.interrupt_main()
logger.debug(u'Interrupted main')
def exit_handler(signum, frame):
"""A :mod:`signal` handler which will exit the program on signal."""
signals = dict((k, v) for v, k in signal.__dict__.iteritems()
if v.startswith('SIG') and not v.startswith('SIG_'))
logger.info(u'Got %s signal', signals[signum])
exit_process()
def stop_all_actors():
num_actors = len(ActorRegistry.get_all())
while num_actors:
logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s',
num_actors, threading.active_count() - num_actors,
', '.join([t.name for t in threading.enumerate()]))
logger.debug(u'Stopping %d actor(s)...', num_actors)
ActorRegistry.stop_all()
num_actors = len(ActorRegistry.get_all())
logger.debug(u'All actors stopped.')
class BaseThread(threading.Thread):
def __init__(self):

View File

@ -1,6 +1,7 @@
# Absolute import needed to import ~/.mopidy/settings.py and not ourselves
from __future__ import absolute_import
from copy import copy
import getpass
import logging
import os
from pprint import pformat
@ -63,12 +64,28 @@ class SettingsProxy(object):
else:
super(SettingsProxy, self).__setattr__(attr, value)
def validate(self):
def validate(self, interactive):
if interactive:
self._read_missing_settings_from_stdin(self.current, self.runtime)
if self.get_errors():
logger.error(u'Settings validation errors: %s',
indent(self.get_errors_as_string()))
raise SettingsError(u'Settings validation failed.')
def _read_missing_settings_from_stdin(self, current, runtime):
for setting, value in current.iteritems():
if isinstance(value, basestring) and len(value) == 0:
runtime[setting] = self._read_from_stdin(setting + u': ')
def _read_from_stdin(self, prompt):
if u'_PASSWORD' in prompt:
return (getpass.getpass(prompt)
.decode(sys.stdin.encoding, 'ignore'))
else:
sys.stdout.write(prompt)
return (sys.stdin.readline().strip()
.decode(sys.stdin.encoding, 'ignore'))
def get_errors(self):
return validate_settings(self.default, self.local)
@ -107,6 +124,7 @@ def validate_settings(defaults, settings):
'SERVER': None,
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
'SERVER_PORT': 'MPD_SERVER_PORT',
'SPOTIFY_HIGH_BITRATE': 'SPOTIFY_BITRATE',
'SPOTIFY_LIB_APPKEY': None,
'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH',
}
@ -127,6 +145,11 @@ def validate_settings(defaults, settings):
'longer available.')
continue
if setting == 'SPOTIFY_BITRATE':
if value not in (96, 160, 320):
errors[setting] = (u'Unavailable Spotify bitrate. ' +
u'Available bitrates are 96, 160, and 320.')
if setting not in defaults:
errors[setting] = u'Unknown setting. Is it misspelled?'
continue

View File

@ -69,11 +69,6 @@ for dirpath, dirnames, filenames in os.walk(project_dir):
data_files.append([dirpath,
[os.path.join(dirpath, f) for f in filenames]])
if os.geteuid() == 0:
# Only try to install this file if we are root
data_files.append(
('/usr/local/share/applications', ['data/mopidy.desktop']))
setup(
name='Mopidy',
version=get_version(),

View File

@ -14,6 +14,7 @@ class HelpTest(unittest.TestCase):
self.assert_('--version' in output)
self.assert_('--help' in output)
self.assert_('--help-gst' in output)
self.assert_('--interactive' in output)
self.assert_('--quiet' in output)
self.assert_('--verbose' in output)
self.assert_('--save-debug-log' in output)

View File

@ -4,7 +4,7 @@ from datetime import date
from mopidy.scanner import Scanner, translator
from mopidy.models import Track, Artist, Album
from tests import path_to_data_dir
from tests import path_to_data_dir, SkipTest
class FakeGstDate(object):
def __init__(self, year, month, day):
@ -144,9 +144,9 @@ class ScannerTest(unittest.TestCase):
uri = data['uri'][len('file://'):]
self.data[uri] = data
def error_callback(self, uri, errors):
def error_callback(self, uri, error, debug):
uri = uri[len('file://'):]
self.errors[uri] = errors
self.errors[uri] = (error, debug)
def test_data_is_set(self):
self.scan('scanner/simple')
@ -184,3 +184,7 @@ class ScannerTest(unittest.TestCase):
def test_other_media_is_ignored(self):
self.scan('scanner/image')
self.assert_(self.errors)
@SkipTest
def test_song_without_time_is_handeled(self):
pass

View File

@ -10,6 +10,7 @@ class ValidateSettingsTest(unittest.TestCase):
self.defaults = {
'MPD_SERVER_HOSTNAME': '::',
'MPD_SERVER_PORT': 6600,
'SPOTIFY_BITRATE': 160,
}
def test_no_errors_yields_empty_dict(self):
@ -42,6 +43,13 @@ class ValidateSettingsTest(unittest.TestCase):
'"mopidy.backends.despotify.DespotifyBackend" is no longer ' +
'available.')
def test_unavailable_bitrate_setting_returns_error(self):
result = validate_settings(self.defaults,
{'SPOTIFY_BITRATE': 50})
self.assertEqual(result['SPOTIFY_BITRATE'],
u'Unavailable Spotify bitrate. ' +
u'Available bitrates are 96, 160, and 320.')
def test_two_errors_are_both_reported(self):
result = validate_settings(self.defaults,
{'FOO': '', 'BAR': ''})
@ -63,6 +71,7 @@ class ValidateSettingsTest(unittest.TestCase):
class SettingsProxyTest(unittest.TestCase):
def setUp(self):
self.settings = SettingsProxy(default_settings_module)
self.settings.local.clear()
def test_set_and_get_attr(self):
self.settings.TEST = 'test'
@ -141,6 +150,20 @@ class SettingsProxyTest(unittest.TestCase):
actual = self.settings.TEST
self.assertEqual(actual, './test')
def test_interactive_input_of_missing_defaults(self):
self.settings.default['TEST'] = ''
interactive_input = 'input'
self.settings._read_from_stdin = lambda _: interactive_input
self.settings.validate(interactive=True)
self.assertEqual(interactive_input, self.settings.TEST)
def test_interactive_input_not_needed_when_setting_is_set_locally(self):
self.settings.default['TEST'] = ''
self.settings.local['TEST'] = 'test'
self.settings._read_from_stdin = lambda _: self.fail(
'Should not read from stdin')
self.settings.validate(interactive=True)
class FormatSettingListTest(unittest.TestCase):
def setUp(self):