]> kaliko git repositories - mpd-sima.git/commitdiff
Huge commit… Running last.fm track mode
authorkaliko <efrim@azylum.org>
Mon, 30 Sep 2013 19:25:07 +0000 (21:25 +0200)
committerkaliko <efrim@azylum.org>
Mon, 30 Sep 2013 19:25:07 +0000 (21:25 +0200)
15 files changed:
doc/examples/all_settings.cfg
launch
sima/client.py
sima/core.py
sima/lib/logger.py
sima/lib/plugin.py
sima/lib/simafm.py [new file with mode: 0644]
sima/lib/simastr.py [new file with mode: 0644]
sima/lib/track.py
sima/plugins/addhist.py
sima/plugins/contrib/placeholder.py
sima/plugins/lastfm.py
sima/plugins/mpd.py [new file with mode: 0644]
sima/utils/config.py
sima/utils/leven.py [new file with mode: 0644]

index 77abb7a89a4d8393fd11225bc359f6434e505dca..1d0fb6ff44fad4eec21790ab4bda43c8c052f602 100644 (file)
@@ -57,13 +57,59 @@ verbosity = info
 #
 [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
@@ -76,6 +122,7 @@ key = Value
 # "AwesomePlugin" declared here gets its configuration from the
 # "[AwesomePlugin]" or "[awesomeplugin]" section (case insensitive).
 #
+#internals = 
 plugins = PlaceHolder
 
 ## HISTORY_DURATION
@@ -88,62 +135,18 @@ history_duration = 8
 
 ## 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
 ##
 
@@ -159,21 +162,6 @@ 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 ##########################
 
diff --git a/launch b/launch
index 192ec399a486bf492263087bee3a25cc10589edb..bf841291a345b87afe03cd4914e35be2b819fe6d 100755 (executable)
--- a/launch
+++ b/launch
@@ -16,17 +16,21 @@ from os.path import isfile, basename
 
 # 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):
@@ -52,6 +56,12 @@ 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
     """
index 641fa22166f106ff2adfa32611223e783b091c71..9d6100535f93e880d1792ce4aa6c74847ecdfd69 100644 (file)
@@ -130,6 +130,11 @@ class PlayerClient(Player):
     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'))
@@ -138,6 +143,12 @@ class PlayerClient(Player):
     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):
         """
@@ -178,7 +189,7 @@ class PlayerClient(Player):
                 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']
 
index 2732540ad297ecbef6c3513408e370b697492305..2d8b960b23e876351a9e234358028d0951f81f2b 100644 (file)
@@ -1,4 +1,3 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 """Core Object dealing with plugins and player client
 """
@@ -10,9 +9,10 @@ __url__ = 'git://git.kaliko.me/sima.git'
 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
 
@@ -21,6 +21,7 @@ class Sima(object):
     """
 
     def __init__(self, conf, dbfile):
+        self.enabled = True
         self.config = conf
         self.sdb = SimaDB(db_path=dbfile)
         self.log = getLogger('sima')
@@ -31,7 +32,10 @@ class Sima(object):
         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..."""
@@ -43,6 +47,27 @@ class Sima(object):
             #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
@@ -78,7 +103,6 @@ class Sima(object):
     def run(self):
         """
         """
-        self.current_track = Track()
         while 42:
             try:
                 self.loop()
@@ -96,21 +120,25 @@ class Sima(object):
         """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
index 761769bbdfd4066cba7cac4c52da5c01122472fa..807f10fcca1d279cfdaa3147a98f172468fb736c 100644 (file)
@@ -1,6 +1,6 @@
 # -*- 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
 #
index e2764f96c849825a9d56a183b679aa51f86f7860..999255f956ce0ff6e0c23ab8230a38129aaf7cc6 100644 (file)
@@ -35,13 +35,19 @@ class Plugin():
         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
 
@@ -59,7 +65,7 @@ class Plugin():
         """
         pass
 
-    def callback_need_song(self):
+    def callback_need_track(self):
         """Returns a list of Track objects to add
         """
         pass
diff --git a/sima/lib/simafm.py b/sima/lib/simafm.py
new file mode 100644 (file)
index 0000000..7918403
--- /dev/null
@@ -0,0 +1,310 @@
+# -*- 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
diff --git a/sima/lib/simastr.py b/sima/lib/simastr.py
new file mode 100644 (file)
index 0000000..7e7668c
--- /dev/null
@@ -0,0 +1,174 @@
+# -*- 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
index 52d1ce2197ddd7e15d45144efbd9e9502cb492dc..87c96fc144fac9ca2b6ae0316d8e288fccc52d97 100644 (file)
@@ -27,7 +27,7 @@ import time
 class Track(object):
     """
     Track object.
-    Instanciate with mpd replies.
+    Instanciate with Player replies.
     """
 
     def __init__(self, file=None, time=0, pos=0, **kwargs):
index 68675fe14afcf2f9e9893f528a773a5aa34bb18c..70c3f1742b57be003a16aa994dcf6b1d29b70630 100644 (file)
@@ -11,7 +11,7 @@ from ..lib.plugin import Plugin
 
 class History(Plugin):
     """
-    History
+    History management
     """
     def __init__(self, daemon):
         Plugin.__init__(self, daemon)
index 3e0481711fc4d094ddb2e1a72ab2004af36ed661..665668d66faa30b2c1631b8b4fe41e0fd48d8d09 100644 (file)
@@ -16,8 +16,9 @@ class PlaceHolder(Plugin):
     """
 
     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
 
 
 
index 43a1ae518bf817ab78e38dc0df718e4ee9eef5aa..01e8cd98160625bf7905d4479e4376c1ff07a690 100644 (file)
 # -*- 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
diff --git a/sima/plugins/mpd.py b/sima/plugins/mpd.py
new file mode 100644 (file)
index 0000000..26a07e5
--- /dev/null
@@ -0,0 +1,41 @@
+# -*- 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
index 1dd9567835d6411affbcf963edd6a7b31b4b4107..06765254da21b24e7d1765921ccfd7181494f2bb 100644 (file)
@@ -45,22 +45,25 @@ DEFAULT_CONF = {
             '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",
+            }
+        }
 #
 
 
diff --git a/sima/utils/leven.py b/sima/utils/leven.py
new file mode 100644 (file)
index 0000000..adc48f7
--- /dev/null
@@ -0,0 +1,55 @@
+# -*- 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