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['artists'] = frozenset(filter(None, self.list('artist')))
218 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
219 self._cache['nombid_artists'] = frozenset(filter(None, artists))
221 def _skipped_track(self, previous):
222 if (self.state == 'stop'
223 or not hasattr(previous, 'id')
224 or not hasattr(self.current, 'id')):
226 return self.current.id != previous.id # pylint: disable=no-member
230 Monitor player for change
231 Returns a list a events among:
233 * database player media library has changed
234 * playlist playlist modified
235 * options player options changed: repeat mode, etc…
236 * player player state changed: paused, stopped, skip track…
237 * skipped current track skipped
241 ret = self.idle('database', 'playlist', 'player', 'options')
242 except (MPDError, IOError) as err:
243 raise PlayerError("Couldn't init idle: %s" % err)
244 if self._skipped_track(curr):
245 ret.append('skipped')
246 if 'database' in ret:
251 """Clean blocking event (idle) and pending commands
253 if 'idle' in self._pending:
256 self.log.warning('pending commands: %s', self._pending)
258 def add(self, payload):
259 """Overriding MPD's add method to accept Track objects"""
260 if isinstance(payload, Track):
261 super().__getattr__('add')(payload.file)
262 elif isinstance(payload, list):
263 self.command_list_ok_begin()
264 map(self.add, payload)
265 self.command_list_end()
267 self.log.error('Cannot add %s', payload)
269 # ######### Properties #####################
272 return self.currentsong()
277 Override deprecated MPD playlist command
279 return self.playlistinfo()
283 plm = {'repeat': None, 'single': None,
284 'random': None, 'consume': None, }
285 for key, val in self.status().items():
286 if key in plm.keys():
287 plm.update({key: bool(int(val))})
293 curr_position = int(self.current.pos)
295 return [trk for trk in plst if int(trk.pos) > curr_position]
299 """Returns (play|stop|pause)"""
300 return str(self.status().get('state'))
301 # ######### / Properties ###################
303 # #### find_tracks ####
304 def find_tracks(self, what):
305 """Find tracks for a specific artist or album
306 >>> player.find_tracks(Artist('Nirvana'))
307 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
309 :param Artist,Album what: Artist or Album to fetch track from
311 Returns a list of :py:obj:Track objects
313 if isinstance(what, Artist):
314 return self._find_art(what)
315 if isinstance(what, Album):
316 return self._find_alb(what)
317 if isinstance(what, str):
318 return self.find_tracks(Artist(name=what))
319 raise PlayerError('Bad input argument')
321 def _find_art(self, artist):
324 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
325 for name in artist.names_sz:
326 tracks |= set(self.find('artist', name))
329 def _find_alb(self, album):
330 if not hasattr(album, 'artist'):
331 raise PlayerError('Album object have no artist attribute')
333 if self.use_mbid and album.mbid:
334 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
335 albums = self.find(filt)
336 # Now look for album with no MusicBrainzIdentifier
337 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
338 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
339 albums = self.find(filt)
340 if not albums: # Falls back to (album)?artist/album name
341 for artist in album.artist.names_sz:
342 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
343 albums.extend(self.find(filt))
345 # #### / find_tracks ##
347 # #### Search Methods #####
349 def search_artist(self, artist):
351 Search artists based on a fuzzy search in the media library
352 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
353 >>> bea = player.search_artist(art)
355 >>> ['The Beatles', 'Beatles', 'the beatles']
357 :param Artist artist: Artist to look for in MPD music library
359 Returns an Artist object
362 if self.use_mbid and artist.mbid:
363 # look for exact search w/ musicbrainz_artistid
364 found = bool(self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')"))
366 self.log.trace('Found mbid "%r" in library', artist)
367 # Fetches remaining artists for potential match
368 artists = self._cache['nombid_artists']
369 else: # not using MusicBrainzIDs
370 artists = self._cache['artists']
371 match = get_close_matches(artist.name, artists, 50, 0.73)
372 if not match and not found:
375 self.log.debug('found close match for "%s": %s', artist, '/'.join(match))
376 # Forst lowercased comparison
377 for close_art in match:
378 # Regular lowered string comparison
379 if artist.name.lower() == close_art.lower():
380 artist.add_alias(close_art)
382 if artist.name != close_art:
383 self.log.debug('"%s" matches "%s".', close_art, artist)
384 # Does not perform fuzzy matching on short and single word strings
385 # Only lowercased comparison
386 if ' ' not in artist.name and len(artist.name) < 8:
387 self.log.trace('no fuzzy matching for %r', artist)
390 # Now perform fuzzy search
392 if fuzz in artist.names: # Already found in lower cased comparison
394 # SimaStr string __eq__ (not regular string comparison here)
395 if SimaStr(artist.name) == fuzz:
397 artist.add_alias(fuzz)
398 self.log.info('"%s" quite probably matches "%s" (SimaStr)',
402 self.log.debug('Found aliases: %s', '/'.join(artist.names))
406 @blacklist(track=True)
407 def search_track(self, artist, title):
408 """Fuzzy search of title by an artist
410 # Retrieve all tracks from artist
411 all_tracks = self.find_tracks(artist)
412 # Get all titles (filter missing titles set to 'None')
413 all_artist_titles = frozenset([tr.title for tr in all_tracks
414 if tr.title is not None])
415 match = get_close_matches(title, all_artist_titles, 50, 0.78)
419 leven = levenshtein_ratio(title.lower(), mtitle.lower())
422 elif leven >= 0.79: # PARAM
423 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
424 mtitle, title, leven)
426 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
427 mtitle, title, leven)
429 return self.find('artist', artist, 'title', mtitle)
431 @blacklist(album=True)
432 def search_albums(self, artist):
433 """Find potential albums for "artist"
435 * Fetch all albums for "AlbumArtist" == artist
436 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
437 * Tries to filter some mutli-artists album
438 For instance an album by Artist_A may have a track by Artist_B. Then
439 looking for albums for Artist_B returns wrongly this album.
441 # First, look for all potential albums
442 self.log.debug('Searching album for "%s"', artist)
444 self.log.debug('Searching album for %s aliases: "%s"',
445 artist, artist.aliases)
446 for name_sz in artist.names_sz:
447 raw_albums = self.list('album', f"( albumartist == '{name_sz}')")
448 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums if a]
451 album_trks = self.find_tracks(album)
452 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
453 if album.artist.names & album_artists:
454 candidates.append(album)
456 if 'Various Artists' in album_artists:
457 self.log.debug('Discarding %s ("Various Artists" set)', album)
459 if album_artists and album.artist.name not in album_artists:
460 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
462 # Attempt to detect false positive
463 # Avoid selecting albums where artist is credited for a single
465 album_trks = self.find(f"(album == '{album.name_sz}')")
466 arts = [trk.artist for trk in album_trks] # Artists in the album
467 # count artist occurences
468 ratio = arts.count(album.artist.name)/len(arts)
470 candidates.append(album)
472 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
473 album, artist, ratio)
476 # #### / Search Methods ###
479 # vim: ai ts=4 sw=4 sts=4 expandtab