diff --git a/control/mpdmeta.py b/control/mpdmeta.py
new file mode 100755
index 00000000..f26578d8
--- /dev/null
+++ b/control/mpdmeta.py
@@ -0,0 +1,1508 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# Authors: Jean-Philippe Braun ,
+# Mantas Mikulėnas
+# Based on mpDris from: Erik Karlsson
+# Some bits taken from quodlibet mpris plugin by
+
+
+from configparser import ConfigParser
+import os
+import sys
+import re
+import shlex
+import socket
+import getopt
+import mpd
+import dbus
+import dbus.service
+from dbus.mainloop.glib import DBusGMainLoop
+import logging
+import gettext
+import time
+import tempfile
+import base64
+import musicbrainzngs
+
+__version__ = "@version@"
+__git_version__ = "@gitversion@"
+
+musicbrainzngs.set_useragent(
+ "python-musicbrainzngs-example",
+ "0.1",
+ "https://github.com/alastair/python-musicbrainzngs/",
+)
+
+try:
+ import mutagen
+except ImportError:
+ mutagen = None
+
+try:
+ import gi
+ gi.require_version('Notify', '0.7')
+except (ImportError, ValueError):
+ pass
+
+using_gi_glib = False
+
+try:
+ from gi.repository import GLib
+ using_gi_glib = True
+except ImportError:
+ import glib as GLib
+
+using_gi_notify = False
+using_old_notify = False
+
+try:
+ from gi.repository import Notify
+ using_gi_notify = True
+except ImportError:
+ try:
+ import pynotify
+ using_old_notify = True
+ except ImportError:
+ pass
+
+_ = gettext.gettext
+
+identity = "Music Player Daemon"
+
+params = {
+ 'progname': sys.argv[0],
+ # Connection
+ 'host': None,
+ 'port': None,
+ 'password': None,
+ 'bus_name': None,
+ # Library
+ 'music_dir': '',
+ 'cover_regex': None,
+ # Bling
+ 'mmkeys': True,
+ 'notify': (using_gi_notify or using_old_notify),
+ 'notify_urgency': 0,
+}
+
+defaults = {
+ # Connection
+ 'host': 'localhost',
+ 'port': 6600,
+ 'password': None,
+ 'bus_name': None,
+ # Library
+ 'cover_regex': r'^(album|cover|\.?folder|front).*\.(gif|jpeg|jpg|png)$',
+}
+
+notification = None
+
+# MPRIS allowed metadata tags
+allowed_tags = {
+ 'mpris:trackid': dbus.ObjectPath,
+ 'mpris:length': dbus.Int64,
+ 'mpris:artUrl': str,
+ 'xesam:album': str,
+ 'xesam:albumArtist': list,
+ 'xesam:artist': list,
+ 'xesam:asText': str,
+ 'xesam:audioBPM': int,
+ 'xesam:comment': list,
+ 'xesam:composer': list,
+ 'xesam:contentCreated': str,
+ 'xesam:discNumber': int,
+ 'xesam:firstUsed': str,
+ 'xesam:genre': list,
+ 'xesam:lastUsed': str,
+ 'xesam:lyricist': str,
+ 'xesam:title': str,
+ 'xesam:trackNumber': int,
+ 'xesam:url': str,
+ 'xesam:useCount': int,
+ 'xesam:userRating': float,
+}
+
+# python dbus bindings don't include annotations and properties
+MPRIS2_INTROSPECTION = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+# Default url handlers if MPD doesn't support 'urlhandlers' command
+urlhandlers = ['http://']
+downloaded_covers = ['~/.covers/%s-%s.jpg']
+
+
+class MPDWrapper(object):
+ """ Wrapper of mpd.MPDClient to handle socket
+ errors and similar
+ """
+
+ def __init__(self, params):
+ self.client = mpd.MPDClient()
+
+ self._dbus = dbus
+ self._params = params
+ self._dbus_service = None
+
+ self._can_single = False
+ self._can_idle = False
+
+ self._errors = 0
+ self._poll_id = None
+ self._watch_id = None
+ self._idling = False
+
+ self._status = {
+ 'state': None,
+ 'volume': None,
+ 'random': None,
+ 'repeat': None,
+ }
+ self._metadata = {}
+ self._temp_song_url = None
+ self._temp_cover = None
+ self._position = 0
+ self._time = 0
+
+ self._bus = dbus.SessionBus()
+ if self._params['mmkeys']:
+ self.setup_mediakeys()
+
+ def run(self):
+ """
+ Try to connect to MPD; retry every 5 seconds on failure.
+ """
+ if self.my_connect():
+ GLib.timeout_add_seconds(5, self.my_connect)
+ return False
+ else:
+ return True
+
+ @property
+ def connected(self):
+ return self.client._sock is not None
+
+ def my_connect(self):
+ """ Init MPD connection """
+ try:
+ self._idling = False
+ self._can_idle = False
+ self._can_single = False
+
+ self.client.connect(self._params['host'], self._params['port'])
+ if self._params['password']:
+ try:
+ self.client.password(self._params['password'])
+ except mpd.CommandError as e:
+ logger.error(e)
+ sys.exit(1)
+
+ commands = self.commands()
+ # added in 0.11
+ if 'urlhandlers' in commands:
+ global urlhandlers
+ urlhandlers = self.urlhandlers()
+ # added in 0.14
+ if 'idle' in commands:
+ self._can_idle = True
+ # added in 0.15
+ if 'single' in commands:
+ self._can_single = True
+
+ if self._errors > 0:
+ notification.notify(identity, _('Reconnected'))
+ logger.info('Reconnected to MPD server.')
+ else:
+ logger.debug('Connected to MPD server.')
+
+ # Make the socket non blocking to detect deconnections
+ self.client._sock.settimeout(5.0)
+ # Export our DBUS service
+ if not self._dbus_service:
+ self._dbus_service = MPRISInterface(self._params)
+ else:
+ # Add our service to the session bus
+ #self._dbus_service.add_to_connection(dbus.SessionBus(),
+ # '/org/mpris/MediaPlayer2')
+ self._dbus_service.acquire_name()
+
+ # Init internal state to throw events at start
+ self.init_state()
+
+ # Add periodic status check for sending MPRIS events
+ if not self._poll_id:
+ interval = 15 if self._can_idle else 1
+ self._poll_id = GLib.timeout_add_seconds(interval,
+ self.timer_callback)
+ if self._can_idle and not self._watch_id:
+ if using_gi_glib:
+ self._watch_id = GLib.io_add_watch(self,
+ GLib.PRIORITY_DEFAULT,
+ GLib.IO_IN | GLib.IO_HUP,
+ self.socket_callback)
+ else:
+ self._watch_id = GLib.io_add_watch(self,
+ GLib.IO_IN | GLib.IO_HUP,
+ self.socket_callback)
+ # Reset error counter
+ self._errors = 0
+
+ self.timer_callback()
+ self.idle_enter()
+ # Return False to stop trying to connect
+ return False
+ except socket.error as e:
+ self._errors += 1
+ if self._errors < 6:
+ logger.error('Could not connect to MPD: %s' % e)
+ if self._errors == 6:
+ logger.info('Continue to connect but going silent')
+ return True
+
+ def reconnect(self):
+ logger.warning("Disconnected")
+ notification.notify(identity, _('Disconnected'), 'error')
+
+ # Release the DBus name and disconnect from bus
+ if self._dbus_service is not None:
+ self._dbus_service.release_name()
+ #self._dbus_service.remove_from_connection()
+
+ # Stop monitoring
+ if self._poll_id:
+ GLib.source_remove(self._poll_id)
+ self._poll_id = None
+ if self._watch_id:
+ GLib.source_remove(self._watch_id)
+ self._watch_id = None
+
+ # Clean mpd client state
+ try:
+ self.disconnect()
+ except:
+ self.disconnect()
+
+ # Try to reconnect
+ self.run()
+
+ def disconnect(self):
+ self._temp_song_url = None
+ if self._temp_cover:
+ self._temp_cover.close()
+ self._temp_cover = None
+
+ self.client.disconnect()
+
+ def init_state(self):
+ # Get current state
+ self._status = self.status()
+ # Invalid some fields to throw events at start
+ self._status['state'] = 'invalid'
+ self._status['songid'] = '-1'
+ self._position = 0
+
+ def idle_enter(self):
+ if not self._can_idle:
+ return False
+ if not self._idling:
+ # NOTE: do not use MPDClient.idle(), which waits for an event
+ self._write_command("idle", [])
+ self._idling = True
+ logger.debug("Entered idle")
+ return True
+ else:
+ logger.warning("Nested idle_enter()!")
+ return False
+
+ def idle_leave(self):
+ if not self._can_idle:
+ return False
+ if self._idling:
+ # NOTE: don't use noidle() or _execute() to avoid infinite recursion
+ self._write_command("noidle", [])
+ self._fetch_object()
+ self._idling = False
+ logger.debug("Left idle")
+ return True
+ else:
+ return False
+
+ ## Events
+
+ def timer_callback(self):
+ try:
+ was_idle = self.idle_leave()
+ except (socket.error, mpd.MPDError, socket.timeout):
+ self.reconnect()
+ return False
+ self._update_properties(force=False)
+ if was_idle:
+ self.idle_enter()
+ return True
+
+ def socket_callback(self, fd, event):
+ logger.debug("Socket event %r on fd %r" % (event, fd))
+ if event & GLib.IO_HUP:
+ self.reconnect()
+ return True
+ elif event & GLib.IO_IN:
+ if self._idling:
+ self._idling = False
+ data = fd._fetch_objects("changed")
+ logger.debug("Idle events: %r" % data)
+ updated = False
+ for item in data:
+ subsystem = item["changed"]
+ # subsystem list:
+ if subsystem in ("player", "mixer", "options", "playlist"):
+ if not updated:
+ self._update_properties(force=True)
+ updated = True
+ self.idle_enter()
+ return True
+
+ def mediakey_callback(self, appname, key):
+ """ GNOME media key handler """
+ logger.debug('Got GNOME mmkey "%s" for "%s"' % (key, appname))
+ if key == 'Play':
+ if self._status['state'] == 'play':
+ self.pause(1)
+ self.notify_about_state('pause')
+ else:
+ self.play()
+ self.notify_about_state('play')
+ elif key == 'Next':
+ self.next()
+ elif key == 'Previous':
+ self.previous()
+ elif key == 'Stop':
+ self.stop()
+ self.notify_about_state('stop')
+
+ def last_currentsong(self):
+ return self._currentsong.copy()
+
+ @property
+ def metadata(self):
+ return self._metadata
+
+ def update_metadata(self):
+ """
+ Translate metadata returned by MPD to the MPRIS v2 syntax.
+ http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata
+ """
+
+ mpd_meta = self.last_currentsong()
+ self._metadata = {}
+
+ for tag in ('album', 'title'):
+ if tag in mpd_meta:
+ self._metadata['xesam:%s' % tag] = mpd_meta[tag]
+
+ if 'id' in mpd_meta:
+ self._metadata['mpris:trackid'] = "/org/mpris/MediaPlayer2/Track/%s" % \
+ mpd_meta['id']
+
+ if 'time' in mpd_meta:
+ self._metadata['mpris:length'] = int(mpd_meta['time']) * 1000000
+
+ if 'date' in mpd_meta:
+ self._metadata['xesam:contentCreated'] = mpd_meta['date'][0:4]
+
+ if 'track' in mpd_meta:
+ # TODO: Is it even *possible* for mpd_meta['track'] to be a list?
+ if type(mpd_meta['track']) == list and len(mpd_meta['track']) > 0:
+ track = str(mpd_meta['track'][0])
+ else:
+ track = str(mpd_meta['track'])
+
+ m = re.match('^([0-9]+)', track)
+ if m:
+ self._metadata['xesam:trackNumber'] = int(m.group(1))
+ # Ensure the integer is signed 32bit
+ if self._metadata['xesam:trackNumber'] & 0x80000000:
+ self._metadata['xesam:trackNumber'] += -0x100000000
+ else:
+ self._metadata['xesam:trackNumber'] = 0
+
+ if 'disc' in mpd_meta:
+ # TODO: Same as above. When is it a list?
+ if type(mpd_meta['disc']) == list and len(mpd_meta['disc']) > 0:
+ disc = str(mpd_meta['disc'][0])
+ else:
+ disc = str(mpd_meta['disc'])
+
+ m = re.match('^([0-9]+)', disc)
+ if m:
+ self._metadata['xesam:discNumber'] = int(m.group(1))
+
+ if 'artist' in mpd_meta:
+ if type(mpd_meta['artist']) == list:
+ self._metadata['xesam:artist'] = mpd_meta['artist']
+ else:
+ self._metadata['xesam:artist'] = [mpd_meta['artist']]
+
+ if 'composer' in mpd_meta:
+ if type(mpd_meta['composer']) == list:
+ self._metadata['xesam:composer'] = mpd_meta['composer']
+ else:
+ self._metadata['xesam:composer'] = [mpd_meta['composer']]
+
+ # Stream: populate some missings tags with stream's name
+ if 'name' in mpd_meta:
+ if 'xesam:title' not in self._metadata:
+ self._metadata['xesam:title'] = mpd_meta['name']
+ elif 'xesam:album' not in self._metadata:
+ self._metadata['xesam:album'] = mpd_meta['name']
+
+ if 'file' in mpd_meta:
+ song_url = mpd_meta['file']
+ if not any([song_url.startswith(prefix) for prefix in urlhandlers]):
+ song_url = os.path.join(self._params['music_dir'], song_url)
+ self._metadata['xesam:url'] = song_url
+ cover = self.find_cover(song_url)
+ if cover:
+ self._metadata['mpris:artUrl'] = cover
+
+ # Cast self._metadata to the correct type, or discard it
+ for key, value in self._metadata.items():
+ try:
+ self._metadata[key] = allowed_tags[key](value)
+ except ValueError:
+ del self._metadata[key]
+ logger.error("Can't cast value %r to %s" %
+ (value, allowed_tags[key]))
+
+ if 'xesam:title' in self._metadata and 'xesam:album' in self._metadata:
+ result = musicbrainzngs.search_releases(artist=self._metadata['xesam:title'], release=self._metadata['xesam:album'],
+ limit=1)
+ if result['release-list']:
+ self._metadata['mpris:artUrl'] = f"http://coverartarchive.org/release/{result['release-list'][0]['id']}/front-250"
+ print(self._metadata['mpris:artUrl'])
+
+ def notify_about_track(self, meta, state='play'):
+ uri = 'sound'
+ if 'mpris:artUrl' in meta:
+ uri = meta['mpris:artUrl']
+
+ title = 'Unknown Title'
+ if 'xesam:title' in meta:
+ title = meta['xesam:title']
+ elif 'xesam:url' in meta:
+ title = meta['xesam:url'].split('/')[-1]
+
+ artist = 'Unknown Artist'
+ if 'xesam:artist' in meta:
+ artist = ", ".join(meta['xesam:artist'])
+
+ body = _('by %s') % artist
+
+ if state == 'pause':
+ uri = 'media-playback-pause-symbolic'
+ body += ' (%s)' % _('Paused')
+
+ notification.notify(title, body, uri)
+
+ def notify_about_state(self, state):
+ if state == 'stop':
+ notification.notify(identity, _('Stopped'), 'media-playback-stop-symbolic')
+ else:
+ self.notify_about_track(self.metadata, state)
+
+ def find_cover(self, song_url):
+ if song_url.startswith('file://'):
+ song_path = song_url[7:]
+ song_dir = os.path.dirname(song_path)
+
+ # Try existing temporary file
+ if self._temp_cover:
+ if song_url == self._temp_song_url:
+ logger.debug("find_cover: Reusing old image at %r" % self._temp_cover.name)
+ return 'file://' + self._temp_cover.name
+ else:
+ logger.debug("find_cover: Cleaning up old image at %r" % self._temp_cover.name)
+ self._temp_song_url = None
+ self._temp_cover.close()
+
+ # Search for embedded cover art
+ song = None
+ if mutagen and os.path.exists(song_path):
+ try:
+ song = mutagen.File(song_path)
+ except mutagen.MutagenError as e:
+ logger.error("Can't extract covers from %r: %r" % (song_path, e))
+ if song is not None:
+ if song.tags:
+ # present but null for some file types
+ for tag in song.tags.keys():
+ if tag.startswith("APIC:"):
+ for pic in song.tags.getall(tag):
+ if pic.type == mutagen.id3.PictureType.COVER_FRONT:
+ self._temp_song_url = song_url
+ return self._create_temp_cover(pic)
+ if hasattr(song, "pictures"):
+ # FLAC
+ for pic in song.pictures:
+ if pic.type == mutagen.id3.PictureType.COVER_FRONT:
+ self._temp_song_url = song_url
+ return self._create_temp_cover(pic)
+ elif song.tags and 'metadata_block_picture' in song.tags:
+ # OGG
+ for b64_data in song.get("metadata_block_picture", []):
+ try:
+ data = base64.b64decode(b64_data)
+ except (TypeError, ValueError):
+ continue
+
+ try:
+ pic = mutagen.flac.Picture(data)
+ except mutagen.flac.error:
+ continue
+
+ if pic.type == mutagen.id3.PictureType.COVER_FRONT:
+ self._temp_song_url = song_url
+ return self._create_temp_cover(pic)
+
+ # Look in song directory for common album cover files
+ if os.path.exists(song_dir) and os.path.isdir(song_dir):
+ for f in os.listdir(song_dir):
+ if self._params['cover_regex'].match(f):
+ return 'file://' + os.path.join(song_dir, f)
+
+ # Search the shared cover directories
+ if 'xesam:artist' in self._metadata and 'xesam:album' in self._metadata:
+ artist = ",".join(self._metadata['xesam:artist'])
+ album = self._metadata['xesam:album']
+ for template in downloaded_covers:
+ f = os.path.expanduser(template % (artist, album))
+ if os.path.exists(f):
+ return 'file://' + f
+ return None
+
+ def _create_temp_cover(self, pic):
+ """
+ Create a temporary file containing pic, and return it's location
+ """
+ extension = {'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif'}
+
+ self._temp_cover = tempfile.NamedTemporaryFile(prefix='cover-', suffix=extension.get(pic.mime, '.jpg'))
+ self._temp_cover.write(pic.data)
+ self._temp_cover.flush()
+ logger.debug("find_cover: Storing embedded image at %r" % self._temp_cover.name)
+ return 'file://' + self._temp_cover.name
+
+ def last_status(self):
+ if time.time() - self._time >= 2:
+ self.timer_callback()
+ return self._status.copy()
+
+ def _update_properties(self, force=False):
+ old_status = self._status
+ old_position = self._position
+ old_time = self._time
+ self._currentsong = self.currentsong()
+ new_status = self.status()
+ self._time = new_time = int(time.time())
+
+ if not new_status:
+ logger.debug("_update_properties: failed to get new status")
+ return
+
+ self._status = new_status
+ logger.debug("_update_properties: current song = %r" % self._currentsong)
+ logger.debug("_update_properties: current status = %r" % self._status)
+
+ if 'elapsed' in new_status:
+ new_position = float(new_status['elapsed'])
+ elif 'time' in new_status:
+ new_position = int(new_status['time'].split(':')[0])
+ else:
+ new_position = 0
+
+ self._position = new_position
+
+ # "player" subsystem
+
+ if old_status['state'] != new_status['state']:
+ self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
+ 'PlaybackStatus')
+
+ if not force:
+ old_id = old_status.get('songid', None)
+ new_id = new_status.get('songid', None)
+ force = (old_id != new_id)
+
+ if not force:
+ if new_status['state'] == 'play':
+ expected_position = old_position + (new_time - old_time)
+ else:
+ expected_position = old_position
+ if abs(new_position - expected_position) > 0.6:
+ logger.debug("Expected pos %r, actual %r, diff %r" % (
+ expected_position, new_position, new_position - expected_position))
+ logger.debug("Old position was %r at %r (%r seconds ago)" % (
+ old_position, old_time, new_time - old_time))
+ self._dbus_service.Seeked(new_position * 1000000)
+
+ else:
+ # Update current song metadata
+ old_meta = self._metadata.copy()
+ self.update_metadata()
+ new_meta = self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
+ 'Metadata')
+
+ if self._params['notify'] and new_status['state'] != 'stop':
+ if old_meta.get('xesam:artist', None) != new_meta.get('xesam:artist', None) \
+ or old_meta.get('xesam:album', None) != new_meta.get('xesam:album', None) \
+ or old_meta.get('xesam:title', None) != new_meta.get('xesam:title', None) \
+ or old_meta.get('xesam:url', None) != new_meta.get('xesam:url', None):
+ self.notify_about_track(new_meta, new_status['state'])
+
+ # "mixer" subsystem
+ if old_status.get('volume') != new_status.get('volume'):
+ self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
+ 'Volume')
+
+ # "options" subsystem
+ # also triggered if consume, crossfade or ReplayGain are updated
+
+ if old_status['random'] != new_status['random']:
+ self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
+ 'Shuffle')
+
+ if (old_status['repeat'] != new_status['repeat']
+ or old_status.get('single', 0) != new_status.get('single', 0)):
+ self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
+ 'LoopStatus')
+
+ if ("nextsongid" in old_status) != ("nextsongid" in new_status):
+ self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
+ 'CanGoNext')
+
+ ## Media keys
+
+ def setup_mediakeys(self):
+ self.register_mediakeys()
+ self._dbus_obj = self._bus.get_object("org.freedesktop.DBus",
+ "/org/freedesktop/DBus")
+ self._dbus_obj.connect_to_signal("NameOwnerChanged",
+ self.gsd_name_owner_changed_callback,
+ arg0="org.gnome.SettingsDaemon")
+
+ def register_mediakeys(self):
+ try:
+ try:
+ gsd_object = self._bus.get_object("org.gnome.SettingsDaemon.MediaKeys",
+ "/org/gnome/SettingsDaemon/MediaKeys")
+ except:
+ # Try older name.
+ gsd_object = self._bus.get_object("org.gnome.SettingsDaemon",
+ "/org/gnome/SettingsDaemon/MediaKeys")
+ gsd_object.GrabMediaPlayerKeys("mpDris2", 0,
+ dbus_interface="org.gnome.SettingsDaemon.MediaKeys")
+ except:
+ logger.warning("Failed to connect to GNOME Settings Daemon. Media keys won't work.")
+ else:
+ self._bus.remove_signal_receiver(self.mediakey_callback)
+ gsd_object.connect_to_signal("MediaPlayerKeyPressed", self.mediakey_callback)
+
+ def gsd_name_owner_changed_callback(self, bus_name, old_owner, new_owner):
+ if bus_name == "org.gnome.SettingsDaemon" and new_owner != "":
+ def reregister():
+ logger.debug("Re-registering with GNOME Settings Daemon (owner %s)" % new_owner)
+ self.register_mediakeys()
+ return False
+ # Timeout is necessary since g-s-d takes some time to load all plugins.
+ GLib.timeout_add(600, reregister)
+
+ ## Compatibility functions
+
+ # Fedora 17 still has python-mpd 0.2, which lacks fileno().
+ if hasattr(mpd.MPDClient, "fileno"):
+ def fileno(self):
+ return self.client.fileno()
+ else:
+ def fileno(self):
+ if not self.connected:
+ raise mpd.ConnectionError("Not connected")
+ return self.client._sock.fileno()
+
+ ## Access to python-mpd internal APIs
+
+ # We use _write_command("idle") to manually enter idle mode, as it has no
+ # immediate response to fetch.
+ #
+ # Similarly, we use _write_command("noidle") + _fetch_object() to manually
+ # leave idle mode (for reasons I don't quite remember). The result of
+ # _fetch_object() is not used.
+
+ if hasattr(mpd.MPDClient, "_write_command"):
+ def _write_command(self, *args):
+ return self.client._write_command(*args)
+ elif hasattr(mpd.MPDClient, "_writecommand"):
+ def _write_command(self, *args):
+ return self.client._writecommand(*args)
+
+ if hasattr(mpd.MPDClient, "_parse_objects_direct"):
+ def _fetch_object(self):
+ objs = self._fetch_objects()
+ if not objs:
+ return {}
+ return objs[0]
+ elif hasattr(mpd.MPDClient, "_fetch_object"):
+ def _fetch_object(self):
+ return self.client._fetch_object()
+ elif hasattr(mpd.MPDClient, "_getobject"):
+ def _fetch_object(self):
+ return self.client._getobject()
+
+ # We use _fetch_objects("changed") to receive unprompted idle events on
+ # socket activity.
+
+ if hasattr(mpd.MPDClient, "_parse_objects_direct"):
+ def _fetch_objects(self, *args):
+ return list(self.client._parse_objects_direct(self.client._read_lines(), *args))
+ elif hasattr(mpd.MPDClient, "_fetch_objects"):
+ def _fetch_objects(self, *args):
+ return self.client._fetch_objects(*args)
+ elif hasattr(mpd.MPDClient, "_getobjects"):
+ def _fetch_objects(self, *args):
+ return self.client._getobjects(*args)
+
+ # Wrapper to catch connection errors when calling mpd client methods.
+
+ def __getattr__(self, attr):
+ if attr[0] == "_":
+ raise AttributeError(attr)
+ return lambda *a, **kw: self.call(attr, *a, **kw)
+
+ def call(self, command, *args):
+ fn = getattr(self.client, command)
+ try:
+ was_idle = self.idle_leave()
+ logger.debug("Sending command %r (was idle? %r)" % (command, was_idle))
+ r = fn(*args)
+ if was_idle:
+ self.idle_enter()
+ return r
+ except (socket.error, mpd.MPDError, socket.timeout) as ex:
+ logger.debug("Trying to reconnect, got %r" % ex)
+ self.reconnect()
+ return False
+
+
+class NotifyWrapper(object):
+
+ def __init__(self, params):
+ self._notification = None
+ self._enabled = True
+
+ if params["notify"]:
+ self._notification = self._bootstrap_notifications()
+ if not self._notification:
+ logger.error("No notification service provider could be found; disabling notifications")
+ else:
+ self._enabled = False
+
+ def _bootstrap_notifications(self):
+ # Check if someone is providing the notification service
+ bus = dbus.SessionBus()
+ try:
+ bus.get_name_owner("org.freedesktop.Notifications")
+ except dbus.exceptions.DBusException:
+ return None
+
+ notif = None
+
+ # Bootstrap whatever notifications system we are using
+ if using_gi_notify:
+ logger.debug("Initializing GObject.Notify")
+ if Notify.init(identity):
+ notif = Notify.Notification()
+ notif.set_hint("desktop-entry", GLib.Variant("s", "mpdris2"))
+ notif.set_hint("transient", GLib.Variant("b", True))
+ else:
+ logger.error("Failed to init libnotify; disabling notifications")
+ elif using_old_notify:
+ logger.debug("Initializing old pynotify")
+ if pynotify.init(identity):
+ notif = pynotify.Notification("", "", "")
+ notif.set_hint("desktop-entry", "mpdris2")
+ notif.set_hint("transient", True)
+ else:
+ logger.error("Failed to init libnotify; disabling notifications")
+
+ return notif
+
+ def notify(self, title, body, uri=''):
+ if not self._enabled:
+ return
+
+ # If we did not yet manage to get a notification service,
+ # try again
+ if not self._notification:
+ logger.info('Retrying to acquire a notification service provider...')
+ self._notification = self._bootstrap_notifications()
+ if self._notification:
+ logger.info('Notification service provider acquired!')
+
+ if self._notification:
+ try:
+ self._notification.set_urgency(params['notify_urgency'])
+ self._notification.update(title, body, uri)
+ self._notification.show()
+ except GLib.GError as err:
+ logger.error("Failed to show notification: %s" % err)
+
+
+class MPRISInterface(dbus.service.Object):
+ ''' The base object of an MPRIS player '''
+
+ __path = "/org/mpris/MediaPlayer2"
+ __introspect_interface = "org.freedesktop.DBus.Introspectable"
+ __prop_interface = dbus.PROPERTIES_IFACE
+
+ def __init__(self, params):
+ dbus.service.Object.__init__(self, dbus.SessionBus(),
+ MPRISInterface.__path)
+ self._params = params or {}
+ self._name = self._params["bus_name"] or "org.mpris.MediaPlayer2.mpd"
+ if not self._name.startswith("org.mpris.MediaPlayer2."):
+ logger.warn("Configured bus name %r is outside MPRIS2 namespace" % self._name)
+
+ self._bus = dbus.SessionBus()
+ self._uname = self._bus.get_unique_name()
+ self._dbus_obj = self._bus.get_object("org.freedesktop.DBus",
+ "/org/freedesktop/DBus")
+ self._dbus_obj.connect_to_signal("NameOwnerChanged",
+ self._name_owner_changed_callback,
+ arg0=self._name)
+
+ self.acquire_name()
+
+ def _name_owner_changed_callback(self, name, old_owner, new_owner):
+ if name == self._name and old_owner == self._uname and new_owner != "":
+ try:
+ pid = self._dbus_obj.GetConnectionUnixProcessID(new_owner)
+ except:
+ pid = None
+ logger.info("Replaced by %s (PID %s)" % (new_owner, pid or "unknown"))
+ loop.quit()
+
+ def acquire_name(self):
+ self._bus_name = dbus.service.BusName(self._name,
+ bus=self._bus,
+ allow_replacement=True,
+ replace_existing=True)
+
+ def release_name(self):
+ if hasattr(self, "_bus_name"):
+ del self._bus_name
+
+ __root_interface = "org.mpris.MediaPlayer2"
+ __root_props = {
+ "CanQuit": (False, None),
+ "CanRaise": (False, None),
+ "DesktopEntry": ("mpdris2", None),
+ "HasTrackList": (False, None),
+ "Identity": (identity, None),
+ "SupportedUriSchemes": (dbus.Array(signature="s"), None),
+ "SupportedMimeTypes": (dbus.Array(signature="s"), None)
+ }
+
+ def __get_playback_status():
+ status = mpd_wrapper.last_status()
+ return {'play': 'Playing', 'pause': 'Paused', 'stop': 'Stopped'}[status['state']]
+
+ def __set_loop_status(value):
+ if value == "Playlist":
+ mpd_wrapper.repeat(1)
+ if mpd_wrapper._can_single:
+ mpd_wrapper.single(0)
+ elif value == "Track":
+ if mpd_wrapper._can_single:
+ mpd_wrapper.repeat(1)
+ mpd_wrapper.single(1)
+ elif value == "None":
+ mpd_wrapper.repeat(0)
+ if mpd_wrapper._can_single:
+ mpd_wrapper.single(0)
+ else:
+ raise dbus.exceptions.DBusException("Loop mode %r not supported" %
+ value)
+ return
+
+ def __get_loop_status():
+ status = mpd_wrapper.last_status()
+ if int(status['repeat']) == 1:
+ if int(status.get('single', 0)) == 1:
+ return "Track"
+ else:
+ return "Playlist"
+ else:
+ return "None"
+
+ def __set_shuffle(value):
+ mpd_wrapper.random(value)
+ return
+
+ def __get_shuffle():
+ if int(mpd_wrapper.last_status()['random']) == 1:
+ return True
+ else:
+ return False
+
+ def __get_metadata():
+ return dbus.Dictionary(mpd_wrapper.metadata, signature='sv')
+
+ def __get_volume():
+ vol = float(mpd_wrapper.last_status().get('volume', 0))
+ if vol > 0:
+ return vol / 100.0
+ else:
+ return 0.0
+
+ def __set_volume(value):
+ if value >= 0 and value <= 1:
+ mpd_wrapper.setvol(int(value * 100))
+ return
+
+ def __get_position():
+ status = mpd_wrapper.last_status()
+ if 'time' in status:
+ current, end = status['time'].split(':')
+ return dbus.Int64((int(current) * 1000000))
+ else:
+ return dbus.Int64(0)
+
+ __player_interface = "org.mpris.MediaPlayer2.Player"
+ __player_props = {
+ "PlaybackStatus": (__get_playback_status, None),
+ "LoopStatus": (__get_loop_status, __set_loop_status),
+ "Rate": (1.0, None),
+ "Shuffle": (__get_shuffle, __set_shuffle),
+ "Metadata": (__get_metadata, None),
+ "Volume": (__get_volume, __set_volume),
+ "Position": (__get_position, None),
+ "MinimumRate": (1.0, None),
+ "MaximumRate": (1.0, None),
+ "CanGoNext": (True, None),
+ "CanGoPrevious": (True, None),
+ "CanPlay": (True, None),
+ "CanPause": (True, None),
+ "CanSeek": (True, None),
+ "CanControl": (True, None),
+ }
+
+ __tracklist_interface = "org.mpris.MediaPlayer2.TrackList"
+
+ __prop_mapping = {
+ __player_interface: __player_props,
+ __root_interface: __root_props,
+ }
+
+ @dbus.service.method(__introspect_interface)
+ def Introspect(self):
+ return MPRIS2_INTROSPECTION
+
+ @dbus.service.signal(__prop_interface, signature="sa{sv}as")
+ def PropertiesChanged(self, interface, changed_properties,
+ invalidated_properties):
+ pass
+
+ @dbus.service.method(__prop_interface,
+ in_signature="ss", out_signature="v")
+ def Get(self, interface, prop):
+ getter, setter = self.__prop_mapping[interface][prop]
+ if callable(getter):
+ return getter()
+ return getter
+
+ @dbus.service.method(__prop_interface,
+ in_signature="ssv", out_signature="")
+ def Set(self, interface, prop, value):
+ getter, setter = self.__prop_mapping[interface][prop]
+ if setter is not None:
+ setter(value)
+
+ @dbus.service.method(__prop_interface,
+ in_signature="s", out_signature="a{sv}")
+ def GetAll(self, interface):
+ read_props = {}
+ props = self.__prop_mapping[interface]
+ for key, (getter, setter) in props.items():
+ if callable(getter):
+ getter = getter()
+ read_props[key] = getter
+ return read_props
+
+ def update_property(self, interface, prop):
+ getter, setter = self.__prop_mapping[interface][prop]
+ if callable(getter):
+ value = getter()
+ else:
+ value = getter
+ logger.debug('Updated property: %s = %s' % (prop, value))
+ self.PropertiesChanged(interface, {prop: value}, [])
+ return value
+
+ # Root methods
+ @dbus.service.method(__root_interface, in_signature='', out_signature='')
+ def Raise(self):
+ return
+
+ @dbus.service.method(__root_interface, in_signature='', out_signature='')
+ def Quit(self):
+ return
+
+ # Player methods
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def Next(self):
+ mpd_wrapper.next()
+ return
+
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def Previous(self):
+ mpd_wrapper.previous()
+ return
+
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def Pause(self):
+ mpd_wrapper.pause(1)
+ mpd_wrapper.notify_about_state('pause')
+ return
+
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def PlayPause(self):
+ status = mpd_wrapper.status()
+ if status['state'] == 'play':
+ mpd_wrapper.pause(1)
+ mpd_wrapper.notify_about_state('pause')
+ else:
+ mpd_wrapper.play()
+ mpd_wrapper.notify_about_state('play')
+ return
+
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def Stop(self):
+ mpd_wrapper.stop()
+ mpd_wrapper.notify_about_state('stop')
+ return
+
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def Play(self):
+ mpd_wrapper.play()
+ mpd_wrapper.notify_about_state('play')
+ return
+
+ @dbus.service.method(__player_interface, in_signature='x', out_signature='')
+ def Seek(self, offset):
+ status = mpd_wrapper.status()
+ current, end = status['time'].split(':')
+ current = int(current)
+ end = int(end)
+ offset = int(offset) / 1000000
+ if current + offset <= end:
+ position = current + offset
+ if position < 0:
+ position = 0
+ mpd_wrapper.seekid(int(status['songid']), position)
+ self.Seeked(position * 1000000)
+ return
+
+ @dbus.service.method(__player_interface, in_signature='ox', out_signature='')
+ def SetPosition(self, trackid, position):
+ song = mpd_wrapper.last_currentsong()
+ # FIXME: use real dbus objects
+ if str(trackid) != '/org/mpris/MediaPlayer2/Track/%s' % song['id']:
+ return
+ # Convert position to seconds
+ position = int(position) / 1000000
+ if position <= int(song['time']):
+ mpd_wrapper.seekid(int(song['id']), position)
+ self.Seeked(position * 1000000)
+ return
+
+ @dbus.service.signal(__player_interface, signature='x')
+ def Seeked(self, position):
+ logger.debug("Seeked to %i" % position)
+ return float(position)
+
+ @dbus.service.method(__player_interface, in_signature='', out_signature='')
+ def OpenUri(self):
+ # TODO
+ return
+
+def each_xdg_config(suffix):
+ """
+ Return each location matching XDG_CONFIG_DIRS/suffix in descending
+ priority order.
+ """
+ config_home = os.environ.get('XDG_CONFIG_HOME',
+ os.path.expanduser('~/.config'))
+ config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg').split(':')
+ return ([os.path.join(config_home, suffix)] +
+ [os.path.join(d, suffix) for d in config_dirs])
+
+
+def open_first_xdg_config(suffix):
+ """
+ Try to open each location matching XDG_CONFIG_DIRS/suffix as a file.
+ Return the first that can be opened successfully, or None.
+ """
+ for filename in each_xdg_config(suffix):
+ try:
+ f = open(filename, 'r')
+ except IOError:
+ pass
+ else:
+ return f
+ else:
+ return None
+
+
+def find_music_dir():
+ if 'XDG_MUSIC_DIR' in os.environ:
+ return os.environ['XDG_MUSIC_DIR']
+
+ conf = open_first_xdg_config('user-dirs.dirs')
+ if conf is not None:
+ for line in conf:
+ if not line.startswith('XDG_MUSIC_DIR='):
+ continue
+ # use shlex to handle "shell escaping"
+ path = shlex.split(line[14:])[0]
+ if path.startswith('$HOME/'):
+ return os.path.expanduser('~' + path[5:])
+ elif path.startswith('/'):
+ return path
+ else:
+ # other forms are not supported
+ break
+
+ paths = '~/Music', '~/music'
+ for path in map(os.path.expanduser, paths):
+ if os.path.isdir(path):
+ return path
+
+ return None
+
+
+def usage(params):
+ print("""\
+Usage: %(progname)s [OPTION]...
+
+ -c, --config=PATH Read a custom configuration file
+
+ -h, --host=ADDR Set the mpd server address
+ --port=PORT Set the TCP port
+ --music-dir=PATH Set the music library path
+
+ -d, --debug Run in debug mode
+ -j, --use-journal Log to systemd journal instead of stderr
+ -v, --version mpDris2 version
+
+Environment variables MPD_HOST and MPD_PORT can be used.
+
+Report bugs to https://github.com/eonpatapon/mpDris2/issues""" % params)
+
+if __name__ == '__main__':
+ DBusGMainLoop(set_as_default=True)
+
+ gettext.bindtextdomain('mpDris2', '@datadir@/locale')
+ gettext.textdomain('mpDris2')
+
+ log_format_stderr = '%(asctime)s %(module)s %(levelname)s: %(message)s'
+
+ log_journal = False
+ log_level = logging.INFO
+ config_file = None
+ music_dir = None
+
+ # Parse command line
+ try:
+ (opts, args) = getopt.getopt(sys.argv[1:], 'c:dh:jp:v',
+ ['help', 'bus-name=', 'config=',
+ 'debug', 'host=', 'music-dir=',
+ 'use-journal', 'path=', 'port=',
+ 'version'])
+ except getopt.GetoptError as ex:
+ (msg, opt) = ex.args
+ print("%s: %s" % (sys.argv[0], msg), file=sys.stderr)
+ print(file=sys.stderr)
+ usage(params)
+ sys.exit(2)
+
+ for (opt, arg) in opts:
+ if opt in ['--help']:
+ usage(params)
+ sys.exit()
+ elif opt in ['--bus-name']:
+ params['bus_name'] = arg
+ elif opt in ['-c', '--config']:
+ config_file = arg
+ elif opt in ['-d', '--debug']:
+ log_level = logging.DEBUG
+ elif opt in ['-h', '--host']:
+ params['host'] = arg
+ elif opt in ['-j', '--use-journal']:
+ log_journal = True
+ elif opt in ['-p', '--path', '--music-dir']:
+ music_dir = arg
+ elif opt in ['--port']:
+ params['port'] = int(arg)
+ elif opt in ['-v', '--version']:
+ v = __version__
+ if __git_version__:
+ v = __git_version__
+ print("mpDris2 version %s" % v)
+ sys.exit()
+
+ if len(args) > 2:
+ usage(params)
+ sys.exit()
+
+ logger = logging.getLogger('mpDris2')
+ logger.propagate = False
+ logger.setLevel(log_level)
+
+ # Attempt to configure systemd journal logging, if enabled
+ if log_journal:
+ try:
+ from systemd.journal import JournalHandler
+ log_handler = JournalHandler(SYSLOG_IDENTIFIER='mpDris2')
+ except ImportError:
+ log_journal = False
+
+ # Log to stderr if journal logging was not enabled, or if setup failed
+ if not log_journal:
+ log_handler = logging.StreamHandler()
+ log_handler.setFormatter(logging.Formatter(log_format_stderr))
+
+ logger.addHandler(log_handler)
+
+ # Pick up the server address (argv -> environment -> config)
+ for arg in args[:2]:
+ if arg.isdigit():
+ params['port'] = arg
+ else:
+ params['host'] = arg
+
+ if not params['host']:
+ if 'MPD_HOST' in os.environ:
+ params['host'] = os.environ['MPD_HOST']
+ if not params['port']:
+ if 'MPD_PORT' in os.environ:
+ params['port'] = os.environ['MPD_PORT']
+
+ # Read configuration
+ config = ConfigParser()
+ if config_file:
+ with open(config_file) as fh:
+ config.read(config_file)
+ else:
+ config.read(['/etc/mpDris2.conf'] +
+ list(reversed(each_xdg_config('mpDris2/mpDris2.conf'))))
+
+ for p in ['host', 'port', 'password', 'bus_name']:
+ if not params[p]:
+ # TODO: switch to get(fallback=…) when possible
+ if config.has_option('Connection', p):
+ params[p] = config.get('Connection', p)
+ else:
+ params[p] = defaults[p]
+
+ if '@' in params['host']:
+ params['password'], params['host'] = params['host'].rsplit('@', 1)
+
+ params['host'] = os.path.expanduser(params['host'])
+
+ for p in ['mmkeys', 'notify']:
+ if config.has_option('Bling', p):
+ params[p] = config.getboolean('Bling', p)
+
+ if config.has_option('Bling', 'notify_urgency'):
+ params['notify_urgency'] = int(config.get('Bling', 'notify_urgency'))
+
+ if not music_dir:
+ if config.has_option('Library', 'music_dir'):
+ music_dir = config.get('Library', 'music_dir')
+ elif config.has_option('Connection', 'music_dir'):
+ music_dir = config.get('Connection', 'music_dir')
+ else:
+ music_dir = find_music_dir()
+
+ if music_dir:
+ # Ensure that music_dir starts with an URL scheme.
+ if not re.match('^[0-9A-Za-z+.-]+://', music_dir):
+ music_dir = 'file://' + music_dir
+ if music_dir.startswith('file://'):
+ music_dir = music_dir[:7] + os.path.expanduser(music_dir[7:])
+ if not os.path.exists(music_dir[7:]):
+ logger.error('Music library path %s does not exist!' % music_dir)
+ # Non-local URLs can still be useful to MPRIS clients, so accept them.
+ params['music_dir'] = music_dir
+ logger.info('Using %s as music library path.' % music_dir)
+ else:
+ logger.warning('By not supplying a path for the music library '
+ 'this program will break the MPRIS specification!')
+
+ if config.has_option('Library', 'cover_regex'):
+ cover_regex = config.get('Library', 'cover_regex')
+ else:
+ cover_regex = defaults['cover_regex']
+ params['cover_regex'] = re.compile(cover_regex, re.I | re.X)
+
+ logger.debug('Parameters: %r' % params)
+
+ if mutagen:
+ logger.info('Using Mutagen to read covers from music files.')
+ else:
+ logger.info('Mutagen not available, covers in music files will be ignored.')
+
+ # Set up the main loop
+ if using_gi_glib:
+ logger.debug('Using GObject-Introspection main loop.')
+ else:
+ logger.debug('Using legacy pygobject2 main loop.')
+ loop = GLib.MainLoop()
+
+ # Wrapper to send notifications
+ notification = NotifyWrapper(params)
+
+ # Create wrapper to handle connection failures with MPD more gracefully
+ mpd_wrapper = MPDWrapper(params)
+ mpd_wrapper.run()
+
+ # Run idle loop
+ try:
+ loop.run()
+ except KeyboardInterrupt:
+ logger.debug('Caught SIGINT, exiting.')
+
+ # Clean up
+ try:
+ mpd_wrapper.client.close()
+ mpd_wrapper.client.disconnect()
+ logger.debug('Exiting')
+ except mpd.ConnectionError:
+ logger.error('Failed to disconnect properly')