From 810c4e1e3febf996dcf0e80e927ec74f406dc917 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 12:19:05 +0100 Subject: [PATCH 01/21] New plugin mpc to connect to a MPD server to gather play statistics. --- beetsplug/mpc/__init__.py | 352 ++++++++++++++++++++++++++++++++++++++ setup.py | 1 + 2 files changed, 353 insertions(+) create mode 100644 beetsplug/mpc/__init__.py diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py new file mode 100644 index 000000000..236aab832 --- /dev/null +++ b/beetsplug/mpc/__init__.py @@ -0,0 +1,352 @@ +# This file is part of beets. +# Copyright 2013, Peter Schnebel. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# requires python-mpd to run. install with: pip install python-mpd + +import logging +# for fetching similar artists, tracks ... +import pylast +# for connecting to mpd +from mpd import MPDClient, CommandError, PendingCommandError +# for catching socket errors +from socket import error as SocketError +# for sockets +from select import select, error +# for time stuff (sleep and unix timestamp) +import time +import os.path + +from beets import ui +from beets.util import normpath, plurality +from beets import config +from beets import library +from beets import plugins + +log = logging.getLogger('beets') + +# for future use +LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) + +# if we lose the connection, how many times do we want to RETRY and how much +# time should we wait between retries +RETRIES = 10 +RETRY_INTERVAL = 5 + +# hookup to the MPDClient internals to get unicode +# see http://www.tarmack.eu/code/mpdunicode.py for the general idea +class MPDClient(MPDClient): + def _write_command(self, command, args=[]): + args = [unicode(arg).encode('utf-8') for arg in args] + super(MPDClient, self)._write_command(command, args) + + def _read_line(self): + line = super(MPDClient, self)._read_line() + if line is not None: + return line.decode('utf-8') + return None + +class Client: + def __init__(self, library, config): + self.lib = library + self.config = config + self.music_directory = self.config['music_directory'].get() + self.host = self.config['host'].get() + self.port = self.config['port'].get() + self.password = self.config['password'].get() + self.user = self.config['user'].get() + self.rating = self.config['rating'].get(bool) + self.rating_mix = self.config['rating_mix'].get(float) + + self.client = MPDClient() + + def connect(self): + try: + self.client.connect(host=self.host, port=self.port) + except SocketError, e: + log.error(e) + exit(1) + if not self.password == u'': + try: + self.client.password(self.password) + except CommandError, e: + log.error(e) + exit(1) + + def disconnect(self): + self.client.close() + self.client.disconnect() + + def is_url(self, path): + """Try to determine if the path is an URL. + """ + # FIXME: cover more URL types ... + if path[:7] == "http://": + return True + return False + + def playlist(self): + """Return the currently active playlist. Prefixes paths with the + music_directory, to get the absolute path. + """ + result = [] + for entry in self._mpdfun('playlistinfo'): + if not self.is_url(entry['file']): + result.append(os.path.join( + self.music_directory, entry['file'])) + else: + result.append(entry['file']) + return result + + def beets_item(self, path): + """Return the beets item related to path. + """ + beetsitems = self.lib.items([path]) + if len(beetsitems) == 0: + return None + return beetsitems[0] + + def _for_user(self, attribute): + if self.user != u'': + return u'{1}[{0}]'.format(self.user, attribute) + return None + + def _rate(self, play_count, skip_count, rating, skipped): + if skipped: + rolling = (rating - rating / 2.0) + else: + rolling = (rating + (1.0 - rating) / 2.0) + stable = (play_count + 1.0) / (play_count + skip_count + 2.0) + return self.rating_mix * stable \ + + (1.0 - self.rating_mix) * rolling + + def _beets_rate(self, item, skipped): + """ Update the rating of the beets item. + """ + if self.rating: + if not item is None: + attribute = 'rating' + item[attribute] = self._rate( + item.get('play_count', 0), + item.get('skip_count', 0), + item.get(attribute, 0.5), + skipped) + log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + attribute, item[attribute], item.path)) + user_attribute = self._for_user('rating') + if not user_attribute is None: + item[user_attribute] = self._rate( + item.get(self._for_user('play_count'), 0), + item.get(self._for_user('skip_count'), 0), + item.get(user_attribute, 0.5), + skipped) + log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + user_attribute, item[user_attribute], item.path)) + item.write() + if item._lib: + item.store() + + + def _beets_set(self, item, attribute, value=None, increment=None): + """ Update the beets item. Set attribute to value or increment the + value of attribute. If a user has been given during initialization, + both the attribute and the attribute with the user prefixed, get + updated. + """ + if not item is None: + changed = False + if self.user != u'': + user_attribute = u'{1}[{0}]'.format(self.user, attribute) + else: + user_attribute = None + if not value is None: + changed = True + item[attribute] = value + if not user_attribute is None: + item[user_attribute] = value + if not increment is None: + changed = True + item[attribute] = item.get(attribute, 0) + increment + if not user_attribute is None: + item[user_attribute] = item.get(user_attribute, 0) \ + + increment + if changed: + log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + attribute, item[attribute], item.path)) + if not user_attribute is None: + log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + user_attribute, item[user_attribute], item.path)) + item.write() + if item._lib: + item.store() + + def _mpdfun(self, func, **kwargs): + """Wrapper for requests to the MPD server. Tries to re-connect if the + connection was lost ... + """ + for i in range(RETRIES): + try: + if func == 'send_idle': + # special case, wait for an event + self.client.send_idle() + try: + select([self.client], [], []) + except error: + # happens during shutdown and during MPDs library refresh + time.sleep(RETRY_INTERVAL) + self.connect() + continue + return self.client.fetch_idle() + elif func == 'playlistinfo': + return self.client.playlistinfo() + elif func == 'status': + return self.client.status() + except error: + # happens during shutdown and during MPDs library refresh + time.sleep(RETRY_INTERVAL) + self.connect() + continue + else: + # if we excited without breaking, we couldn't reconnect in time :( + raise Exception(u'failed to re-connect to MPD server') + return None + + def run(self): + self.connect() + self.running = True # exit condition for our main loop + startup = True # we need to do some special stuff on startup + now_playing = None # the currently playing song + current_playlist = None # the currently active playlist + while self.running: + if startup: + # don't wait for an event, read in status and playlist + changed = ['player', 'playlist'] + startup = False + else: + # wait for an event from the MPD server + changed = self._mpdfun('send_idle') + + if 'player' in changed: + # the status has changed + status = self._mpdfun('status') + if status['state'] == 'stop': + log.info(u'mpc(stop)') + now_playing = None + elif status['state'] == 'pause': + log.info(u'mpc(pause)') + now_playing = None + elif status['state'] == 'play': + current_playlist = self.playlist() + song = current_playlist[int(status['song'])] + beets_item = self.beets_item(song) + if self.is_url(song): + # we ignore streams + log.info(u'mpc(play|stream): {0}'.format(song)) + else: + log.info(u'mpc(play): {0}'.format(song)) + # status['time'] = position:duration (in seconds) + t = status['time'].split(':') + remaining = (int(t[1]) -int(t[0])) + + if now_playing is not None and now_playing['path'] != song: + # song change + last_played = now_playing + # get the difference of when the song was supposed + # to end to now. if it's smaller then 10 seconds, + # we consider if fully played. + diff = abs(now_playing['remaining'] - + (time.time() - + now_playing['started'])) + if diff < 10.0: + log.info('mpc(played): {0}' + .format(now_playing)) + skipped = False + else: + log.info('mpc(skipped): {0}' + .format(now_playing)) + skipped = True + if skipped: + self._beets_set(beets_item, + 'skip_count', increment=1) + else: + self._beets_set(beets_item, + 'play_count', increment=1) + self._beets_set(beets_item, + 'last_played', value=int(time.time())) + self._beets_rate(beets_item, skipped) + now_playing = { + 'started' : time.time(), + 'remaining' : remaining, + 'path' : song, + } + log.info(u'mpc(now_playing): {0}' + .format(now_playing)) + self._beets_set(beets_item, + 'last_started', value=int(time.time())) + else: + log.info(u'mpc(status): {0}'.format(status)) + + if 'playlist' in changed: + playlist = self.playlist() + continue + for item in playlist: + beetsitem = self.beets_item(item) + if not beetsitem is None: + log.info(u'mpc(playlist|beets): {0}'.format(beetsitem.path)) + else: + log.info(u'mpc(playlist): {0}'.format(item)) + + +class MPCPlugin(plugins.BeetsPlugin): + def __init__(self): + super(MPCPlugin, self).__init__() + self.config.add({ + 'host': u'127.0.0.1', + 'port': 6600, + 'password': u'', + 'music_directory': u'', + 'user': u'', + 'rating': True, + 'rating_mix': 0.75, + }) + + def commands(self): + cmd = ui.Subcommand('mpc', + help='run a MPD client to gather play statistics') + cmd.parser.add_option('--host', dest='host', + type='string', + help='set the hostname of the server to connect to') + cmd.parser.add_option('--port', dest='port', + type='int', + help='set the port of the MPD server to connect to') + cmd.parser.add_option('--password', dest='password', + type='string', + help='set the password of the MPD server to connect to') + cmd.parser.add_option('--user', dest='user', + type='string', + help='set the user for whom we want to gather statistics') + + def func(lib, opts, args): + self.config.set_args(opts) + # ATM we need to set the music_directory where the files are + # located, as the MPD server just tells us the relative paths to + # the files. This is good and bad. 'bad' because we have to set + # an extra option. 'good' because if the MPD server is running on + # a different host and has mounted the music directory somewhere + # else, we don't care ... + Client(lib, self.config).run() + + cmd.func = func + return [cmd] + +# eof diff --git a/setup.py b/setup.py index 03cf855b0..5c3279cde 100755 --- a/setup.py +++ b/setup.py @@ -62,6 +62,7 @@ setup(name='beets', 'beetsplug.bpd', 'beetsplug.web', 'beetsplug.lastgenre', + 'beetsplug.mpc', ], namespace_packages=['beetsplug'], entry_points={ From 6e893389d77daf98f4f82b19fed4aaf2e869a8d5 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 13:24:03 +0100 Subject: [PATCH 02/21] bugfix: update statistics of the correct song --- beetsplug/mpc/__init__.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py index 236aab832..e3040a2be 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc/__init__.py @@ -111,10 +111,10 @@ class Client: def beets_item(self, path): """Return the beets item related to path. """ - beetsitems = self.lib.items([path]) - if len(beetsitems) == 0: + items = self.lib.items([path]) + if len(items) == 0: return None - return beetsitems[0] + return items[0] def _for_user(self, attribute): if self.user != u'': @@ -137,18 +137,18 @@ class Client: if not item is None: attribute = 'rating' item[attribute] = self._rate( - item.get('play_count', 0), - item.get('skip_count', 0), - item.get(attribute, 0.5), + (int)(item.get('play_count', 0)), + (int)(item.get('skip_count', 0)), + (float)(item.get(attribute, 0.5)), skipped) log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( attribute, item[attribute], item.path)) user_attribute = self._for_user('rating') if not user_attribute is None: item[user_attribute] = self._rate( - item.get(self._for_user('play_count'), 0), - item.get(self._for_user('skip_count'), 0), - item.get(user_attribute, 0.5), + (int)(item.get(self._for_user('play_count'), 0)), + (int)(item.get(self._for_user('skip_count'), 0)), + (float)(item.get(user_attribute, 0.5)), skipped) log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( user_attribute, item[user_attribute], item.path)) @@ -276,22 +276,23 @@ class Client: .format(now_playing)) skipped = True if skipped: - self._beets_set(beets_item, + self._beets_set(now_playing['beets_item'], 'skip_count', increment=1) else: - self._beets_set(beets_item, + self._beets_set(now_playing['beets_item'], 'play_count', increment=1) - self._beets_set(beets_item, + self._beets_set(now_playing['beets_item'], 'last_played', value=int(time.time())) - self._beets_rate(beets_item, skipped) + self._beets_rate(now_playing['beets_item'], skipped) now_playing = { - 'started' : time.time(), - 'remaining' : remaining, - 'path' : song, + 'started' : time.time(), + 'remaining' : remaining, + 'path' : song, + 'beets_item' : beets_item, } log.info(u'mpc(now_playing): {0}' .format(now_playing)) - self._beets_set(beets_item, + self._beets_set(now_playing['beets_item'], 'last_started', value=int(time.time())) else: log.info(u'mpc(status): {0}'.format(status)) From f5b1b11cd927a7433cbfc827400adb8a71b0e2b7 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 13:30:14 +0100 Subject: [PATCH 03/21] bugfix: should survive MPD restart now --- beetsplug/mpc/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py index e3040a2be..5cef7c8a9 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc/__init__.py @@ -18,7 +18,7 @@ import logging # for fetching similar artists, tracks ... import pylast # for connecting to mpd -from mpd import MPDClient, CommandError, PendingCommandError +from mpd import MPDClient, CommandError, PendingCommandError, ConnectionError # for catching socket errors from socket import error as SocketError # for sockets @@ -211,9 +211,11 @@ class Client: return self.client.playlistinfo() elif func == 'status': return self.client.status() - except error: + except (error, ConnectionError) as err: # happens during shutdown and during MPDs library refresh + log.error(u'mpc: {0}'.format(err)) time.sleep(RETRY_INTERVAL) + self.disconnect() self.connect() continue else: @@ -298,7 +300,7 @@ class Client: log.info(u'mpc(status): {0}'.format(status)) if 'playlist' in changed: - playlist = self.playlist() + new_playlist = self.playlist() continue for item in playlist: beetsitem = self.beets_item(item) From e6a22953de59153f713af9b6380c02b2fcacafbd Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 14:04:25 +0100 Subject: [PATCH 04/21] detect playlist changes (add / remove) --- beetsplug/mpc/__init__.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py index 5cef7c8a9..784cb739c 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc/__init__.py @@ -176,10 +176,10 @@ class Client: item[user_attribute] = value if not increment is None: changed = True - item[attribute] = item.get(attribute, 0) + increment + item[attribute] = (float)(item.get(attribute, 0)) + increment if not user_attribute is None: - item[user_attribute] = item.get(user_attribute, 0) \ - + increment + item[user_attribute] = \ + (float)(item.get(user_attribute, 0)) + increment if changed: log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( attribute, item[attribute], item.path)) @@ -293,7 +293,7 @@ class Client: 'beets_item' : beets_item, } log.info(u'mpc(now_playing): {0}' - .format(now_playing)) + .format(now_playing['path'])) self._beets_set(now_playing['beets_item'], 'last_started', value=int(time.time())) else: @@ -301,14 +301,13 @@ class Client: if 'playlist' in changed: new_playlist = self.playlist() - continue - for item in playlist: - beetsitem = self.beets_item(item) - if not beetsitem is None: - log.info(u'mpc(playlist|beets): {0}'.format(beetsitem.path)) - else: - log.info(u'mpc(playlist): {0}'.format(item)) - + for new_file in new_playlist: + if not new_file in current_playlist: + log.info(u'mpc(playlist+): {0}'.format(new_file)) + for old_file in current_playlist: + if not old_file in new_playlist: + log.info(u'mpc(playlist-): {0}'.format(old_file)) + current_playlist = new_playlist class MPCPlugin(plugins.BeetsPlugin): def __init__(self): From be70b74450493716d7f9d3dba2e4333cc3caa110 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 15:04:34 +0100 Subject: [PATCH 05/21] use songid to detect currently playing song --- beetsplug/mpc/__init__.py | 101 +++++++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 35 deletions(-) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py index 784cb739c..22540d61a 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc/__init__.py @@ -70,20 +70,21 @@ class Client: self.client = MPDClient() - def connect(self): + def mpd_connect(self): try: self.client.connect(host=self.host, port=self.port) except SocketError, e: log.error(e) - exit(1) + return if not self.password == u'': try: self.client.password(self.password) except CommandError, e: log.error(e) - exit(1) + return + log.debug(u'mpc(commands): {0}'.format(self.client.commands())) - def disconnect(self): + def mpd_disconnect(self): self.client.close() self.client.disconnect() @@ -95,19 +96,30 @@ class Client: return True return False - def playlist(self): + def mpd_playlist(self): """Return the currently active playlist. Prefixes paths with the music_directory, to get the absolute path. """ - result = [] + result = {} for entry in self._mpdfun('playlistinfo'): + log.debug(u'mpc(playlist|entry): {0}'.format(entry)) if not self.is_url(entry['file']): - result.append(os.path.join( - self.music_directory, entry['file'])) + result[entry['id']] = os.path.join( + self.music_directory, entry['file']) else: - result.append(entry['file']) + result[entry['id']] = entry['file'] + log.debug(u'mpc(playlist): {0}'.format(result)) return result + def mpd_status(self): + status = self._mpdfun('status') + if status is None: + return None + log.debug(u'mpc(status): {0}'.format(status)) + self.consume = status.get('consume', u'0') == u'1' + self.random = status.get('random', u'0') == u'1' + return status + def beets_item(self, path): """Return the beets item related to path. """ @@ -204,8 +216,11 @@ class Client: except error: # happens during shutdown and during MPDs library refresh time.sleep(RETRY_INTERVAL) - self.connect() + self.mpd_connect() continue + except KeyboardInterrupt: + self.running = False + return None return self.client.fetch_idle() elif func == 'playlistinfo': return self.client.playlistinfo() @@ -215,8 +230,8 @@ class Client: # happens during shutdown and during MPDs library refresh log.error(u'mpc: {0}'.format(err)) time.sleep(RETRY_INTERVAL) - self.disconnect() - self.connect() + self.mpd_disconnect() + self.mpd_connect() continue else: # if we excited without breaking, we couldn't reconnect in time :( @@ -224,23 +239,33 @@ class Client: return None def run(self): - self.connect() + self.mpd_connect() self.running = True # exit condition for our main loop startup = True # we need to do some special stuff on startup now_playing = None # the currently playing song current_playlist = None # the currently active playlist + consume = False + random = False while self.running: if startup: # don't wait for an event, read in status and playlist - changed = ['player', 'playlist'] + events = ['player', 'playlist'] startup = False else: # wait for an event from the MPD server - changed = self._mpdfun('send_idle') + events = self._mpdfun('send_idle') + if events is None: + continue # probably KeyboardInterrupt + log.info(u'mpc(events): {0}'.format(events)) - if 'player' in changed: - # the status has changed - status = self._mpdfun('status') + if 'options' in events: + status = self.mpd_status() + + if 'player' in events: + status = self.mpd_status() + if status is None: + continue # probably KeyboardInterrupt + log.debug(u'mpc(status): {0}'.format(status)) if status['state'] == 'stop': log.info(u'mpc(stop)') now_playing = None @@ -248,8 +273,10 @@ class Client: log.info(u'mpc(pause)') now_playing = None elif status['state'] == 'play': - current_playlist = self.playlist() - song = current_playlist[int(status['song'])] + current_playlist = self.mpd_playlist() + if len(current_playlist) == 0: + continue # something is wrong ... + song = current_playlist[status['songid']] beets_item = self.beets_item(song) if self.is_url(song): # we ignore streams @@ -271,11 +298,11 @@ class Client: now_playing['started'])) if diff < 10.0: log.info('mpc(played): {0}' - .format(now_playing)) + .format(now_playing['path'])) skipped = False else: log.info('mpc(skipped): {0}' - .format(now_playing)) + .format(now_playing['path'])) skipped = True if skipped: self._beets_set(now_playing['beets_item'], @@ -299,13 +326,16 @@ class Client: else: log.info(u'mpc(status): {0}'.format(status)) - if 'playlist' in changed: - new_playlist = self.playlist() - for new_file in new_playlist: - if not new_file in current_playlist: + if 'playlist' in events: + status = self.mpd_status() + new_playlist = self.mpd_playlist() + if new_playlist is None: + continue + for new_file in new_playlist.items(): + if not new_file in current_playlist.items(): log.info(u'mpc(playlist+): {0}'.format(new_file)) - for old_file in current_playlist: - if not old_file in new_playlist: + for old_file in current_playlist.items(): + if not old_file in new_playlist.items(): log.info(u'mpc(playlist-): {0}'.format(old_file)) current_playlist = new_playlist @@ -313,13 +343,14 @@ class MPCPlugin(plugins.BeetsPlugin): def __init__(self): super(MPCPlugin, self).__init__() self.config.add({ - 'host': u'127.0.0.1', - 'port': 6600, - 'password': u'', - 'music_directory': u'', - 'user': u'', - 'rating': True, - 'rating_mix': 0.75, + 'host' : u'127.0.0.1', + 'port' : 6600, + 'password' : u'', + 'music_directory' : u'', + 'user' : u'', + 'rating' : True, + 'rating_mix' : 0.75, + 'min_queue' : 2, }) def commands(self): From 1d2ba6ef266fccba3cc615e625ac518fc85728d5 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 15:22:46 +0100 Subject: [PATCH 06/21] use songid to detect playlist changes --- beetsplug/mpc/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py index 22540d61a..e0728e321 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc/__init__.py @@ -331,12 +331,14 @@ class Client: new_playlist = self.mpd_playlist() if new_playlist is None: continue - for new_file in new_playlist.items(): - if not new_file in current_playlist.items(): - log.info(u'mpc(playlist+): {0}'.format(new_file)) - for old_file in current_playlist.items(): - if not old_file in new_playlist.items(): - log.info(u'mpc(playlist-): {0}'.format(old_file)) + for new_id in new_playlist.keys(): + if not new_id in current_playlist.keys(): + log.info(u'mpc(playlist+): {0}' + .format(new_playlist[new_id])) + for old_id in current_playlist.keys(): + if not old_id in new_playlist.keys(): + log.info(u'mpc(playlist-): {0}' + .format(current_playlist[old_id])) current_playlist = new_playlist class MPCPlugin(plugins.BeetsPlugin): From 62a06ef3b462c78955095a6a8dc8062b5208a764 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 15:33:05 +0100 Subject: [PATCH 07/21] add support for prio and prioid if MPD supports it --- beetsplug/mpc/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc/__init__.py index e0728e321..1cca0795a 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc/__init__.py @@ -46,6 +46,15 @@ RETRY_INTERVAL = 5 # hookup to the MPDClient internals to get unicode # see http://www.tarmack.eu/code/mpdunicode.py for the general idea class MPDClient(MPDClient): + def connect(self, **kwargs): + super(MPDClient, self).connect(**kwargs) + # add prio and prioid if supported (MPD >= 0.17?) + commands = self.commands() + if 'prio' in commands: + self._commands['prio'] = self._fetch_nothing + if 'prioid' in commands: + self._commands['prioid'] = self._fetch_nothing + def _write_command(self, command, args=[]): args = [unicode(arg).encode('utf-8') for arg in args] super(MPDClient, self)._write_command(command, args) @@ -108,7 +117,7 @@ class Client: self.music_directory, entry['file']) else: result[entry['id']] = entry['file'] - log.debug(u'mpc(playlist): {0}'.format(result)) + # log.debug(u'mpc(playlist): {0}'.format(result)) return result def mpd_status(self): From 3d964fb2db34e11798dd670a53f71d28de3ca487 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 16:31:53 +0100 Subject: [PATCH 08/21] clean up --- beetsplug/{mpc/__init__.py => mpc.py} | 123 +++++++++----------------- setup.py | 1 - 2 files changed, 43 insertions(+), 81 deletions(-) rename beetsplug/{mpc/__init__.py => mpc.py} (76%) diff --git a/beetsplug/mpc/__init__.py b/beetsplug/mpc.py similarity index 76% rename from beetsplug/mpc/__init__.py rename to beetsplug/mpc.py index 1cca0795a..0ae2d7fa5 100644 --- a/beetsplug/mpc/__init__.py +++ b/beetsplug/mpc.py @@ -46,15 +46,6 @@ RETRY_INTERVAL = 5 # hookup to the MPDClient internals to get unicode # see http://www.tarmack.eu/code/mpdunicode.py for the general idea class MPDClient(MPDClient): - def connect(self, **kwargs): - super(MPDClient, self).connect(**kwargs) - # add prio and prioid if supported (MPD >= 0.17?) - commands = self.commands() - if 'prio' in commands: - self._commands['prio'] = self._fetch_nothing - if 'prioid' in commands: - self._commands['prioid'] = self._fetch_nothing - def _write_command(self, command, args=[]): args = [unicode(arg).encode('utf-8') for arg in args] super(MPDClient, self)._write_command(command, args) @@ -65,7 +56,7 @@ class MPDClient(MPDClient): return line.decode('utf-8') return None -class Client: +class Client(object): def __init__(self, library, config): self.lib = library self.config = config @@ -91,7 +82,6 @@ class Client: except CommandError, e: log.error(e) return - log.debug(u'mpc(commands): {0}'.format(self.client.commands())) def mpd_disconnect(self): self.client.close() @@ -110,8 +100,8 @@ class Client: music_directory, to get the absolute path. """ result = {} - for entry in self._mpdfun('playlistinfo'): - log.debug(u'mpc(playlist|entry): {0}'.format(entry)) + for entry in self.mpd_func('playlistinfo'): + # log.debug(u'mpc(playlist|entry): {0}'.format(entry)) if not self.is_url(entry['file']): result[entry['id']] = os.path.join( self.music_directory, entry['file']) @@ -121,28 +111,27 @@ class Client: return result def mpd_status(self): - status = self._mpdfun('status') + status = self.mpd_func('status') if status is None: return None - log.debug(u'mpc(status): {0}'.format(status)) - self.consume = status.get('consume', u'0') == u'1' - self.random = status.get('random', u'0') == u'1' + # log.debug(u'mpc(status): {0}'.format(status)) return status - def beets_item(self, path): + def beets_get_item(self, path): """Return the beets item related to path. """ items = self.lib.items([path]) if len(items) == 0: + log.info(u'mpc(beets): item not found {0}'.format(path)) return None return items[0] - def _for_user(self, attribute): + def user_attr(self, attribute): if self.user != u'': return u'{1}[{0}]'.format(self.user, attribute) return None - def _rate(self, play_count, skip_count, rating, skipped): + def rating(self, play_count, skip_count, rating, skipped): if skipped: rolling = (rating - rating / 2.0) else: @@ -151,38 +140,35 @@ class Client: return self.rating_mix * stable \ + (1.0 - self.rating_mix) * rolling - def _beets_rate(self, item, skipped): + def beetsrating(self, item, skipped): """ Update the rating of the beets item. """ - if self.rating: - if not item is None: - attribute = 'rating' - item[attribute] = self._rate( - (int)(item.get('play_count', 0)), - (int)(item.get('skip_count', 0)), - (float)(item.get(attribute, 0.5)), + if self.rating and not item is None: + attribute = 'rating' + item[attribute] = self.rating( + (int)(item.get('play_count', 0)), + (int)(item.get('skip_count', 0)), + (float)(item.get(attribute, 0.5)), + skipped) + log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + attribute, item[attribute], item.path)) + user_attribute = self.user_attr('rating') + if not user_attribute is None: + item[user_attribute] = self.rating( + (int)(item.get(self.user_attr('play_count'), 0)), + (int)(item.get(self.user_attr('skip_count'), 0)), + (float)(item.get(user_attribute, 0.5)), skipped) log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( - attribute, item[attribute], item.path)) - user_attribute = self._for_user('rating') - if not user_attribute is None: - item[user_attribute] = self._rate( - (int)(item.get(self._for_user('play_count'), 0)), - (int)(item.get(self._for_user('skip_count'), 0)), - (float)(item.get(user_attribute, 0.5)), - skipped) - log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( - user_attribute, item[user_attribute], item.path)) - item.write() - if item._lib: - item.store() + user_attribute, item[user_attribute], item.path)) + item.write() + if item._lib: + item.store() - - def _beets_set(self, item, attribute, value=None, increment=None): + def beets_update(self, item, attribute, value=None, increment=None): """ Update the beets item. Set attribute to value or increment the - value of attribute. If a user has been given during initialization, - both the attribute and the attribute with the user prefixed, get - updated. + value of attribute. If we have a user both the attribute and the + attribute with the user suffix, get updated. """ if not item is None: changed = False @@ -211,7 +197,7 @@ class Client: if item._lib: item.store() - def _mpdfun(self, func, **kwargs): + def mpd_func(self, func, **kwargs): """Wrapper for requests to the MPD server. Tries to re-connect if the connection was lost ... """ @@ -253,28 +239,22 @@ class Client: startup = True # we need to do some special stuff on startup now_playing = None # the currently playing song current_playlist = None # the currently active playlist - consume = False - random = False while self.running: if startup: # don't wait for an event, read in status and playlist - events = ['player', 'playlist'] + events = ['player'] startup = False else: # wait for an event from the MPD server - events = self._mpdfun('send_idle') + events = self.mpd_func('send_idle') if events is None: continue # probably KeyboardInterrupt - log.info(u'mpc(events): {0}'.format(events)) - - if 'options' in events: - status = self.mpd_status() + log.debug(u'mpc(events): {0}'.format(events)) if 'player' in events: status = self.mpd_status() if status is None: continue # probably KeyboardInterrupt - log.debug(u'mpc(status): {0}'.format(status)) if status['state'] == 'stop': log.info(u'mpc(stop)') now_playing = None @@ -286,11 +266,11 @@ class Client: if len(current_playlist) == 0: continue # something is wrong ... song = current_playlist[status['songid']] - beets_item = self.beets_item(song) if self.is_url(song): # we ignore streams log.info(u'mpc(play|stream): {0}'.format(song)) else: + beets_item = self.beets_get_item(song) log.info(u'mpc(play): {0}'.format(song)) # status['time'] = position:duration (in seconds) t = status['time'].split(':') @@ -314,14 +294,13 @@ class Client: .format(now_playing['path'])) skipped = True if skipped: - self._beets_set(now_playing['beets_item'], + self.beets_update(now_playing['beets_item'], 'skip_count', increment=1) else: - self._beets_set(now_playing['beets_item'], + self.beets_update(now_playing['beets_item'], 'play_count', increment=1) - self._beets_set(now_playing['beets_item'], - 'last_played', value=int(time.time())) - self._beets_rate(now_playing['beets_item'], skipped) + self.beetsrating(now_playing['beets_item'], + skipped) now_playing = { 'started' : time.time(), 'remaining' : remaining, @@ -330,26 +309,11 @@ class Client: } log.info(u'mpc(now_playing): {0}' .format(now_playing['path'])) - self._beets_set(now_playing['beets_item'], - 'last_started', value=int(time.time())) + self.beets_update(now_playing['beets_item'], + 'last_played', value=int(time.time())) else: log.info(u'mpc(status): {0}'.format(status)) - if 'playlist' in events: - status = self.mpd_status() - new_playlist = self.mpd_playlist() - if new_playlist is None: - continue - for new_id in new_playlist.keys(): - if not new_id in current_playlist.keys(): - log.info(u'mpc(playlist+): {0}' - .format(new_playlist[new_id])) - for old_id in current_playlist.keys(): - if not old_id in new_playlist.keys(): - log.info(u'mpc(playlist-): {0}' - .format(current_playlist[old_id])) - current_playlist = new_playlist - class MPCPlugin(plugins.BeetsPlugin): def __init__(self): super(MPCPlugin, self).__init__() @@ -361,7 +325,6 @@ class MPCPlugin(plugins.BeetsPlugin): 'user' : u'', 'rating' : True, 'rating_mix' : 0.75, - 'min_queue' : 2, }) def commands(self): diff --git a/setup.py b/setup.py index 5c3279cde..03cf855b0 100755 --- a/setup.py +++ b/setup.py @@ -62,7 +62,6 @@ setup(name='beets', 'beetsplug.bpd', 'beetsplug.web', 'beetsplug.lastgenre', - 'beetsplug.mpc', ], namespace_packages=['beetsplug'], entry_points={ From e43b67640eda57d6304b96c641d3a72383d3ee32 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 16:37:28 +0100 Subject: [PATCH 09/21] clean up --- beetsplug/mpc.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/mpc.py b/beetsplug/mpc.py index 0ae2d7fa5..67a95acbc 100644 --- a/beetsplug/mpc.py +++ b/beetsplug/mpc.py @@ -271,8 +271,6 @@ class Client(object): log.info(u'mpc(play|stream): {0}'.format(song)) else: beets_item = self.beets_get_item(song) - log.info(u'mpc(play): {0}'.format(song)) - # status['time'] = position:duration (in seconds) t = status['time'].split(':') remaining = (int(t[1]) -int(t[0])) @@ -307,7 +305,7 @@ class Client(object): 'path' : song, 'beets_item' : beets_item, } - log.info(u'mpc(now_playing): {0}' + log.info(u'mpc(playing): {0}' .format(now_playing['path'])) self.beets_update(now_playing['beets_item'], 'last_played', value=int(time.time())) From 497746051efb18bc2d4e160f073e890b94197f59 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 16:40:22 +0100 Subject: [PATCH 10/21] clean up --- beetsplug/mpc.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/beetsplug/mpc.py b/beetsplug/mpc.py index 67a95acbc..7e53e444c 100644 --- a/beetsplug/mpc.py +++ b/beetsplug/mpc.py @@ -71,6 +71,8 @@ class Client(object): self.client = MPDClient() def mpd_connect(self): + """Connect to the MPD. + """ try: self.client.connect(host=self.host, port=self.port) except SocketError, e: @@ -84,6 +86,8 @@ class Client(object): return def mpd_disconnect(self): + """Disconnect from the MPD. + """ self.client.close() self.client.disconnect() @@ -111,11 +115,9 @@ class Client(object): return result def mpd_status(self): - status = self.mpd_func('status') - if status is None: - return None - # log.debug(u'mpc(status): {0}'.format(status)) - return status + """Return the current status of the MPD. + """ + return self.mpd_func('status') def beets_get_item(self, path): """Return the beets item related to path. @@ -127,11 +129,17 @@ class Client(object): return items[0] def user_attr(self, attribute): + """Return the attribute postfixed with the user or None if user is not + set. + """ if self.user != u'': return u'{1}[{0}]'.format(self.user, attribute) return None def rating(self, play_count, skip_count, rating, skipped): + """Calculate a new rating based on play count, skip count, old rating + and the fact if it was skipped or not. + """ if skipped: rolling = (rating - rating / 2.0) else: From 2ddff72752ebd6afaef5cb4c54d66b015eadf977 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 17:02:34 +0100 Subject: [PATCH 11/21] bugfix: clean up was a mess up --- beetsplug/mpc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/mpc.py b/beetsplug/mpc.py index 7e53e444c..fc6162e45 100644 --- a/beetsplug/mpc.py +++ b/beetsplug/mpc.py @@ -65,7 +65,7 @@ class Client(object): self.port = self.config['port'].get() self.password = self.config['password'].get() self.user = self.config['user'].get() - self.rating = self.config['rating'].get(bool) + self.do_rating = self.config['rating'].get(bool) self.rating_mix = self.config['rating_mix'].get(float) self.client = MPDClient() @@ -151,7 +151,7 @@ class Client(object): def beetsrating(self, item, skipped): """ Update the rating of the beets item. """ - if self.rating and not item is None: + if self.do_rating and not item is None: attribute = 'rating' item[attribute] = self.rating( (int)(item.get('play_count', 0)), From 0d2458cfb4cbbac1dcff2f43331d01ec7ede1517 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 17:07:47 +0100 Subject: [PATCH 12/21] first draft for documentation --- docs/plugins/index.rst | 3 +++ docs/plugins/mpc.rst | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 docs/plugins/mpc.rst diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index a71bcae6f..7d1393b0c 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -24,6 +24,7 @@ by typing ``beet version``. echonest_tempo echonest bpd + mpc mpdupdate fetchart embedart @@ -80,6 +81,8 @@ Metadata * :doc:`zero`: Nullify fields by pattern or unconditionally. * :doc:`ftintitle`: Move "featured" artists from the artist field to the title field. +* :doc:`mpc`: Connect to `MPD`_ and update the beets library with playing + statistics (last_played, play_count, skip_count). .. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html .. _the Echo Nest: http://www.echonest.com diff --git a/docs/plugins/mpc.rst b/docs/plugins/mpc.rst new file mode 100644 index 000000000..04ea06e74 --- /dev/null +++ b/docs/plugins/mpc.rst @@ -0,0 +1,26 @@ +MPC Plugin +================ + +``mpc`` is a plugin for beets that collects statistics about your listening +habits from `MPD`_. It collects the following information about tracks:: + +* play_count: The number of times you *fully* listened to this track. +* skip_count: The number of times you *skipped* this track. +* last_played: UNIX timestamp when you last played this track. +* rating: A rating based on *play_count* and *skip_count*. + +.. _MPD: http://mpd.wikia.com/wiki/Music_Player_Daemon_Wiki + +To use it, enable it in your ``config.yaml`` by putting ``mpc`` on your +``plugins`` line. Then, you'll probably want to configure the specifics of your +MPD server. You can do that using an ``mpc:`` section in your +``config.yaml``, which looks like this:: + + mpc: + host: localhost + port: 6600 + password: seekrit + +Now use the ``mpc`` command to fire it up:: + + $ beet mpc From 2bbf83e1a095dcb4532e4dec106c1eb0d0df3dac Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 17:10:25 +0100 Subject: [PATCH 13/21] first draft for documentation --- docs/plugins/mpc.rst | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/plugins/mpc.rst b/docs/plugins/mpc.rst index 04ea06e74..3b1934495 100644 --- a/docs/plugins/mpc.rst +++ b/docs/plugins/mpc.rst @@ -12,14 +12,18 @@ habits from `MPD`_. It collects the following information about tracks:: .. _MPD: http://mpd.wikia.com/wiki/Music_Player_Daemon_Wiki To use it, enable it in your ``config.yaml`` by putting ``mpc`` on your -``plugins`` line. Then, you'll probably want to configure the specifics of your -MPD server. You can do that using an ``mpc:`` section in your +``plugins`` line. Then, you'll probably want to configure the specifics of +your MPD server. You can do that using an ``mpc:`` section in your ``config.yaml``, which looks like this:: mpc: host: localhost port: 6600 password: seekrit + music_directory: /PATH/TO/YOUR/FILES + +*music_directory* needs to the same path where MPDs *music_directory* is. See +your local ``mpd.conf``. Now use the ``mpc`` command to fire it up:: From 1e4f33209a61d90e900ddc10e5fecd9ee9a678b4 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 20:24:44 +0100 Subject: [PATCH 14/21] clean up unused last.fm stuff --- beetsplug/mpc.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/beetsplug/mpc.py b/beetsplug/mpc.py index fc6162e45..fc9b72eac 100644 --- a/beetsplug/mpc.py +++ b/beetsplug/mpc.py @@ -35,9 +35,6 @@ from beets import plugins log = logging.getLogger('beets') -# for future use -LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) - # if we lose the connection, how many times do we want to RETRY and how much # time should we wait between retries RETRIES = 10 From 80c29c4f9c0f5b4a57c815d04304419e8b4ca3f6 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 20:27:09 +0100 Subject: [PATCH 15/21] renaming mpc to mpdstats --- beetsplug/{mpc.py => mpdstats.py} | 44 +++++++++++++------------------ 1 file changed, 19 insertions(+), 25 deletions(-) rename beetsplug/{mpc.py => mpdstats.py} (89%) diff --git a/beetsplug/mpc.py b/beetsplug/mpdstats.py similarity index 89% rename from beetsplug/mpc.py rename to beetsplug/mpdstats.py index fc9b72eac..1798191cb 100644 --- a/beetsplug/mpc.py +++ b/beetsplug/mpdstats.py @@ -102,13 +102,13 @@ class Client(object): """ result = {} for entry in self.mpd_func('playlistinfo'): - # log.debug(u'mpc(playlist|entry): {0}'.format(entry)) + # log.debug(u'mpdstats(playlist|entry): {0}'.format(entry)) if not self.is_url(entry['file']): result[entry['id']] = os.path.join( self.music_directory, entry['file']) else: result[entry['id']] = entry['file'] - # log.debug(u'mpc(playlist): {0}'.format(result)) + # log.debug(u'mpdstats(playlist): {0}'.format(result)) return result def mpd_status(self): @@ -121,7 +121,7 @@ class Client(object): """ items = self.lib.items([path]) if len(items) == 0: - log.info(u'mpc(beets): item not found {0}'.format(path)) + log.info(u'mpdstats(beets): item not found {0}'.format(path)) return None return items[0] @@ -155,7 +155,7 @@ class Client(object): (int)(item.get('skip_count', 0)), (float)(item.get(attribute, 0.5)), skipped) - log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( attribute, item[attribute], item.path)) user_attribute = self.user_attr('rating') if not user_attribute is None: @@ -164,7 +164,7 @@ class Client(object): (int)(item.get(self.user_attr('skip_count'), 0)), (float)(item.get(user_attribute, 0.5)), skipped) - log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( user_attribute, item[user_attribute], item.path)) item.write() if item._lib: @@ -193,10 +193,10 @@ class Client(object): item[user_attribute] = \ (float)(item.get(user_attribute, 0)) + increment if changed: - log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( attribute, item[attribute], item.path)) if not user_attribute is None: - log.debug(u'mpc(updated beets): {0} = {1} [{2}]'.format( + log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( user_attribute, item[user_attribute], item.path)) item.write() if item._lib: @@ -228,7 +228,7 @@ class Client(object): return self.client.status() except (error, ConnectionError) as err: # happens during shutdown and during MPDs library refresh - log.error(u'mpc: {0}'.format(err)) + log.error(u'mpdstats: {0}'.format(err)) time.sleep(RETRY_INTERVAL) self.mpd_disconnect() self.mpd_connect() @@ -254,17 +254,17 @@ class Client(object): events = self.mpd_func('send_idle') if events is None: continue # probably KeyboardInterrupt - log.debug(u'mpc(events): {0}'.format(events)) + log.debug(u'mpdstats(events): {0}'.format(events)) if 'player' in events: status = self.mpd_status() if status is None: continue # probably KeyboardInterrupt if status['state'] == 'stop': - log.info(u'mpc(stop)') + log.info(u'mpdstats(stop)') now_playing = None elif status['state'] == 'pause': - log.info(u'mpc(pause)') + log.info(u'mpdstats(pause)') now_playing = None elif status['state'] == 'play': current_playlist = self.mpd_playlist() @@ -273,7 +273,7 @@ class Client(object): song = current_playlist[status['songid']] if self.is_url(song): # we ignore streams - log.info(u'mpc(play|stream): {0}'.format(song)) + log.info(u'mpdstats(play|stream): {0}'.format(song)) else: beets_item = self.beets_get_item(song) t = status['time'].split(':') @@ -289,11 +289,11 @@ class Client(object): (time.time() - now_playing['started'])) if diff < 10.0: - log.info('mpc(played): {0}' + log.info('mpdstats(played): {0}' .format(now_playing['path'])) skipped = False else: - log.info('mpc(skipped): {0}' + log.info('mpdstats(skipped): {0}' .format(now_playing['path'])) skipped = True if skipped: @@ -310,16 +310,16 @@ class Client(object): 'path' : song, 'beets_item' : beets_item, } - log.info(u'mpc(playing): {0}' + log.info(u'mpdstats(playing): {0}' .format(now_playing['path'])) self.beets_update(now_playing['beets_item'], 'last_played', value=int(time.time())) else: - log.info(u'mpc(status): {0}'.format(status)) + log.info(u'mpdstats(status): {0}'.format(status)) -class MPCPlugin(plugins.BeetsPlugin): +class MPDStatsPlugin(plugins.BeetsPlugin): def __init__(self): - super(MPCPlugin, self).__init__() + super(MPDStatsPlugin, self).__init__() self.config.add({ 'host' : u'127.0.0.1', 'port' : 6600, @@ -331,7 +331,7 @@ class MPCPlugin(plugins.BeetsPlugin): }) def commands(self): - cmd = ui.Subcommand('mpc', + cmd = ui.Subcommand('mpdstats', help='run a MPD client to gather play statistics') cmd.parser.add_option('--host', dest='host', type='string', @@ -348,12 +348,6 @@ class MPCPlugin(plugins.BeetsPlugin): def func(lib, opts, args): self.config.set_args(opts) - # ATM we need to set the music_directory where the files are - # located, as the MPD server just tells us the relative paths to - # the files. This is good and bad. 'bad' because we have to set - # an extra option. 'good' because if the MPD server is running on - # a different host and has mounted the music directory somewhere - # else, we don't care ... Client(lib, self.config).run() cmd.func = func From e4abf0af352335f098465932f2b8b64d52fcba72 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 20:28:09 +0100 Subject: [PATCH 16/21] renaming mpc to mpdstats --- docs/plugins/index.rst | 6 +++--- docs/plugins/{mpc.rst => mpdstats.py} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename docs/plugins/{mpc.rst => mpdstats.py} (100%) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 7d1393b0c..c0061789f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -24,8 +24,8 @@ by typing ``beet version``. echonest_tempo echonest bpd - mpc mpdupdate + mpdstats fetchart embedart web @@ -81,8 +81,8 @@ Metadata * :doc:`zero`: Nullify fields by pattern or unconditionally. * :doc:`ftintitle`: Move "featured" artists from the artist field to the title field. -* :doc:`mpc`: Connect to `MPD`_ and update the beets library with playing - statistics (last_played, play_count, skip_count). +* :doc:`mpdstats`: Connect to `MPD`_ and update the beets library with play + statistics (last_played, play_count, skip_count, rating). .. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html .. _the Echo Nest: http://www.echonest.com diff --git a/docs/plugins/mpc.rst b/docs/plugins/mpdstats.py similarity index 100% rename from docs/plugins/mpc.rst rename to docs/plugins/mpdstats.py From 890e522bc05c1cf1f30929e13685c4510d4ccab0 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 20:30:13 +0100 Subject: [PATCH 17/21] removed more last.fm stuff --- beetsplug/mpdstats.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 1798191cb..d7c3a2ae5 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -15,8 +15,6 @@ # requires python-mpd to run. install with: pip install python-mpd import logging -# for fetching similar artists, tracks ... -import pylast # for connecting to mpd from mpd import MPDClient, CommandError, PendingCommandError, ConnectionError # for catching socket errors From ac0f62eaf153d8cac37804c9a3a221505fa261e2 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 21:03:11 +0100 Subject: [PATCH 18/21] proposal for one global 'mpd' config section --- beetsplug/mpdstats.py | 51 ++++++++++++++++++++++++++++-------------- beetsplug/mpdupdate.py | 28 +++++++++++++++++------ 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index d7c3a2ae5..178b9f204 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -52,16 +52,29 @@ class MPDClient(MPDClient): return None class Client(object): - def __init__(self, library, config): + def __init__(self, library): self.lib = library - self.config = config - self.music_directory = self.config['music_directory'].get() - self.host = self.config['host'].get() - self.port = self.config['port'].get() - self.password = self.config['password'].get() - self.user = self.config['user'].get() - self.do_rating = self.config['rating'].get(bool) - self.rating_mix = self.config['rating_mix'].get(float) + # defaults + self.mpd_config = { + 'host' : u'localhost', + 'port' : 6600, + 'password' : u'' + } + # from global 'mpd' section + if 'mpd' in config.keys(): + for opt in ('host', 'port', 'password'): + if opt in config['mpd'].keys(): + self.mpd_config[opt] = config['mpd'][opt].get() + + # plugin specific / optargs + for opt in ('host', 'port', 'password'): + if config['mpdstats'][opt].get() is not None: + self.mpd_config[opt] = config['mpdstats'][opt].get() + + self.music_directory = config['mpdstats']['music_directory'].get() + self.user = config['mpdstats']['user'].get() + self.do_rating = config['mpdstats']['rating'].get(bool) + self.rating_mix = config['mpdstats']['rating_mix'].get(float) self.client = MPDClient() @@ -69,13 +82,17 @@ class Client(object): """Connect to the MPD. """ try: - self.client.connect(host=self.host, port=self.port) + log.info(u'mpdstats(connecting): MPD@{0}:{1}' + .format(self.mpd_config['host'], + self.mpd_config['port'])) + self.client.connect(host=self.mpd_config['host'], + port=self.mpd_config['port']) except SocketError, e: log.error(e) return - if not self.password == u'': + if not self.mpd_config['password'] == u'': try: - self.client.password(self.password) + self.client.password(self.mpd_config['password']) except CommandError, e: log.error(e) return @@ -319,10 +336,10 @@ class MPDStatsPlugin(plugins.BeetsPlugin): def __init__(self): super(MPDStatsPlugin, self).__init__() self.config.add({ - 'host' : u'127.0.0.1', - 'port' : 6600, - 'password' : u'', - 'music_directory' : u'', + 'host' : None, + 'port' : None, + 'password' : None, + 'music_directory' : config['directory'].get(unicode), 'user' : u'', 'rating' : True, 'rating_mix' : 0.75, @@ -346,7 +363,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin): def func(lib, opts, args): self.config.set_args(opts) - Client(lib, self.config).run() + Client(lib).run() cmd.func = func return [cmd] diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index e21b8e158..25358530b 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -15,7 +15,7 @@ """Updates an MPD index whenever the library is changed. Put something like the following in your config.yaml to configure: - mpdupdate: + mpd: host: localhost port: 6600 password: seekrit @@ -98,9 +98,9 @@ class MPDUpdatePlugin(BeetsPlugin): def __init__(self): super(MPDUpdatePlugin, self).__init__() self.config.add({ - 'host': u'localhost', - 'port': 6600, - 'password': u'', + 'host': None, + 'port': None, + 'password': None, }) @@ -113,8 +113,22 @@ def handle_change(lib=None): @MPDUpdatePlugin.listen('cli_exit') def update(lib=None): if database_changed: + mpd_config = { + 'host' : u'localhost', + 'port' : 6600, + 'password' : u'' + } + # try to get global mpd config + if 'mpd' in config.keys(): + for opt in ('host', 'port', 'password'): + if opt in config['mpd'].keys(): + mpd_config[opt] = config['mpd'][opt].get() + # overwrite with plugin specific + for opt in ('host', 'port', 'password'): + if config['mpdupdate'][opt].get() is not None: + mpd_config[opt] = config['mpdupdate'][opt].get() update_mpd( - config['mpdupdate']['host'].get(unicode), - config['mpdupdate']['port'].get(int), - config['mpdupdate']['password'].get(unicode), + mpd_config['host'], + mpd_config['port'], + mpd_config['password'] ) From accaf5c21d493a46d9c1758f1a3f2b025af73050 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 21:12:02 +0100 Subject: [PATCH 19/21] update docs --- docs/plugins/mpdstats.py | 26 +++++++++++++++++--------- docs/plugins/mpdupdate.rst | 6 +++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/plugins/mpdstats.py b/docs/plugins/mpdstats.py index 3b1934495..15335d704 100644 --- a/docs/plugins/mpdstats.py +++ b/docs/plugins/mpdstats.py @@ -1,7 +1,7 @@ -MPC Plugin +MPDStats Plugin ================ -``mpc`` is a plugin for beets that collects statistics about your listening +``mpdstats`` is a plugin for beets that collects statistics about your listening habits from `MPD`_. It collects the following information about tracks:: * play_count: The number of times you *fully* listened to this track. @@ -11,20 +11,28 @@ habits from `MPD`_. It collects the following information about tracks:: .. _MPD: http://mpd.wikia.com/wiki/Music_Player_Daemon_Wiki -To use it, enable it in your ``config.yaml`` by putting ``mpc`` on your +To use it, enable it in your ``config.yaml`` by putting ``mpdstats`` on your ``plugins`` line. Then, you'll probably want to configure the specifics of -your MPD server. You can do that using an ``mpc:`` section in your +your MPD server. You can do that using an ``mpd:`` section in your ``config.yaml``, which looks like this:: - mpc: + mpd: host: localhost port: 6600 password: seekrit + +If your MPD library is at another location then the beets library e.g. because +one is mounted on a NFS share, you can specify the ```music_directory``` in +the config like this:: + + mpdstats: music_directory: /PATH/TO/YOUR/FILES -*music_directory* needs to the same path where MPDs *music_directory* is. See -your local ``mpd.conf``. +Now use the ``mpdstats`` command to fire it up:: -Now use the ``mpc`` command to fire it up:: + $ beet mpdstats - $ beet mpc +This has only been tested with MPD versions >= 0.16. It may have difficulties +on older versions. If that is the case, please report an `Issue`_. + +.. _Issue: https://github.com/sampsyo/beets/issues diff --git a/docs/plugins/mpdupdate.rst b/docs/plugins/mpdupdate.rst index ad4e7e600..a23bbdc91 100644 --- a/docs/plugins/mpdupdate.rst +++ b/docs/plugins/mpdupdate.rst @@ -8,10 +8,10 @@ update `MPD`_'s index whenever you change your beets library. To use it, enable it in your ``config.yaml`` by putting ``mpdupdate`` on your ``plugins`` line. Then, you'll probably want to configure the specifics of your -MPD server. You can do that using an ``mpdupdate:`` section in your -``config.yaml``, which looks like this:: +MPD server. You can do that using an ``mpd:`` section in your ``config.yaml``, +which looks like this:: - mpdupdate: + mpd: host: localhost port: 6600 password: seekrit From 263da2bc5276a39c6f91497df89c1390b189ba29 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 31 Oct 2013 21:30:39 +0100 Subject: [PATCH 20/21] too much text ... --- docs/plugins/mpdstats.py | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/docs/plugins/mpdstats.py b/docs/plugins/mpdstats.py index 15335d704..55306b7fc 100644 --- a/docs/plugins/mpdstats.py +++ b/docs/plugins/mpdstats.py @@ -11,6 +11,19 @@ habits from `MPD`_. It collects the following information about tracks:: .. _MPD: http://mpd.wikia.com/wiki/Music_Player_Daemon_Wiki +Installing Dependencies +----------------------- + +This plugin requires the python-mpd library in order to talk to the MPD +server. + +Install the library from `pip`_, like so:: + + $ pip install python-mpd + +Configuring +----------- + To use it, enable it in your ``config.yaml`` by putting ``mpdstats`` on your ``plugins`` line. Then, you'll probably want to configure the specifics of your MPD server. You can do that using an ``mpd:`` section in your @@ -28,10 +41,66 @@ the config like this:: mpdstats: music_directory: /PATH/TO/YOUR/FILES +If you don't want the plugin to automatically update the rating, you can +disable it with:: + + mpdstats: + rating: False + +If you want to change the way the rating is calculated, you can set the +```rating_mix``` option like this:: + + mpdstats: + rating_mix: 1.0 + +For details, see below. + + +Usage +----- + Now use the ``mpdstats`` command to fire it up:: $ beet mpdstats +A Word On Ratings +----------------- + +Ratings are calculated based on the *play_count*, *skip_count* and the last +*action* (play or skip). It consists in one part of a *stable_rating* and in +another part on a *rolling_rating*. The *stable_rating* is calculated like +this:: + + stable_rating = (play_count + 1.0) / (play_count + skip_count + 2.0) + +So if the *play_count* equals the *skip_count*, the *stable_rating* is always +0.5. More *play_counts* adjust the rating up to 1.0. More *skip_counts* +adjust it down to 0.0. One of the disadvantages of this rating system, is +that it doesn't really cover *recent developments*. e.g. a song that you +loved last year and played over 50 times will keep a high rating even if you +skipped it the last 10 times. That's were the *rolling_rating* comes in. + +If a song has been fully played, the *rolling_rating* is calculated like +this:: + + rolling_rating = old_rating + (1.0 - old_rating) / 2.0 + +If a song has been skipped, like this:: + + rolling_rating = old_rating - old_rating / 2.0 + +So *rolling_rating* adapts pretty fast to *recent developments*. But it's too +fast. Taking the example from above, your old favorite with 50 plays will get +a negative rating (<0.5) the first time you skip it. Also not good. + +To take the best of both worlds, we mix the ratings together with the +```rating_mix``` factor. A ```rating_mix``` of 0.0 means all +*rolling* and 1.0 means all *stable*. We found 0.75 to be a good compromise, +but fell free to play with that. + +Warning +------- + This has only been tested with MPD versions >= 0.16. It may have difficulties on older versions. If that is the case, please report an `Issue`_. From 46df2e5630253cf197813db8d25687cf400a2cb3 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Fri, 1 Nov 2013 12:27:56 +0100 Subject: [PATCH 21/21] removed user stuff --- beetsplug/mpdstats.py | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 178b9f204..faee1e407 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -72,7 +72,6 @@ class Client(object): self.mpd_config[opt] = config['mpdstats'][opt].get() self.music_directory = config['mpdstats']['music_directory'].get() - self.user = config['mpdstats']['user'].get() self.do_rating = config['mpdstats']['rating'].get(bool) self.rating_mix = config['mpdstats']['rating_mix'].get(float) @@ -140,14 +139,6 @@ class Client(object): return None return items[0] - def user_attr(self, attribute): - """Return the attribute postfixed with the user or None if user is not - set. - """ - if self.user != u'': - return u'{1}[{0}]'.format(self.user, attribute) - return None - def rating(self, play_count, skip_count, rating, skipped): """Calculate a new rating based on play count, skip count, old rating and the fact if it was skipped or not. @@ -172,47 +163,25 @@ class Client(object): skipped) log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( attribute, item[attribute], item.path)) - user_attribute = self.user_attr('rating') - if not user_attribute is None: - item[user_attribute] = self.rating( - (int)(item.get(self.user_attr('play_count'), 0)), - (int)(item.get(self.user_attr('skip_count'), 0)), - (float)(item.get(user_attribute, 0.5)), - skipped) - log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( - user_attribute, item[user_attribute], item.path)) item.write() if item._lib: item.store() def beets_update(self, item, attribute, value=None, increment=None): """ Update the beets item. Set attribute to value or increment the - value of attribute. If we have a user both the attribute and the - attribute with the user suffix, get updated. + value of attribute. """ if not item is None: changed = False - if self.user != u'': - user_attribute = u'{1}[{0}]'.format(self.user, attribute) - else: - user_attribute = None if not value is None: changed = True item[attribute] = value - if not user_attribute is None: - item[user_attribute] = value if not increment is None: changed = True item[attribute] = (float)(item.get(attribute, 0)) + increment - if not user_attribute is None: - item[user_attribute] = \ - (float)(item.get(user_attribute, 0)) + increment if changed: log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( attribute, item[attribute], item.path)) - if not user_attribute is None: - log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format( - user_attribute, item[user_attribute], item.path)) item.write() if item._lib: item.store() @@ -340,7 +309,6 @@ class MPDStatsPlugin(plugins.BeetsPlugin): 'port' : None, 'password' : None, 'music_directory' : config['directory'].get(unicode), - 'user' : u'', 'rating' : True, 'rating_mix' : 0.75, }) @@ -357,9 +325,6 @@ class MPDStatsPlugin(plugins.BeetsPlugin): cmd.parser.add_option('--password', dest='password', type='string', help='set the password of the MPD server to connect to') - cmd.parser.add_option('--user', dest='user', - type='string', - help='set the user for whom we want to gather statistics') def func(lib, opts, args): self.config.set_args(opts)