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_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
125 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
126 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
129 def __init__(self, daemon):
133 self.log = daemon.log
134 self.config = self.daemon.config['MPD']
137 # ######### Overriding MPDClient ###########
138 def __getattr__(self, cmd):
139 """Wrapper around MPDClient calls for abstract overriding"""
140 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
141 if cmd in track_wrapped:
142 return tracks_wrapper(super().__getattr__(cmd))
143 return super().__getattr__(cmd)
145 def disconnect(self):
146 """Overriding explicitly MPDClient.disconnect()"""
151 """Overriding explicitly MPDClient.connect()"""
152 # host, port, password
153 host = self.config.get('host')
154 port = self.config.get('port')
155 password = self.config.get('password', fallback=None)
158 super().connect(host, port)
159 # Catch socket errors
160 except IOError as err:
161 raise PlayerError('Could not connect to "%s:%s": %s' %
162 (host, port, err.strerror))
163 # Catch all other possible errors
164 # ConnectionError and ProtocolError are always fatal. Others may not
165 # be, but we don't know how to handle them here, so treat them as if
166 # they are instead of ignoring them.
167 except MPDError as err:
168 raise PlayerError('Could not connect to "%s:%s": %s' %
172 self.password(password)
173 except (MPDError, IOError) as err:
174 raise PlayerError("Could not connect to '%s': %s" % (host, err))
175 # Controls we have sufficient rights
176 available_cmd = self.commands()
177 for cmd in MPD.needed_cmds:
178 if cmd not in available_cmd:
180 raise PlayerError('Could connect to "%s", '
181 'but command "%s" not available' %
183 self.tagtypes('clear')
184 for tag in MPD.needed_tags:
185 self.tagtypes('enable', tag)
186 tt = set(map(str.lower, self.tagtypes()))
187 needed_tags = set(map(str.lower, MPD.needed_tags))
188 if len(needed_tags & tt) != len(MPD.needed_tags):
189 self.log.warning('MPD exposes: %s', tt)
190 self.log.warning('Tags needed: %s', needed_tags)
191 raise PlayerError('Missing mandatory metadata!')
192 for tag in MPD.needed_mbid_tags:
193 self.tagtypes('enable', tag)
194 # Controls use of MusicBrainzIdentifier
195 if self.daemon.config.get('sima', 'musicbrainzid'):
196 tt = set(self.tagtypes())
197 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
198 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
199 'is not providing related metadata')
201 self.log.warning('Disabling MusicBrainzIdentifier')
202 self.use_mbid = Meta.use_mbid = False
204 self.log.debug('Available metadata: %s', tt)
205 self.use_mbid = Meta.use_mbid = True
207 self.log.warning('Use of MusicBrainzIdentifier disabled!')
208 self.log.info('Consider using MusicBrainzIdentifier for your music library')
209 self.use_mbid = Meta.use_mbid = False
211 # ######### / Overriding MPDClient #########
213 def _reset_cache(self):
215 Both flushes and instantiates _cache
217 * artists: all artists
218 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
219 * artist_tracks: caching last artist tracks, used in search_track
221 if isinstance(self._cache, dict):
222 self.log.info('Player: Flushing cache!')
224 self.log.info('Player: Initialising cache!')
225 self._cache = {'artists': frozenset(),
226 'nombid_artists': frozenset(),
228 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
230 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
231 self._cache['nombid_artists'] = frozenset(filter(None, artists))
233 def _skipped_track(self, previous):
234 if (self.state == 'stop'
235 or not hasattr(previous, 'id')
236 or not hasattr(self.current, 'id')):
238 return self.current.id != previous.id # pylint: disable=no-member
241 """Monitor player for change
242 Returns a list a events among:
244 * database player media library has changed
245 * playlist playlist modified
246 * options player options changed: repeat mode, etc…
247 * player player state changed: paused, stopped, skip track…
248 * skipped current track skipped
252 ret = self.idle('database', 'playlist', 'player', 'options')
253 except (MPDError, IOError) as err:
254 raise PlayerError("Couldn't init idle: %s" % err)
255 if self._skipped_track(curr):
256 ret.append('skipped')
257 if 'database' in ret:
262 """Clean blocking event (idle) and pending commands
264 if 'idle' in self._pending:
267 self.log.warning('pending commands: %s', self._pending)
269 def add(self, payload):
270 """Overriding MPD's add method to accept Track objects
272 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
274 if isinstance(payload, Track):
275 super().__getattr__('add')(payload.file)
276 elif isinstance(payload, list):
277 self.command_list_ok_begin()
278 map(self.add, payload)
279 self.command_list_end()
281 self.log.error('Cannot add %s', payload)
283 # ######### Properties #####################
286 return self.currentsong()
291 Override deprecated MPD playlist command
293 return self.playlistinfo()
297 plm = {'repeat': None, 'single': None,
298 'random': None, 'consume': None, }
299 for key, val in self.status().items():
300 if key in plm.keys():
301 plm.update({key: bool(int(val))})
307 curr_position = int(self.current.pos)
309 return [trk for trk in plst if int(trk.pos) > curr_position]
313 """Returns (play|stop|pause)"""
314 return str(self.status().get('state'))
315 # ######### / Properties ###################
317 # #### find_tracks ####
318 def find_tracks(self, what):
319 """Find tracks for a specific artist or album
320 >>> player.find_tracks(Artist('Nirvana'))
321 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
323 :param Artist,Album what: Artist or Album to fetch track from
325 Returns a list of :py:obj:Track objects
327 if isinstance(what, Artist):
328 return self._find_art(what)
329 if isinstance(what, Album):
330 return self._find_alb(what)
331 if isinstance(what, str):
332 return self.find_tracks(Artist(name=what))
333 raise PlayerError('Bad input argument')
335 def _find_art(self, artist):
338 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
339 for name in artist.names_sz:
340 tracks |= set(self.find('artist', name))
343 def _find_alb(self, album):
344 if not hasattr(album, 'artist'):
345 raise PlayerError('Album object have no artist attribute')
347 if self.use_mbid and album.mbid:
348 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
349 albums = self.find(filt)
350 # Now look for album with no MusicBrainzIdentifier
351 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
352 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
353 albums = self.find(filt)
354 if not albums: # Falls back to (album)?artist/album name
355 for artist in album.artist.names_sz:
356 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
357 albums.extend(self.find(filt))
359 # #### / find_tracks ##
361 # #### Search Methods #####
363 def search_artist(self, artist):
365 Search artists based on a fuzzy search in the media library
366 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
367 >>> bea = player.search_artist(art)
369 >>> ['The Beatles', 'Beatles', 'the beatles']
371 :param Artist artist: Artist to look for in MPD music library
373 Returns an Artist object
376 if self.use_mbid and artist.mbid:
377 # look for exact search w/ musicbrainz_artistid
378 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
381 self.log.trace('Found mbid "%r" in library', artist)
382 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
384 self.log.debug('I got "%s" searching for %r', library, artist)
385 elif len(library) == 1 and library[0] != artist.name:
386 self.log.info('Update artist name %s->%s', artist, library[0])
387 artist = Artist(name=library[0], mbid=artist.mbid)
388 # Fetches remaining artists for potential match
389 artists = self._cache['nombid_artists']
390 else: # not using MusicBrainzIDs
391 artists = self._cache['artists']
392 match = get_close_matches(artist.name, artists, 50, 0.73)
393 if not match and not found:
396 self.log.debug('found close match for "%s": %s',
397 artist, '/'.join(match))
398 # First lowercased comparison
399 for close_art in match:
400 # Regular lowered string comparison
401 if artist.name.lower() == close_art.lower():
402 artist.add_alias(close_art)
404 if artist.name != close_art:
405 self.log.debug('"%s" matches "%s".', close_art, artist)
406 # Does not perform fuzzy matching on short and single word strings
407 # Only lowercased comparison
408 if ' ' not in artist.name and len(artist.name) < 8:
409 self.log.trace('no fuzzy matching for %r', artist)
413 # Now perform fuzzy search
415 if fuzz in artist.names: # Already found in lower cased comparison
417 # SimaStr string __eq__ (not regular string comparison here)
418 if SimaStr(artist.name) == fuzz:
420 artist.add_alias(fuzz)
421 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
425 self.log.info('Found aliases: %s', '/'.join(artist.names))
429 @blacklist(track=True)
430 def search_track(self, artist, title):
431 """Fuzzy search of title by an artist
433 cache = self._cache.get('artist_tracks').get(artist)
434 # Retrieve all tracks from artist
435 all_tracks = cache or self.find_tracks(artist)
437 self._cache['artist_tracks'] = {} # clean up
438 self._cache.get('artist_tracks')[artist] = all_tracks
439 # Get all titles (filter missing titles set to 'None')
440 all_artist_titles = frozenset([tr.title for tr in all_tracks
441 if tr.title is not None])
442 match = get_close_matches(title, all_artist_titles, 50, 0.78)
447 leven = levenshtein_ratio(title, mtitle)
449 tracks.extend([t for t in all_tracks if t.title == mtitle])
451 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
452 mtitle, title, leven)
453 tracks.extend([t for t in all_tracks if t.title == mtitle])
455 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
456 mtitle, title, leven)
459 @blacklist(album=True)
460 def search_albums(self, artist):
461 """Find potential albums for "artist"
463 * Fetch all albums for "AlbumArtist" == artist
464 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
465 * Tries to filter some mutli-artists album
466 For instance an album by Artist_A may have a track by Artist_B. Then
467 looking for albums for Artist_B returns wrongly this album.
469 # First, look for all potential albums
470 self.log.debug('Searching album for "%s"', artist)
472 self.log.debug('Searching album for %s aliases: "%s"',
473 artist, artist.aliases)
474 for name_sz in artist.names_sz:
475 raw_albums = self.list('album', f"( albumartist == '{name_sz}')")
476 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums if a]
479 album_trks = self.find_tracks(album)
480 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
481 if album.artist.names & album_artists:
482 candidates.append(album)
484 if 'Various Artists' in album_artists:
485 self.log.debug('Discarding %s ("Various Artists" set)', album)
487 if album_artists and album.artist.name not in album_artists:
488 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
490 # Attempt to detect false positive
491 # Avoid selecting albums where artist is credited for a single
493 album_trks = self.find(f"(album == '{album.name_sz}')")
494 arts = [trk.artist for trk in album_trks] # Artists in the album
495 # count artist occurences
496 ratio = arts.count(album.artist.name)/len(arts)
498 candidates.append(album)
500 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
501 album, artist, ratio)
504 # #### / Search Methods ###
507 # vim: ai ts=4 sw=4 sts=4 expandtab