mirror of
https://github.com/beetbox/beets.git
synced 2025-12-26 02:24:33 +01:00
Merge branch 'master' of https://github.com/sampsyo/beets
This commit is contained in:
commit
34bf650ccb
5 changed files with 470 additions and 10 deletions
336
beetsplug/mpdstats.py
Normal file
336
beetsplug/mpdstats.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
# 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 connecting to mpd
|
||||
from mpd import MPDClient, CommandError, PendingCommandError, ConnectionError
|
||||
# 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')
|
||||
|
||||
# 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(object):
|
||||
def __init__(self, library):
|
||||
self.lib = library
|
||||
# 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.do_rating = config['mpdstats']['rating'].get(bool)
|
||||
self.rating_mix = config['mpdstats']['rating_mix'].get(float)
|
||||
|
||||
self.client = MPDClient()
|
||||
|
||||
def mpd_connect(self):
|
||||
"""Connect to the MPD.
|
||||
"""
|
||||
try:
|
||||
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.mpd_config['password'] == u'':
|
||||
try:
|
||||
self.client.password(self.mpd_config['password'])
|
||||
except CommandError, e:
|
||||
log.error(e)
|
||||
return
|
||||
|
||||
def mpd_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.
|
||||
"""
|
||||
# FIXME: cover more URL types ...
|
||||
if path[:7] == "http://":
|
||||
return True
|
||||
return False
|
||||
|
||||
def mpd_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'):
|
||||
# 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'mpdstats(playlist): {0}'.format(result))
|
||||
return result
|
||||
|
||||
def mpd_status(self):
|
||||
"""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.
|
||||
"""
|
||||
items = self.lib.items([path])
|
||||
if len(items) == 0:
|
||||
log.info(u'mpdstats(beets): item not found {0}'.format(path))
|
||||
return None
|
||||
return items[0]
|
||||
|
||||
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:
|
||||
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 beetsrating(self, item, skipped):
|
||||
""" Update the rating of the beets item.
|
||||
"""
|
||||
if self.do_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'mpdstats(updated beets): {0} = {1} [{2}]'.format(
|
||||
attribute, item[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 not item is None:
|
||||
changed = False
|
||||
if not value is None:
|
||||
changed = True
|
||||
item[attribute] = value
|
||||
if not increment is None:
|
||||
changed = True
|
||||
item[attribute] = (float)(item.get(attribute, 0)) + increment
|
||||
if changed:
|
||||
log.debug(u'mpdstats(updated beets): {0} = {1} [{2}]'.format(
|
||||
attribute, item[attribute], item.path))
|
||||
item.write()
|
||||
if item._lib:
|
||||
item.store()
|
||||
|
||||
def mpd_func(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.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 (error, 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
|
||||
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.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))
|
||||
|
||||
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]))
|
||||
|
||||
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('mpdstats(played): {0}'
|
||||
.format(now_playing['path']))
|
||||
skipped = False
|
||||
else:
|
||||
log.info('mpdstats(skipped): {0}'
|
||||
.format(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(now_playing['path']))
|
||||
self.beets_update(now_playing['beets_item'],
|
||||
'last_played', value=int(time.time()))
|
||||
else:
|
||||
log.info(u'mpdstats(status): {0}'.format(status))
|
||||
|
||||
class MPDStatsPlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(MPDStatsPlugin, self).__init__()
|
||||
self.config.add({
|
||||
'host' : None,
|
||||
'port' : None,
|
||||
'password' : None,
|
||||
'music_directory' : config['directory'].get(unicode),
|
||||
'rating' : True,
|
||||
'rating_mix' : 0.75,
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
def func(lib, opts, args):
|
||||
self.config.set_args(opts)
|
||||
Client(lib).run()
|
||||
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
||||
# eof
|
||||
|
|
@ -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']
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ by typing ``beet version``.
|
|||
echonest
|
||||
bpd
|
||||
mpdupdate
|
||||
mpdstats
|
||||
fetchart
|
||||
embedart
|
||||
web
|
||||
|
|
@ -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:`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
|
||||
|
|
|
|||
107
docs/plugins/mpdstats.py
Normal file
107
docs/plugins/mpdstats.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
MPDStats Plugin
|
||||
================
|
||||
|
||||
``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.
|
||||
* 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
|
||||
|
||||
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
|
||||
``config.yaml``, which looks like this::
|
||||
|
||||
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
|
||||
|
||||
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`_.
|
||||
|
||||
.. _Issue: https://github.com/sampsyo/beets/issues
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue