diff --git a/docs/api/backends.rst b/docs/api/backends.rst index f0aadd53..32c04d37 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -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` diff --git a/docs/changes.rst b/docs/changes.rst index 296e7e38..a04de250 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -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 +`. 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) ==================== diff --git a/docs/modules/backends/stream.rst b/docs/modules/backends/stream.rst new file mode 100644 index 00000000..73e53048 --- /dev/null +++ b/docs/modules/backends/stream.rst @@ -0,0 +1,7 @@ +*********************************************** +:mod:`mopidy.backends.stream` -- Stream backend +*********************************************** + +.. automodule:: mopidy.backends.stream + :synopsis: Backend for playing audio streams + :members: diff --git a/fabfile.py b/fabfile.py index 370c81be..3321cb16 100644 --- a/fabfile.py +++ b/fabfile.py @@ -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/') diff --git a/js/README.rst b/js/README.rst index a68dd9a0..e8782213 100644 --- a/js/README.rst +++ b/js/README.rst @@ -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 `_ and `Grunt - `_ 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 `_ 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 `_ 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 diff --git a/js/grunt.js b/js/grunt.js index d835fd77..46afc8af 100644 --- a/js/grunt.js +++ b/js/grunt.js @@ -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"); }; diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..a8737cfb --- /dev/null +++ b/js/package.json @@ -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" + } +} diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 3485b442..5a75a836 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -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(); diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 7dcc5972..8842ebf4 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -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); } }, diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 9d66b722..0a8e68aa 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -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 diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 952f158c..2847497a 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -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] diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 1b6c79b3..f6f8dbe9 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -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: diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py new file mode 100644 index 00000000..3f5f685e --- /dev/null +++ b/mopidy/audio/utils.py @@ -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 diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 8250a24c..f49aa89b 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -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): diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 390fd92a..b029d367 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -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) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py new file mode 100644 index 00000000..82755540 --- /dev/null +++ b/mopidy/backends/stream/__init__.py @@ -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 diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py new file mode 100644 index 00000000..0c91f291 --- /dev/null +++ b/mopidy/backends/stream/actor.py @@ -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)] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 39a1e99c..e4be7ce8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -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] diff --git a/mopidy/core/playlists.py b/mopidy/core/playlists.py index 62098c7f..f0187d44 100644 --- a/mopidy/core/playlists.py +++ b/mopidy/core/playlists.py @@ -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) """ diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 5b022c0c..f41695e6 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -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(); diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index 797b47ec..e727cefd 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! 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 */ -function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +function Mopidy(e){if(!(this instanceof Mopidy))return new Mopidy(e);this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 11e07aa7..8907fe22 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -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: diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 1827624b..2b2260ef 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -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)) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index e26d7dce..15ca181d 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -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'): diff --git a/mopidy/models.py b/mopidy/models.py index 73209b6e..e14fd8b4 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -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) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 0b10d061..bfeb9fd1 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -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) diff --git a/mopidy/settings.py b/mopidy/settings.py index c2081e27..fd3dfd6f 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -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', +) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index c4fa0ce2..7d988a90 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -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 diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5edf287e..6be8937c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -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() diff --git a/tests/backends/base/events.py b/tests/backends/base/events.py index 0a2e6722..2b6df347 100644 --- a/tests/backends/base/events.py +++ b/tests/backends/base/events.py @@ -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): diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 32e618d2..e01696c7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -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') diff --git a/tests/core/playlists_test.py b/tests/core/playlists_test.py index cea93c5b..f11e1776 100644 --- a/tests/core/playlists_test.py +++ b/tests/core/playlists_test.py @@ -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 diff --git a/tests/models_test.py b/tests/models_test.py index 89d0b132..c2d65ec6 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -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) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 92e9a269..d8466e26 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -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): diff --git a/tests/utils/encoding_test.py b/tests/utils/encoding_test.py index 1a4e56c5..b7292de0 100644 --- a/tests/utils/encoding_test.py +++ b/tests/utils/encoding_test.py @@ -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' diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 59cb89b5..44ec1b09 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -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( diff --git a/tests/version_test.py b/tests/version_test.py index 1cb3967c..a444ea1f 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -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'))