mirror of
https://github.com/beetbox/beets.git
synced 2025-12-15 13:07:09 +01:00
mpdstats: refactor plugin
This commit is contained in:
parent
ad2028ddf7
commit
df274e2b15
1 changed files with 190 additions and 179 deletions
|
|
@ -1,5 +1,7 @@
|
|||
# coding=utf-8
|
||||
# This file is part of beets.
|
||||
# Copyright 2013, Peter Schnebel.
|
||||
# Copyright 2013, Johann Klähn.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -35,6 +37,12 @@ RETRIES = 10
|
|||
RETRY_INTERVAL = 5
|
||||
|
||||
|
||||
def is_url(path):
|
||||
"""Try to determine if the path is an URL.
|
||||
"""
|
||||
return path.split('://', 1)[0] in ['http', 'https']
|
||||
|
||||
|
||||
# Use the MPDClient internals to get unicode.
|
||||
# see http://www.tarmack.eu/code/mpdunicode.py for the general idea
|
||||
class MPDClient(mpd.MPDClient):
|
||||
|
|
@ -49,17 +57,14 @@ class MPDClient(mpd.MPDClient):
|
|||
return None
|
||||
|
||||
|
||||
class Client(object):
|
||||
def __init__(self, lib):
|
||||
self.lib = lib
|
||||
|
||||
self.music_directory = config['mpdstats']['music_directory'].get()
|
||||
self.do_rating = config['mpdstats']['rating'].get(bool)
|
||||
self.rating_mix = config['mpdstats']['rating_mix'].get(float)
|
||||
class MPDClientWrapper(object):
|
||||
def __init__(self):
|
||||
self.music_directory = (
|
||||
config['mpdstats']['music_directory'].get(unicode))
|
||||
|
||||
self.client = MPDClient()
|
||||
|
||||
def mpd_connect(self):
|
||||
def connect(self):
|
||||
"""Connect to the MPD.
|
||||
"""
|
||||
host = config['mpd']['host'].get(unicode)
|
||||
|
|
@ -83,37 +88,84 @@ class Client(object):
|
|||
'could not authenticate to MPD: {0}'.format(e)
|
||||
)
|
||||
|
||||
def mpd_disconnect(self):
|
||||
def disconnect(self):
|
||||
"""Disconnect from the MPD.
|
||||
"""
|
||||
self.client.close()
|
||||
self.client.disconnect()
|
||||
|
||||
def is_url(self, path):
|
||||
"""Try to determine if the path is an URL.
|
||||
def get(self, command, retries=RETRIES):
|
||||
"""Wrapper for requests to the MPD server. Tries to re-connect if the
|
||||
connection was lost (f.ex. during MPD's library refresh).
|
||||
"""
|
||||
# FIXME: cover more URL types ...
|
||||
return path[:7] == "http://"
|
||||
try:
|
||||
return getattr(self.client, command)()
|
||||
except (select.error, mpd.ConnectionError) as err:
|
||||
log.error(u'mpdstats: {0}'.format(err))
|
||||
|
||||
def mpd_playlist(self):
|
||||
if retries <= 0:
|
||||
# if we exited without breaking, we couldn't reconnect in time :(
|
||||
raise ui.UserError(u'communication with MPD server failed')
|
||||
|
||||
time.sleep(RETRY_INTERVAL)
|
||||
|
||||
try:
|
||||
self.disconnect()
|
||||
except mpd.ConnectionError:
|
||||
pass
|
||||
|
||||
self.connect()
|
||||
return self.get(command, retries=retries - 1)
|
||||
|
||||
def playlist(self):
|
||||
"""Return the currently active playlist. Prefixes paths with the
|
||||
music_directory, to get the absolute path.
|
||||
"""
|
||||
result = {}
|
||||
for entry in self.mpd_func('playlistinfo'):
|
||||
if not self.is_url(entry['file']):
|
||||
for entry in self.get('playlistinfo'):
|
||||
if not is_url(entry['file']):
|
||||
result[entry['id']] = os.path.join(
|
||||
self.music_directory, entry['file'])
|
||||
else:
|
||||
result[entry['id']] = entry['file']
|
||||
return result
|
||||
|
||||
def mpd_status(self):
|
||||
def status(self):
|
||||
"""Return the current status of the MPD.
|
||||
"""
|
||||
return self.mpd_func('status')
|
||||
return self.get('status')
|
||||
|
||||
def beets_get_item(self, path):
|
||||
def events(self):
|
||||
"""Return list of events. This may block a long time while waiting for
|
||||
an answer from MPD.
|
||||
"""
|
||||
return self.get('idle')
|
||||
|
||||
|
||||
class MPDStats(object):
|
||||
def __init__(self, lib):
|
||||
self.lib = lib
|
||||
|
||||
self.do_rating = config['mpdstats']['rating'].get(bool)
|
||||
self.rating_mix = config['mpdstats']['rating_mix'].get(float)
|
||||
self.time_threshold = 10.0 # TODO: maybe add config option?
|
||||
|
||||
self.now_playing = None
|
||||
self.mpd = MPDClientWrapper()
|
||||
|
||||
def rating(self, play_count, skip_count, rating, skipped):
|
||||
"""Calculate a new rating for a song 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:
|
||||
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 get_item(self, path):
|
||||
"""Return the beets item related to path.
|
||||
"""
|
||||
query = library.MatchQuery('path', path)
|
||||
|
|
@ -125,172 +177,127 @@ class Client(object):
|
|||
displayable_path(path)
|
||||
))
|
||||
|
||||
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.
|
||||
@staticmethod
|
||||
def update_item(item, attribute, value=None, increment=None):
|
||||
"""Update the beets item. Set attribute to value or increment the value
|
||||
of attribute. If the increment argument is used the value is cast to the
|
||||
corresponding type.
|
||||
"""
|
||||
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
|
||||
if item is None:
|
||||
return
|
||||
|
||||
if increment is not None:
|
||||
value = type(increment)(item.get(attribute, 0)) + increment
|
||||
|
||||
if value is not None:
|
||||
item[attribute] = value
|
||||
item.store()
|
||||
|
||||
def beetsrating(self, item, skipped):
|
||||
""" Update the rating of the beets item.
|
||||
"""
|
||||
if self.do_rating and item:
|
||||
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'mpdstats: updated: {0} = {1} [{2}]'.format(
|
||||
attribute,
|
||||
item[attribute],
|
||||
displayable_path(item.path),
|
||||
))
|
||||
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.
|
||||
def update_rating(self, item, skipped):
|
||||
"""Update the rating for a beets item.
|
||||
"""
|
||||
if item is not None:
|
||||
changed = False
|
||||
if value is not None:
|
||||
changed = True
|
||||
item[attribute] = value
|
||||
if increment is not None:
|
||||
changed = True
|
||||
item[attribute] = (float)(item.get(attribute, 0)) + increment
|
||||
if changed:
|
||||
log.debug(u'mpdstats: updated: {0} = {1} [{2}]'.format(
|
||||
attribute,
|
||||
item[attribute],
|
||||
displayable_path(item.path),
|
||||
))
|
||||
item.store()
|
||||
rating = self.rating(
|
||||
int(item.get('play_count', 0)),
|
||||
int(item.get('skip_count', 0)),
|
||||
float(item.get('rating', 0.5)),
|
||||
skipped)
|
||||
|
||||
def mpd_func(self, func, **kwargs):
|
||||
"""Wrapper for requests to the MPD server. Tries to re-connect if the
|
||||
connection was lost ...
|
||||
self.update_item(item, 'rating', rating)
|
||||
|
||||
def handle_song_change(self, song):
|
||||
"""Determine if a song was skipped or not and update its attributes.
|
||||
To this end the difference between the song's supposed end time
|
||||
and the current time is calculated. If it's greater than a threshold,
|
||||
the song is considered skipped.
|
||||
"""
|
||||
for i in range(RETRIES):
|
||||
try:
|
||||
if func == 'send_idle':
|
||||
# special case, wait for an event
|
||||
self.client.send_idle()
|
||||
try:
|
||||
select.select([self.client], [], [])
|
||||
except select.error:
|
||||
# Happens during shutdown and during MPDs
|
||||
# library refresh.
|
||||
time.sleep(RETRY_INTERVAL)
|
||||
self.mpd_connect()
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
self.running = False
|
||||
return None
|
||||
return self.client.fetch_idle()
|
||||
elif func == 'playlistinfo':
|
||||
return self.client.playlistinfo()
|
||||
elif func == 'status':
|
||||
return self.client.status()
|
||||
except (select.error, mpd.ConnectionError) as err:
|
||||
# happens during shutdown and during MPDs library refresh
|
||||
log.error(u'mpdstats: {0}'.format(err))
|
||||
time.sleep(RETRY_INTERVAL)
|
||||
self.mpd_disconnect()
|
||||
self.mpd_connect()
|
||||
continue
|
||||
diff = abs(song['remaining'] - (time.time() - song['started']))
|
||||
|
||||
skipped = diff >= self.time_threshold
|
||||
|
||||
if skipped:
|
||||
self.handle_skipped(song)
|
||||
else:
|
||||
# if we exited without breaking, we couldn't reconnect in time :(
|
||||
raise ui.UserError(u'failed to re-connect to MPD server')
|
||||
self.handle_played(song)
|
||||
|
||||
if self.do_rating:
|
||||
self.update_rating(song['beets_item'], skipped)
|
||||
|
||||
def handle_played(self, song):
|
||||
"""Updates the play count of a song.
|
||||
"""
|
||||
self.update_item(song['beets_item'], 'play_count', increment=1)
|
||||
log.info(u'mpdstats: played {0}'.format(
|
||||
displayable_path(song['path'])
|
||||
))
|
||||
|
||||
def handle_skipped(self, song):
|
||||
"""Updates the skip count of a song.
|
||||
"""
|
||||
self.update_item(song['beets_item'], 'skip_count', increment=1)
|
||||
log.info(u'mpdstats: skipped {0}'.format(
|
||||
displayable_path(song['path'])
|
||||
))
|
||||
|
||||
def on_stop(self, status):
|
||||
log.info(u'mpdstats: stop')
|
||||
self.now_playing = None
|
||||
|
||||
def on_pause(self, status):
|
||||
log.info(u'mpdstats: pause')
|
||||
self.now_playing = None
|
||||
|
||||
def on_play(self, status):
|
||||
playlist = self.mpd.playlist()
|
||||
path = playlist.get(status['songid'])
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
if is_url(path):
|
||||
log.info(u'mpdstats: playing stream {0}'.format(
|
||||
displayable_path(path)
|
||||
))
|
||||
return
|
||||
|
||||
duration, played = map(int, status['time'].split(':', 1))
|
||||
remaining = duration - played
|
||||
|
||||
if self.now_playing and self.now_playing['path'] != path:
|
||||
self.handle_song_change(self.now_playing)
|
||||
|
||||
log.info(u'mpdstats: playing {0}'.format(
|
||||
displayable_path(path)
|
||||
))
|
||||
|
||||
self.now_playing = {
|
||||
'started': time.time(),
|
||||
'remaining': remaining,
|
||||
'path': path,
|
||||
'beets_item': self.get_item(path),
|
||||
}
|
||||
|
||||
def run(self):
|
||||
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
|
||||
while self.running:
|
||||
if startup:
|
||||
# don't wait for an event, read in status and playlist
|
||||
events = ['player']
|
||||
startup = False
|
||||
else:
|
||||
# wait for an event from the MPD server
|
||||
events = self.mpd_func('send_idle')
|
||||
if events is None:
|
||||
continue # probably KeyboardInterrupt
|
||||
log.debug(u'mpdstats: events: {0}'.format(events))
|
||||
self.mpd.connect()
|
||||
events = ['player']
|
||||
|
||||
while True:
|
||||
if 'player' in events:
|
||||
status = self.mpd_status()
|
||||
if status is None:
|
||||
continue # probably KeyboardInterrupt
|
||||
if status['state'] == 'stop':
|
||||
log.info(u'mpdstats: stop')
|
||||
now_playing = None
|
||||
elif status['state'] == 'pause':
|
||||
log.info(u'mpdstats: pause')
|
||||
now_playing = None
|
||||
elif status['state'] == 'play':
|
||||
current_playlist = self.mpd_playlist()
|
||||
if len(current_playlist) == 0:
|
||||
continue # something is wrong ...
|
||||
song = current_playlist[status['songid']]
|
||||
if self.is_url(song):
|
||||
# we ignore streams
|
||||
log.info(u'mpdstats: play/stream: {0}'.format(song))
|
||||
else:
|
||||
beets_item = self.beets_get_item(song)
|
||||
t = status['time'].split(':')
|
||||
remaining = int(t[1]) - int(t[0])
|
||||
status = self.mpd.status()
|
||||
|
||||
if now_playing and \
|
||||
now_playing['path'] != song:
|
||||
# song change
|
||||
# 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(u'mpdstats: played: {0}'.format(
|
||||
displayable_path(now_playing['path'])
|
||||
))
|
||||
skipped = False
|
||||
else:
|
||||
log.info(u'mpdstats: skipped: {0}'.format(
|
||||
displayable_path(now_playing['path'])
|
||||
))
|
||||
skipped = True
|
||||
if skipped:
|
||||
self.beets_update(now_playing['beets_item'],
|
||||
'skip_count', increment=1)
|
||||
else:
|
||||
self.beets_update(now_playing['beets_item'],
|
||||
'play_count', increment=1)
|
||||
self.beetsrating(now_playing['beets_item'],
|
||||
skipped)
|
||||
now_playing = {
|
||||
'started': time.time(),
|
||||
'remaining': remaining,
|
||||
'path': song,
|
||||
'beets_item': beets_item,
|
||||
}
|
||||
log.info(u'mpdstats: playing: {0}'.format(
|
||||
displayable_path(now_playing['path'])
|
||||
))
|
||||
self.beets_update(now_playing['beets_item'],
|
||||
'last_played', value=int(time.time()))
|
||||
handler = getattr(self, 'on_' + status['state'], None)
|
||||
|
||||
if handler:
|
||||
handler(status)
|
||||
else:
|
||||
log.info(u'mpdstats: status: {0}'.format(status))
|
||||
log.debug(u'mpdstats: unhandled status "{0}"'.format(status))
|
||||
|
||||
events = self.mpd.events()
|
||||
|
||||
|
||||
class MPDStatsPlugin(plugins.BeetsPlugin):
|
||||
|
|
@ -308,17 +315,18 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
|||
})
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('mpdstats',
|
||||
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 = ui.Subcommand(
|
||||
'mpdstats',
|
||||
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')
|
||||
|
||||
def func(lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
|
|
@ -331,7 +339,10 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
|||
if opts.password:
|
||||
config['mpd']['password'] = opts.password.decode('utf8')
|
||||
|
||||
Client(lib).run()
|
||||
try:
|
||||
MPDStats(lib).run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
|
|
|||
Loading…
Reference in a new issue