diff --git a/AUTHORS b/AUTHORS index 3794a267..8269452d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -28,3 +28,4 @@ - Pavol Babincak - Javier Domingo - Lasse Bigum +- David Eisner diff --git a/docs/changelog.rst b/docs/changelog.rst index 189e06d3..dec41bc0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,15 +13,48 @@ v0.17.0 (UNRELEASED) - The search field ``track`` has been renamed to ``track_name`` to avoid confusion with ``track_no``. (Fixes: :issue:`535`) +- The signature of the tracklist's + :meth:`~mopidy.core.TracklistController.filter` and + :meth:`~mopidy.core.TracklistController.remove` methods have changed. + Previously, they expected e.g. ``tracklist.filter(tlid=17)``. Now, the value + must always be a list, e.g. ``tracklist.filter(tlid=[17])``. This change + allows you to get or remove multiple tracks with a single call, e.g. + ``tracklist.remove(tlid=[1, 2, 7])``. This is especially useful for web + clients, as requests can be batched. This also brings the interface closer to + the library's :meth:`~mopidy.core.LibraryController.find_exact` and + :meth:`~mopidy.core.LibraryController.search` methods. + **Local backend** +- Library scanning has been switched back to custom code due to various issues + with GStreamer's built in scanner in 0.10. This also fixes the scanner + slowdown. (Fixes: :issue:`565`) + - When scanning, we no longer default the album artist to be the same as the track artist. Album artist is now only populated if the scanned file got an explicit album artist set. -- Library scanning has been switched back to custom code due to various issues - with GStreamer's built in scanner in 0.10. This also fixes the scanner slowdown. - (Fixes: :issue:`565`) -- Fix scanner so that mtime is respected when deciding which files can be skipped. + +- The scanner will now extract multiple artists from files with multiple artist + tags. + +- Fix scanner so that time of last modification is respected when deciding + which files can be skipped. + +**MPD frontend** + +- The MPD service is now published as a Zeroconf service if avahi-daemon is + running on the system. Some MPD clients will use this to present Mopidy as an + available server on the local network without needing any configuration. See + the :confval:`mpd/zeroconf` config value to change the service name or + disable the service. (Fixes: :issue:`39`) + +**HTTP frontend** + +- The HTTP service is now published as a Zeroconf service if avahi-daemon is + running on the system. Some browsers will present HTTP Zeroconf services on + the local network as "local sites" bookmarks. See the + :confval:`http/zeroconf` config value to change the service name or disable + the service. (Fixes: :issue:`39`) **MPD frontend** diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 65bddb73..ce79588e 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -59,6 +59,13 @@ Configuration values Change this to have Mopidy serve e.g. files for your JavaScript client. "/mopidy" will continue to work as usual even if you change this setting. +.. confval:: http/zeroconf + + Name of the HTTP service when published through Zeroconf. The variables + ``$hostname`` and ``$port`` can be used in the name. + + Set to an empty string to disable Zeroconf for HTTP. + Usage ===== diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 6c619e92..eb502221 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -96,6 +96,13 @@ Configuration values Number of seconds an MPD client can stay inactive before the connection is closed by the server. +.. confval:: mpd/zeroconf + + Name of the MPD service when published through Zeroconf. The variables + ``$hostname`` and ``$port`` can be used in the name. + + Set to an empty string to disable Zeroconf for MPD. + Usage ===== diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 8499f73c..2b04c5a0 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -150,7 +150,13 @@ def audio_data_to_track(data): track_kwargs['last_modified'] = int(data['mtime']) track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND track_kwargs['album'] = Album(**album_kwargs) - track_kwargs['artists'] = [Artist(**artist_kwargs)] + + if ('name' in artist_kwargs + and not isinstance(artist_kwargs['name'], basestring)): + track_kwargs['artists'] = [Artist(name=artist) + for artist in artist_kwargs['name']] + else: + track_kwargs['artists'] = [Artist(**artist_kwargs)] if ('name' in composer_kwargs and not isinstance(composer_kwargs['name'], basestring)): diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index dbc81945..d3cc0d75 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import collections import logging import random @@ -292,36 +293,51 @@ class TracklistController(object): """ Filter the tracklist by the given criterias. + A criteria consists of a model field to check and a list of values to + compare it against. If the model field matches one of the values, it + may be returned. + + Only tracks that matches all the given criterias are returned. + Examples:: - # Returns track with TLID 7 (tracklist ID) - filter({'tlid': 7}) - filter(tlid=7) + # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID) + filter({'tlid': [1, 2, 3, 4]}) + filter(tlid=[1, 2, 3, 4]) - # Returns track with ID 1 - filter({'id': 1}) - filter(id=1) + # Returns track with IDs 1, 5, or 7 + filter({'id': [1, 5, 7]}) + filter(id=[1, 5, 7]) - # Returns track with URI 'xyz' - filter({'uri': 'xyz'}) - filter(uri='xyz') + # Returns track with URIs 'xyz' or 'abc' + filter({'uri': ['xyz', 'abc']}) + filter(uri=['xyz', 'abc']) - # Returns track with ID 1 and URI 'xyz' - filter({'id': 1, 'uri': 'xyz'}) - filter(id=1, uri='xyz') + # Returns tracks with ID 1 and URI 'xyz' + filter({'id': [1], 'uri': ['xyz']}) + filter(id=[1], uri=['xyz']) + + # Returns track with a matching ID (1, 3 or 6) and a matching URI + # ('xyz' or 'abc') + filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']}) + filter(id=[1, 3, 6], uri=['xyz', 'abc']) :param criteria: on or more criteria to match by - :type criteria: dict + :type criteria: dict, of (string, list) pairs :rtype: list of :class:`mopidy.models.TlTrack` """ criteria = criteria or kwargs matches = self._tl_tracks - for (key, value) in criteria.iteritems(): + for (key, values) in criteria.iteritems(): + if (not isinstance(values, collections.Iterable) + or isinstance(values, basestring)): + # Fail hard if anyone is using the <0.17 calling style + raise ValueError('Filter values must be iterable: %r' % values) if key == 'tlid': - matches = filter(lambda ct: ct.tlid == value, matches) + matches = filter(lambda ct: ct.tlid in values, matches) else: matches = filter( - lambda ct: getattr(ct.track, key) == value, matches) + lambda ct: getattr(ct.track, key) in values, matches) return matches def move(self, start, end, to_position): @@ -435,7 +451,7 @@ class TracklistController(object): """Private method used by :class:`mopidy.core.PlaybackController`.""" if not self.consume: return False - self.remove(tlid=tl_track.tlid) + self.remove(tlid=[tl_track.tlid]) return True def _trigger_tracklist_changed(self): diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 6d84b25b..64cb88f9 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -21,6 +21,7 @@ class Extension(ext.Extension): schema['hostname'] = config.Hostname() schema['port'] = config.Port() schema['static_dir'] = config.Path(optional=True) + schema['zeroconf'] = config.String(optional=True) return schema def validate_environment(self): diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 5e49d2cd..4e3493d4 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -11,6 +11,7 @@ from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool from mopidy import models from mopidy.core import CoreListener +from mopidy.utils import zeroconf from . import ws @@ -22,6 +23,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): super(HttpFrontend, self).__init__() self.config = config self.core = core + + self.hostname = config['http']['hostname'] + self.port = config['http']['port'] + self.zeroconf_name = config['http']['zeroconf'] + self.zeroconf_service = None + self._setup_server() self._setup_websocket_plugin() app = self._create_app() @@ -30,8 +37,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): def _setup_server(self): cherrypy.config.update({ 'engine.autoreload_on': False, - 'server.socket_host': self.config['http']['hostname'], - 'server.socket_port': self.config['http']['port'], + 'server.socket_host': self.hostname, + 'server.socket_port': self.port, }) def _setup_websocket_plugin(self): @@ -88,7 +95,21 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): cherrypy.engine.start() logger.info('HTTP server running at %s', cherrypy.server.base()) + if self.zeroconf_name: + self.zeroconf_service = zeroconf.Zeroconf( + stype='_http._tcp', name=self.zeroconf_name, + host=self.hostname, port=self.port) + + if self.zeroconf_service.publish(): + logger.info('Registered HTTP with Zeroconf as "%s"', + self.zeroconf_service.name) + else: + logger.warning('Registering HTTP with Zeroconf failed.') + def on_stop(self): + if self.zeroconf_service: + self.zeroconf_service.unpublish() + logger.debug('Stopping HTTP server') cherrypy.engine.exit() logger.info('Stopped HTTP server') diff --git a/mopidy/frontends/http/ext.conf b/mopidy/frontends/http/ext.conf index 04fb1aae..fc239230 100644 --- a/mopidy/frontends/http/ext.conf +++ b/mopidy/frontends/http/ext.conf @@ -3,6 +3,7 @@ enabled = true hostname = 127.0.0.1 port = 6680 static_dir = +zeroconf = Mopidy HTTP server on $hostname [loglevels] cherrypy = warning diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 276be450..571d6455 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -23,6 +23,7 @@ class Extension(ext.Extension): schema['password'] = config.Secret(optional=True) schema['max_connections'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1) + schema['zeroconf'] = config.String(optional=True) return schema def validate_environment(self): diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 4d983b73..9df7ba07 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -7,7 +7,7 @@ import pykka from mopidy.core import CoreListener from mopidy.frontends.mpd import session -from mopidy.utils import encoding, network, process +from mopidy.utils import encoding, network, process, zeroconf logger = logging.getLogger('mopidy.frontends.mpd') @@ -15,12 +15,16 @@ logger = logging.getLogger('mopidy.frontends.mpd') class MpdFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(MpdFrontend, self).__init__() + hostname = network.format_hostname(config['mpd']['hostname']) - port = config['mpd']['port'] + self.hostname = hostname + self.port = config['mpd']['port'] + self.zeroconf_name = config['mpd']['zeroconf'] + self.zeroconf_service = None try: network.Server( - hostname, port, + self.hostname, self.port, protocol=session.MpdSession, protocol_kwargs={ 'config': config, @@ -34,9 +38,24 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): encoding.locale_decode(error)) sys.exit(1) - logger.info('MPD server running at [%s]:%s', hostname, port) + logger.info('MPD server running at [%s]:%s', self.hostname, self.port) + + def on_start(self): + if self.zeroconf_name: + self.zeroconf_service = zeroconf.Zeroconf( + stype='_mpd._tcp', name=self.zeroconf_name, + host=self.hostname, port=self.port) + + if self.zeroconf_service.publish(): + logger.info('Registered MPD with Zeroconf as "%s"', + self.zeroconf_service.name) + else: + logger.warning('Registering MPD with Zeroconf failed.') def on_stop(self): + if self.zeroconf_service: + self.zeroconf_service.unpublish() + process.stop_actors_by_class(session.MpdSession) def send_idle(self, subsystem): diff --git a/mopidy/frontends/mpd/ext.conf b/mopidy/frontends/mpd/ext.conf index bf77100c..c62c37ef 100644 --- a/mopidy/frontends/mpd/ext.conf +++ b/mopidy/frontends/mpd/ext.conf @@ -5,3 +5,4 @@ port = 6600 password = max_connections = 20 connection_timeout = 60 +zeroconf = Mopidy MPD server on $hostname diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 20452203..bc040067 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -76,7 +76,7 @@ def delete_range(context, start, end=None): if not tl_tracks: raise MpdArgError('Bad song index', command='delete') for (tlid, _) in tl_tracks: - context.core.tracklist.remove(tlid=tlid) + context.core.tracklist.remove(tlid=[tlid]) @handle_request(r'^delete "(?P\d+)"$') @@ -86,7 +86,7 @@ def delete_songpos(context, songpos): songpos = int(songpos) (tlid, _) = context.core.tracklist.slice( songpos, songpos + 1).get()[0] - context.core.tracklist.remove(tlid=tlid) + context.core.tracklist.remove(tlid=[tlid]) except IndexError: raise MpdArgError('Bad song index', command='delete') @@ -101,7 +101,7 @@ def deleteid(context, tlid): Deletes the song ``SONGID`` from the playlist """ tlid = int(tlid) - tl_tracks = context.core.tracklist.remove(tlid=tlid).get() + tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get() if not tl_tracks: raise MpdNoExistError('No such song', command='deleteid') @@ -157,7 +157,7 @@ def moveid(context, tlid, to): """ tlid = int(tlid) to = int(to) - tl_tracks = context.core.tracklist.filter(tlid=tlid).get() + tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: raise MpdNoExistError('No such song', command='moveid') position = context.core.tracklist.index(tl_tracks[0]).get() @@ -195,7 +195,7 @@ def playlistfind(context, tag, needle): - does not add quotes around the tag. """ if tag == 'filename': - tl_tracks = context.core.tracklist.filter(uri=needle).get() + tl_tracks = context.core.tracklist.filter(uri=[needle]).get() if not tl_tracks: return None position = context.core.tracklist.index(tl_tracks[0]).get() @@ -215,7 +215,7 @@ def playlistid(context, tlid=None): """ if tlid is not None: tlid = int(tlid) - tl_tracks = context.core.tracklist.filter(tlid=tlid).get() + tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: raise MpdNoExistError('No such song', command='playlistid') position = context.core.tracklist.index(tl_tracks[0]).get() @@ -380,8 +380,8 @@ def swapid(context, tlid1, tlid2): """ tlid1 = int(tlid1) tlid2 = int(tlid2) - tl_tracks1 = context.core.tracklist.filter(tlid=tlid1).get() - tl_tracks2 = context.core.tracklist.filter(tlid=tlid2).get() + tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get() + tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get() if not tl_tracks1 or not tl_tracks2: raise MpdNoExistError('No such song', command='swapid') position1 = context.core.tracklist.index(tl_tracks1[0]).get() diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b9289d8a..0d6bfe75 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -151,7 +151,7 @@ def playid(context, tlid): tlid = int(tlid) if tlid == -1: return _play_minus_one(context) - tl_tracks = context.core.tracklist.filter(tlid=tlid).get() + tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get() if not tl_tracks: raise MpdNoExistError('No such song', command='playid') return context.core.playback.play(tl_tracks[0]).get() diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 715aca1a..dee0fb96 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -12,9 +12,8 @@ def setup_logging(config, verbosity_level, save_debug_log): setup_console_logging(config, verbosity_level) if save_debug_log: setup_debug_logging_to_file(config) - if hasattr(logging, 'captureWarnings'): - # New in Python 2.7 - logging.captureWarnings(True) + + logging.captureWarnings(True) if config['logging']['config_file']: logging.config.fileConfig(config['logging']['config_file']) diff --git a/mopidy/utils/zeroconf.py b/mopidy/utils/zeroconf.py new file mode 100644 index 00000000..c1781867 --- /dev/null +++ b/mopidy/utils/zeroconf.py @@ -0,0 +1,81 @@ +from __future__ import unicode_literals + +import logging +import re +import socket +import string + +logger = logging.getLogger('mopidy.utils.zerconf') + +try: + import dbus +except ImportError: + dbus = None + +_AVAHI_IF_UNSPEC = -1 +_AVAHI_PROTO_UNSPEC = -1 +_AVAHI_PUBLISHFLAGS_NONE = 0 + + +def _filter_loopback_and_meta_addresses(host): + # TODO: see if we can find a cleaner way of handling this. + if re.search(r'(?