Merge branch 'develop' into feature/extension-registry

This commit is contained in:
Thomas Adamcik 2013-12-30 00:37:17 +01:00
commit 60112d7c6f
30 changed files with 142 additions and 473 deletions

View File

@ -4,6 +4,15 @@
API reference
*************
.. warning:: API stability
Only APIs documented here are public and open for use by Mopidy
extensions. We will change these APIs, but will keep the changelog up to
date with all breaking changes.
From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable.
.. toctree::
:glob:
@ -16,4 +25,5 @@ API reference
commands
ext
config
zeroconf
http

11
docs/api/zeroconf.rst Normal file
View File

@ -0,0 +1,11 @@
.. _zeroconf-api:
************
Zeroconf API
************
.. module:: mopidy.zeroconf
:synopsis: Helper for publishing of services on Zeroconf
.. autoclass:: Zeroconf
:members:

View File

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 519 KiB

View File

@ -18,7 +18,7 @@ See :ref:`http-api` for details on how to build your own web client.
woutervanwijk/Mopidy-Webclient
==============================
.. image:: /_static/woutervanwijk-mopidy-webclient.png
.. image:: woutervanwijk-mopidy-webclient.png
:width: 1275
:height: 600
@ -36,7 +36,7 @@ Also the web client used for Wouter's popular `Pi Musicbox
Mopidy Lux
==========
.. image:: /_static/dz0ny-mopidy-lux.png
.. image:: dz0ny-mopidy-lux.png
:width: 1000
:height: 645
@ -50,7 +50,7 @@ A Mopidy web client made with AngularJS by Janez Troha.
Moped
=====
.. image:: /_static/martijnboland-moped.png
.. image:: martijnboland-moped.png
:width: 720
:height: 450

View File

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

View File

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -51,7 +51,7 @@ ncmpcpp
A console client that works well with Mopidy, and is regularly used by Mopidy
developers.
.. image:: /_static/mpd-client-ncmpcpp.png
.. image:: mpd-client-ncmpcpp.png
:width: 575
:height: 426
@ -84,7 +84,7 @@ GMPC
`GMPC <http://gmpc.wikia.com>`_ is a graphical MPD client (GTK+) which works
well with Mopidy.
.. image:: /_static/mpd-client-gmpc.png
.. image:: mpd-client-gmpc.png
:width: 1000
:height: 565
@ -101,7 +101,7 @@ Sonata
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+).
It generally works well with Mopidy, except for search.
.. image:: /_static/mpd-client-sonata.png
.. image:: mpd-client-sonata.png
:width: 475
:height: 424
@ -140,7 +140,7 @@ Test date:
Tested version:
1.03.1 (released 2012-10-16)
.. image:: /_static/mpd-client-mpdroid.jpg
.. image:: mpd-client-mpdroid.jpg
:width: 288
:height: 512
@ -269,7 +269,7 @@ Test date:
Tested version:
1.7.1
.. image:: /_static/mpd-client-mpod.jpg
.. image:: mpd-client-mpod.jpg
:width: 320
:height: 480
@ -297,7 +297,7 @@ Test date:
Tested version:
1.7.1
.. image:: /_static/mpd-client-mpad.jpg
.. image:: mpd-client-mpad.jpg
:width: 480
:height: 360
@ -332,7 +332,7 @@ other web clients, see :ref:`http-clients`.
Rompr
-----
.. image:: /_static/rompr.png
.. image:: rompr.png
:width: 557
:height: 600

View File

@ -24,7 +24,7 @@ sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the
Rhytmbox music player, but many other players can integrate with the sound
menu, including the official Spotify player and Mopidy.
.. image:: /_static/ubuntu-sound-menu.png
.. image:: ubuntu-sound-menu.png
:height: 480
:width: 955

View File

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 284 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -28,6 +28,12 @@ class Mock(object):
def __getattr__(self, name):
if name in ('__file__', '__path__'):
return '/dev/null'
elif name == 'get_system_config_dirs':
# glib.get_system_config_dirs()
return tuple
elif name == 'get_user_config_dir':
# glib.get_user_config_dir()
return str
elif (name[0] == name[0].upper()
# gst.interfaces.MIXER_TRACK_*
and not name.startswith('MIXER_TRACK_')

View File

@ -27,48 +27,6 @@ code. So, if you're out of work, the code coverage and flake8 data at the CI
server should give you a place to start.
Protocol debugger
=================
Since the main interface provided to Mopidy is through the MPD protocol, it is
crucial that we try and stay in sync with protocol developments. In an attempt
to make it easier to debug differences Mopidy and MPD protocol handling we have
created ``tools/debug-proxy.py``.
This tool is proxy that sits in front of two MPD protocol aware servers and
sends all requests to both, returning the primary response to the client and
then printing any diff in the two responses.
Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time
of writing. See :option:`tools/debug-proxy.py --help` for available options.
Sample session::
[127.0.0.1]:59714
listallinfo
--- Reference response
+++ Actual response
@@ -1,16 +1,1 @@
-file: uri1
-Time: 4
-Artist: artist1
-Title: track1
-Album: album1
-file: uri2
-Time: 4
-Artist: artist2
-Title: track2
-Album: album2
-file: uri3
-Time: 4
-Artist: artist3
-Title: track3
-Album: album3
-OK
+ACK [2@0] {listallinfo} incorrect arguments
To ensure that Mopidy and MPD have comparable state it is suggested you scan
the same media directory with both servers.
Documentation writing
=====================

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -9,7 +9,7 @@ January 2013, Mopidy will run with Spotify support on both the armel
(soft-float) and armhf (hard-float) architectures, which includes the Raspbian
distribution.
.. image:: /_static/raspberry-pi-by-jwrodgers.jpg
.. image:: raspberry-pi-by-jwrodgers.jpg
:width: 640
:height: 427

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import print_function, unicode_literals
import logging
import os
@ -117,7 +117,7 @@ def main():
try:
return args.command.run(args, proxied_config)
except NotImplementedError:
print root_cmd.format_help()
print(root_cmd.format_help())
return 1
except KeyboardInterrupt:

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import print_function, unicode_literals
import argparse
import collections
@ -112,7 +112,7 @@ class Command(object):
def exit(self, status_code=0, message=None, usage=None):
"""Optionally print a message and exit."""
print '\n\n'.join(m for m in (usage, message) if m)
print('\n\n'.join(m for m in (usage, message) if m))
sys.exit(status_code)
def format_usage(self, prog=None):
@ -335,7 +335,7 @@ class ConfigCommand(Command):
self.set(base_verbosity_level=-1)
def run(self, config, errors, extensions):
print config_lib.format(config, extensions, errors)
print(config_lib.format(config, extensions, errors))
return 0
@ -347,5 +347,5 @@ class DepsCommand(Command):
self.set(base_verbosity_level=-1)
def run(self):
print deps.format_dependency_list()
print(deps.format_dependency_list())
return 0

View File

@ -1,4 +1,4 @@
from __future__ import unicode_literals
from __future__ import print_function, unicode_literals
import io
import os.path
@ -10,13 +10,13 @@ from mopidy.utils import path
def load():
settings_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/settings.py')
print 'Checking %s' % settings_file
print('Checking %s' % settings_file)
setting_globals = {}
try:
execfile(settings_file, setting_globals)
except Exception as e:
print 'Problem loading settings: %s' % e
print('Problem loading settings: %s' % e)
return setting_globals
@ -106,20 +106,20 @@ def main():
'spotify', 'scrobbler', 'mpd', 'mpris', 'local', 'stream', 'http']
extensions = [e for e in ext.load_extensions() if e.ext_name in known]
print b'Converted config:\n'
print config_lib.format(config, extensions)
print(b'Converted config:\n')
print(config_lib.format(config, extensions))
conf_file = path.expand_path(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf')
if os.path.exists(conf_file):
print '%s exists, exiting.' % conf_file
print('%s exists, exiting.' % conf_file)
sys.exit(1)
print 'Write new config to %s? [yN]' % conf_file,
print('Write new config to %s? [yN]' % conf_file, end=' ')
if raw_input() != 'y':
print 'Not saving, exiting.'
print('Not saving, exiting.')
sys.exit(0)
serialized_config = config_lib.format(config, extensions, display=False)
with io.open(conf_file, 'wb') as filehandle:
filehandle.write(serialized_config)
print 'Done.'
print('Done.')

View File

@ -9,9 +9,8 @@ import pykka
from ws4py.messaging import TextMessage
from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
from mopidy import models
from mopidy import models, zeroconf
from mopidy.core import CoreListener
from mopidy.utils import zeroconf
from . import ws

View File

@ -5,9 +5,10 @@ import sys
import pykka
from mopidy import zeroconf
from mopidy.core import CoreListener
from mopidy.frontends.mpd import session
from mopidy.utils import encoding, network, process, zeroconf
from mopidy.utils import encoding, network, process
logger = logging.getLogger('mopidy.frontends.mpd')

View File

@ -136,6 +136,31 @@ def model_json_decoder(dct):
return dct
class Ref(ImmutableObject):
"""
Model to represent URI references with a human friendly name and type
attached. This is intended for use a lightweight object "free" of metadata
that can be passed around instead of using full blown models.
:param uri: object URI
:type uri: string
:param name: object name
:type name: string
:param type: object type
:type name: string
"""
#: The object URI. Read-only.
uri = None
#: The object name. Read-only.
name = None
#: The object type, e.g. "artist", "album", "track", "playlist",
#: "directory". Read-only.
type = None
class Artist(ImmutableObject):
"""
:param uri: artist URI

View File

@ -4,7 +4,7 @@ import logging
import socket
import string
logger = logging.getLogger('mopidy.utils.zeroconf')
logger = logging.getLogger('mopidy.zeroconf')
try:
import dbus
@ -25,7 +25,20 @@ def _convert_text_to_dbus_bytes(text):
class Zeroconf(object):
"""Publish a network service with Zeroconf using Avahi."""
"""Publish a network service with Zeroconf.
Currently, this only works on Linux using Avahi via D-Bus.
:param str name: human readable name of the service, e.g. 'MPD on neptune'
:param int port: TCP port of the service, e.g. 6600
:param str stype: service type, e.g. '_mpd._tcp'
:param str domain: local network domain name, defaults to ''
:param str host: interface to advertise the service on, defaults to all
interfaces
:param text: extra information depending on ``stype``, defaults to empty
list
:type text: list of str
"""
def __init__(self, name, port, stype=None, domain=None,
host=None, text=None):
@ -44,6 +57,11 @@ class Zeroconf(object):
hostname=self.host or socket.getfqdn(), port=self.port)
def publish(self):
"""Publish the service.
Call when your service starts.
"""
if _is_loopback_address(self.host):
logger.info(
'Zeroconf publish on loopback interface is not supported.')
@ -83,6 +101,11 @@ class Zeroconf(object):
return False
def unpublish(self):
"""Unpublish the service.
Call when your service shuts down.
"""
if self.group:
try:
self.group.Reset()

View File

@ -5,7 +5,7 @@ import json
import unittest
from mopidy.models import (
Artist, Album, TlTrack, Track, Playlist, SearchResult,
Ref, Artist, Album, TlTrack, Track, Playlist, SearchResult,
ModelJSONEncoder, model_json_decoder)
@ -54,6 +54,40 @@ class GenericCopyTest(unittest.TestCase):
self.assertRaises(TypeError, test)
class RefTest(unittest.TestCase):
def test_uri(self):
uri = 'an_uri'
ref = Ref(uri=uri)
self.assertEqual(ref.uri, uri)
self.assertRaises(AttributeError, setattr, ref, 'uri', None)
def test_name(self):
name = 'a name'
ref = Ref(name=name)
self.assertEqual(ref.name, name)
self.assertRaises(AttributeError, setattr, ref, 'name', None)
def test_invalid_kwarg(self):
test = lambda: SearchResult(foo='baz')
self.assertRaises(TypeError, test)
def test_repr_without_results(self):
self.assertEquals(
"Ref(name=u'foo', type=u'artist', uri=u'uri')",
repr(Ref(uri='uri', name='foo', type='artist')))
def test_serialize_without_results(self):
self.assertDictEqual(
{'__model__': 'Ref', 'uri': 'uri'},
Ref(uri='uri').serialize())
def test_to_json_and_back(self):
ref1 = Ref(uri='uri')
serialized = json.dumps(ref1, cls=ModelJSONEncoder)
ref2 = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(ref1, ref2)
class ArtistTest(unittest.TestCase):
def test_uri(self):
uri = 'an_uri'

View File

@ -1,195 +0,0 @@
#! /usr/bin/env python
from __future__ import unicode_literals
import argparse
import difflib
import sys
from gevent import select, server, socket
COLORS = ['\033[1;%dm' % (30 + i) for i in range(8)]
BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS
RESET = "\033[0m"
BOLD = "\033[1m"
def proxy(client, address, reference_address, actual_address):
"""Main handler code that gets called for each connection."""
client.setblocking(False)
reference = connect(reference_address)
actual = connect(actual_address)
if reference and actual:
loop(client, address, reference, actual)
else:
print 'Could not connect to one of the backends.'
for sock in (client, reference, actual):
close(sock)
def connect(address):
"""Connect to given address and set socket non blocking."""
try:
sock = socket.socket()
sock.connect(address)
sock.setblocking(False)
except socket.error:
return None
return sock
def close(sock):
"""Shutdown and close our sockets."""
try:
sock.shutdown(socket.SHUT_WR)
sock.close()
except socket.error:
pass
def loop(client, address, reference, actual):
"""Loop that handles one MPD reqeust/response pair per iteration."""
# Consume banners from backends
responses = dict()
disconnected = read(
[reference, actual], responses, find_response_end_token)
diff(address, '', responses[reference], responses[actual])
# We lost a backend, might as well give up.
if disconnected:
return
client.sendall(responses[reference])
while True:
responses = dict()
# Get the command from the client. Not sure how an if this will handle
# client sending multiple commands currently :/
disconnected = read([client], responses, find_request_end_token)
# We lost the client, might as well give up.
if disconnected:
return
# Send the entire command to both backends.
reference.sendall(responses[client])
actual.sendall(responses[client])
# Get the entire resonse from both backends.
disconnected = read(
[reference, actual], responses, find_response_end_token)
# Send the client the complete reference response
client.sendall(responses[reference])
# Compare our responses
diff(address,
responses[client], responses[reference], responses[actual])
# Give up if we lost a backend.
if disconnected:
return
def read(sockets, responses, find_end_token):
"""Keep reading from sockets until they disconnet or we find our token."""
# This function doesn't go to well with idle when backends are out of sync.
disconnected = False
for sock in sockets:
responses.setdefault(sock, '')
while sockets:
for sock in select.select(sockets, [], [])[0]:
data = sock.recv(4096)
responses[sock] += data
if find_end_token(responses[sock]):
sockets.remove(sock)
if not data:
sockets.remove(sock)
disconnected = True
return disconnected
def find_response_end_token(data):
"""Find token that indicates the response is over."""
for line in data.splitlines(True):
if line.startswith(('OK', 'ACK')) and line.endswith('\n'):
return True
return False
def find_request_end_token(data):
"""Find token that indicates that request is over."""
lines = data.splitlines(True)
if not lines:
return False
elif 'command_list_ok_begin' == lines[0].strip():
return 'command_list_end' == lines[-1].strip()
else:
return lines[0].endswith('\n')
def diff(address, command, reference_response, actual_response):
"""Print command from client and a unified diff of the responses."""
sys.stdout.write('[%s]:%s\n%s' % (address[0], address[1], command))
for line in difflib.unified_diff(reference_response.splitlines(True),
actual_response.splitlines(True),
fromfile='Reference response',
tofile='Actual response'):
if line.startswith('+') and not line.startswith('+++'):
sys.stdout.write(GREEN)
elif line.startswith('-') and not line.startswith('---'):
sys.stdout.write(RED)
elif line.startswith('@@'):
sys.stdout.write(CYAN)
sys.stdout.write(line)
sys.stdout.write(RESET)
sys.stdout.flush()
def parse_args():
"""Handle flag parsing."""
parser = argparse.ArgumentParser(
description='Proxy and compare MPD protocol interactions.')
parser.add_argument('--listen', default=':6600', type=parse_address,
help='address:port to listen on.')
parser.add_argument('--reference', default=':6601', type=parse_address,
help='address:port for the reference backend.')
parser.add_argument('--actual', default=':6602', type=parse_address,
help='address:port for the actual backend.')
return parser.parse_args()
def parse_address(address):
"""Convert host:port or port to address to pass to connect."""
if ':' not in address:
return ('', int(address))
host, port = address.rsplit(':', 1)
return (host, int(port))
if __name__ == '__main__':
args = parse_args()
def handle(client, address):
"""Wrapper that adds reference and actual backends to proxy calls."""
return proxy(client, address, args.reference, args.actual)
try:
server.StreamServer(args.listen, handle).serve_forever()
except (KeyboardInterrupt, SystemExit):
pass

View File

@ -1,203 +0,0 @@
#! /usr/bin/env python
# This script is helper to systematicly test the behaviour of MPD's idle
# command. It is simply provided as a quick hack, expect nothing more.
from __future__ import unicode_literals
import logging
import pprint
import socket
host = ''
port = 6601
url = "13 - a-ha - White Canvas.mp3"
artist = "a-ha"
data = {'id': None, 'id2': None, 'url': url, 'artist': artist}
# Commands to run before test requests to coerce MPD into right state
setup_requests = [
'clear',
'add "%(url)s"',
'add "%(url)s"',
'add "%(url)s"',
'play',
#'pause', # Uncomment to test paused idle behaviour
#'stop', # Uncomment to test stopped idle behaviour
]
# List of commands to test for idle behaviour. Ordering of list is important in
# order to keep MPD state as intended. Commands that are obviously
# informational only or "harmfull" have been excluded.
test_requests = [
'add "%(url)s"',
'addid "%(url)s" "1"',
'clear',
#'clearerror',
#'close',
#'commands',
'consume "1"',
'consume "0"',
# 'count',
'crossfade "1"',
'crossfade "0"',
#'currentsong',
#'delete "1:2"',
'delete "0"',
'deleteid "%(id)s"',
'disableoutput "0"',
'enableoutput "0"',
#'find',
#'findadd "artist" "%(artist)s"',
#'idle',
#'kill',
#'list',
#'listall',
#'listallinfo',
#'listplaylist',
#'listplaylistinfo',
#'listplaylists',
#'lsinfo',
'move "0:1" "2"',
'move "0" "1"',
'moveid "%(id)s" "1"',
'next',
#'notcommands',
#'outputs',
#'password',
'pause',
#'ping',
'play',
'playid "%(id)s"',
#'playlist',
'playlistadd "foo" "%(url)s"',
'playlistclear "foo"',
'playlistadd "foo" "%(url)s"',
'playlistdelete "foo" "0"',
#'playlistfind',
#'playlistid',
#'playlistinfo',
'playlistadd "foo" "%(url)s"',
'playlistadd "foo" "%(url)s"',
'playlistmove "foo" "0" "1"',
#'playlistsearch',
#'plchanges',
#'plchangesposid',
'previous',
'random "1"',
'random "0"',
'rm "bar"',
'rename "foo" "bar"',
'repeat "0"',
'rm "bar"',
'save "bar"',
'load "bar"',
#'search',
'seek "1" "10"',
'seekid "%(id)s" "10"',
#'setvol "10"',
'shuffle',
'shuffle "0:1"',
'single "1"',
'single "0"',
#'stats',
#'status',
'stop',
'swap "1" "2"',
'swapid "%(id)s" "%(id2)s"',
#'tagtypes',
#'update',
#'urlhandlers',
#'volume',
]
def create_socketfile():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
sock.settimeout(0.5)
fd = sock.makefile('rw', 1) # 1 = line buffered
fd.readline() # Read banner
return fd
def wait(fd, prefix=None, collect=None):
while True:
line = fd.readline().rstrip()
if prefix:
logging.debug('%s: %s', prefix, repr(line))
if line.split()[0] in ('OK', 'ACK'):
break
def collect_ids(fd):
fd.write('playlistinfo\n')
ids = []
while True:
line = fd.readline()
if line.split()[0] == 'OK':
break
if line.split()[0] == 'Id:':
ids.append(line.split()[1])
return ids
def main():
subsystems = {}
command = create_socketfile()
for test in test_requests:
# Remove any old ids
del data['id']
del data['id2']
# Run setup code to force MPD into known state
for setup in setup_requests:
command.write(setup % data + '\n')
wait(command)
data['id'], data['id2'] = collect_ids(command)[:2]
# This connection needs to be make after setup commands are done or
# else they will cause idle events.
idle = create_socketfile()
# Wait for new idle events
idle.write('idle\n')
test = test % data
logging.debug('idle: %s', repr('idle'))
logging.debug('command: %s', repr(test))
command.write(test + '\n')
wait(command, prefix='command')
while True:
try:
line = idle.readline().rstrip()
except socket.timeout:
# Abort try if we time out.
idle.write('noidle\n')
break
logging.debug('idle: %s', repr(line))
if line == 'OK':
break
request_type = test.split()[0]
subsystem = line.split()[1]
subsystems.setdefault(request_type, set()).add(subsystem)
logging.debug('---')
pprint.pprint(subsystems)
if __name__ == '__main__':
main()