From c1bda032095902bdcd183c530a9c4de28f3c828a Mon Sep 17 00:00:00 2001 From: kaliko Date: Mon, 30 Sep 2013 21:25:07 +0200 Subject: [PATCH] =?utf8?q?Huge=20commit=E2=80=A6=20Running=20last.fm=20tra?= =?utf8?q?ck=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- doc/examples/all_settings.cfg | 122 +++++------ launch | 16 +- sima/client.py | 13 +- sima/core.py | 46 +++- sima/lib/logger.py | 2 +- sima/lib/plugin.py | 16 +- sima/lib/simafm.py | 310 ++++++++++++++++++++++++++ sima/lib/simastr.py | 174 +++++++++++++++ sima/lib/track.py | 2 +- sima/plugins/addhist.py | 2 +- sima/plugins/contrib/placeholder.py | 5 +- sima/plugins/lastfm.py | 323 +++++++++++++++++++++++++++- sima/plugins/mpd.py | 41 ++++ sima/utils/config.py | 21 +- sima/utils/leven.py | 55 +++++ 15 files changed, 1046 insertions(+), 102 deletions(-) create mode 100644 sima/lib/simafm.py create mode 100644 sima/lib/simastr.py create mode 100644 sima/plugins/mpd.py create mode 100644 sima/utils/leven.py diff --git a/doc/examples/all_settings.cfg b/doc/examples/all_settings.cfg index 77abb7a..1d0fb6f 100644 --- a/doc/examples/all_settings.cfg +++ b/doc/examples/all_settings.cfg @@ -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 192ec39..bf84129 100755 --- 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 """ diff --git a/sima/client.py b/sima/client.py index 641fa22..9d61005 100644 --- a/sima/client.py +++ b/sima/client.py @@ -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'] diff --git a/sima/core.py b/sima/core.py index 2732540..2d8b960 100644 --- a/sima/core.py +++ b/sima/core.py @@ -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 diff --git a/sima/lib/logger.py b/sima/lib/logger.py index 761769b..807f10f 100644 --- a/sima/lib/logger.py +++ b/sima/lib/logger.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2009, 2010, 2013 Jack Kaliko +# Copyright (c) 2009, 2010, 2013 Jack Kaliko # # This file is part of sima # diff --git a/sima/lib/plugin.py b/sima/lib/plugin.py index e2764f9..999255f 100644 --- a/sima/lib/plugin.py +++ b/sima/lib/plugin.py @@ -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 index 0000000..7918403 --- /dev/null +++ b/sima/lib/simafm.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2009, 2010, 2011, 2012, 2013 Jack Kaliko +# Copyright (c) 2010 Eric Casteleijn (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 . +# +# + +""" +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_()') + 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 index 0000000..7e7668c --- /dev/null +++ b/sima/lib/simastr.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + +# +# Copyright (c) 2009, 2010, 2013 Jack Kaliko +# +# 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 . +# + +""" +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 " + regexp_dict.update({'trail': '([- !?\.]|\(? ?[Ll]ive ?\)?)'}) + + reg_lead = compile('^(?P%(lead)s )(?P.*)$' % regexp_dict, I | U) + reg_midl = compile('^(?P.*)(?P %(mid)s )(?P.*)' % regexp_dict, U) + reg_trail = compile('^(?P.*?)(?P%(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 diff --git a/sima/lib/track.py b/sima/lib/track.py index 52d1ce2..87c96fc 100644 --- a/sima/lib/track.py +++ b/sima/lib/track.py @@ -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): diff --git a/sima/plugins/addhist.py b/sima/plugins/addhist.py index 68675fe..70c3f17 100644 --- a/sima/plugins/addhist.py +++ b/sima/plugins/addhist.py @@ -11,7 +11,7 @@ from ..lib.plugin import Plugin class History(Plugin): """ - History + History management """ def __init__(self, daemon): Plugin.__init__(self, daemon) diff --git a/sima/plugins/contrib/placeholder.py b/sima/plugins/contrib/placeholder.py index 3e04817..665668d 100644 --- a/sima/plugins/contrib/placeholder.py +++ b/sima/plugins/contrib/placeholder.py @@ -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 diff --git a/sima/plugins/lastfm.py b/sima/plugins/lastfm.py index 43a1ae5..01e8cd9 100644 --- a/sima/plugins/lastfm.py +++ b/sima/plugins/lastfm.py @@ -1,18 +1,335 @@ # -*- 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 index 0000000..26a07e5 --- /dev/null +++ b/sima/plugins/mpd.py @@ -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 diff --git a/sima/utils/config.py b/sima/utils/config.py index 1dd9567..0676525 100644 --- a/sima/utils/config.py +++ b/sima/utils/config.py @@ -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 index 0000000..adc48f7 --- /dev/null +++ b/sima/utils/leven.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2009, 2010, 2013 Jack Kaliko +# +# 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 . +# +# + +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 -- 2.39.2