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 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)
62 def tracks_wrapper(func):
64 def wrapper(*args, **kwargs):
65 ret = func(*args, **kwargs)
66 if isinstance(ret, dict):
68 elif isinstance(ret, list):
69 return [Track(**t) for t in ret]
73 def blacklist(artist=False, album=False, track=False):
74 #pylint: disable=C0111,W0212
75 field = (album, track)
77 def wrapper(*args, **kwargs):
78 if not args[0].database:
79 return func(*args, **kwargs)
81 boolgen = (bl for bl in field)
82 bl_fun = (cls.database.get_bl_album,
83 cls.database.get_bl_track,)
84 #bl_getter = next(fn for fn, bl in zip(bl_fun, boolgen) if bl is True)
85 bl_getter = next(dropwhile(lambda _: not next(boolgen), bl_fun))
86 #cls.log.debug('using {0} as bl filter'.format(bl_getter.__name__))
88 for elem in func(*args, **kwargs):
89 if bl_getter(elem, add_not=True):
90 #cls.log.debug('Blacklisted "{0}"'.format(elem))
92 if track and cls.database.get_bl_album(elem, add_not=True):
93 # filter album as well in track mode
94 # (artist have already been)
95 cls.log.debug('Blacklisted alb. "{0.album}"'.format(elem))
103 class MPD(MPDClient):
105 Player instance inheriting from MPDClient (python-musicpd).
107 Some methods are overridden to format objects as sima.lib.Track for
108 instance, other are calling parent class directly through super().
113 * find methods are looking for exact match of the object provided attributes in MPD music library
114 * search methods are looking for exact match + fuzzy match.
116 needed_cmds = ['status', 'stats', 'add', 'find',
117 'search', 'currentsong', 'ping']
120 def __init__(self, daemon):
124 self.log = daemon.log
125 self.config = self.daemon.config['MPD']
128 # ######### Overriding MPDClient ###########
129 def __getattr__(self, cmd):
130 """Wrapper around MPDClient calls for abstract overriding"""
136 if cmd in track_wrapped:
137 return tracks_wrapper(super().__getattr__(cmd))
138 return super().__getattr__(cmd)
140 def disconnect(self):
141 """Overriding explicitly MPDClient.disconnect()"""
146 """Overriding explicitly MPDClient.connect()"""
147 # host, port, password
148 host = self.config.get('host')
149 port = self.config.get('port')
150 password = self.config.get('password', fallback=None)
153 super().connect(host, port)
154 # Catch socket errors
155 except IOError as err:
156 raise PlayerError('Could not connect to "%s:%s": %s' %
157 (host, port, err.strerror))
158 # Catch all other possible errors
159 # ConnectionError and ProtocolError are always fatal. Others may not
160 # be, but we don't know how to handle them here, so treat them as if
161 # they are instead of ignoring them.
162 except MPDError as err:
163 raise PlayerError('Could not connect to "%s:%s": %s' %
167 self.password(password)
168 except (MPDError, IOError) as err:
169 raise PlayerError("Could not connect to '%s': %s", (host, err))
170 # Controls we have sufficient rights
171 available_cmd = self.commands()
172 for cmd in MPD.needed_cmds:
173 if cmd not in available_cmd:
175 raise PlayerError('Could connect to "%s", '
176 'but command "%s" not available' %
178 # Controls use of MusicBrainzIdentifier
179 # TODO: Use config instead of Artist object attibute?
182 if 'MUSICBRAINZ_ARTISTID' not in tt:
183 self.log.warning('Use of MusicBrainzIdentifier is set but MPD is '
184 'not providing related metadata')
186 self.log.warning('Disabling MusicBrainzIdentifier')
187 self.use_mbid = False
189 self.log.debug('Available metadata: %s', tt) # pylint: disable=no-member
191 self.log.warning('Use of MusicBrainzIdentifier disabled!')
192 self.log.info('Consider using MusicBrainzIdentifier for your music library')
194 # ######### / Overriding MPDClient #########
196 def _reset_cache(self):
198 Both flushes and instantiates _cache
200 if isinstance(self._cache, dict):
201 self.log.info('Player: Flushing cache!')
203 self.log.info('Player: Initialising cache!')
204 self._cache = {'artists': frozenset(),
205 'nombid_artists': frozenset()}
206 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
208 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
209 self._cache['nombid_artists'] = frozenset(filter(None, artists))
211 def _skipped_track(self, previous):
212 if (self.state == 'stop'
213 or not hasattr(previous, 'id')
214 or not hasattr(self.current, 'id')):
216 return self.current.id != previous.id # pylint: disable=no-member
220 Monitor player for change
221 Returns a list a events among:
223 * database player media library has changed
224 * playlist playlist modified
225 * options player options changed: repeat mode, etc…
226 * player player state changed: paused, stopped, skip track…
227 * skipped current track skipped
231 ret = self.idle('database', 'playlist', 'player', 'options')
232 except (MPDError, IOError) as err:
233 raise PlayerError("Couldn't init idle: %s" % err)
234 if self._skipped_track(curr):
235 ret.append('skipped')
236 if 'database' in ret:
241 """Clean blocking event (idle) and pending commands
243 if 'idle' in self._pending:
246 self.log.warning('pending commands: %s', self._pending)
248 def add(self, payload):
249 """Overriding MPD's add method to accept Track objects"""
250 if isinstance(payload, Track):
251 super().__getattr__('add')(payload.file)
252 elif isinstance(payload, list):
253 self.command_list_ok_begin()
254 for tr in payload: # TODO: use send command here
256 results = client.command_list_end()
258 self.log.error('Cannot add %s', payload)
260 # ######### Properties #####################
263 return self.currentsong()
268 Override deprecated MPD playlist command
270 return self.playlistinfo()
274 plm = {'repeat': None, 'single': None,
275 'random': None, 'consume': None, }
276 for key, val in self.status().items():
277 if key in plm.keys():
278 plm.update({key: bool(int(val))})
284 curr_position = int(self.current.pos)
286 return [trk for trk in plst if int(trk.pos) > curr_position]
290 """Returns (play|stop|pause)"""
291 return str(self.status().get('state'))
292 # ######### / Properties ###################
294 # #### find_tracks ####
295 def find_tracks(self, what):
296 """Find tracks for a specific artist or album
297 >>> player.find_tracks(Artist('Nirvana'))
298 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
300 :param Artist,Album what: Artist or Album to fetch track from
302 Returns a list of :py:obj:Track objects
304 if isinstance(what, Artist):
305 return self._find_art(what)
306 elif isinstance(what, Album):
307 return self._find_alb(what)
308 elif isinstance(what, str):
309 return self.find_tracks(Artist(name=what))
311 def _find_art(self, artist):
314 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
315 for name in artist.names:
316 tracks |= set(self.find('artist', name))
319 def _find_alb(self, album):
320 if not hasattr(album, 'artist'):
321 PlayerError('Album object have no artist attribute')
323 if self.use_mbid and album.mbid:
324 filt = f'(MUSICBRAINZ_ALBUMID == {album.mbid})'
325 albums = self.find(filt)
326 # Now look for album with no MusicBrainzIdentifier
327 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
328 filt = f"((MUSICBRAINZ_ARTISTID == '{album.artist.mbid}') AND (album == '{album!s}'))"
329 albums = self.find(filt)
330 if not albums: # Falls back to (album)?artist/album name
331 filt = f"((albumartist == '{album.artist!s}') AND (album == '{album!s}'))"
332 albums = self.find(filt)
334 # #### / find_tracks ##
336 # #### Search Methods #####
338 def search_artist(self, artist):
340 Search artists based on a fuzzy search in the media library
341 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
342 >>> bea = player.search_artist(art)c
344 >>> ['The Beatles', 'Beatles', 'the beatles']
346 :param Artist artist: Artist to look for in MPD music library
348 Returns an Artist object
351 if self.use_mbid and artist.mbid:
352 # look for exact search w/ musicbrainz_artistid
353 found = bool(self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')"))
355 self.log.trace('Found mbid "%r" in library', artist)
356 # Fetches remaining artists for potential match
357 artists = self._cache['nombid_artists']
358 else: # not using MusicBrainzIDs
359 artists = self._cache['artists']
360 match = get_close_matches(artist.name, artists, 50, 0.73)
361 if not match and not found:
364 self.log.debug('found close match for "%s": %s', artist, '/'.join(match))
365 # Forst lowercased comparison
366 for close_art in match:
367 # Regular lowered string comparison
368 if artist.name.lower() == close_art.lower():
369 artist.add_alias(close_art)
371 if artist.name != close_art:
372 self.log.debug('"%s" matches "%s".', close_art, artist)
373 # Does not perform fuzzy matching on short and single word strings
374 # Only lowercased comparison
375 if ' ' not in artist.name and len(artist.name) < 8:
376 self.log.trace('no fuzzy matching for %r', artist)
379 # Now perform fuzzy search
381 if fuzz in artist.names: # Already found in lower cased comparison
383 # SimaStr string __eq__ (not regular string comparison here)
384 if SimaStr(artist.name) == fuzz:
386 artist.add_alias(fuzz)
387 self.log.info('"%s" quite probably matches "%s" (SimaStr)',
391 self.log.debug('Found: %s', '/'.join(list(artist.names)[:4]))
395 @blacklist(track=True)
396 def search_track(self, artist, title):
397 """Fuzzy search of title by an artist
399 # Retrieve all tracks from artist
400 all_tracks = self.find_tracks(artist)
401 # Get all titles (filter missing titles set to 'None')
402 all_artist_titles = frozenset([tr.title for tr in all_tracks
403 if tr.title is not None])
404 match = get_close_matches(title, all_artist_titles, 50, 0.78)
408 leven = levenshtein_ratio(title.lower(), mtitle.lower())
411 elif leven >= 0.79: # PARAM
412 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
413 mtitle, title, leven)
415 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
416 mtitle, title, leven)
418 return self.find('artist', artist, 'title', mtitle)
420 @blacklist(album=True)
421 def search_albums(self, artist):
423 Fetch all albums for "AlbumArtist" == artist
424 Then look for albums for "artist" == artist and try to filters
427 NB: Running "client.list('album', 'artist', name)" MPD returns any album
428 containing at least a track with "artist" == name
429 TODO: Use MusicBrainzID here cf. #30 @gitlab
432 for name in artist.names:
434 self.log.debug('Searching album for aliase: "%s"', name)
435 kwalbart = {'albumartist': name, 'artist': name}
436 # MPD falls back to artist if albumartist is not available
437 for album in self.list('album', f"( albumartist == '{name}')"):
438 if not album: # list can return "" as an album
440 album_trks = self.find_tracks(Album(name=album, artist=Artist(name=name)))
441 album_artists = [tr.albumartist for tr in album_trks if tr.albumartist]
442 if 'Various Artists' in [tr.albumartist for tr in album_trks]:
443 self.log.debug('Discarding %s ("Various Artists" set)', album)
445 if name not in album_artists:
446 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, name)
448 arts = {trk.artist for trk in album_trks}
449 # Avoid selecting album where artist is credited for a single
451 if len(set(arts)) < 2: # TODO: better heuristic, use a ratio instead
452 if album not in albums:
453 albums.append(Album(name=album, **kwalbart))
454 elif album not in albums:
455 self.log.debug('"{0}" probably not an album of "{1}"'.format(
456 album, artist) + '({0})'.format('/'.join(arts)))
458 # #### / Search Methods ###
461 # vim: ai ts=4 sw=4 sts=4 expandtab