commit ec665403eab36b2becd42fb00f74b3ceeab71690 Author: Dustin C. Hatch Date: Tue Aug 5 13:36:06 2014 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c882b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.egg-info/ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..088f751 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from setuptools import find_packages, setup + +setup( + name='mpdnotify', + version='0.1', + description='Send libnotify notifications for MPD', + author='Dustin C. Hatch', + author_email='dustin@hatch.name', + url='http://dustin.hatch.name/', + license='', + packages=find_packages('src'), + package_dir={'': 'src'}, + package_data={'': ['mpd-logo.png']}, + entry_points={ + 'console_scripts': [ + 'mpdnotify=mpdnotify:main', + ], + }, +) diff --git a/src/mpd-logo.png b/src/mpd-logo.png new file mode 100644 index 0000000..d8b6d96 Binary files /dev/null and b/src/mpd-logo.png differ diff --git a/src/mpdnotify.py b/src/mpdnotify.py new file mode 100644 index 0000000..2442952 --- /dev/null +++ b/src/mpdnotify.py @@ -0,0 +1,280 @@ +from gi.repository import GdkPixbuf +from gi.repository import GLib +from gi.repository import Gtk +import argparse +import contextlib +import dbus.mainloop.glib +import hashlib +import json +import mpd +import os +import pkg_resources +import signal +import sys +import threading +import urllib.parse +import urllib.request + + +LASTFM_API_KEY = '929dc7a1779b10b09f9c3e375b43463c' +LASTFM_API_URL = 'http://ws.audioscrobbler.com/2.0' +XDG_CACHE_DIR = os.environ.get('XDG_CACHE_DIR', os.path.expanduser('~/.cache')) + +MPD_HOST = os.environ.get('MPD_HOST', 'localhost') +MPD_PORT = int(os.environ.get('MPD_PORT', '6600')) + + +class Song(object): + + CACHE_DIR = os.path.join(XDG_CACHE_DIR, 'album-art') + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + try: + key = ':'.join((self.artist, self.album)).encode('utf-8') + except AttributeError: + pass + else: + self.album_art = os.path.join( + self.CACHE_DIR, + hashlib.sha1(key).hexdigest(), + ) + try: + duration = int(self.time) + except AttributeError: + pass + else: + self.duration = '{0}:{1}'.format(duration // 60, duration % 60) + + def fetch_album_art(self, callback=None): + if not os.path.exists(os.path.dirname(self.album_art)): + os.makedirs(os.path.dirname(self.album_art)) + try: + fd = os.open(self.album_art, os.O_WRONLY | os.O_CREAT | os.O_EXCL) + except OSError: + return + + params = { + 'method': 'album.getinfo', + 'api_key': LASTFM_API_KEY, + 'artist': self.artist, + 'album': self.album, + 'format': 'json', + } + url = '{0}?{1}'.format(LASTFM_API_URL, urllib.parse.urlencode(params)) + res = urllib.request.urlopen(url) + info = json.loads(res.read().decode('utf-8')) + for i in info['album']['image']: + if i['size'] != 'large' or not i['#text']: + continue + res = urllib.request.urlopen(i['#text']) + with os.fdopen(fd, 'wb') as f: + f.write(res.read()) + break + else: + sys.stderr.write( + 'Failed to fetch album art for {artist} - {album}\n'.format( + artist=self.artist, + album=self.album + ) + ) + os.unlink(self.album_art) + return + if callback: + callback() + + +class Notifier(object): + + BUS_NAME = 'org.freedesktop.Notifications' + OBJECT_PATH = '/org/freedesktop/Notifications' + INTERFACE_NAME = 'org.freedesktop.Notifications' + + APP_NAME = 'mpdnotify' + + RECONNECT_INTERVAL = 1 + + def __init__(self, host, port, password=None): + self.icon = None + self.host = host + self.port = port + self.password = password + dbus_loop = dbus.mainloop.glib.DBusGMainLoop() + self.bus = dbus.SessionBus(mainloop=dbus_loop) + self.proxy = self.bus.get_object(self.BUS_NAME, self.OBJECT_PATH) + self.msg_id = 0 + self.client = mpd.MPDClient() + + def _changed(self, source, condition): + try: + self.client.fetch_idle() + self.notify() + self.client.send_idle('player') + except mpd.ConnectionError: + self._notify( + 'Disconnected', 'Lost connection to MPD, reconnecting', + icon='audio-x-generic') + if self.icon: + self.icon.set_tooltip_text('Not connected') + self._reconnect() + return False + else: + return True + + def _notify(self, summary, body='', icon='', actions=[], hints={}, + expire_timeout=2000): + proxy = self.bus.get_object(self.BUS_NAME, self.OBJECT_PATH) + self.msg_id = proxy.get_dbus_method('Notify', self.INTERFACE_NAME)( + self.APP_NAME, self.msg_id, icon, summary, body, actions, + hints, expire_timeout + ) + + def _reconnect(self): + def reconnect(data): + try: + self.connect() + except: + return True + else: + self.notify() + self.client.send_idle('player') + return False + timer = GLib.Timeout(self.RECONNECT_INTERVAL * 1000) + timer.set_callback(reconnect) + timer.attach() + + def connect(self): + self.client._sock = None + self.client.connect(self.host, self.port) + if self.password: + self.client.password(self.password) + GLib.io_add_watch(self.client._sock.fileno(), GLib.PRIORITY_DEFAULT, + GLib.IO_IN, self._changed) + + def notify(self): + status = self.client.status() + song = Song(**self.client.currentsong()) + try: + title = song.title + except AttributeError: + return + if status['state'] == 'pause': + title += ' (Paused)' + elif status['state'] == 'stop': + title += ' (Stopped)' + + if self.icon: + self.icon.set_tooltip_text('{artist} - {title}'.format( + artist=song.artist, + title=title, + )) + + body = 'from {album} by {artist} ({duration})'.format( + album=song.album, + artist=song.artist, + duration=song.duration, + ) + hints = {'transient': True} + if os.path.exists(song.album_art): + icon = song.album_art + else: + def renotify(): + self.client.noidle() + self.notify() + self.client.send_idle('player') + GLib.idle_add(song.fetch_album_art, renotify) + icon = 'audio-x-generic' + self._notify(title, body, icon=icon, hints=hints) + + def start(self): + try: + self.connect() + except: + sys.stderr.write('Failed to connect to {0}, will retry in ' + '{1} second(s).\n' + ''.format(self.host, self.RECONNECT_INTERVAL)) + self._reconnect() + else: + self.notify() + self.client.send_idle('player') + + def show_status_icon(self): + loader = GdkPixbuf.PixbufLoader() + img = pkg_resources.resource_stream('mpdnotify', 'mpd-logo.png') + with contextlib.closing(loader): + for d in iter(lambda: img.read(1), b''): + loader.write(d) + self.icon = Gtk.StatusIcon.new_from_pixbuf(loader.get_pixbuf()) + self.icon.set_tooltip_text('Not connected') + + def stop(self): + try: + self.client.close() + except mpd.PendingCommandError: + self.client.noidle() + self.client.close() + except mpd.ConnectionError: + pass + else: + self.client.disconnect() + + +def _parse_args(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument('--help', action='help', + help='show this help message and exit') + parser.add_argument('--no-fork', '-f', action='store_true', default=False, + help='Do not fork into the background') + parser.add_argument('--no-status-icon', action='store_true', default=False, + help=('Do not display a status icon in the ' + 'notification area (tray)')) + parser.add_argument('--host', '-h', default=MPD_HOST, + help=('The MPD server to connect to; if not given, ' + 'the value of the environment variable MPD_HOST ' + 'is checked before defaulting to localhost. To ' + 'use a password, provide a value of the form ' + '"password@host".')) + parser.add_argument('--port', '-p', type=int, default=MPD_PORT, + help=('The MPD server port; if not given, the value ' + 'of the environment valriable MPD_PORT is ' + 'checked before defaulting to 6600')) + return parser.parse_args() + + +def daemonize(): + if os.fork(): + raise SystemExit + os.setsid() + os.chdir('/') + if os.fork(): + raise SystemExit + + +def main(): + args = _parse_args() + + if not args.no_fork: + daemonize() + os.umask(0o0022) + + if '@' in args.host: + password, host = args.host.split('@', 1) + else: + password = None + host = args.host + notifier = Notifier(host, args.port, password) + if not args.no_status_icon: + notifier.show_status_icon() + notifier.start() + + def quit(): + notifier.stop() + Gtk.main_quit() + raise SystemExit + for s in (signal.SIGINT, signal.SIGTERM): + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, s, quit) + Gtk.main() + + +if __name__ == '__main__': + main()