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 self._cache['nombid_artists'] = frozenset(filter(None, self.list('artist', 'musicbrainz_artistid', '')))
210 def _skipped_track(self, previous):
211 if (self.state == 'stop'
212 or not hasattr(previous, 'id')
213 or not hasattr(self.current, 'id')):
215 return self.current.id != previous.id # pylint: disable=no-member
219 Monitor player for change
220 Returns a list a events among:
222 * database player media library has changed
223 * playlist playlist modified
224 * options player options changed: repeat mode, etc…
225 * player player state changed: paused, stopped, skip track…
226 * skipped current track skipped
230 ret = self.idle('database', 'playlist', 'player', 'options')
231 except (MPDError, IOError) as err:
232 raise PlayerError("Couldn't init idle: %s" % err)
233 if self._skipped_track(curr):
234 ret.append('skipped')
235 if 'database' in ret:
240 """Clean blocking event (idle) and pending commands
242 if 'idle' in self._pending:
245 self.log.warning('pending commands: %s', self._pending)
247 def add(self, payload):
248 """Overriding MPD's add method to accept Track objects"""
249 if isinstance(payload, Track):
250 super().__getattr__('add')(payload.file)
251 elif isinstance(payload, list):
252 for tr in payload: # TODO: use send command here
255 self.log.error('Cannot add %s', payload)
257 # ######### Properties #####################
260 return self.currentsong()
265 Override deprecated MPD playlist command
267 return self.playlistinfo()
271 plm = {'repeat': None, 'single': None,
272 'random': None, 'consume': None, }
273 for key, val in self.status().items():
274 if key in plm.keys():
275 plm.update({key: bool(int(val))})
281 curr_position = int(self.current.pos)
283 return [trk for trk in plst if int(trk.pos) > curr_position]
287 """Returns (play|stop|pause)"""
288 return str(self.status().get('state'))
289 # ######### / Properties ###################
291 # #### find_tracks ####
292 def find_album(self, artist, album_name):
293 self.log.warning('update call to find_album→find_tracks(<Album object>)')
294 return self.find_tracks(Album(name=album_name, artist=artist))
296 def find_track(self, *args, **kwargs):
297 self.log.warning('update call to find_track→find_tracks')
298 return self.find_tracks(*args, **kwargs)
300 def find_tracks(self, what):
301 """Find tracks for a specific artist or album
302 >>> player.find_tracks(Artist('Nirvana'))
303 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
305 :param Artist,Album what: Artist or Album to fetch track from
307 Returns a list of :py:obj:Track objects
309 if isinstance(what, Artist):
310 return self._find_art(what)
311 elif isinstance(what, Album):
312 return self._find_alb(what)
313 elif isinstance(what, str):
314 return self.find_tracks(Artist(name=what))
316 def _find_art(self, artist):
319 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
320 for name in artist.names:
321 tracks |= set(self.find('artist', name))
324 def _find_alb(self, album):
326 if album.mbid and self.use_mbid:
327 filt = f'(MUSICBRAINZ_ALBUMID == {album.mbid})'
328 albums |= set(self.find(filt))
329 # Now look for album with no MusicBrainzIdentifier
330 if album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
331 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album!s}'))"
332 albums |= set(self.find(filt))
333 if not albums: # Falls back to albumartist/album name
334 filt = f"((albumartist == '{album.artist!s}') AND (album == '{album!s}'))"
335 albums |= set(self.find(filt))
336 if not albums: # Falls back to artist/album name
337 filt = f"((artist == '{album.artist!s}') AND (album == '{album!s}'))"
338 albums |= set(self.find(filt))
340 # #### / find_tracks ##
342 # #### Search Methods #####
344 def search_artist(self, artist):
346 Search artists based on a fuzzy search in the media library
347 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
348 >>> bea = player.search_artist(art)c
350 >>> ['The Beatles', 'Beatles', 'the beatles']
352 Returns an Artist object
353 TODO: Re-use find method here!!!
357 # look for exact search w/ musicbrainz_artistid
358 exact_m = self.list('artist', 'musicbrainz_artistid', artist.mbid)
360 _ = [artist.add_alias(name) for name in exact_m]
362 # then complete with fuzzy search on artist with no musicbrainz_artistid
364 # we already performed a lookup on artists with mbid set
365 # search through remaining artists
366 artists = self._cache.get('nombid_artists')
368 artists = self._cache.get('artists')
369 match = get_close_matches(artist.name, artists, 50, 0.73)
370 if not match and not found:
373 self.log.debug('found close match for "%s": %s', artist, '/'.join(match))
374 # Does not perform fuzzy matching on short and single word strings
375 # Only lowercased comparison
376 if ' ' not in artist.name and len(artist.name) < 8:
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)
384 for fuzz_art in match:
385 # Regular lowered string comparison
386 if artist.name.lower() == fuzz_art.lower():
388 artist.add_alias(fuzz_art)
389 if artist.name != fuzz_art:
390 self.log.debug('"%s" matches "%s".', fuzz_art, artist)
392 # SimaStr string __eq__ (not regular string comparison here)
393 if SimaStr(artist.name) == fuzz_art:
395 artist.add_alias(fuzz_art)
396 self.log.info('"%s" quite probably matches "%s" (SimaStr)',
400 self.log.debug('Found: %s', '/'.join(list(artist.names)[:4]))
403 @blacklist(track=True)
404 def search_track(self, artist, title):
405 """Fuzzy search of title by an artist
407 # Retrieve all tracks from artist
408 all_tracks = self.find_tracks(artist)
409 # Get all titles (filter missing titles set to 'None')
410 all_artist_titles = frozenset([tr.title for tr in all_tracks
411 if tr.title is not None])
412 match = get_close_matches(title, all_artist_titles, 50, 0.78)
416 leven = levenshtein_ratio(title.lower(), mtitle.lower())
419 elif leven >= 0.79: # PARAM
420 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
421 mtitle, title, leven)
423 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
424 mtitle, title, leven)
426 return self.find('artist', artist, 'title', mtitle)
428 @blacklist(album=True)
429 def search_albums(self, artist):
431 Fetch all albums for "AlbumArtist" == artist
432 Then look for albums for "artist" == artist and try to filters
435 NB: Running "client.list('album', 'artist', name)" MPD returns any album
436 containing at least a track with "artist" == name
437 TODO: Use MusicBrainzID here cf. #30 @gitlab
440 for name in artist.names:
442 self.log.debug('Searching album for aliase: "%s"', name)
443 kwalbart = {'albumartist': name, 'artist': name}
444 for album in self.list('album', 'albumartist', name):
445 if album and album not in albums:
446 albums.append(Album(name=album, **kwalbart))
447 for album in self.list('album', 'artist', name):
448 album_trks = [trk for trk in self.find('album', album)]
449 if 'Various Artists' in [tr.albumartist for tr in album_trks]:
450 self.log.debug('Discarding %s ("Various Artists" set)', album)
452 arts = {trk.artist for trk in album_trks}
453 # Avoid selecting album where artist is credited for a single
455 if len(set(arts)) < 2: # TODO: better heuristic, use a ratio instead
456 if album not in albums:
457 albums.append(Album(name=album, **kwalbart))
458 elif album and album not in albums:
459 self.log.debug('"{0}" probably not an album of "{1}"'.format(
460 album, artist) + '({0})'.format('/'.join(arts)))
462 # #### / Search Methods ###
465 # vim: ai ts=4 sw=4 sts=4 expandtab