#!/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 . # # Author: Johannes Pohl # Based on mpDris2 by # Jean-Philippe Braun , # Mantas Mikulėnas # Based on mpDris by: # Erik Karlsson # Some bits taken from quodlibet mpris plugin by: # # Dependencies: # - python-mpd2 # - musicbrainzngs import os import sys import socket import getopt import mpd from dbus.mainloop.glib import DBusGMainLoop import logging import gettext import time import json import musicbrainzngs import fcntl __version__ = "@version@" __git_version__ = "@gitversion@" musicbrainzngs.set_useragent( "snapcast-mtea-mpd", "0.1", "https://github.com/badaix/snapcast", ) using_gi_glib = False try: from gi.repository import GLib using_gi_glib = True except ImportError: import glib as GLib # _ = gettext.gettext params = { 'progname': sys.argv[0], # Connection 'mpd-host': None, 'mpd-port': None, 'mpd-password': None, 'snapcast-host': None, 'snapcast-port': None, 'stream': None, } defaults = { # Connection 'mpd-host': 'localhost', 'mpd-port': 6600, 'mpd-password': None, 'snapcast-host': 'localhost', 'snapcast-port': 1780, 'stream': 'default', } # Player.Status status_mapping = { # https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#properties # R/O - play => playing, pause => paused, stop => stopped 'state': ['playbackStatus', lambda val: {'play': 'playing', 'pause': 'paused', 'stop': 'stopped'}[val]], # R/W - 0 => none, 1 => track, n/a => playlist 'repeat': ['loopStatus', lambda val: {'0': 'none', '1': 'track', '2': 'playlist'}[val]], # 'Rate d (Playback_Rate) R/W # R/W - 0 => false, 1 => true 'random': ['shuffle', lambda val: {'0': False, '1': True}[val]], # 'Metadata a{sv} (Metadata_Map) Read only 'volume': ['volume', int], # R/W - 0-100 => 0-100 'elapsed': ['position', float], # R/O - seconds? ms? # 'MinimumRate d (Playback_Rate) Read only # 'MaximumRate d (Playback_Rate) Read only # 'CanGoNext b Read only # 'CanGoPrevious b Read only # 'CanPlay b Read only # 'CanPause b Read only # 'CanSeek b Read only # 'CanControl b Read only # https://mpd.readthedocs.io/en/stable/protocol.html#status # partition: the name of the current partition (see Partition commands) # single 2: 0, 1, or oneshot 6 # consume 2: 0 or 1 # playlist: 31-bit unsigned integer, the playlist version number # playlistlength: integer, the length of the playlist # song: playlist song number of the current song stopped on or playing # songid: playlist songid of the current song stopped on or playing # nextsong 2: playlist song number of the next song to be played # nextsongid 2: playlist songid of the next song to be played # time: total time elapsed (of current playing/paused song) in seconds (deprecated, use elapsed instead) # duration 5: Duration of the current song in seconds. 'duration': ['duration', float], # bitrate: instantaneous bitrate in kbps # xfade: crossfade in seconds # mixrampdb: mixramp threshold in dB # mixrampdelay: mixrampdelay in seconds # audio: The format emitted by the decoder plugin during playback, format: samplerate:bits:channels. See Global Audio Format for a detailed explanation. # updating_db: job id # error: if there is an error, returns message here # Snapcast # R/W true/false 'mute': ['mute', lambda val: {'0': False, '1': True}[val]] } # Player.Metadata # MPD to Snapcast tag mapping: : [, , ] tag_mapping = { 'file': ['url', str, False], 'id': ['trackId', str, False], 'album': ['album', str, False], 'albumsort': ['albumSort', str, False], 'albumartist': ['albumArtist', str, True], 'albumartistsort': ['albumArtistSort', str, True], 'artist': ['artist', str, True], 'artistsort': ['artistSort', str, True], 'astext': ['asText', str, True], 'audiobpm': ['audioBPM', int, False], 'autorating': ['autoRating', float, False], 'comment': ['comment', str, True], 'composer': ['composer', str, True], 'date': ['contentCreated', str, False], 'disc': ['discNumber', int, False], 'firstused': ['firstUsed', str, False], 'genre': ['genre', str, True], 'lastused': ['lastUsed', str, False], 'lyricist': ['lyricist', str, True], 'title': ['title', str, False], 'track': ['trackNumber', int, False], 'arturl': ['artUrl', str, False], 'usecount': ['useCount', int, False], 'userrating': ['userRating', float, False], 'duration': ['duration', float, False], 'name': ['name', str, False], 'originaldate': ['originalDate', str, False], 'performer': ['performer', str, True], 'conductor': ['conductor', str, True], 'work': ['work', str, False], 'grouping': ['grouping', str, False], 'label': ['label', str, False], 'musicbrainz_artistid': ['musicbrainzArtistId', str, False], 'musicbrainz_albumid': ['musicbrainzAlbumId', str, False], 'musicbrainz_albumartistid': ['musicbrainzAlbumArtistId', str, False], 'musicbrainz_trackid': ['musicbrainzTrackId', str, False], 'musicbrainz_releasetrackid': ['musicbrainzReleaseTrackId', str, False], 'musicbrainz_workid': ['musicbrainzWorkId', str, False], } # Default url handlers if MPD doesn't support 'urlhandlers' command urlhandlers = ['http://'] def send(json_msg): print(json.dumps(json_msg)) sys.stdout.flush() class MPDWrapper(object): """ Wrapper of mpd.MPDClient to handle socket errors and similar """ def __init__(self, params): self.client = mpd.MPDClient() self._params = params self._can_single = False self._can_idle = False self._errors = 0 self._poll_id = None self._watch_id = None self._idling = False self._status = {} self._currentsong = {} self._position = 0 self._time = 0 self._album_art_map = {} 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._buffer = '' self.client.connect( self._params['mpd-host'], self._params['mpd-port']) if self._params['mpd-password']: try: self.client.password(self._params['mpd-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: 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) # 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) flags = fcntl.fcntl(sys.stdin.fileno(), fcntl.F_GETFL) flags |= os.O_NONBLOCK fcntl.fcntl(sys.stdin.fileno(), fcntl.F_SETFL, flags) GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, self.io_callback) # Reset error counter self._errors = 0 self.timer_callback() self.idle_enter() send({"jsonrpc": "2.0", "method": "Plugin.Stream.Ready"}) # 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") # Clean mpd client state try: self.disconnect() except: self.disconnect() # Try to reconnect self.run() def disconnect(self): self.client.disconnect() def init_state(self): # Get current state self._status = self.client.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): logger.error('') self.reconnect() return False self._update_properties(force=False) if was_idle: self.idle_enter() return True def control(self, cmd): try: request = json.loads(cmd) id = request['id'] [interface, cmd] = request['method'].rsplit('.', 1) if interface == 'Plugin.Stream.Player': if cmd == 'Control': success = True command = request['params']['command'] params = request['params'].get('params', {}) logger.debug( f'Control command: {command}, params: {params}') if command == 'next': self.next() elif command == 'previous': self.previous() elif command == 'play': self.play() elif command == 'pause': self.pause(1) elif command == 'playPause': if self.status()['state'] == 'play': self.pause(1) else: self.play() elif command == 'stop': self.stop() elif command == 'setPosition': position = float(params['position']) logger.info(f'setPosition {position}') self.seekcur(position) elif command == 'seek': offset = float(params['offset']) strOffset = str(offset) if offset >= 0: strOffset = "+" + strOffset self.seekcur(strOffset) elif cmd == 'SetProperty': property = request['params'] logger.info(f'SetProperty: {property}') if 'shuffle' in property: self.random(int(property['shuffle'])) if 'loopStatus' in property: value = property['loopStatus'] if value == "playlist": self.repeat(1) if self._can_single: self.single(0) elif value == "track": if self._can_single: self.repeat(1) self.single(1) elif value == "none": self.repeat(0) if self._can_single: self.single(0) if 'volume' in property: self.setvol(int(property['volume'])) elif cmd == 'GetProperties': snapstatus = self._get_properties(self.status()) logger.info(f'Snapstatus: {snapstatus}') return send({"jsonrpc": "2.0", "id": id, "result": snapstatus}) # return send({"jsonrpc": "2.0", "error": {"code": -32601, # "message": "TODO: GetProperties not yet implemented"}, "id": id}) elif cmd == 'GetMetadata': send({"jsonrpc": "2.0", "method": "Plugin.Stream.Log", "params": { "severity": "Info", "message": "Logmessage"}}) return send({"jsonrpc": "2.0", "error": {"code": -32601, "message": "TODO: GetMetadata not yet implemented"}, "id": id}) else: return send({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": id}) else: return send({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": id}) send({"jsonrpc": "2.0", "result": "ok", "id": id}) except Exception as e: send({"jsonrpc": "2.0", "error": { "code": -32700, "message": "Parse error", "data": str(e)}, "id": id}) def io_callback(self, fd, event): logger.debug("IO event %r on fd %r" % (event, fd)) if event & GLib.IO_HUP: logger.debug("IO_HUP") return True elif event & GLib.IO_IN: chunk = fd.read() for char in chunk: if char == '\n': logger.info(f'Received: {self._buffer}') self.control(self._buffer) self._buffer = '' else: self._buffer += char return True def socket_callback(self, fd, event): try: 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: logger.info(f'Subsystem: {subsystem}') self._update_properties(force=True) updated = True self.idle_enter() return True except: logger.error('Exception in socket_callback') self.reconnect() return True def __track_key(self, snapmeta): return hash(snapmeta.get('artist', [''])[0] + snapmeta.get('album', snapmeta.get('title', ''))) def get_albumart(self, snapmeta, cached): album_key = 'musicbrainzAlbumId' track_key = self.__track_key(snapmeta) album_art = self._album_art_map.get(track_key) if album_art is not None: if album_art == '': return None else: return album_art if cached: return None self._album_art_map[track_key] = '' try: if not album_key in snapmeta: mbartist = None mbrelease = None if 'artist' in snapmeta: mbartist = snapmeta['artist'][0] if 'album' in snapmeta: mbrelease = snapmeta['album'] else: if 'title' in snapmeta: mbrelease = snapmeta['title'] if mbartist is not None and mbrelease is not None: logger.info( f'Querying album art for artist "{mbartist}", release: "{mbrelease}"') result = musicbrainzngs.search_releases(artist=mbartist, release=mbrelease, limit=1) if result['release-list']: snapmeta[album_key] = result['release-list'][0]['id'] if album_key in snapmeta: data = musicbrainzngs.get_image_list(snapmeta[album_key]) for image in data["images"]: if "Front" in image["types"] and image["approved"]: album_art = image["thumbnails"]["small"] logger.debug( f'{album_art} is an approved front image') self._album_art_map[track_key] = album_art break except musicbrainzngs.musicbrainz.ResponseError as e: logger.error( f'Error while getting cover for {snapmeta[album_key]}: {e}') album_art = self._album_art_map[track_key] if album_art == '': return None return album_art def get_metadata(self): """ Translate metadata returned by MPD to the MPRIS v2 syntax. http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata """ mpd_meta = self._currentsong.copy() logger.debug(f'mpd meta: {mpd_meta}') snapmeta = {} for key, values in mpd_meta.items(): try: value = {} if type(values) == list: if len(values) == 0: continue if tag_mapping[key][2]: value = list(map(type(tag_mapping[key][1]), values)) else: value = tag_mapping[key][1](values[0]) else: if tag_mapping[key][2]: value = [tag_mapping[key][1](values)] else: value = tag_mapping[key][1](values) snapmeta[tag_mapping[key][0]] = value logger.debug( f'key: {key}, value: {value}, mapped key: {tag_mapping[key][0]}, mapped value: {snapmeta[tag_mapping[key][0]]}') except KeyError: logger.debug(f'tag "{key}" not supported') except (ValueError, TypeError): logger.warning( f"Can't cast value {value} to {tag_mapping[key][1]}") logger.debug(f'snapcast meta: {snapmeta}') # Hack for web radio: # "name" and "title" are set, but not "album" and not "artist" # where # - "name" containts the name of the radio station # - "title" has the format " - " # {'url': 'http://wdr-wdr2-aachenundregion.icecast.wdr.de/wdr/wdr2/aachenundregion/mp3/128/stream.mp3', 'title': 'Johannes Oerding - An guten Tagen', 'name': 'WDR 2 Aachen und die Region aktuell, Westdeutscher Rundfunk Koeln', 'trackId': '1'} if 'title' in snapmeta and 'name' in snapmeta and 'url' in snapmeta and not 'album' in snapmeta and not 'artist' in snapmeta: if snapmeta['url'].find('http') == 0: fields = snapmeta['title'].split(' - ', 1) if len(fields) == 1: fields = snapmeta['title'].split(' / ', 1) if len(fields) == 2: snapmeta['artist'] = [fields[0]] snapmeta['title'] = fields[1] art_url = self.get_albumart(snapmeta, True) if art_url is not None: logger.info(f'album art cache hit: "{art_url}"') snapmeta['artUrl'] = art_url return snapmeta def __diff_map(self, old_map, new_map): diff = {} for key, value in new_map.items(): if not key in old_map: diff[key] = [None, value] elif value != old_map[key]: diff[key] = [old_map[key], value] for key, value in old_map.items(): if not key in new_map: diff[key] = [value, None] return diff def _get_properties(self, mpd_status): snapstatus = {} for key, value in mpd_status.items(): try: mapped_key = status_mapping[key][0] mapped_val = status_mapping[key][1](value) snapstatus[mapped_key] = mapped_val logger.debug( f'key: {key}, value: {value}, mapped key: {mapped_key}, mapped value: {mapped_val}') except KeyError: logger.debug(f'property "{key}" not supported') except (ValueError, TypeError): logger.warning( f"Can't cast value {value} to {status_mapping[key][1]}") snapstatus['canGoNext'] = True snapstatus['canGoPrevious'] = True snapstatus['canPlay'] = True snapstatus['canPause'] = True snapstatus['canSeek'] = 'duration' in snapstatus snapstatus['canControl'] = True return snapstatus def _update_properties(self, force=False): logger.debug(f'update_properties force: {force}') old_position = self._position old_time = self._time new_song = self.client.currentsong() if not new_song: logger.warning("_update_properties: failed to get current song") new_song = {} new_status = self.client.status() if not new_status: logger.warning("_update_properties: failed to get new status") new_status = {} changed_status = self.__diff_map(self._status, new_status) if len(changed_status) > 0: self._status = new_status changed_song = self.__diff_map(self._currentsong, new_song) if len(changed_song) > 0: self._currentsong = new_song if len(changed_song) == 0 and len(changed_status) == 0: logger.debug('nothing to do') return logger.info( f'new status: {new_status}, changed_status: {changed_status}, changed_song: {changed_song}') self._time = new_time = int(time.time()) snapstatus = self._get_properties(new_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 new_song = len(changed_song) > 0 if not new_song: 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 snapstatus["metadata"] = self.get_metadata() send({"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties", "params": snapstatus}) if new_song: if 'artUrl' not in snapstatus['metadata']: album_art = self.get_albumart(snapstatus['metadata'], False) if album_art is not None: snapstatus['metadata']['artUrl'] = album_art send( {"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties", "params": snapstatus}) # 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 def usage(params): print("""\ Usage: %(progname)s [OPTION]... --mpd-host=ADDR Set the mpd server address --mpd-port=PORT Set the TCP port --snapcast-host=ADDR Set the snapcast server address --snapcast-port=PORT Set the snapcast server port --stream=ID Set the stream id -d, --debug Run in debug mode -v, --version meta_mpd version Report bugs to https://github.com/badaix/snapcast/issues""" % params) if __name__ == '__main__': DBusGMainLoop(set_as_default=True) gettext.bindtextdomain('meta_mpd', '@datadir@/locale') gettext.textdomain('meta_mpd') log_format_stderr = '%(asctime)s %(module)s %(levelname)s: %(message)s' log_level = logging.INFO # Parse command line try: (opts, args) = getopt.getopt(sys.argv[1:], 'hdjv', ['help', 'mpd-host=', 'mpd-port=', 'snapcast-host=', 'snapcast-port=', 'stream=', 'debug', '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 ['-h', '--help']: usage(params) sys.exit() elif opt in ['--mpd-host']: params['mpd-host'] = arg elif opt in ['--mpd-port']: params['mpd-port'] = int(arg) elif opt in ['--snapcast-host']: params['snapcast-host'] = arg elif opt in ['--snapcast-port']: params['snapcast-port'] = int(arg) elif opt in ['--stream']: params['stream'] = arg elif opt in ['-d', '--debug']: log_level = logging.DEBUG elif opt in ['-v', '--version']: v = __version__ if __git_version__: v = __git_version__ print("meta_mpd version %s" % v) sys.exit() if len(args) > 2: usage(params) sys.exit() logger = logging.getLogger('meta_mpd') logger.propagate = False logger.setLevel(log_level) # Log to stderr log_handler = logging.StreamHandler() log_handler.setFormatter(logging.Formatter(log_format_stderr)) logger.addHandler(log_handler) for p in ['mpd-host', 'mpd-port', 'snapcast-host', 'snapcast-port', 'mpd-password', 'stream']: if not params[p]: params[p] = defaults[p] if '@' in params['mpd-host']: params['mpd-password'], params['mpd-host'] = params['mpd-host'].rsplit( '@', 1) params['mpd-host'] = os.path.expanduser(params['mpd-host']) logger.debug(f'Parameters: {params}') # 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() # 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')