1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2020 kaliko <kaliko@azylum.org>
4 # This file is part of sima
6 # sima is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # sima is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with sima. If not, see <http://www.gnu.org/licenses/>.
19 # standard library import
20 from difflib import get_close_matches
21 from functools import wraps
22 from itertools import dropwhile
25 from musicpd import MPDClient, MPDError
29 from .lib.meta import Meta, Artist, Album
30 from .lib.track import Track
31 from .lib.simastr import SimaStr
32 from .utils.leven import levenshtein_ratio
35 class PlayerError(Exception):
36 """Fatal error in poller."""
41 def wrapper(*args, **kwargs):
44 return func(*args, **kwargs)
45 result = func(*args, **kwargs)
49 for art in result.names:
50 if cls.database.get_bl_artist(art, add_not=True):
51 cls.log.debug('Blacklisted "%s"', art)
56 resp = Artist(name=names.pop(), mbid=result.mbid)
63 def tracks_wrapper(func):
64 """Convert plain track mapping as returned by MPDClient into :py:obj:Track
65 objects. This decorator accepts single track or list of tracks as input.
68 def wrapper(*args, **kwargs):
69 ret = func(*args, **kwargs)
70 if isinstance(ret, dict):
72 return [Track(**t) for t in ret]
77 def blacklist(artist=False, album=False, track=False):
78 # pylint: disable=C0111,W0212
79 field = (album, track)
82 def wrapper(*args, **kwargs):
83 if not args[0].database:
84 return func(*args, **kwargs)
86 boolgen = (bl for bl in field)
87 bl_fun = (cls.database.get_bl_album,
88 cls.database.get_bl_track,)
89 #bl_getter = next(fn for fn, bl in zip(bl_fun, boolgen) if bl is True)
90 bl_getter = next(dropwhile(lambda _: not next(boolgen), bl_fun))
91 #cls.log.debug('using {0} as bl filter'.format(bl_getter.__name__))
93 for elem in func(*args, **kwargs):
94 if bl_getter(elem, add_not=True):
95 #cls.log.debug('Blacklisted "{0}"'.format(elem))
97 if track and cls.database.get_bl_album(elem, add_not=True):
98 # filter album as well in track mode
99 # (artist have already been)
100 cls.log.debug('Blacklisted alb. "%s"', elem)
108 class MPD(MPDClient):
110 Player instance inheriting from MPDClient (python-musicpd).
112 Some methods are overridden to format objects as sima.lib.Track for
113 instance, other are calling parent class directly through super().
118 * find methods are looking for exact match of the object provided
119 attributes in MPD music library
120 * search methods are looking for exact match + fuzzy match.
122 needed_cmds = ['status', 'stats', 'add', 'find',
123 'search', 'currentsong', 'ping']
124 needed_mbid_tags = {'Artist', 'Album', 'AlbumArtist',
125 'Title', 'Track', 'Genre',
126 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
127 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
130 def __init__(self, daemon):
134 self.log = daemon.log
135 self.config = self.daemon.config['MPD']
138 # ######### Overriding MPDClient ###########
139 def __getattr__(self, cmd):
140 """Wrapper around MPDClient calls for abstract overriding"""
141 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
142 if cmd in track_wrapped:
143 return tracks_wrapper(super().__getattr__(cmd))
144 return super().__getattr__(cmd)
146 def disconnect(self):
147 """Overriding explicitly MPDClient.disconnect()"""
152 """Overriding explicitly MPDClient.connect()"""
153 # host, port, password
154 host = self.config.get('host')
155 port = self.config.get('port')
156 password = self.config.get('password', fallback=None)
159 super().connect(host, port)
160 # Catch socket errors
161 except IOError as err:
162 raise PlayerError('Could not connect to "%s:%s": %s' %
163 (host, port, err.strerror))
164 # Catch all other possible errors
165 # ConnectionError and ProtocolError are always fatal. Others may not
166 # be, but we don't know how to handle them here, so treat them as if
167 # they are instead of ignoring them.
168 except MPDError as err:
169 raise PlayerError('Could not connect to "%s:%s": %s' %
173 self.password(password)
174 except (MPDError, IOError) as err:
175 raise PlayerError("Could not connect to '%s': %s" % (host, err))
176 # Controls we have sufficient rights
177 available_cmd = self.commands()
178 for cmd in MPD.needed_cmds:
179 if cmd not in available_cmd:
181 raise PlayerError('Could connect to "%s", '
182 'but command "%s" not available' %
184 # Controls use of MusicBrainzIdentifier
185 self.tagtypes('clear')
186 for tag in MPD.needed_mbid_tags:
187 self.tagtypes('enable', tag)
188 if self.daemon.config.get('sima', 'musicbrainzid'):
189 tt = set(self.tagtypes())
190 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
191 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
192 'is not providing related metadata')
194 self.log.warning('Disabling MusicBrainzIdentifier')
195 self.use_mbid = Meta.use_mbid = False
197 self.log.debug('Available metadata: %s', tt)
198 self.use_mbid = Meta.use_mbid = True
200 self.log.warning('Use of MusicBrainzIdentifier disabled!')
201 self.log.info('Consider using MusicBrainzIdentifier for your music library')
202 self.use_mbid = Meta.use_mbid = False
204 # ######### / Overriding MPDClient #########
206 def _reset_cache(self):
208 Both flushes and instantiates _cache
210 if isinstance(self._cache, dict):
211 self.log.info('Player: Flushing cache!')
213 self.log.info('Player: Initialising cache!')
214 self._cache = {'artists': frozenset(),
215 'nombid_artists': frozenset()}
216 self._cache['artist_tracks'] = {}
218 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
219 self._cache['nombid_artists'] = frozenset(filter(None, artists))
221 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
223 def _skipped_track(self, previous):
224 if (self.state == 'stop'
225 or not hasattr(previous, 'id')
226 or not hasattr(self.current, 'id')):
228 return self.current.id != previous.id # pylint: disable=no-member
231 """Monitor player for change
232 Returns a list a events among:
234 * database player media library has changed
235 * playlist playlist modified
236 * options player options changed: repeat mode, etc…
237 * player player state changed: paused, stopped, skip track…
238 * skipped current track skipped
242 ret = self.idle('database', 'playlist', 'player', 'options')
243 except (MPDError, IOError) as err:
244 raise PlayerError("Couldn't init idle: %s" % err)
245 if self._skipped_track(curr):
246 ret.append('skipped')
247 if 'database' in ret:
252 """Clean blocking event (idle) and pending commands
254 if 'idle' in self._pending:
257 self.log.warning('pending commands: %s', self._pending)
259 def add(self, payload):
260 """Overriding MPD's add method to accept Track objects
262 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
264 if isinstance(payload, Track):
265 super().__getattr__('add')(payload.file)
266 elif isinstance(payload, list):
267 self.command_list_ok_begin()
268 map(self.add, payload)
269 self.command_list_end()
271 self.log.error('Cannot add %s', payload)
273 # ######### Properties #####################
276 return self.currentsong()
281 Override deprecated MPD playlist command
283 return self.playlistinfo()
287 plm = {'repeat': None, 'single': None,
288 'random': None, 'consume': None, }
289 for key, val in self.status().items():
290 if key in plm.keys():
291 plm.update({key: bool(int(val))})
297 curr_position = int(self.current.pos)
299 return [trk for trk in plst if int(trk.pos) > curr_position]
303 """Returns (play|stop|pause)"""
304 return str(self.status().get('state'))
305 # ######### / Properties ###################
307 # #### find_tracks ####
308 def find_tracks(self, what):
309 """Find tracks for a specific artist or album
310 >>> player.find_tracks(Artist('Nirvana'))
311 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
313 :param Artist,Album what: Artist or Album to fetch track from
315 Returns a list of :py:obj:Track objects
317 if isinstance(what, Artist):
318 return self._find_art(what)
319 if isinstance(what, Album):
320 return self._find_alb(what)
321 if isinstance(what, str):
322 return self.find_tracks(Artist(name=what))
323 raise PlayerError('Bad input argument')
325 def _find_art(self, artist):
328 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
329 for name in artist.names_sz:
330 tracks |= set(self.find('artist', name))
333 def _find_alb(self, album):
334 if not hasattr(album, 'artist'):
335 raise PlayerError('Album object have no artist attribute')
337 if self.use_mbid and album.mbid:
338 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
339 albums = self.find(filt)
340 # Now look for album with no MusicBrainzIdentifier
341 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
342 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
343 albums = self.find(filt)
344 if not albums: # Falls back to (album)?artist/album name
345 for artist in album.artist.names_sz:
346 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
347 albums.extend(self.find(filt))
349 # #### / find_tracks ##
351 # #### Search Methods #####
353 def search_artist(self, artist):
355 Search artists based on a fuzzy search in the media library
356 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
357 >>> bea = player.search_artist(art)
359 >>> ['The Beatles', 'Beatles', 'the beatles']
361 :param Artist artist: Artist to look for in MPD music library
363 Returns an Artist object
366 if self.use_mbid and artist.mbid:
367 # look for exact search w/ musicbrainz_artistid
368 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
371 self.log.trace('Found mbid "%r" in library', artist)
372 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
374 self.log.debug('I got "%s" searching for %r', library, artist)
375 elif len(library) == 1 and library[0] != artist.name:
376 self.log.info('Update artist name %s->%s', artist, library[0])
377 artist = Artist(name=library[0], mbid=artist.mbid)
378 # Fetches remaining artists for potential match
379 artists = self._cache['nombid_artists']
380 else: # not using MusicBrainzIDs
381 artists = self._cache['artists']
382 match = get_close_matches(artist.name, artists, 50, 0.73)
383 if not match and not found:
386 self.log.debug('found close match for "%s": %s',
387 artist, '/'.join(match))
388 # First lowercased comparison
389 for close_art in match:
390 # Regular lowered string comparison
391 if artist.name.lower() == close_art.lower():
392 artist.add_alias(close_art)
394 if artist.name != close_art:
395 self.log.debug('"%s" matches "%s".', close_art, artist)
396 # Does not perform fuzzy matching on short and single word strings
397 # Only lowercased comparison
398 if ' ' not in artist.name and len(artist.name) < 8:
399 self.log.trace('no fuzzy matching for %r', artist)
403 # Now perform fuzzy search
405 if fuzz in artist.names: # Already found in lower cased comparison
407 # SimaStr string __eq__ (not regular string comparison here)
408 if SimaStr(artist.name) == fuzz:
410 artist.add_alias(fuzz)
411 self.log.info('"%s" quite probably matches "%s" (SimaStr)',
415 self.log.debug('Found aliases: %s', '/'.join(artist.names))
419 @blacklist(track=True)
420 def search_track(self, artist, title):
421 """Fuzzy search of title by an artist
423 cache = self._cache.get('artist_tracks').get(artist)
424 # Retrieve all tracks from artist
425 all_tracks = cache or self.find_tracks(artist)
427 self._cache['artist_tracks'] = {} # clean up
428 self._cache.get('artist_tracks')[artist] = all_tracks
429 # Get all titles (filter missing titles set to 'None')
430 all_artist_titles = frozenset([tr.title for tr in all_tracks
431 if tr.title is not None])
432 match = get_close_matches(title, all_artist_titles, 50, 0.78)
437 leven = levenshtein_ratio(title, mtitle)
439 tracks.extend([t for t in all_tracks if t.title == mtitle])
441 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
442 mtitle, title, leven)
443 tracks.extend([t for t in all_tracks if t.title == mtitle])
445 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
446 mtitle, title, leven)
449 @blacklist(album=True)
450 def search_albums(self, artist):
451 """Find potential albums for "artist"
453 * Fetch all albums for "AlbumArtist" == artist
454 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
455 * Tries to filter some mutli-artists album
456 For instance an album by Artist_A may have a track by Artist_B. Then
457 looking for albums for Artist_B returns wrongly this album.
459 # First, look for all potential albums
460 self.log.debug('Searching album for "%s"', artist)
462 self.log.debug('Searching album for %s aliases: "%s"',
463 artist, artist.aliases)
464 for name_sz in artist.names_sz:
465 raw_albums = self.list('album', f"( albumartist == '{name_sz}')")
466 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums if a]
469 album_trks = self.find_tracks(album)
470 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
471 if album.artist.names & album_artists:
472 candidates.append(album)
474 if 'Various Artists' in album_artists:
475 self.log.debug('Discarding %s ("Various Artists" set)', album)
477 if album_artists and album.artist.name not in album_artists:
478 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
480 # Attempt to detect false positive
481 # Avoid selecting albums where artist is credited for a single
483 album_trks = self.find(f"(album == '{album.name_sz}')")
484 arts = [trk.artist for trk in album_trks] # Artists in the album
485 # count artist occurences
486 ratio = arts.count(album.artist.name)/len(arts)
488 candidates.append(album)
490 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
491 album, artist, ratio)
494 # #### / Search Methods ###
497 # vim: ai ts=4 sw=4 sts=4 expandtab