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 c611c8b6..3a3525c5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,18 +10,61 @@ v0.17.0 (UNRELEASED) **Core** +- The :class:`~mopidy.models.Track` model has grown fields for ``composers``, + ``performers``, ``genre``, and ``comment``. + - 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. + +- The scanner will now extract composers and performers, as well as genre, + bitrate, and comments. (Fixes: :issue:`577`) + +- 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`) + +- Add support for ``composer``, ``performer``, ``comment``, ``genre``, and + ``performer``. These tags can be used with ``list ...``, ``search ...``, and + ``find ...`` and their variants, and are supported in the ``any`` tag also + +**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`) **Sub-commands** 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 9dbcbe11..eb502221 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -34,7 +34,6 @@ Items on this list will probably not be supported in the near future. - Crossfade is not supported - Replay gain is not supported - ``stats`` does not provide any statistics -- ``list`` does not support listing tracks by genre - ``decoders`` does not provide information about available decoders The following items are currently not supported, but should be added in the @@ -97,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 435cac87..6eb5576c 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -121,6 +121,8 @@ def audio_data_to_track(data): albumartist_kwargs = {} album_kwargs = {} artist_kwargs = {} + composer_kwargs = {} + performer_kwargs = {} track_kwargs = {} def _retrieve(source_key, target_key, target): @@ -131,6 +133,22 @@ def audio_data_to_track(data): _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) _retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs) _retrieve(gst.TAG_ARTIST, 'name', artist_kwargs) + _retrieve(gst.TAG_COMPOSER, 'name', composer_kwargs) + _retrieve(gst.TAG_PERFORMER, 'name', performer_kwargs) + _retrieve(gst.TAG_ALBUM_ARTIST, 'name', albumartist_kwargs) + _retrieve(gst.TAG_TITLE, 'name', track_kwargs) + _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) + _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs) + _retrieve(gst.TAG_GENRE, 'genre', track_kwargs) + _retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs) + + # Following keys don't seem to have TAG_* constant. + _retrieve('comment', 'comment', track_kwargs) + _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) + _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) + _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) + _retrieve( + 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if gst.TAG_DATE in data and data[gst.TAG_DATE]: date = data[gst.TAG_DATE] @@ -141,18 +159,6 @@ def audio_data_to_track(data): else: track_kwargs['date'] = date.isoformat() - _retrieve(gst.TAG_TITLE, 'name', track_kwargs) - _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) - _retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs) - - # Following keys don't seem to have TAG_* constant. - _retrieve('album-artist', 'name', albumartist_kwargs) - _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) - _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) - _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) - _retrieve( - 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) - if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] @@ -160,6 +166,28 @@ 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)): + track_kwargs['composers'] = [Artist(name=artist) + for artist in composer_kwargs['name']] + else: + track_kwargs['composers'] = \ + [Artist(**composer_kwargs)] if composer_kwargs else '' + + if ('name' in performer_kwargs + and not isinstance(performer_kwargs['name'], basestring)): + track_kwargs['performers'] = [Artist(name=artist) + for artist in performer_kwargs['name']] + else: + track_kwargs['performers'] = \ + [Artist(**performer_kwargs)] if performer_kwargs else '' return Track(**track_kwargs) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 9791c518..da4e4bfd 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -79,15 +79,28 @@ class LocalLibraryProvider(base.BaseLibraryProvider): albumartist_filter = lambda t: any([ q == a.name for a in getattr(t.album, 'artists', [])]) + composer_filter = lambda t: any([ + q == a.name + for a in getattr(t, 'composers', [])]) + performer_filter = lambda t: any([ + q == a.name + for a in getattr(t, 'performers', [])]) track_no_filter = lambda t: q == t.track_no + genre_filter = lambda t: t.genre and q == t.genre date_filter = lambda t: q == t.date + comment_filter = lambda t: q == t.comment any_filter = lambda t: ( uri_filter(t) or track_name_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or - date_filter(t)) + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -99,10 +112,18 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(artist_filter, result_tracks) elif field == 'albumartist': result_tracks = filter(albumartist_filter, result_tracks) + elif field == 'composer': + result_tracks = filter(composer_filter, result_tracks) + elif field == 'performer': + result_tracks = filter(performer_filter, result_tracks) elif field == 'track_no': result_tracks = filter(track_no_filter, result_tracks) + elif field == 'genre': + result_tracks = filter(genre_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) + elif field == 'comment': + result_tracks = filter(comment_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: @@ -137,15 +158,28 @@ class LocalLibraryProvider(base.BaseLibraryProvider): albumartist_filter = lambda t: any([ q in a.name.lower() for a in getattr(t.album, 'artists', [])]) + composer_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t, 'composers', [])]) + performer_filter = lambda t: any([ + q in a.name.lower() + for a in getattr(t, 'performers', [])]) track_no_filter = lambda t: q == t.track_no + genre_filter = lambda t: t.genre and q in t.genre.lower() date_filter = lambda t: t.date and t.date.startswith(q) + comment_filter = lambda t: t.comment and q in t.comment.lower() any_filter = lambda t: ( uri_filter(t) or track_name_filter(t) or album_filter(t) or artist_filter(t) or albumartist_filter(t) or - date_filter(t)) + composer_filter(t) or + performer_filter(t) or + track_no_filter(t) or + genre_filter(t) or + date_filter(t) or + comment_filter(t)) if field == 'uri': result_tracks = filter(uri_filter, result_tracks) @@ -157,10 +191,18 @@ class LocalLibraryProvider(base.BaseLibraryProvider): result_tracks = filter(artist_filter, result_tracks) elif field == 'albumartist': result_tracks = filter(albumartist_filter, result_tracks) + elif field == 'composer': + result_tracks = filter(composer_filter, result_tracks) + elif field == 'performer': + result_tracks = filter(performer_filter, result_tracks) elif field == 'track_no': result_tracks = filter(track_no_filter, result_tracks) + elif field == 'genre': + result_tracks = filter(genre_filter, result_tracks) elif field == 'date': result_tracks = filter(date_filter, result_tracks) + elif field == 'comment': + result_tracks = filter(comment_filter, result_tracks) elif field == 'any': result_tracks = filter(any_filter, result_tracks) else: diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 3c6b8151..b9aad3e0 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -131,15 +131,27 @@ def _convert_mpd_data(data, tracks): if 'albumartist' in data: albumartist_kwargs['name'] = data['albumartist'] + if 'composer' in data: + track_kwargs['composers'] = [Artist(name=data['composer'])] + + if 'performer' in data: + track_kwargs['performers'] = [Artist(name=data['performer'])] + if 'album' in data: album_kwargs['name'] = data['album'] if 'title' in data: track_kwargs['name'] = data['title'] + if 'genre' in data: + track_kwargs['genre'] = data['genre'] + if 'date' in data: track_kwargs['date'] = data['date'] + if 'comment' in data: + track_kwargs['comment'] = data['comment'] + if 'musicbrainz_trackid' in data: track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] 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/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index e70815f8..c2035b15 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -10,8 +10,9 @@ from mopidy.frontends.mpd.protocol import handle_request, stored_playlists QUERY_RE = ( - r'(?P("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Dd]ate|[Ff]ile|' - r'[Ff]ilename|[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$') + r'(?P("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Cc]omment|' + r'[Cc]omposer|[Dd]ate|[Ff]ile|[Ff]ilename|[Gg]enre|[Pp]erformer|' + r'[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$') def _get_field(field, search_results): @@ -100,7 +101,10 @@ def find(context, mpd_query): return results = context.core.library.find_exact(**query).get() result_tracks = [] - if 'artist' not in query and 'albumartist' not in query: + if ('artist' not in query and + 'albumartist' not in query and + 'composer' not in query and + 'performer' not in query): result_tracks += [_artist_as_track(a) for a in _get_artists(results)] if 'album' not in query: result_tracks += [_album_as_track(a) for a in _get_albums(results)] @@ -127,8 +131,9 @@ def findadd(context, mpd_query): @handle_request( - r'^list "?(?P([Aa]rtist|[Aa]lbumartist|[Aa]lbum|[Dd]ate|' - r'[Gg]enre))"?( (?P.*))?$') + r'^list "?(?P([Aa]rtist|[Aa]lbumartist|[Aa]lbum|[Cc]omposer|' + r'[Dd]ate|[Gg]enre|[Pp]erformer))"?' + r'( (?P.*))?$') def list_(context, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -222,10 +227,14 @@ def list_(context, field, mpd_query=None): return _list_albumartist(context, query) elif field == 'album': return _list_album(context, query) + elif field == 'composer': + return _list_composer(context, query) + elif field == 'performer': + return _list_performer(context, query) elif field == 'date': return _list_date(context, query) elif field == 'genre': - pass # TODO We don't have genre in our internal data structures yet + return _list_genre(context, query) def _list_artist(context, query): @@ -258,6 +267,26 @@ def _list_album(context, query): return albums +def _list_composer(context, query): + composers = set() + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): + for composer in track.composers: + if composer.name: + composers.add(('Composer', composer.name)) + return composers + + +def _list_performer(context, query): + performers = set() + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): + for performer in track.performers: + if performer.name: + performers.add(('Performer', performer.name)) + return performers + + def _list_date(context, query): dates = set() results = context.core.library.find_exact(**query).get() @@ -267,6 +296,15 @@ def _list_date(context, query): return dates +def _list_genre(context, query): + genres = set() + results = context.core.library.find_exact(**query).get() + for track in _get_tracks(results): + if track.genre: + genres.add(('Genre', track.genre)) + return genres + + @handle_request(r'^listall$') @handle_request(r'^listall "(?P[^"]+)"$') def listall(context, uri=None): 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/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 236b814f..e103e170 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -37,8 +37,11 @@ def track_to_mpd_format(track, position=None): ('Artist', artists_to_mpd_format(track.artists)), ('Title', track.name or ''), ('Album', track.album and track.album.name or ''), - ('Date', track.date or ''), ] + + if track.date: + result.append(('Date', track.date)) + if track.album is not None and track.album.num_tracks != 0: result.append(('Track', '%d/%d' % ( track.track_no, track.album.num_tracks))) @@ -63,14 +66,31 @@ def track_to_mpd_format(track, position=None): artists = filter(lambda a: a.musicbrainz_id is not None, track.artists) if artists: result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id)) + + if track.composers: + result.append(('Composer', artists_to_mpd_format(track.composers))) + + if track.performers: + result.append(('Performer', artists_to_mpd_format(track.performers))) + + if track.genre: + result.append(('Genre', track.genre)) + + if track.disc_no: + result.append(('Disc', track.disc_no)) + + if track.comment: + result.append(('Comment', track.comment)) + if track.musicbrainz_id is not None: result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result MPD_KEY_ORDER = ''' - key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID - MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime + key file Time Artist Album AlbumArtist Title Track Genre Date Composer + Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID + MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime '''.split() @@ -165,7 +185,8 @@ def query_from_mpd_list_format(field, mpd_query): key = tokens[0].lower() value = tokens[1] tokens = tokens[2:] - if key not in ('artist', 'album', 'albumartist', 'date', 'genre'): + if key not in ('artist', 'album', 'albumartist', 'composer', + 'date', 'genre', 'performer'): raise MpdArgError('not able to parse args', command='list') if not value: raise ValueError @@ -188,9 +209,13 @@ MPD_SEARCH_QUERY_RE = re.compile(r""" [Aa]lbum | [Aa]rtist | [Aa]lbumartist + | [Cc]omment + | [Cc]omposer | [Dd]ate | [Ff]ile | [Ff]ilename + | [Gg]enre + | [Pp]erformer | [Tt]itle | [Tt]rack | [Aa]ny @@ -207,9 +232,13 @@ MPD_SEARCH_QUERY_PART_RE = re.compile(r""" [Aa]lbum | [Aa]rtist | [Aa]lbumartist + | [Cc]omment + | [Cc]omposer | [Dd]ate | [Ff]ile | [Ff]ilename + | [Gg]enre + | [Pp]erformer | [Tt]itle | [Tt]rack | [Aa]ny diff --git a/mopidy/models.py b/mopidy/models.py index 3fc92bb4..04d71591 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -219,6 +219,12 @@ class Track(ImmutableObject): :type artists: list of :class:`Artist` :param album: track album :type album: :class:`Album` + :param composers: track composers + :type composers: string + :param performers: track performers + :type performers: string + :param genre: track genre + :type genre: string :param track_no: track number in album :type track_no: integer :param disc_no: disc number in album @@ -229,6 +235,8 @@ class Track(ImmutableObject): :type length: integer :param bitrate: bitrate in kbit/s :type bitrate: integer + :param comment: track comment + :type comment: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string :param last_modified: Represents last modification time @@ -247,6 +255,15 @@ class Track(ImmutableObject): #: The track :class:`Album`. Read-only. album = None + #: A set of track composers. Read-only. + composers = frozenset() + + #: A set of track performers`. Read-only. + performers = frozenset() + + #: The track genre. Read-only. + genre = None + #: The track number in the album. Read-only. track_no = 0 @@ -262,6 +279,9 @@ class Track(ImmutableObject): #: The track's bitrate in kbit/s. Read-only. bitrate = None + #: The track comment. Read-only. + comment = None + #: The MusicBrainz ID of the track. Read-only. musicbrainz_id = None @@ -272,6 +292,8 @@ class Track(ImmutableObject): def __init__(self, *args, **kwargs): self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) + self.__dict__['composers'] = frozenset(kwargs.pop('composers', [])) + self.__dict__['performers'] = frozenset(kwargs.pop('performers', [])) super(Track, self).__init__(*args, **kwargs) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index f7877614..896fd707 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -38,9 +38,7 @@ def setup_logging(config, verbosity_level, save_debug_log): 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'(?