#
[placeholder]
key = Value
-##
+
+[lastfm]
+
+depth = 3
+
+## QUEUE_MODE # NOT COMPLETED #
+# type: string
+# description: The default is to queue random tracks from similar artists.
+# Possible values:
+# track : Will queue tracks from similar artists (default).
+# top : Will queue top tracks from similar artists. # NOT IMPLEMENTED #
+# album : Will queue whole album from similar artists. # NOT IMPLEMENTED #
+queue_mode = track
+
+## SIMILARITY
+# type: integer in [0 100]
+# description: Similarity as a percentage of similarity between artists
+# (this is a last.fm metric)
+similarity = 15
+
+## DYNAMIC
+# type: integer
+# description: Number of similar artist to retrieve from local media library.
+# When set to something superior to zero, MPD_sima tries to get as much similar
+# artists from media library provided artists similarity is superior to
+# similarity value.
+dynamic = 10
+
+## SINGLE_ALBUM # NOT IMPLEMENTED #
+# type: boolean
+# scope: "track" and "top" queue modes
+# description: Prevent from queueing a track from the same album (for instance with OST).
+single_album = true
+
+## TRACK_TO_ADD
+# type: integer
+# scope: "track" and "top" queue modes
+# description: how many tracks the plugin will try to get
+track_to_add = 1
+
+## ALBUM_TO_ADD
+# type: integer
+# scope: "album" queue mode
+# description: how many albums the plugin will try to get
+album_to_add = 1
+
#
#######################################################################
######################## SIMA CORE ####################################
#
+# These settings deal with MPD_sima core behaviour.
[sima]
## PLUGINS
# type: comma separated string list
# "AwesomePlugin" declared here gets its configuration from the
# "[AwesomePlugin]" or "[awesomeplugin]" section (case insensitive).
#
+#internals =
plugins = PlaceHolder
## HISTORY_DURATION
## CONSUME
# type: integer
-#
-# How many played tracks to keep in the playlist.
-# Allow to maintain a fixed length playlist.
-# set to 0 to keep all played tracks.
+# description: How many played tracks to keep in the playlist.
+# Allow to maintain a fixed length playlist.
+# set to 0 to keep all played tracks.
#
consume = 0
##
-## SINGLE_ALBUM
-# type: boolean
-# scope: "track" and "top" queue modes
-#
-# Prevent from queueing a track from the same album (for instance with OST).
-single_album = false
-##
-
-
-# These settings deal with MPD_sima core behaviour.
-
-## Queue Mode
-##
-# The default is to queue random tracks from similar artists.
-#
-## QUEUE_MODE
-# type: string
-#
-# Possible values:
-# track : Will queue tracks from similar artists (default).
-# top : Will queue top tracks from similar artists.
-# album : Will queue whole album from similar artists.
-queue_mode = track
-
-## SIMILARITY
-# type: integer in [0 100]
-#
-# Similarity as a percentage of similarity for the artist the code is
-# looking for.
-similarity = 15
-##
-
-## DYNAMIC
-# type: integer
-#
-# Number of similar artist to retrieve from local media library.
-# When set to something superior to zero, MPD_sima tries to get as much similar
-# artists from media library provided artists similarity is superior to
-# similarity value.
-dynamic = 10
-##
-
-## USER_DB
+## USER_DB # NOT IMPLEMENTED #
# type: boolean
-#
-# Load user database to find similar artists
-# User DB is loaded from $XDG_CONFIG_HOME/mpd_sima/sima.db
-# Use simadb_cli to edit/add entries.
+# description: Load user database to find similar artists
+# User DB is loaded from $XDG_CONFIG_HOME/sima/sima.db
+# Use simadb_cli to edit/add entries.
user_db = false
##
queue_length = 1
##
-## TRACK_TO_ADD
-# type: integer
-# scope: "track" and "top" queue modes
-#
-# Missing Description…
-track_to_add = 1
-##
-
-## ALBUM_TO_ADD
-# type: integer
-# scope: "album" queue mode
-#
-# Missing Description…
-album_to_add = 1
-##
#
####################### END OF CONFIGURATION ##########################
# local import
from sima import core
-from sima.plugins.crop import Crop
-from sima.plugins.addhist import History
from sima.lib.logger import set_logger
from sima.lib.simadb import SimaDB
from sima.utils.config import ConfMan
from sima.utils.startopt import StartOpt
from sima.utils.utils import exception_log
##
+# internal plugins
+from sima.plugins.crop import Crop
+from sima.plugins.addhist import History
+from sima.plugins.lastfm import Lastfm
+from sima.plugins.mpd import MpdOptions
# official plugins to start
-PLUGINS = (Crop, History)
+PLUGINS = (Crop, History, MpdOptions,
+ Lastfm)
def load_contrib_plugins(sima):
sima.register_plugin(plugin_obj)
+def load_internal_plugins(sima):
+ """Handles contrib/external plugins
+ """
+ raise NotImplementedError
+
+
def main():
"""Entry point, deal w/ CLI and starts application
"""
def remove(self, position=0):
self._client.delete(position)
+ def add(self, track):
+ """Overriding MPD's add method to accept add signature with a Track
+ object"""
+ self._client.add(track.file)
+
@property
def state(self):
return str(self._client.status().get('state'))
def current(self):
return self.currentsong()
+ @property
+ def queue(self):
+ plst = self.playlist
+ plst.reverse()
+ return [ trk for trk in plst if int(trk.pos) > int(self.current.pos)]
+
@property
def playlist(self):
"""
raise PlayerError("Could not connect to '%s': "
"error with password command: %s" %
(self._host, err))
- # Controls we have sufficient rights for MPD_sima
+ # Controls we have sufficient rights
needed_cmds = ['status', 'stats', 'add', 'find', \
'search', 'currentsong', 'ping']
-#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Core Object dealing with plugins and player client
"""
import sys
import time
+from collections import deque
from logging import getLogger
-from .client import PlayerClient, Track
+from .client import PlayerClient
from .client import PlayerError, PlayerUnHandledError
from .lib.simadb import SimaDB
"""
def __init__(self, conf, dbfile):
+ self.enabled = True
self.config = conf
self.sdb = SimaDB(db_path=dbfile)
self.log = getLogger('sima')
except (PlayerError, PlayerUnHandledError) as err:
self.log.error('Fails to connect player: {}'.format(err))
self.shutdown()
- self.current_track = None
+ self.short_history = deque(maxlen=40)
+
+ def add_history(self):
+ self.short_history.appendleft(self.player.current)
def register_plugin(self, plugin_class):
"""Registers plubin in Sima instance..."""
#self.log.debug('dispatching {0} to {1}'.format(method, plugin))
getattr(plugin, method)(*args, **kwds)
+ def need_tracks(self):
+ if not self.enabled:
+ self.log.debug('Queueing disabled!')
+ return False
+ queue = self.player.queue
+ queue_trigger = self.config.getint('sima', 'queue_length')
+ self.log.debug('Currently {0} track(s) ahead. (target {1})'.format(
+ len(queue), queue_trigger))
+ if len(queue) < queue_trigger:
+ return True
+ return False
+
+ def queue(self):
+ to_add = list()
+ for plugin in self.plugins:
+ pl_callback = getattr(plugin, 'callback_need_track')()
+ if pl_callback:
+ to_add.extend(pl_callback)
+ for track in to_add:
+ self.player.add(track)
+
def reconnect_player(self):
"""Trying to reconnect cycling through longer timeout
cycle : 5s 10s 1m 5m 20m 1h
def run(self):
"""
"""
- self.current_track = Track()
while 42:
try:
self.loop()
"""Dispatching callbacks to plugins
"""
# hanging here untill a monitored event is raised in the player
- if getattr(self, 'changed', False): # first loop detection
+ if getattr(self, 'changed', False): # first iteration exception
self.changed = self.player.monitor()
- else:
+ else: # first iteration goes through else
self.changed = ['playlist', 'player', 'skipped']
self.log.debug('changed: {}'.format(', '.join(self.changed)))
if 'playlist' in self.changed:
self.foreach_plugin('callback_playlist')
- if 'player' in self.changed:
+ if ('player' in self.changed
+ or 'options' in self.changed):
self.foreach_plugin('callback_player')
+ if 'database' in self.changed:
+ self.foreach_plugin('callback_player_database')
if 'skipped' in self.changed:
if self.player.state == 'play':
self.log.info('Playing: {}'.format(self.player.current))
+ self.add_history()
self.foreach_plugin('callback_next_song')
- self.current_track = self.player.current
-
+ if self.need_tracks():
+ self.queue()
# VIM MODLINE
# vim: ai ts=4 sw=4 sts=4 expandtab
# -*- coding: utf-8 -*-
-# Copyright (c) 2009, 2010, 2013 Jack Kaliko <efrim@azylum.org>
+# Copyright (c) 2009, 2010, 2013 Jack Kaliko <kaliko@azylum.org>
#
# This file is part of sima
#
for sec in conf.sections():
if sec.lower() == self.__class__.__name__.lower():
self.plugin_conf = dict(conf.items(sec))
- if self.plugin_conf:
- self.log.debug('Got config for {0}: {1}'.format(self,
- self.plugin_conf))
+ #if self.plugin_conf:
+ # self.log.debug('Got config for {0}: {1}'.format(self,
+ # self.plugin_conf))
def callback_player(self):
"""
- Called on player changes
+ Called on player changes, stopped, paused, skipped
+ """
+ pass
+
+ def callback_player_database(self):
+ """
+ Called on player music library changes
"""
pass
"""
pass
- def callback_need_song(self):
+ def callback_need_track(self):
"""Returns a list of Track objects to add
"""
pass
--- /dev/null
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2009, 2010, 2011, 2012, 2013 Jack Kaliko <kaliko@azylum.org>
+# Copyright (c) 2010 Eric Casteleijn <thisfred@gmail.com> (Throttle decorator)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+#
+
+"""
+Consume last.fm web service
+
+"""
+
+__version__ = '0.3.0'
+__author__ = 'Jack Kaliko'
+
+
+import urllib.request, urllib.error, urllib.parse
+
+from datetime import datetime, timedelta
+from http.client import BadStatusLine
+from socket import timeout as SocketTimeOut
+from time import sleep
+from xml.etree.cElementTree import ElementTree
+
+# Some definitions
+WAIT_BETWEEN_REQUESTS = timedelta(0, 0.4)
+LFM_ERRORS = dict({'2': 'Invalid service -This service does not exist',
+ '3': 'Invalid Method - No method with that name in this package',
+ '4': 'Authentication Failed - You do not have permissions to access the service',
+ '5': "'Invalid format - This service doesn't exist in that format",
+ '6': 'Invalid parameters - Your request is missing a required parameter',
+ '7': 'Invalid resource specified',
+ '9': 'Invalid session key - Please re-authenticate',
+ '10': 'Invalid API key - You must be granted a valid key by last.fm',
+ '11': 'Service Offline - This service is temporarily offline. Try again later.',
+ '12': 'Subscription Error - The user needs to be subscribed in order to do that',
+ '13': 'Invalid method signature supplied',
+ '26': 'Suspended API key - Access for your account has been suspended, please contact Last.fm',
+ })
+
+
+class XmlFMError(Exception): # Errors
+ """
+ Exception raised for errors in the input.
+ """
+
+ def __init__(self, expression):
+ self.expression = expression
+
+ def __str__(self):
+ return repr(self.expression)
+
+
+class EncodingError(XmlFMError):
+ """Raised when string is not unicode"""
+ pass
+
+
+class XmlFMHTTPError(XmlFMError):
+ """Raised when failed to connect server"""
+
+ def __init__(self, expression):
+ if hasattr(expression, 'code'):
+ self.expression = 'error %d: %s' % (expression.code,
+ expression.msg)
+ else:
+ self.expression = 'error: %s' % expression
+
+
+class XmlFMNotFound(XmlFMError):
+ """Raised when no artist is found"""
+
+ def __init__(self, message=None):
+ if not message:
+ message = 'Artist probably not found (http error 400)'
+ self.expression = (message)
+
+
+class XmlFMMissingArtist(XmlFMError):
+ """Raised when no artist name provided"""
+
+ def __init__(self, message=None):
+ if not message:
+ message = 'Missing artist name.'
+ self.expression = (message)
+
+
+class XmlFMTimeOut(XmlFMError):
+ """Raised when urlopen times out"""
+
+ def __init__(self, message=None):
+ if not message:
+ message = 'Connection to last.fm web services times out!'
+ self.expression = (message)
+
+
+class Throttle():
+ def __init__(self, wait):
+ self.wait = wait
+ self.last_called = datetime.now()
+
+ def __call__(self, func):
+ def wrapper(*args, **kwargs):
+ while self.last_called + self.wait > datetime.now():
+ #print('waiting…')
+ sleep(0.1)
+ result = func(*args, **kwargs)
+ self.last_called = datetime.now()
+ return result
+ return wrapper
+
+
+class AudioScrobblerCache():
+ def __init__(self, elem, last):
+ self.elemtree = elem
+ self.requestdate = last
+
+ def created(self):
+ return self.requestdate
+
+ def gettree(self):
+ return self.elemtree
+
+
+class SimaFM():
+ """
+ """
+ api_key = '4a1c9ddec29816ed803d7be9113ba4cb'
+ host = 'ws.audioscrobbler.com'
+ version = '2.0'
+ root_url = 'http://%s/%s/' % (host, version)
+ request = dict({'similar': '?method=artist.getsimilar&artist=%s&' +\
+ 'api_key=%s' % api_key,
+ 'top': '?method=artist.gettoptracks&artist=%s&' +\
+ 'api_key=%s' % api_key,
+ 'track': '?method=track.getsimilar&artist=%s' +\
+ '&track=%s' + '&api_key=%s' % api_key,
+ 'info': '?method=artist.getinfo&artist=%s' +\
+ '&api_key=%s' % api_key,
+ })
+ cache = dict({})
+ timestamp = datetime.utcnow()
+ count = 0
+
+ def __init__(self, artist=None, cache=True):
+ self._url = None
+ #SimaFM.count += 1
+ self.current_element = None
+ self.caching = cache
+ self.purge_cache()
+
+ def _is_in_cache(self):
+ """Controls presence of url in cache.
+ """
+ if self._url in SimaFM.cache:
+ #print('already fetch {0}'.format(self.artist))
+ return True
+ return False
+
+ def _fetch(self):
+ """Use cached elements or proceed http request"""
+ if self._is_in_cache():
+ self.current_element = SimaFM.cache.get(self._url).gettree()
+ return
+ self._fetch_lfm()
+
+ @Throttle(WAIT_BETWEEN_REQUESTS)
+ def _fetch_lfm(self):
+ """Get artists, fetch xml from last.fm"""
+ try:
+ fd = urllib.request.urlopen(url=self._url,
+ timeout=15)
+ except SocketTimeOut:
+ raise XmlFMTimeOut()
+ except BadStatusLine as err:
+ raise XmlFMHTTPError(err)
+ except urllib.error.URLError as err:
+ if hasattr(err, 'reason'):
+ # URLError, failed to reach server
+ raise XmlFMError(repr(err.reason))
+ if hasattr(err, 'code'):
+ # HTTPError, the server couldn't fulfill the request
+ if err.code == 400:
+ raise XmlFMNotFound()
+ raise XmlFMHTTPError(err)
+ raise XmlFMError(err)
+ headers = dict(fd.getheaders())
+ content_type = headers.get('Content-Type').split(';')
+ if content_type[0] != "text/xml":
+ raise XmlFMError('None XML returned from the server')
+ if content_type[1].strip() != "charset=utf-8":
+ raise XmlFMError('XML not UTF-8 encoded!')
+ try:
+ self.current_element = ElementTree(file=fd)
+ except SocketTimeOut:
+ raise XmlFMTimeOut()
+ finally:
+ fd.close()
+ self._controls_lfm_answer()
+ if self.caching:
+ SimaFM.cache[self._url] = AudioScrobblerCache(self.current_element,
+ datetime.utcnow())
+
+ def _controls_lfm_answer(self):
+ """Controls last.fm answer.
+ """
+ status = self.current_element.getroot().attrib.get('status')
+ if status == 'ok':
+ return True
+ if status == 'failed':
+ error = self.current_element.find('error').attrib.get('code')
+ errormsg = self.current_element.findtext('error')
+ #if error in LFM_ERRORS.keys():
+ # print LFM_ERRORS.get(error)
+ raise XmlFMNotFound(errormsg)
+
+ def _controls_artist(self, artist):
+ """
+ """
+ self.artist = artist
+ if not self.artist:
+ raise XmlFMMissingArtist('Missing artist name calling SimaFM.get_<method>()')
+ if not isinstance(self.artist, str):
+ raise EncodingError('"%s" not unicode object' % self.artist)
+ # last.fm is UTF-8 encoded URL
+ self.artist_utf8 = self.artist.encode('UTF-8')
+
+ def purge_cache(self, age=4):
+ now = datetime.utcnow()
+ if now.hour == SimaFM.timestamp.hour:
+ return
+ SimaFM.timestamp = datetime.utcnow()
+ cache = SimaFM.cache
+ delta = timedelta(hours=age)
+ for url in list(cache.keys()):
+ timestamp = cache.get(url).created()
+ if now - timestamp > delta:
+ cache.pop(url)
+
+ def get_similar(self, artist=None):
+ """
+ """
+ self._controls_artist(artist)
+ # Construct URL
+ url = SimaFM.root_url + SimaFM.request.get('similar')
+ self._url = url % (urllib.parse.quote(self.artist_utf8, safe=''))
+ self._fetch()
+ # TODO: controls name encoding
+ elem = self.current_element
+ for art in elem.getiterator(tag='artist'):
+ yield str(art.findtext('name')), 100 * float(art.findtext('match'))
+
+ def get_toptracks(self, artist=None):
+ """
+ """
+ self._controls_artist(artist)
+ # Construct URL
+ url = SimaFM.root_url + SimaFM.request.get('top')
+ self._url = url % (urllib.parse.quote(self.artist_utf8, safe=''))
+ self._fetch()
+ # TODO: controls name encoding
+ elem = self.current_element
+ for track in elem.getiterator(tag='track'):
+ yield str(track.findtext('name')), int(track.attrib.get('rank'))
+
+ def get_mbid(self, artist=None):
+ """
+ """
+ self._controls_artist(artist)
+ # Construct URL
+ url = SimaFM.root_url + SimaFM.request.get('info')
+ self._url = url % (urllib.parse.quote(self.artist_utf8, safe=''))
+ self._fetch()
+ # TODO: controls name encoding
+ elem = self.current_element
+ return str(elem.find('artist').findtext('mbid'))
+
+
+def run():
+ test = SimaFM()
+ for a, m in test.get_similar(artist='Tool'):
+ pass
+ return
+
+if __name__ == '__main__':
+ try:
+ run()
+ except XmlFMHTTPError as conn_err:
+ print("error trying to connect: %s" % conn_err)
+ except XmlFMNotFound as not_found:
+ print("looks like no artists were found: %s" % not_found)
+ except XmlFMError as err:
+ print(err)
+
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab
--- /dev/null
+# -*- coding: utf-8 -*-
+
+#
+# Copyright (c) 2009, 2010, 2013 Jack Kaliko <kaliko@azylum.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public
+# License along with this program.
+# If not, see <http://www.gnu.org/licenses/>.
+#
+
+"""
+SimaStr
+
+Special unicode() subclass to perform fuzzy match on specific strings with
+known noise.
+
+Artist names often contain a leading 'The ' which might, or might not be
+present. Some other noise sources in artist name are 'and' words :
+ 'and'/'&'/'n'/'N'.
+
+The SimaStr() object removes these words and compute equality on "stripped"
+strings.
+
+>>> from simastr import SimaStr
+>>> art0 = SimaStr('The Desert Sessions & PJ Harvey')
+>>> art1 = SimaStr('Desert Sessions And PJ Harvey')
+>>> art0 == art1
+>>> True
+>>> art0 == 'Desert Sessions And PJ Harvey'
+>>> True
+>>>
+
+Current stripped word patterns (usually English followed by French andx
+Spanish alternatives)
+ leading (case-insensitive):
+ "the","le","la","les","el","los"
+ middle:
+ "[Aa]nd","&","[Nn]'?","[Ee]t"
+ trailing:
+ combination of "[- !?\.]+" "\(? ?[Ll]ive ?\)?"
+
+
+Possibility to access to stripped string :
+
+>>> art0 = SimaStr('The Desert Sessions & PJ Harvey')
+>>> art.stripped
+>>> print (art0, art0.stripped)
+>>> ('The Desert Sessions & PJ Harvey', 'Desert Sessions PJ Harvey')
+
+TODO:
+ * Have a look to difflib.SequenceMatcher to find possible improvements
+ * Find a way to allow users patterns.
+"""
+
+__author__ = 'Jack Kaliko'
+__version__ = '0.3'
+
+# IMPORTS
+from re import (compile, U, I)
+
+
+class SimaStr(str):
+ """
+ Specific string object for artist names and song titles.
+ Here follows some class variables for regex to run on strings.
+ """
+ regexp_dict = dict()
+
+ # Leading patterns: The Le Les
+ # case-insensitive matching for this RE
+ regexp_dict.update({'lead': '(the|l[ae][s]?|los|el)'})
+
+ # Middle patterns: And & Et N
+ regexp_dict.update({'mid': '(And|&|and|[Nn]\'?|et)'})
+
+ # Trailing patterns: ! ? live
+ # TODO: add "concert" key word
+ # add "Live at <somewhere>"
+ regexp_dict.update({'trail': '([- !?\.]|\(? ?[Ll]ive ?\)?)'})
+
+ reg_lead = compile('^(?P<lead>%(lead)s )(?P<root0>.*)$' % regexp_dict, I | U)
+ reg_midl = compile('^(?P<root0>.*)(?P<mid> %(mid)s )(?P<root1>.*)' % regexp_dict, U)
+ reg_trail = compile('^(?P<root0>.*?)(?P<trail>%(trail)s+$)' % regexp_dict, U)
+
+ def __init__(self, fuzzstr):
+ """
+ """
+ str().__init__(fuzzstr)
+ self.orig = str(fuzzstr)
+ self.stripped = str(fuzzstr.strip())
+ # fuzzy computation
+ self._get_root()
+
+ def _get_root(self):
+ """
+ Remove all patterns in string.
+ """
+ sea = SimaStr.reg_lead.search(self.stripped)
+ if sea:
+ #print sea.groupdict()
+ self.stripped = sea.group('root0')
+
+ sea = SimaStr.reg_midl.search(self.stripped)
+ if sea:
+ #print sea.groupdict()
+ self.stripped = str().join([sea.group('root0'), ' ',
+ sea.group('root1')])
+
+ sea = SimaStr.reg_trail.search(self.stripped)
+ if sea:
+ #print sea.groupdict()
+ self.stripped = sea.group('root0')
+
+ def __hash__(self):
+ return hash(self.stripped)
+
+ def __eq__(self, other):
+ if not isinstance(other, SimaStr):
+ return hash(self) == hash(SimaStr(other))
+ return hash(self) == hash(other)
+
+ def __ne__(self, other):
+ if not isinstance(other, SimaStr):
+ return hash(self) != hash(SimaStr(other))
+ return hash(self) != hash(other)
+
+
+# Script starts here
+if __name__ == "__main__":
+ import time
+ print(SimaStr('Kétanoue'))
+ #from leven import levenshtein_ratio
+ CASES_LIST = list([
+ dict({
+ 'got': 'Guns N\' Roses (live)!! !',
+ 'look for': 'Guns And Roses'}),
+ dict({
+ 'got': 'Jesus & Mary Chains',
+ 'look for': 'The Jesus and Mary Chains - live'}),
+ dict({
+ 'got': 'Desert sessions',
+ 'look for': 'The Desert Sessions'}),
+ dict({
+ 'got': 'Têtes Raides',
+ 'look for': 'Les Têtes Raides'}),
+ dict({
+ 'got': 'Noir Désir',
+ 'look for': 'Noir Désir'}),
+ dict({
+ 'got': 'No Future',
+ 'look for': 'Future'})])
+
+ for case in CASES_LIST[:]:
+ str0 = case.get('got')
+ str1 = case.get('look for')
+ fz_str0 = SimaStr(str0)
+ fz_str1 = SimaStr(str1)
+ print(fz_str0, '\n', fz_str1)
+ print(fz_str0.stripped == fz_str1.stripped)
+ #print levenshtein_ratio(fz_str0.lower(), fz_str1.lower())
+ time.sleep(1)
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab
class Track(object):
"""
Track object.
- Instanciate with mpd replies.
+ Instanciate with Player replies.
"""
def __init__(self, file=None, time=0, pos=0, **kwargs):
class History(Plugin):
"""
- History
+ History management
"""
def __init__(self, daemon):
Plugin.__init__(self, daemon)
"""
def callback_player(self):
- self.log.info(self.plugin_conf)
- self.log.debug('{0} contrib plugin!!!'.format(self))
+ #self.log.info(self.plugin_conf)
+ #self.log.debug('{0} contrib plugin!!!'.format(self))
+ pass
# -*- coding: utf-8 -*-
+"""
+Fetching similar artists from last.fm web services
+"""
# standart library import
-#from select import select
+import random
+
+from collections import deque
+from difflib import get_close_matches
+from hashlib import md5
# third parties componants
# local import
+from ..utils.leven import levenshtein_ratio
from ..lib.plugin import Plugin
+from ..lib.simafm import SimaFM, XmlFMHTTPError, XmlFMNotFound, XmlFMError
+from ..lib.simastr import SimaStr
+from ..lib.track import Track
+
+
+def cache(func):
+ """Caching decorator"""
+ def wrapper(*args, **kwargs):
+ #pylint: disable=W0212,C0111
+ cls = args[0]
+ similarities = [art + str(match) for art, match in args[1]]
+ hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest()
+ if hashedlst in cls._cache.get('asearch'):
+ cls.log.debug('cached request')
+ results = cls._cache.get('asearch').get(hashedlst)
+ else:
+ results = func(*args, **kwargs)
+ cls._cache.get('asearch').update({hashedlst:list(results)})
+ random.shuffle(results)
+ return results
+ return wrapper
-class Last(Plugin):
+
+class Lastfm(Plugin):
"""last.fm similar artists
"""
- pass
+ def __init__(self, daemon):
+ Plugin.__init__(self, daemon)
+ self.daemon_conf = daemon.config
+ self.sdb = daemon.sdb
+ self.player = daemon.player
+ self.history = daemon.short_history
+ ##
+ self.to_add = list()
+ self._cache = None
+ self._flush_cache()
+ wrapper = {
+ 'track': self._track,
+ 'top': self._top,
+ 'album': self._album,
+ }
+ self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
+
+ def _flush_cache(self):
+ """
+ Both flushes and instanciates _cache
+ """
+ if isinstance(self._cache, dict):
+ self.log.info('Lastfm: Flushing cache!')
+ else:
+ self.log.info('Lastfm: Initialising cache!')
+ self._cache = {
+ 'artists': None,
+ 'asearch': dict(),
+ 'tsearch': dict(),
+ }
+ self._cache['artists'] = frozenset(self.player.list('artist'))
+
+ def _cleanup_cache(self):
+ """Avoid bloated cache
+ """
+ for _ , val in self._cache.items():
+ if isinstance(val, dict):
+ while len(val) > 100:
+ val.popitem()
+
+ def get_history(self, artist):
+ """Check against history for tracks already in history for a specific
+ artist.
+ """
+ duration = self.daemon_conf.getint('sima', 'history_duration')
+ tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
+ # Construct Track() objects list from database history
+ played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
+ file=tr[3]) for tr in tracks_from_db]
+ return played_tracks
+
+ def filter_track(self, tracks):
+ """
+ Extract one unplayed track from a Track object list.
+ * not in history
+ * not already in the queue
+ """
+ artist = tracks[0].artist
+ black_list = self.player.queue + self.to_add
+ not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
+ if not not_in_hist:
+ self.log.debug('All tracks already played for "{}"'.format(artist))
+ random.shuffle(not_in_hist)
+ candidate = [ trk for trk in not_in_hist if trk not in black_list ]
+ if not candidate:
+ self.log.debug('Unable to find title to add' +
+ ' for "%s".' % artist)
+ return None
+ self.to_add.append(random.choice(candidate))
+
+ def _get_artists_list_reorg(self, alist):
+ """
+ Move around items in artists_list in order to play first not recently
+ played artists
+ """
+ duration = self.daemon_conf.getint('sima', 'history_duration')
+ art_in_hist = list()
+ for trk in self.sdb.get_history(duration=duration,
+ artists=alist):
+ if trk[0] not in art_in_hist:
+ art_in_hist.append(trk[0])
+ art_in_hist.reverse()
+ art_not_in_hist = [ ar for ar in alist if ar not in art_in_hist ]
+ random.shuffle(art_not_in_hist)
+ art_not_in_hist.extend(art_in_hist)
+ self.log.debug('history ordered: {}'.format(
+ ' / '.join(art_not_in_hist)))
+ return art_not_in_hist
+
+ def _cross_check_artist(self, art):
+ """
+ Controls presence of artists in liste in music library.
+ Crosschecking artist names with SimaStr objects / difflib / levenshtein
+
+ TODO: proceed crosschecking even when an artist matched !!!
+ Not because we found "The Doors" as "The Doors" that there is no
+ remaining entries as "Doors" :/
+ not straight forward, need probably heavy refactoring.
+ """
+ matching_artists = list()
+ artist = SimaStr(art)
+ all_artists = self._cache.get('artists')
+
+ # Check against the actual string in artist list
+ if artist.orig in all_artists:
+ self.log.debug('found exact match for "%s"' % artist)
+ return [artist]
+ # Then proceed with fuzzy matching if got nothing
+ match = get_close_matches(artist.orig, all_artists, 50, 0.73)
+ if not match:
+ return []
+ self.log.debug('found close match for "%s": %s' %
+ (artist, '/'.join(match)))
+ # Does not perform fuzzy matching on short and single word strings
+ # Only lowercased comparison
+ if ' ' not in artist.orig and len(artist) < 8:
+ for fuzz_art in match:
+ # Regular string comparison SimaStr().lower is regular string
+ if artist.lower() == fuzz_art.lower():
+ matching_artists.append(fuzz_art)
+ self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
+ return matching_artists
+ for fuzz_art in match:
+ # Regular string comparison SimaStr().lower is regular string
+ if artist.lower() == fuzz_art.lower():
+ matching_artists.append(fuzz_art)
+ self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
+ return matching_artists
+ # Proceed with levenshtein and SimaStr
+ leven = levenshtein_ratio(artist.stripped.lower(),
+ SimaStr(fuzz_art).stripped.lower())
+ # SimaStr string __eq__, not regular string comparison here
+ if artist == fuzz_art:
+ matching_artists.append(fuzz_art)
+ self.log.info('"%s" quite probably matches "%s" (SimaStr)' %
+ (fuzz_art, artist))
+ elif leven >= 0.82: # PARAM
+ matching_artists.append(fuzz_art)
+ self.log.debug('FZZZ: "%s" should match "%s" (lr=%1.3f)' %
+ (fuzz_art, artist, leven))
+ else:
+ self.log.debug('FZZZ: "%s" does not match "%s" (lr=%1.3f)' %
+ (fuzz_art, artist, leven))
+ return matching_artists
+
+ @cache
+ def get_artists_from_player(self, similarities):
+ """
+ Look in player library for availability of similar artists in
+ similarities
+ """
+ dynamic = int(self.plugin_conf.get('dynamic'))
+ if dynamic <= 0:
+ dynamic = 100
+ similarity = int(self.plugin_conf.get('similarity'))
+ results = list()
+ similarities.reverse()
+ while (len(results) < dynamic
+ and len(similarities) > 0):
+ art_pop, match = similarities.pop()
+ if match < similarity:
+ break
+ results.extend(self._cross_check_artist(art_pop))
+ results and self.log.debug('Similarity: %d%%' % match)
+ return results
+
+ def lfm_similar_artists(self, artist=None):
+ """
+ Retrieve similar artists on last.fm server.
+ """
+ if artist is None:
+ current = self.player.current
+ else:
+ current = artist
+ simafm = SimaFM()
+ # initialize artists deque list to construct from DB
+ as_art = deque()
+ as_artists = simafm.get_similar(artist=current.artist)
+ self.log.debug('Requesting last.fm for "{0.artist}"'.format(current))
+ try:
+ [as_art.append((a, m)) for a, m in as_artists]
+ except XmlFMHTTPError as err:
+ self.log.warning('last.fm http error: %s' % err)
+ except XmlFMNotFound as err:
+ self.log.warning("last.fm: %s" % err)
+ except XmlFMError as err:
+ self.log.warning('last.fm module error: %s' % err)
+ if as_art:
+ self.log.debug('Fetched %d artist(s) from last.fm' % len(as_art))
+ return as_art
+
+ def get_recursive_similar_artist(self):
+ history = deque(self.history)
+ history.popleft()
+ ret_extra = list()
+ depth = 0
+ current = self.player.current
+ extra_arts = list()
+ while depth < int(self.plugin_conf.get('depth')):
+ trk = history.popleft()
+ if trk.artist in [trk.artist for trk in extra_arts]:
+ continue
+ extra_arts.append(trk)
+ depth += 1
+ if len(history) == 0:
+ break
+ self.log.info('EXTRA ARTS: {}'.format(
+ '/'.join([trk.artist for trk in extra_arts])))
+ for artist in extra_arts:
+ self.log.debug('Looking for artist similar to "{0.artist}" as well'.format(artist))
+ similar = self.lfm_similar_artists(artist=artist)
+ similar = sorted(similar, key=lambda sim: sim[1], reverse=True)
+ ret_extra.extend(self.get_artists_from_player(similar))
+ if current.artist in ret_extra:
+ ret_extra.remove(current.artist)
+ return ret_extra
+
+ def get_local_similar_artists(self):
+ """Check against local player for similar artists fetched from last.fm
+ """
+ current = self.player.current
+ self.log.info('Looking for artist similar to "{0.artist}"'.format(current))
+ similar = self.lfm_similar_artists()
+ if not similar:
+ self.log.info('Got nothing from last.fm!')
+ return []
+ similar = sorted(similar, key=lambda sim: sim[1], reverse=True)
+ self.log.info('First five similar artist(s): {}...'.format(
+ ' / '.join([a for a, m in similar[0:5]])))
+ self.log.info('Looking availability in music library')
+ ret = self.get_artists_from_player(similar)
+ ret_extra = None
+ if len(self.history) >= 2:
+ ret_extra = self.get_recursive_similar_artist()
+ if not ret:
+ self.log.warning('Got nothing from music library.')
+ self.log.warning('Try running in debug mode to guess why...')
+ return []
+ if ret_extra:
+ ret = list(set(ret) | set(ret_extra))
+ self.log.info('Got {} artists in library'.format(len(ret)))
+ self.log.info(' / '.join(ret))
+ # Move around similars items to get in unplayed|not recently played
+ # artist first.
+ return self._get_artists_list_reorg(ret)
+
+ def _track(self):
+ """Get some tracks for track queue mode
+ """
+ artists = self.get_local_similar_artists()
+ nbtracks_target = int(self.plugin_conf.get('track_to_add'))
+ for artist in artists:
+ self.log.debug('Trying to find titles to add for "{}"'.format(
+ artist))
+ found = self.player.find_track(artist)
+ # find tracks not in history
+ self.filter_track(found)
+ if len(self.to_add) == nbtracks_target:
+ break
+ if not self.to_add:
+ self.log.debug('Found no unplayed tracks, is your ' +
+ 'history getting too large?')
+ return None
+ for track in self.to_add:
+ self.log.info('last.fm candidate: {0!s}'.format(track))
+
+ def _album(self):
+ """Get albums for album queue mode
+ """
+ artists = self.get_local_similar_artists()
+
+ def _top(self):
+ """Get some tracks for top track queue mode
+ """
+ artists = self.get_local_similar_artists()
+
+ def callback_need_track(self):
+ self._cleanup_cache()
+ if not self.player.current:
+ self.log.info('No currently playing track, cannot queue')
+ return None
+ self.queue_mode()
+ candidates = self.to_add
+ self.to_add = list()
+ return candidates
+
+ def callback_player_database(self):
+ self._flush_cache()
# VIM MODLINE
# vim: ai ts=4 sw=4 sts=4 expandtab
--- /dev/null
+# -*- coding: utf-8 -*-
+"""
+"""
+
+# standard library import
+
+# third parties components
+
+# local import
+from ..lib.plugin import Plugin
+
+
+class MpdOptions(Plugin):
+ """
+ Deal with MPD options ‑ idle and repeat mode
+ """
+
+ def __init__(self, daemon):
+ Plugin.__init__(self, daemon)
+ self.daemon = daemon
+
+ def callback_player(self):
+ """
+ Called on player changes
+ """
+ player = self.daemon.player
+ if player.status().get('single') == str(1):
+ self.log.info('MPD "single" mode activated.')
+ self.daemon.enabled = False
+ elif player.status().get('repeat') == str(1):
+ self.log.info('MPD "repeat" mode activated.')
+ self.daemon.enabled = False
+ else:
+ self.daemon.enabled = True
+
+ def shutdown(self):
+ pass
+
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab
'password': "false",
'port': "6600"},
'sima': {
- 'similarity': "15",
- 'dynamic': "10",
- 'queue_mode': "track", #TODO control values
'user_db': "false",
'history_duration': "8",
'queue_length': "1",
- 'track_to_add': "1",
- 'album_to_add': "1",
- 'consume': "0",
- 'single_album': "false",
- 'check_new_version':"false",},
+ 'consume': "0",},
'daemon':{
'daemon': "false",
'pidfile': "",},
'log': {
- 'verbosity': "info"}}
+ 'verbosity': "info"},
+ 'lastfm': {
+ 'dynamic': "10",
+ 'similarity': "18",
+ 'queue_mode': "track", #TODO control values
+ 'single_album': "false",
+ 'track_to_add': "1",
+ 'album_to_add': "1",
+ 'depth': "1",
+ }
+ }
#
--- /dev/null
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2009, 2010, 2013 Jack Kaliko <kaliko@azylum.org>
+#
+# This file is part of sima
+#
+# sima is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# sima is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with sima. If not, see <http://www.gnu.org/licenses/>.
+#
+#
+
+def levenshtein(a_st, b_st):
+ """Computes the Levenshtein distance between two strings."""
+ n_a, m_b = len(a_st), len(b_st)
+ if n_a > m_b:
+ # Make sure n <= m, to use O(min(n_a,m_b)) space
+ a_st, b_st = b_st, a_st
+ n_a, m_b = m_b, n_a
+
+ current = list(range(n_a+1))
+ for i in range(1, m_b+1):
+ previous, current = current, [i]+[0]*n_a
+ for j in range(1, n_a+1):
+ add, delete = previous[j] + 1, current[j-1] + 1
+ change = previous[j-1]
+ if a_st[j-1] != b_st[i-1]:
+ change = change + 1
+ current[j] = min(add, delete, change)
+
+ return current[n_a]
+
+def levenshtein_ratio(string, strong):
+ """
+ Compute levenshtein ratio.
+ Ratio = levenshtein distance / lenght of longer string
+ The longer string length is the upper bound of levenshtein distance.
+ """
+ lev_dist = levenshtein(string, strong)
+ max_len = max(len(string), len(strong))
+ ratio = 1 - (float(lev_dist) / float(max_len))
+ return ratio
+
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab