From 6402445cb58902ab23298df19c020bd453914048 Mon Sep 17 00:00:00 2001 From: kaliko Date: Tue, 16 Dec 2014 17:35:06 +0100 Subject: [PATCH] Better MusicBrainz ID integration --- mpd-sima | 1 + sima/client.py | 81 +++++++++++++++++++++++++++++++++++++++++++++ sima/lib/meta.py | 32 +++++++++++++++--- sima/lib/player.py | 65 +++--------------------------------- sima/lib/webserv.py | 39 ++++++++++++---------- tests/test_meta.py | 49 ++++++++++++++++++++++----- 6 files changed, 177 insertions(+), 90 deletions(-) diff --git a/mpd-sima b/mpd-sima index 5cf19d0..33d56ef 100755 --- a/mpd-sima +++ b/mpd-sima @@ -3,6 +3,7 @@ # Script starts here from sima.launch import main + main() # VIM MODLINE diff --git a/sima/client.py b/sima/client.py index f3d1614..b8ccea5 100644 --- a/sima/client.py +++ b/sima/client.py @@ -36,6 +36,7 @@ except ImportError as err: sexit(1) # local import +from .lib.simastr import SimaStr from .lib.player import Player, blacklist from .lib.track import Track from .lib.meta import Album, Artist @@ -50,6 +51,28 @@ class PlayerCommandError(PlayerError): PlayerUnHandledError = MPDError # pylint: disable=C0103 +def bl_artist(func): + def wrapper(*args, **kwargs): + cls = args[0] + if not args[0].database: + return func(*args, **kwargs) + result = func(*args, **kwargs) + if not result: + return + names = list() + for art in result.names: + if cls.database.get_bl_artist(art, add_not=True): + cls.log.debug('Blacklisted "{0}"'.format(art)) + continue + names.append(art) + if not names: + return + resp = Artist(name=names.pop(), mbid=result.mbid) + for name in names: + resp.add_alias(name) + return resp + return wrapper + class PlayerClient(Player): """MPD Client @@ -137,8 +160,10 @@ class PlayerClient(Player): self.log.info('Player: Initialising cache!') self._cache = { 'artists': None, + 'nombid_artists': None, } self._cache['artists'] = frozenset(self._client.list('artist')) + self._cache['nombid_artists'] = frozenset(self._client.list('artist', 'musicbrainz_artistid', '')) @blacklist(track=True) def find_track(self, artist, title=None): @@ -156,6 +181,62 @@ class PlayerClient(Player): 'title', title)) return list(tracks) + @bl_artist + def search_artist(self, artist): + """ + Search artists based on a fuzzy search in the media library + >>> art = Artist(name='the beatles', mbid=) # mbid optional + >>> bea = player.search_artist(art) + >>> print(bea.names) + >>> ['The Beatles', 'Beatles', 'the beatles'] + + Returns an Artist object + """ + found = False + if artist.mbid: + # look for exact search w/ musicbrainz_artistid + [artist.add_alias(name) for name in + self._client.list('artist', 'musicbrainz_artistid', artist.mbid)] + if artist.aliases: + found = True + else: + artist = Artist(name=artist.name) + # then complete with fuzzy search on artist with no musicbrainz_artistid + nombid_artists = self._cache.get('nombid_artists', []) + match = get_close_matches(artist.name, nombid_artists, 50, 0.73) + if not match and not found: + return + if len(match) > 1: + 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.name and len(artist.name) < 8: + for fuzz_art in match: + # Regular lowered string comparison + if artist.name.lower() == fuzz_art.lower(): + artist.add_alias(fuzz_art) + return artist + fzartist = SimaStr(artist.name) + for fuzz_art in match: + # Regular lowered string comparison + if artist.name.lower() == fuzz_art.lower(): + found = True + artist.add_alias(fuzz_art) + if artist.name != fuzz_art: + self.log.debug('"%s" matches "%s".' % (fuzz_art, artist)) + continue + # SimaStr string __eq__ (not regular string comparison here) + if fzartist == fuzz_art: + found = True + artist.add_alias(fuzz_art) + self.log.info('"%s" quite probably matches "%s" (SimaStr)' % + (fuzz_art, artist)) + if found: + if artist.aliases: + self.log.debug('Found: {}'.format('/'.join(list(artist.names)[:4]))) + return artist + def fuzzy_find_track(self, artist, title): # Retrieve all tracks from artist all_tracks = self.find_track(artist, title) diff --git a/sima/lib/meta.py b/sima/lib/meta.py index 44369a2..5bf1e5f 100644 --- a/sima/lib/meta.py +++ b/sima/lib/meta.py @@ -21,6 +21,7 @@ Defines some object to handle audio file metadata """ +import collections.abc # python >= 3.3 import logging import re @@ -68,7 +69,7 @@ class Meta: if 'mbid' in kwargs and kwargs.get('mbid'): try: is_uuid4(kwargs.get('mbid')) - self.__mbid = kwargs.pop('mbid') + self.__mbid = kwargs.pop('mbid').upper() except WrongUUID4: self.log.warning('Wrong mbid {}:{}'.format(self.__name, kwargs.get('mbid'))) @@ -88,8 +89,7 @@ class Meta: """ #if hasattr(other, 'mbid'): # better isinstance? if isinstance(other, Meta) and self.mbid and other.mbid: - if self.mbid and other.mbid: - return self.mbid == other.mbid + return self.mbid == other.mbid elif isinstance(other, Meta): return bool(self.names & other.names) elif getattr(other, '__str__', None): @@ -149,7 +149,7 @@ class Artist(Meta): >>> artobj0 = Artist(**trk) >>> artobj1 = Artist(name='Tool') """ - name = kwargs.get('artist', name) + name = kwargs.get('artist', name).split(', ')[0] mbid = kwargs.get('musicbrainz_artistid', mbid) if (kwargs.get('albumartist', False) and kwargs.get('albumartist') != 'Various Artists'): @@ -159,5 +159,29 @@ class Artist(Meta): mbid = kwargs.get('musicbrainz_albumartistid').split(', ')[0] super().__init__(name=name, mbid=mbid) +class MetaContainer(collections.abc.Set): + + def __init__(self, iterable): + self.elements = lst = [] + for value in iterable: + if value not in lst: + lst.append(value) + else: + for inlst in lst: + if value == inlst: + inlst.add_alias(value) + + def __iter__(self): + return iter(self.elements) + + def __contains__(self, value): + return value in self.elements + + def __len__(self): + return len(self.elements) + + def __repr__(self): + return repr(self.elements) + # VIM MODLINE # vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/lib/player.py b/sima/lib/player.py index 20a4958..5920911 100644 --- a/sima/lib/player.py +++ b/sima/lib/player.py @@ -60,28 +60,6 @@ def blacklist(artist=False, album=False, track=False): return wrapper return decorated -def bl_artist(func): - def wrapper(*args, **kwargs): - cls = args[0] - if not args[0].database: - return func(*args, **kwargs) - result = func(*args, **kwargs) - if not result: - return - names = list() - for art in result.names: - if cls.database.get_bl_artist(art, add_not=True): - cls.log.debug('Blacklisted "{0}"'.format(art)) - continue - names.append(art) - if not names: - return - resp = Artist(name=names.pop(), mbid=result.mbid) - for name in names: - resp.add_alias(name) - return resp - return wrapper - class Player(object): """Player interface to inherit from. @@ -149,52 +127,17 @@ class Player(object): """ raise NotImplementedError - @bl_artist def search_artist(self, artist): """ Search artists based on a fuzzy search in the media library - >>> bea = player.search_artist('The beatles') + >>> art = Artist(name='the beatles', mbid=) # mbid optional + >>> bea = player.search_artist(art) >>> print(bea.names) >>> ['The Beatles', 'Beatles', 'the beatles'] - Returns a list of strings (artist names) + Returns an Artist object """ - found = False - # Then proceed with fuzzy matching if got nothing - match = get_close_matches(artist.name, self.artists, 50, 0.73) - if not match: - return - if len(match) > 1: - 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.name and len(artist.name) < 8: - for fuzz_art in match: - # Regular lowered string comparison - if artist.name.lower() == fuzz_art.lower(): - artist.add_alias(fuzz_art) - return artist - fzartist = SimaStr(artist.name) - for fuzz_art in match: - # Regular lowered string comparison - if artist.name.lower() == fuzz_art.lower(): - found = True - artist.add_alias(fuzz_art) - if artist.name != fuzz_art: - self.log.debug('"%s" matches "%s".' % (fuzz_art, artist)) - continue - # SimaStr string __eq__ (not regular string comparison here) - if fzartist == fuzz_art: - found = True - artist.add_alias(fuzz_art) - self.log.info('"%s" quite probably matches "%s" (SimaStr)' % - (fuzz_art, artist)) - if found: - if artist.aliases: - self.log.debug('Found: {}'.format('/'.join(artist.names))) - return artist - return + raise NotImplementedError def disconnect(self): """Closing client connection with the Player diff --git a/sima/lib/webserv.py b/sima/lib/webserv.py index 79a9b9b..d7fb6fa 100644 --- a/sima/lib/webserv.py +++ b/sima/lib/webserv.py @@ -32,7 +32,7 @@ from hashlib import md5 # local import from .plugin import Plugin from .track import Track -from .meta import Artist +from .meta import Artist, MetaContainer from ..utils.utils import WSError, WSNotFound def cache(func): @@ -189,20 +189,21 @@ class WebService(Plugin): return as_art def get_recursive_similar_artist(self): - history = deque(self.history) - history.popleft() - depth = 0 if not self.player.playlist: return - last_trk = self.player.playlist[-1] + history = list(self.history) + history = self.player.queue + history + history = deque(history) + last_trk = history.popleft() # remove extra_arts = list() ret_extra = list() + depth = 0 while depth < self.plugin_conf.getint('depth'): if len(history) == 0: break trk = history.popleft() if (trk.Artist in extra_arts - or trk.Artist == last_trk.Artist): + or trk.Artist == last_trk.Artist): continue extra_arts.append(trk.Artist) depth += 1 @@ -213,10 +214,14 @@ class WebService(Plugin): 'to "{}" as well'.format(artist)) similar = self.ws_similar_artists(artist=artist) if not similar: - return [] - ret_extra = self.get_artists_from_player(similar) - if last_trk.Artist in ret_extra: - ret_extra.remove(last_trk.Artist) + continue + ret_extra.extend(self.get_artists_from_player(similar)) + + if ret_extra: + self.log.debug('similar artist(s) fond: {}...'.format( + ' / '.join(map(str, ret_extra)))) + if last_trk.Artist in ret_extra: + ret_extra.remove(last_trk.Artist) return ret_extra def get_local_similar_artists(self): @@ -234,7 +239,7 @@ class WebService(Plugin): self.log.info('First five similar artist(s): {}...'.format( ' / '.join(map(str, list(similar)[:5])))) self.log.info('Looking availability in music library') - ret = set(self.get_artists_from_player(similar)) + ret = MetaContainer(self.get_artists_from_player(similar)) ret_extra = None if len(self.history) >= 2: if self.plugin_conf.getint('depth') > 1: @@ -243,30 +248,30 @@ class WebService(Plugin): # get them reorg to pick up best element ret_extra = self._get_artists_list_reorg(ret_extra) # pickup half the number of ret artist - ret_extra = set(ret_extra[:len(ret)//2]) + ret_extra = MetaContainer(ret_extra[:len(ret)//2]) self.log.debug('Using extra: {}'.format( ' / '.join(map(str, ret_extra)))) ret = ret | ret_extra if not ret: self.log.warning('Got nothing from music library.') - self.log.warning('Try running in debug mode to guess why...') return [] # WARNING: # * operation on set will not match against aliases # * composite set w/ mbid set and whitout won't match either - queued_artists = {trk.Artist for trk in self.player.queue} + queued_artists = MetaContainer([trk.Artist for trk in self.player.queue]) if ret & queued_artists: self.log.debug('Removing already queued artists: ' - '{0}'.format(ret & queued_artists)) + '{0}'.format('/'.join(map(str, ret & queued_artists)))) ret = ret - queued_artists if self.player.current and self.player.current.Artist in ret: self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist)) - ret = ret - {self.player.current.Artist} + ret = ret - MetaContainer([self.player.current.Artist]) # Move around similars items to get in unplayed|not recently played # artist first. self.log.info('Got {} artists in library'.format(len(ret))) candidates = self._get_artists_list_reorg(list(ret)) - self.log.info(' / '.join(map(str, candidates))) + if candidates: + self.log.info(' / '.join(map(str, candidates))) return candidates def _get_album_history(self, artist=None): diff --git a/tests/test_meta.py b/tests/test_meta.py index 031999f..cf55a0e 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -2,7 +2,7 @@ import unittest -from sima.lib.meta import Meta, Artist, is_uuid4 +from sima.lib.meta import Meta, Artist, MetaContainer, is_uuid4 from sima.lib.meta import WrongUUID4, MetaException VALID = '110E8100-E29B-41D1-A716-116655250000' @@ -31,23 +31,20 @@ class TestMetaObject(unittest.TestCase): def test_equality(self): a = Meta(mbid=VALID, name='a') b = Meta(mbid=VALID, name='b') + c = Meta(mbid=VALID.lower(), name='c') self.assertEqual(a, b) + self.assertEqual(a, c) def test_hash(self): a = Meta(mbid=VALID, name='a') b = Meta(mbid=VALID, name='b') c = Meta(mbid=VALID, name='c') - self.assertTrue(len({a,b,c}) == 1) + self.assertTrue(len({a, b, c}) == 1) self.assertTrue(a in [c, b]) self.assertTrue(a in {c, b}) # mbid is immutable self.assertRaises(AttributeError, a.__setattr__, 'mbid', VALID) - def test_identity(self): - a = Meta(mbid=VALID, name='a') - b = Meta(mbid=VALID, name='a') - self.assertTrue(a is not b) - def test_aliases(self): art0 = Meta(name='Silver Mt. Zion') art0.add_alias('A Silver Mt. Zion') @@ -67,6 +64,8 @@ class TestMetaObject(unittest.TestCase): mbid='f22942a1-6f70-4f48-866e-238cb2308fbd') art02 = Meta(name='Some Other Name not even close, avoid fuzzy match', mbid='f22942a1-6f70-4f48-866e-238cb2308fbd') + art03 = Meta(name='Aphex Twin', + mbid='322942a1-6f70-4f48-866e-238cb2308fbd') self.assertTrue(len({art00, art02}) == 1) art00._Meta__name = art02._Meta__name = 'Aphex Twin' @@ -84,6 +83,9 @@ class TestMetaObject(unittest.TestCase): self.assertTrue(len({art00, art02}) == 1, 'wrong: hash({!r}) != hash({!r})'.format(art00, art02)) + self.assertTrue(hash(art00) != hash(art03), + 'wrong: hash({!r}) == hash({!r})'.format(art00, art03)) + def test_comparison(self): art00 = Meta(name='Aphex Twin', mbid='f22942a1-6f70-4f48-866e-238cb2308fbd') @@ -106,7 +108,7 @@ class TestMetaObject(unittest.TestCase): class TestArtistObject(unittest.TestCase): def test_init(self): - artist = {'artist': ['Name featuring', 'Feature'], + artist = {'artist': ', '.join(['Original Name', 'Featuring Nane', 'Feature…']), 'albumartist': 'Name', 'musicbrainz_artistid': VALID, 'musicbrainz_albumartistid': VALID.replace('11', '22'), @@ -119,5 +121,36 @@ class TestArtistObject(unittest.TestCase): self.assertTrue(art.mbid == VALID) artist.pop('albumartist') art = Artist(**artist) + self.assertTrue(art.name == 'Original Name', art.name) + + +class TestMetaContainers(unittest.TestCase): + + def test_init(self): + a = Meta(mbid=VALID, name='a') + b = Meta(mbid=VALID, name='b') + c = Meta(mbid=VALID.replace('11', '22'), name='b') + # redondant with Meta test_comparison, but anyway + cont = MetaContainer([a, b, c]) + self.assertTrue(len(cont) == 2) + self.assertTrue(a in cont) + self.assertTrue(b in cont) + self.assertTrue(Meta(name='a') in cont) + + def test_intersection_difference(self): + # Now set works as expected with composite (name/mbid) collections of Meta + # cf Meta test_union + # >>> len(MetaContainer([Artist(name='Name'), Artist(name='Name', mbid=)]) == 1 + # but + # >>> len({Artist(name='Name'), Artist(name='Name', mbid=}) == 2 + art00 = Meta(name='Aphex Twin', mbid='f22942a1-6f70-4f48-866e-238cb2308fbd') + art01 = Meta(name='Aphex Twin', mbid=None) + self.assertTrue(MetaContainer([art00]) & MetaContainer([art01])) + self.assertFalse(MetaContainer([art01]) - MetaContainer([art01])) + art01._Meta__mbid = art00.mbid + self.assertTrue(MetaContainer([art00]) & MetaContainer([art01])) + self.assertFalse(MetaContainer([art01]) - MetaContainer([art01])) + art01._Meta__mbid = art00.mbid.replace('229', '330') + self.assertFalse(MetaContainer([art00]) & MetaContainer([art01])) # vim: ai ts=4 sw=4 sts=4 expandtab -- 2.39.5