Release v0.12.0

This commit is contained in:
Stein Magnus Jodal 2013-03-12 21:04:06 +01:00
commit fb326ce395
37 changed files with 483 additions and 135 deletions

View File

@ -46,5 +46,6 @@ Backend implementations
=======================
* :mod:`mopidy.backends.dummy`
* :mod:`mopidy.backends.spotify`
* :mod:`mopidy.backends.local`
* :mod:`mopidy.backends.spotify`
* :mod:`mopidy.backends.stream`

View File

@ -5,6 +5,66 @@ Changes
This change log is used to track all major changes to Mopidy.
v0.12.0 (2013-03-12)
====================
The 0.12 release has been delayed for a while because of some issues related
some ongoing GStreamer cleanup we didn't invest enough time to finish. Finally,
we've come to our senses and have now cherry-picked the good parts to bring you
a new release, while postponing the GStreamer changes to 0.13. The release adds
a new backend for playing audio streams, as well as various minor improvements
throughout Mopidy.
- Make Mopidy work on early Python 2.6 versions. (Fixes: :issue:`302`)
- ``optparse`` fails if the first argument to ``add_option`` is a unicode
string on Python < 2.6.2rc1.
- ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python
< 2.6.5rc1.
**Audio sub-system**
- Improve selection of mixer tracks for volume control. (Fixes: :issue:`307`)
**Local backend**
- Make ``mopidy-scan`` support symlinks.
**Stream backend**
We've added a new backend for playing audio streams, the :mod:`stream backend
<mopidy.backends.stream>`. It is activated by default. The stream backend
supports the intersection of what your GStreamer installation supports and what
protocols are included in the :attr:`mopidy.settings.STREAM_PROTOCOLS` setting.
Current limitations:
- No metadata about the current track in the stream is available.
- Playlists are not parsed, so you can't play e.g. a M3U or PLS file which
contains stream URIs. You need to extract the stream URL from the playlist
yourself. See :issue:`303` for progress on this.
**Core API**
- :meth:`mopidy.core.PlaylistsController.get_playlists` now accepts an argument
``include_tracks``. This defaults to :class:`True`, which has the same old
behavior. If set to :class:`False`, the tracks are stripped from the
playlists before they are returned. This can be used to limit the amount of
data returned if the response is to be passed out of the application, e.g. to
a web client. (Fixes: :issue:`297`)
**Models**
- Add :attr:`mopidy.models.Album.images` field for including album art URIs.
(Partly fixes :issue:`263`)
- Add :attr:`mopidy.models.Track.disc_no` field. (Partly fixes: :issue:`286`)
- Add :attr:`mopidy.models.Album.num_discs` field. (Partly fixes: :issue:`286`)
v0.11.1 (2012-12-24)
====================

View File

@ -0,0 +1,7 @@
***********************************************
:mod:`mopidy.backends.stream` -- Stream backend
***********************************************
.. automodule:: mopidy.backends.stream
:synopsis: Backend for playing audio streams
:members:

5
fabfile.py vendored
View File

@ -1,4 +1,4 @@
from fabric.api import local
from fabric.api import local, settings
def test(path=None):
@ -9,7 +9,8 @@ def test(path=None):
def autotest(path=None):
while True:
local('clear')
test(path)
with settings(warn_only=True):
test(path)
local(
'inotifywait -q -e create -e modify -e delete '
'--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/')

View File

@ -36,40 +36,27 @@ Building from source
sudo apt-get update
sudo apt-get install nodejs npm
2. Assuming you install from PPA, setup your ``NODE_PATH`` environment variable
to include ``/usr/lib/node_modules``. Add the following to your
``~/.bashrc`` or equivalent::
export NODE_PATH=/usr/lib/node_modules:$NODE_PATH
3. Install `Buster.js <http://busterjs.org/>`_ and `Grunt
<http://gruntjs.com/>`_ globally (or locally, and make sure you get their
binaries on your ``PATH``)::
sudo npm -g install buster grunt
4. Install the grunt-buster Grunt plugin locally, when in the ``js/`` dir::
2. Enter the ``js/`` dir and install development dependencies::
cd js/
npm install grunt-buster
npm install
5. Install `PhantomJS <http://phantomjs.org/>`_ so that we can run the tests
without a browser::
That's it.
sudo apt-get install phantomjs
You can now run the tests::
It is packaged in Ubuntu since 12.04, but I haven't tested with versions
older than 1.6 which is the one packaged in Ubuntu 12.10.
npm test
6. Run Grunt to lint, test, concatenate, and minify the source::
To run tests automatically when you save a file::
grunt
npm run-script watch
The files in ``../mopidy/frontends/http/data/`` should now be up to date.
To run tests, concatenate, minify the source, and update the JavaScript files
in ``mopidy/frontends/http/data/``::
npm run-script build
Development tips
================
To run other `grunt <http://gruntjs.com/>`_ targets which isn't predefined in
``package.json`` and thus isn't available through ``npm run-script``::
If you're coding on the JavaScript library, you should know about ``grunt
watch``. It lints and tests the code every time you save a file.
PATH=./node_modules/.bin:$PATH grunt foo

View File

@ -64,7 +64,9 @@ module.exports = function (grunt) {
uglify: {}
});
grunt.registerTask("default", "lint buster concat min");
grunt.registerTask("test", "lint buster");
grunt.registerTask("build", "lint buster concat min");
grunt.registerTask("default", "build");
grunt.loadNpmTasks("grunt-buster");
};

15
js/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "mopidy",
"version": "0.0.0",
"devDependencies": {
"buster": "*",
"grunt": "*",
"grunt-buster": "*",
"phantomjs": "*"
},
"scripts": {
"test": "grunt test",
"build": "grunt build",
"watch": "grunt watch"
}
}

View File

@ -1,6 +1,10 @@
/*global bane:false, when:false*/
function Mopidy(settings) {
if (!(this instanceof Mopidy)) {
return new Mopidy(settings);
}
this._settings = this._configure(settings || {});
this._console = this._getConsole();

View File

@ -48,6 +48,15 @@ buster.testCase("Mopidy", {
new Mopidy({webSocket: {}});
refute.called(this.webSocketConstructorStub);
},
"works without 'new' keyword": function () {
var mopidyConstructor = Mopidy; // To trick jshint into submission
var mopidy = mopidyConstructor({webSocket: {}});
assert.isObject(mopidy);
assert(mopidy instanceof Mopidy);
}
},

View File

@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring)
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.11.1'
__version__ = '0.12.0'
from mopidy import settings as default_settings_module

View File

@ -79,37 +79,39 @@ def main():
def parse_options():
parser = optparse.OptionParser(
version='Mopidy %s' % versioning.get_version())
# NOTE First argument to add_option must be bytestrings on Python < 2.6.2
# See https://github.com/mopidy/mopidy/issues/302 for details
parser.add_option(
'--help-gst',
b'--help-gst',
action='store_true', dest='help_gst',
help='show GStreamer help options')
parser.add_option(
'-i', '--interactive',
b'-i', '--interactive',
action='store_true', dest='interactive',
help='ask interactively for required settings which are missing')
parser.add_option(
'-q', '--quiet',
b'-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_option(
'-v', '--verbose',
b'-v', '--verbose',
action='count', default=1, dest='verbosity_level',
help='more output (debug level)')
parser.add_option(
'--save-debug-log',
b'--save-debug-log',
action='store_true', dest='save_debug_log',
help='save debug log to "./mopidy.log"')
parser.add_option(
'--list-settings',
b'--list-settings',
action='callback',
callback=settings_utils.list_settings_optparse_callback,
help='list current settings')
parser.add_option(
'--list-deps',
b'--list-deps',
action='callback', callback=deps.list_deps_optparse_callback,
help='list dependencies and their versions')
parser.add_option(
'--debug-thread',
b'--debug-thread',
action='store_true', dest='debug_thread',
help='run background thread that dumps tracebacks on SIGUSR1')
return parser.parse_args(args=mopidy_args)[0]

View File

@ -158,15 +158,23 @@ class Audio(pykka.ThreadingActor):
mixer.get_factory().get_name(), track.label)
def _select_mixer_track(self, mixer, track_label):
# Look for track with label == MIXER_TRACK, otherwise fallback to
# master track which is also an output.
# Ignore tracks without volumes, then look for track with
# label == settings.MIXER_TRACK, otherwise fallback to first usable
# track hoping the mixer gave them to us in a sensible order.
usable_tracks = []
for track in mixer.list_tracks():
if track_label:
if track.label == track_label:
return track
if not mixer.get_volume(track):
continue
if track_label and track.label == track_label:
return track
elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER |
gst.interfaces.MIXER_TRACK_OUTPUT):
return track
usable_tracks.append(track)
if usable_tracks:
return usable_tracks[0]
def _teardown_mixer(self):
if self._mixer is not None:

23
mopidy/audio/utils.py Normal file
View File

@ -0,0 +1,23 @@
from __future__ import unicode_literals
import pygst
pygst.require('0.10')
import gst
def supported_uri_schemes(uri_schemes):
"""Determine which URIs we can actually support from provided whitelist.
:param uri_schemes: list/set of URIs to check support for.
:type uri_schemes: list or set or URI schemes as strings.
:rtype: set of URI schemes we can support via this GStreamer install.
"""
supported_schemes= set()
registry = gst.registry_get_default()
for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY):
for uri in factory.get_uri_protocols():
if uri in uri_schemes:
supported_schemes.add(uri)
return supported_schemes

View File

@ -57,9 +57,9 @@ class BaseLibraryProvider(object):
"""
See :meth:`mopidy.core.LibraryController.find_exact`.
*MUST be implemented by subclass.*
*MAY be implemented by subclass.*
"""
raise NotImplementedError
pass
def lookup(self, uri):
"""
@ -73,17 +73,17 @@ class BaseLibraryProvider(object):
"""
See :meth:`mopidy.core.LibraryController.refresh`.
*MUST be implemented by subclass.*
*MAY be implemented by subclass.*
"""
raise NotImplementedError
pass
def search(self, **query):
"""
See :meth:`mopidy.core.LibraryController.search`.
*MUST be implemented by subclass.*
*MAY be implemented by subclass.*
"""
raise NotImplementedError
pass
class BasePlaybackProvider(object):

View File

@ -98,6 +98,9 @@ def _convert_mpd_data(data, tracks, music_dir):
if not data:
return
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details.
track_kwargs = {}
album_kwargs = {}
artist_kwargs = {}
@ -105,38 +108,38 @@ def _convert_mpd_data(data, tracks, music_dir):
if 'track' in data:
if '/' in data['track']:
album_kwargs['num_tracks'] = int(data['track'].split('/')[1])
track_kwargs['track_no'] = int(data['track'].split('/')[0])
album_kwargs[b'num_tracks'] = int(data['track'].split('/')[1])
track_kwargs[b'track_no'] = int(data['track'].split('/')[0])
else:
track_kwargs['track_no'] = int(data['track'])
track_kwargs[b'track_no'] = int(data['track'])
if 'artist' in data:
artist_kwargs['name'] = data['artist']
albumartist_kwargs['name'] = data['artist']
artist_kwargs[b'name'] = data['artist']
albumartist_kwargs[b'name'] = data['artist']
if 'albumartist' in data:
albumartist_kwargs['name'] = data['albumartist']
albumartist_kwargs[b'name'] = data['albumartist']
if 'album' in data:
album_kwargs['name'] = data['album']
album_kwargs[b'name'] = data['album']
if 'title' in data:
track_kwargs['name'] = data['title']
track_kwargs[b'name'] = data['title']
if 'date' in data:
track_kwargs['date'] = data['date']
track_kwargs[b'date'] = data['date']
if 'musicbrainz_trackid' in data:
track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid']
track_kwargs[b'musicbrainz_id'] = data['musicbrainz_trackid']
if 'musicbrainz_albumid' in data:
album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid']
album_kwargs[b'musicbrainz_id'] = data['musicbrainz_albumid']
if 'musicbrainz_artistid' in data:
artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid']
artist_kwargs[b'musicbrainz_id'] = data['musicbrainz_artistid']
if 'musicbrainz_albumartistid' in data:
albumartist_kwargs['musicbrainz_id'] = (
albumartist_kwargs[b'musicbrainz_id'] = (
data['musicbrainz_albumartistid'])
if data['file'][0] == '/':
@ -147,18 +150,18 @@ def _convert_mpd_data(data, tracks, music_dir):
if artist_kwargs:
artist = Artist(**artist_kwargs)
track_kwargs['artists'] = [artist]
track_kwargs[b'artists'] = [artist]
if albumartist_kwargs:
albumartist = Artist(**albumartist_kwargs)
album_kwargs['artists'] = [albumartist]
album_kwargs[b'artists'] = [albumartist]
if album_kwargs:
album = Album(**album_kwargs)
track_kwargs['album'] = album
track_kwargs[b'album'] = album
track_kwargs['uri'] = path_to_uri(music_dir, path)
track_kwargs['length'] = int(data.get('time', 0)) * 1000
track_kwargs[b'uri'] = path_to_uri(music_dir, path)
track_kwargs[b'length'] = int(data.get('time', 0)) * 1000
track = Track(**track_kwargs)
tracks.add(track)

View File

@ -0,0 +1,23 @@
"""A backend for playing music for streaming music.
This backend will handle streaming of URIs in
:attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are
installed.
**Issues:**
https://github.com/mopidy/mopidy/issues?labels=Stream+backend
**Dependencies:**
- None
**Settings:**
- :attr:`mopidy.settings.STREAM_PROTOCOLS`
"""
from __future__ import unicode_literals
# flake8: noqa
from .actor import StreamBackend

View File

@ -0,0 +1,38 @@
from __future__ import unicode_literals
import logging
import urlparse
import pykka
from mopidy import settings
from mopidy.audio import utils
from mopidy.backends import base
from mopidy.models import SearchResult, Track
logger = logging.getLogger('mopidy.backends.stream')
class StreamBackend(pykka.ThreadingActor, base.Backend):
def __init__(self, audio):
super(StreamBackend, self).__init__()
self.library = StreamLibraryProvider(backend=self)
self.playback = base.BasePlaybackProvider(audio=audio, backend=self)
self.playlists = None
self.uri_schemes = utils.supported_uri_schemes(
settings.STREAM_PROTOCOLS)
# TODO: Should we consider letting lookup know how to expand common playlist
# formats (m3u, pls, etc) for http(s) URIs?
class StreamLibraryProvider(base.BaseLibraryProvider):
def lookup(self, uri):
if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes:
return []
# TODO: actually lookup the stream metadata by getting tags in same
# way as we do for updating the local library with mopidy.scanner
# Note that we would only want the stream metadata at this stage,
# not the currently playing track's.
return [Track(uri=uri, name=uri)]

View File

@ -41,7 +41,7 @@ class LibraryController(object):
query = query or kwargs
futures = [
b.library.find_exact(**query) for b in self.backends.with_library]
return pykka.get_all(futures)
return [result for result in pykka.get_all(futures) if result]
def lookup(self, uri):
"""
@ -101,4 +101,4 @@ class LibraryController(object):
query = query or kwargs
futures = [
b.library.search(**query) for b in self.backends.with_library]
return pykka.get_all(futures)
return [result for result in pykka.get_all(futures) if result]

View File

@ -15,11 +15,14 @@ class PlaylistsController(object):
self.backends = backends
self.core = core
def get_playlists(self):
def get_playlists(self, include_tracks=True):
futures = [
b.playlists.playlists for b in self.backends.with_playlists]
results = pykka.get_all(futures)
return list(itertools.chain(*results))
playlists = list(itertools.chain(*results))
if not include_tracks:
playlists = [p.copy(tracks=[]) for p in playlists]
return playlists
playlists = property(get_playlists)
"""

View File

@ -1,6 +1,6 @@
/*! Mopidy.js - built 2012-12-04
/*! Mopidy.js - built 2013-01-16
* http://www.mopidy.com/
* Copyright (c) 2012 Stein Magnus Jodal and contributors
* Copyright (c) 2013 Stein Magnus Jodal and contributors
* Licensed under the Apache License, Version 2.0 */
/**
@ -910,6 +910,10 @@ define(['module'], function () {
/*global bane:false, when:false*/
function Mopidy(settings) {
if (!(this instanceof Mopidy)) {
return new Mopidy(settings);
}
this._settings = this._configure(settings || {});
this._console = this._getConsole();

File diff suppressed because one or more lines are too long

View File

@ -19,10 +19,12 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME)
port = settings.MPD_SERVER_PORT
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details.
try:
network.Server(
hostname, port,
protocol=session.MpdSession, protocol_kwargs={'core': core},
protocol=session.MpdSession, protocol_kwargs={b'core': core},
max_connections=settings.MPD_SERVER_MAX_CONNECTIONS,
timeout=settings.MPD_SERVER_CONNECTION_TIMEOUT)
except IOError as error:

View File

@ -56,7 +56,12 @@ def handle_request(pattern, auth_required=True):
if match is not None:
mpd_commands.add(
MpdCommand(name=match.group(), auth_required=auth_required))
compiled_pattern = re.compile(pattern, flags=re.UNICODE)
# NOTE Make pattern a bytestring to get bytestring keys in the dict
# returned from matches.groupdict(), which is again used as a **kwargs
# dict. This is needed to work on Python < 2.6.5.
# See https://github.com/mopidy/mopidy/issues/302 for details.
bytestring_pattern = pattern.encode('utf-8')
compiled_pattern = re.compile(bytestring_pattern, flags=re.UNICODE)
if compiled_pattern in request_handlers:
raise ValueError('Tried to redefine handler for %s with %s' % (
pattern, func))

View File

@ -140,6 +140,8 @@ def query_from_mpd_list_format(field, mpd_query):
"""
Converts an MPD ``list`` query to a Mopidy query.
"""
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
if mpd_query is None:
return {}
try:
@ -155,15 +157,14 @@ def query_from_mpd_list_format(field, mpd_query):
if field == 'album':
if not tokens[0]:
raise ValueError
return {'artist': [tokens[0]]}
return {b'artist': [tokens[0]]} # See above NOTE
else:
raise MpdArgError(
'should be "Album" for 3 arguments', command='list')
elif len(tokens) % 2 == 0:
query = {}
while tokens:
key = tokens[0].lower()
key = str(key) # Needed for kwargs keys on OS X and Windows
key = str(tokens[0].lower()) # See above NOTE
value = tokens[1]
tokens = tokens[2:]
if key not in ('artist', 'album', 'date', 'genre'):

View File

@ -66,13 +66,15 @@ class ImmutableObject(object):
:type values: dict
:rtype: new instance of the model being copied
"""
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
data = {}
for key in self.__dict__.keys():
public_key = key.lstrip('_')
data[public_key] = values.pop(public_key, self.__dict__[key])
data[str(public_key)] = values.pop(public_key, self.__dict__[key])
for key in values.keys():
if hasattr(self, key):
data[key] = values.pop(key)
data[str(key)] = values.pop(key)
if values:
raise TypeError(
'copy() got an unexpected keyword argument "%s"' % key)
@ -85,7 +87,9 @@ class ImmutableObject(object):
public_key = key.lstrip('_')
value = self.__dict__[key]
if isinstance(value, (set, frozenset, list, tuple)):
value = [o.serialize() for o in value]
value = [
v.serialize() if isinstance(v, ImmutableObject) else v
for v in value]
elif isinstance(value, ImmutableObject):
value = value.serialize()
if value:
@ -123,11 +127,16 @@ def model_json_decoder(dct):
{u'a_track': Track(artists=[], name=u'name')}
"""
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details.
if '__model__' in dct:
model_name = dct.pop('__model__')
cls = globals().get(model_name, None)
if issubclass(cls, ImmutableObject):
return cls(**dct)
kwargs = {}
for key, value in dct.items():
kwargs[str(key)] = value
return cls(**kwargs)
return dct
@ -161,10 +170,14 @@ class Album(ImmutableObject):
:type artists: list of :class:`Artist`
:param num_tracks: number of tracks in album
:type num_tracks: integer
:param num_discs: number of discs in album
:type num_discs: integer or :class:`None` if unknown
:param date: album release date (YYYY or YYYY-MM-DD)
:type date: string
:param musicbrainz_id: MusicBrainz ID
:type musicbrainz_id: string
:param images: album image URIs
:type images: list of strings
"""
#: The album URI. Read-only.
@ -179,14 +192,23 @@ class Album(ImmutableObject):
#: The number of tracks in the album. Read-only.
num_tracks = 0
#: The number of discs in the album. Read-only.
num_discs = None
#: The album release date. Read-only.
date = None
#: The MusicBrainz ID of the album. Read-only.
musicbrainz_id = None
#: The album image URIs. Read-only.
images = frozenset()
def __init__(self, *args, **kwargs):
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
self.__dict__[b'artists'] = frozenset(kwargs.pop('artists', []))
self.__dict__[b'images'] = frozenset(kwargs.pop('images', []))
super(Album, self).__init__(*args, **kwargs)
@ -202,6 +224,8 @@ class Track(ImmutableObject):
:type album: :class:`Album`
:param track_no: track number in album
:type track_no: integer
:param disc_no: disc number in album
:type disc_no: integer or :class:`None` if unknown
:param date: track release date (YYYY or YYYY-MM-DD)
:type date: string
:param length: track length in milliseconds
@ -224,9 +248,12 @@ class Track(ImmutableObject):
#: The track :class:`Album`. Read-only.
album = None
#: The track number in album. Read-only.
#: The track number in the album. Read-only.
track_no = 0
#: The disc number in the album. Read-only.
disc_no = None
#: The track release date. Read-only.
date = None
@ -240,7 +267,9 @@ class Track(ImmutableObject):
musicbrainz_id = None
def __init__(self, *args, **kwargs):
self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
self.__dict__[b'artists'] = frozenset(kwargs.pop('artists', []))
super(Track, self).__init__(*args, **kwargs)
@ -272,9 +301,11 @@ class TlTrack(ImmutableObject):
track = None
def __init__(self, *args, **kwargs):
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
if len(args) == 2 and len(kwargs) == 0:
kwargs['tlid'] = args[0]
kwargs['track'] = args[1]
kwargs[b'tlid'] = args[0]
kwargs[b'track'] = args[1]
args = []
super(TlTrack, self).__init__(*args, **kwargs)
@ -309,7 +340,9 @@ class Playlist(ImmutableObject):
last_modified = None
def __init__(self, *args, **kwargs):
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', []))
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
self.__dict__[b'tracks'] = tuple(kwargs.pop('tracks', []))
super(Playlist, self).__init__(*args, **kwargs)
# TODO: def insert(self, pos, track): ... ?
@ -345,7 +378,9 @@ class SearchResult(ImmutableObject):
albums = tuple()
def __init__(self, *args, **kwargs):
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', []))
self.__dict__['artists'] = tuple(kwargs.pop('artists', []))
self.__dict__['albums'] = tuple(kwargs.pop('albums', []))
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details
self.__dict__[b'tracks'] = tuple(kwargs.pop('tracks', []))
self.__dict__[b'artists'] = tuple(kwargs.pop('artists', []))
self.__dict__[b'albums'] = tuple(kwargs.pop('albums', []))
super(SearchResult, self).__init__(*args, **kwargs)

View File

@ -79,12 +79,14 @@ def main():
def parse_options():
parser = optparse.OptionParser(
version='Mopidy %s' % versioning.get_version())
# NOTE First argument to add_option must be bytestrings on Python < 2.6.2
# See https://github.com/mopidy/mopidy/issues/302 for details
parser.add_option(
'-q', '--quiet',
b'-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_option(
'-v', '--verbose',
b'-v', '--verbose',
action='count', default=1, dest='verbosity_level',
help='more output (debug level)')
return parser.parse_args(args=mopidy_args)[0]
@ -96,9 +98,12 @@ def translator(data):
artist_kwargs = {}
track_kwargs = {}
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details.
def _retrieve(source_key, target_key, target):
if source_key in data:
target[target_key] = data[source_key]
target[str(target_key)] = data[source_key]
_retrieve(gst.TAG_ALBUM, 'name', album_kwargs)
_retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs)
@ -111,7 +116,7 @@ def translator(data):
except ValueError:
pass # Ignore invalid dates
else:
track_kwargs['date'] = date.isoformat()
track_kwargs[b'date'] = date.isoformat()
_retrieve(gst.TAG_TITLE, 'name', track_kwargs)
_retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs)
@ -125,12 +130,12 @@ def translator(data):
'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs)
if albumartist_kwargs:
album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
album_kwargs[b'artists'] = [Artist(**albumartist_kwargs)]
track_kwargs['uri'] = data['uri']
track_kwargs['length'] = data[gst.TAG_DURATION]
track_kwargs['album'] = Album(**album_kwargs)
track_kwargs['artists'] = [Artist(**artist_kwargs)]
track_kwargs[b'uri'] = data['uri']
track_kwargs[b'length'] = data[gst.TAG_DURATION]
track_kwargs[b'album'] = Album(**album_kwargs)
track_kwargs[b'artists'] = [Artist(**artist_kwargs)]
return Track(**track_kwargs)

View File

@ -20,10 +20,12 @@ from __future__ import unicode_literals
#: BACKENDS = (
#: u'mopidy.backends.local.LocalBackend',
#: u'mopidy.backends.spotify.SpotifyBackend',
#: u'mopidy.backends.spotify.StreamBackend',
#: )
BACKENDS = (
'mopidy.backends.local.LocalBackend',
'mopidy.backends.spotify.SpotifyBackend',
'mopidy.backends.stream.StreamBackend',
)
#: The log format used for informational logging.
@ -286,7 +288,7 @@ SPOTIFY_PROXY_USERNAME = None
#: Spotify proxy password.
#:
#: Used by :mod:`mopidy.backends.spotify`
#: Used by :mod:`mopidy.backends.spotify`.
#:
#: Default::
#:
@ -295,9 +297,32 @@ SPOTIFY_PROXY_PASSWORD = None
#: Max number of seconds to wait for Spotify operations to complete.
#:
#: Used by :mod:`mopidy.backends.spotify`
#: Used by :mod:`mopidy.backends.spotify`.
#:
#: Default::
#:
#: SPOTIFY_TIMEOUT = 10
SPOTIFY_TIMEOUT = 10
#: Whitelist of URIs to support streaming from.
#:
#: Used by :mod:`mopidy.backends.stream`.
#:
#: Default::
#:
#: STREAM_PROTOCOLS = (
#: u'http',
#: u'https',
#: u'mms',
#: u'rtmp',
#: u'rtmps',
#: u'rtsp',
#: )
STREAM_PROTOCOLS = (
'http',
'https',
'mms',
'rtmp',
'rtmps',
'rtsp',
)

View File

@ -120,7 +120,7 @@ def find_files(path):
if not os.path.basename(path).startswith(b'.'):
yield path
else:
for dirpath, dirnames, filenames in os.walk(path):
for dirpath, dirnames, filenames in os.walk(path, followlinks=True):
for dirname in dirnames:
if dirname.startswith(b'.'):
# Skip hidden folders by modifying dirnames inplace

View File

@ -101,7 +101,7 @@ class DebugThread(threading.Thread):
stack = ''.join(traceback.format_stack(frame))
logger.debug(
'Current state of %s (%s):\n%s',
threads[ident], ident, stack)
threads.get(ident, '?'), ident, stack)
del frame
self.event.clear()

View File

@ -11,7 +11,7 @@ from mopidy.backends import listener
class BackendEventsTest(object):
def setUp(self):
self.audio = mock.Mock(spec=audio.Audio)
self.backend = self.backend_class.start(audio=audio).proxy()
self.backend = self.backend_class.start(audio=self.audio).proxy()
self.core = core.Core.start(backends=[self.backend]).proxy()
def tearDown(self):

View File

@ -90,6 +90,22 @@ class CoreLibraryTest(unittest.TestCase):
self.library1.find_exact.assert_called_once_with(any=['a'])
self.library2.find_exact.assert_called_once_with(any=['a'])
def test_find_exact_filters_out_none(self):
track1 = Track(uri='dummy1:a')
result1 = SearchResult(tracks=[track1])
self.library1.find_exact().get.return_value = result1
self.library1.find_exact.reset_mock()
self.library2.find_exact().get.return_value = None
self.library2.find_exact.reset_mock()
result = self.core.library.find_exact(any=['a'])
self.assertIn(result1, result)
self.assertNotIn(None, result)
self.library1.find_exact.assert_called_once_with(any=['a'])
self.library2.find_exact.assert_called_once_with(any=['a'])
def test_find_accepts_query_dict_instead_of_kwargs(self):
track1 = Track(uri='dummy1:a')
track2 = Track(uri='dummy2:a')
@ -126,6 +142,22 @@ class CoreLibraryTest(unittest.TestCase):
self.library1.search.assert_called_once_with(any=['a'])
self.library2.search.assert_called_once_with(any=['a'])
def test_search_filters_out_none(self):
track1 = Track(uri='dummy1:a')
result1 = SearchResult(tracks=[track1])
self.library1.search().get.return_value = result1
self.library1.search.reset_mock()
self.library2.search().get.return_value = None
self.library2.search.reset_mock()
result = self.core.library.search(any=['a'])
self.assertIn(result1, result)
self.assertNotIn(None, result)
self.library1.search.assert_called_once_with(any=['a'])
self.library2.search.assert_called_once_with(any=['a'])
def test_search_accepts_query_dict_instead_of_kwargs(self):
track1 = Track(uri='dummy1:a')
track2 = Track(uri='dummy2:a')

View File

@ -46,6 +46,22 @@ class PlaylistsTest(unittest.TestCase):
self.assertIn(self.pl2a, result)
self.assertIn(self.pl2b, result)
def test_get_playlists_includes_tracks_by_default(self):
result = self.core.playlists.get_playlists()
self.assertEqual(result[0].name, 'A')
self.assertEqual(len(result[0].tracks), 1)
self.assertEqual(result[1].name, 'B')
self.assertEqual(len(result[1].tracks), 1)
def test_get_playlist_can_strip_tracks_from_returned_playlists(self):
result = self.core.playlists.get_playlists(include_tracks=False)
self.assertEqual(result[0].name, 'A')
self.assertEqual(len(result[0].tracks), 0)
self.assertEqual(result[1].name, 'B')
self.assertEqual(len(result[1].tracks), 0)
def test_create_without_uri_scheme_uses_first_backend(self):
playlist = Playlist()
self.sp1.create().get.return_value = playlist

View File

@ -199,10 +199,16 @@ class AlbumTest(unittest.TestCase):
def test_num_tracks(self):
num_tracks = 11
album = Album(num_tracks=11)
album = Album(num_tracks=num_tracks)
self.assertEqual(album.num_tracks, num_tracks)
self.assertRaises(AttributeError, setattr, album, 'num_tracks', None)
def test_num_discs(self):
num_discs = 2
album = Album(num_discs=num_discs)
self.assertEqual(album.num_discs, num_discs)
self.assertRaises(AttributeError, setattr, album, 'num_discs', None)
def test_date(self):
date = '1977-01-01'
album = Album(date=date)
@ -216,18 +222,25 @@ class AlbumTest(unittest.TestCase):
self.assertRaises(
AttributeError, setattr, album, 'musicbrainz_id', None)
def test_images(self):
image = 'data:foobar'
album = Album(images=[image])
self.assertIn(image, album.images)
self.assertRaises(AttributeError, setattr, album, 'images', None)
def test_invalid_kwarg(self):
test = lambda: Album(foo='baz')
self.assertRaises(TypeError, test)
def test_repr_without_artists(self):
self.assertEquals(
"Album(artists=[], name=u'name', uri=u'uri')",
"Album(artists=[], images=[], name=u'name', uri=u'uri')",
repr(Album(uri='uri', name='name')))
def test_repr_with_artists(self):
self.assertEquals(
"Album(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')",
"Album(artists=[Artist(name=u'foo')], images=[], name=u'name', "
"uri=u'uri')",
repr(Album(uri='uri', name='name', artists=[Artist(name='foo')])))
def test_serialize_without_artists(self):
@ -242,6 +255,13 @@ class AlbumTest(unittest.TestCase):
'artists': [artist.serialize()]},
Album(uri='uri', name='name', artists=[artist]).serialize())
def test_serialize_with_images(self):
image = 'data:foobar'
self.assertDictEqual(
{'__model__': 'Album', 'uri': 'uri', 'name': 'name',
'images': [image]},
Album(uri='uri', name='name', images=[image]).serialize())
def test_to_json_and_back(self):
album1 = Album(uri='uri', name='name', artists=[Artist(name='foo')])
serialized = json.dumps(album1, cls=ModelJSONEncoder)
@ -389,6 +409,12 @@ class TrackTest(unittest.TestCase):
self.assertEqual(track.track_no, track_no)
self.assertRaises(AttributeError, setattr, track, 'track_no', None)
def test_disc_no(self):
disc_no = 2
track = Track(disc_no=disc_no)
self.assertEqual(track.disc_no, disc_no)
self.assertRaises(AttributeError, setattr, track, 'disc_no', None)
def test_date(self):
date = '1977-01-01'
track = Track(date=date)

View File

@ -32,36 +32,40 @@ class TranslatorTest(unittest.TestCase):
'musicbrainz-albumartistid': 'mbalbumartistid',
}
# NOTE: kwargs are explicitly made bytestrings to work on Python
# 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for
# details.
self.album = {
'name': 'albumname',
'num_tracks': 2,
'musicbrainz_id': 'mbalbumid',
b'name': 'albumname',
b'num_tracks': 2,
b'musicbrainz_id': 'mbalbumid',
}
self.artist = {
'name': 'name',
'musicbrainz_id': 'mbartistid',
b'name': 'name',
b'musicbrainz_id': 'mbartistid',
}
self.albumartist = {
'name': 'albumartistname',
'musicbrainz_id': 'mbalbumartistid',
b'name': 'albumartistname',
b'musicbrainz_id': 'mbalbumartistid',
}
self.track = {
'uri': 'uri',
'name': 'trackname',
'date': '2006-01-01',
'track_no': 1,
'length': 4531,
'musicbrainz_id': 'mbtrackid',
b'uri': 'uri',
b'name': 'trackname',
b'date': '2006-01-01',
b'track_no': 1,
b'length': 4531,
b'musicbrainz_id': 'mbtrackid',
}
def build_track(self):
if self.albumartist:
self.album['artists'] = [Artist(**self.albumartist)]
self.track['album'] = Album(**self.album)
self.track['artists'] = [Artist(**self.artist)]
self.album[b'artists'] = [Artist(**self.albumartist)]
self.track[b'album'] = Album(**self.album)
self.track[b'artists'] = [Artist(**self.artist)]
return Track(**self.track)
def check(self):

View File

@ -15,15 +15,19 @@ class LocaleDecodeTest(unittest.TestCase):
result = locale_decode(
b'[Errno 98] Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e')
self.assertEquals('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result)
self.assertEqual('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result)
def test_can_decode_an_ioerror_with_french_content(self, mock):
mock.return_value = 'UTF-8'
error = IOError(98, b'Adresse d\xc3\xa9j\xc3\xa0 utilis\xc3\xa9e')
result = locale_decode(error)
expected = '[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e'
self.assertEquals('[Errno 98] Adresse d\xe9j\xe0 utilis\xe9e', result)
self.assertEqual(
expected, result,
'%r decoded to %r does not match expected %r' % (
error, result, expected))
def test_does_not_use_locale_to_decode_unicode_strings(self, mock):
mock.return_value = 'UTF-8'

View File

@ -201,10 +201,12 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase):
self.assertEqual(self.core.playback.get_volume().get(), 37)
def test_call_methods_with_named_params(self):
# NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5
# See https://github.com/mopidy/mopidy/issues/302 for details.
request = {
'jsonrpc': '2.0',
'method': 'core.playback.set_volume',
'params': {'volume': 37},
'params': {b'volume': 37},
'id': 1,
}
response = self.jrw.handle_data(request)
@ -603,7 +605,7 @@ class JsonRpcInspectorTest(JsonRpcTestBase):
self.assertIn('core.playlists.get_playlists', methods)
self.assertEquals(
len(methods['core.playlists.get_playlists']['params']), 0)
len(methods['core.playlists.get_playlists']['params']), 1)
self.assertIn('core.tracklist.filter', methods.keys())
self.assertEquals(

View File

@ -34,5 +34,6 @@ class VersionTest(unittest.TestCase):
self.assertLess(SV('0.8.1'), SV('0.9.0'))
self.assertLess(SV('0.9.0'), SV('0.10.0'))
self.assertLess(SV('0.10.0'), SV('0.11.0'))
self.assertLess(SV('0.11.0'), SV(__version__))
self.assertLess(SV(__version__), SV('0.11.2'))
self.assertLess(SV('0.11.0'), SV('0.11.1'))
self.assertLess(SV('0.11.1'), SV(__version__))
self.assertLess(SV(__version__), SV('0.12.1'))