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)
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. "%s"', 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']
118 needed_mbid_tags = {'Artist', 'Album', 'AlbumArtist',
119 'Title', 'Track', 'Genre',
120 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
121 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
124 def __init__(self, daemon):
128 self.log = daemon.log
129 self.config = self.daemon.config['MPD']
132 # ######### Overriding MPDClient ###########
133 def __getattr__(self, cmd):
134 """Wrapper around MPDClient calls for abstract overriding"""
140 if cmd in track_wrapped:
141 return tracks_wrapper(super().__getattr__(cmd))
142 return super().__getattr__(cmd)
144 def disconnect(self):
145 """Overriding explicitly MPDClient.disconnect()"""
150 """Overriding explicitly MPDClient.connect()"""
151 # host, port, password
152 host = self.config.get('host')
153 port = self.config.get('port')
154 password = self.config.get('password', fallback=None)
157 super().connect(host, port)
158 # Catch socket errors
159 except IOError as err:
160 raise PlayerError('Could not connect to "%s:%s": %s' %
161 (host, port, err.strerror))
162 # Catch all other possible errors
163 # ConnectionError and ProtocolError are always fatal. Others may not
164 # be, but we don't know how to handle them here, so treat them as if
165 # they are instead of ignoring them.
166 except MPDError as err:
167 raise PlayerError('Could not connect to "%s:%s": %s' %
171 self.password(password)
172 except (MPDError, IOError) as err:
173 raise PlayerError("Could not connect to '%s': %s", (host, err))
174 # Controls we have sufficient rights
175 available_cmd = self.commands()
176 for cmd in MPD.needed_cmds:
177 if cmd not in available_cmd:
179 raise PlayerError('Could connect to "%s", '
180 'but command "%s" not available' %
182 # Controls use of MusicBrainzIdentifier
183 self.tagtypes('clear')
184 for tag in MPD.needed_mbid_tags:
185 self.tagtypes('enable', tag)
186 if self.daemon.config.get('sima', 'musicbrainzid'):
187 tt = set(self.tagtypes())
188 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
189 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
190 'is not providing related metadata')
192 self.log.warning('Disabling MusicBrainzIdentifier')
193 self.use_mbid = Meta.use_mbid = False
195 self.log.debug('Available metadata: %s', tt)
196 self.use_mbid = Meta.use_mbid = True
198 self.log.warning('Use of MusicBrainzIdentifier disabled!')
199 self.log.info('Consider using MusicBrainzIdentifier for your music library')
200 self.use_mbid = Meta.use_mbid = False
202 # ######### / Overriding MPDClient #########
204 def _reset_cache(self):
206 Both flushes and instantiates _cache
208 if isinstance(self._cache, dict):
209 self.log.info('Player: Flushing cache!')
211 self.log.info('Player: Initialising cache!')
212 self._cache = {'artists': frozenset(),
213 'nombid_artists': frozenset()}
214 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
216 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
217 self._cache['nombid_artists'] = frozenset(filter(None, artists))
219 def _skipped_track(self, previous):
220 if (self.state == 'stop'
221 or not hasattr(previous, 'id')
222 or not hasattr(self.current, 'id')):
224 return self.current.id != previous.id # pylint: disable=no-member
228 Monitor player for change
229 Returns a list a events among:
231 * database player media library has changed
232 * playlist playlist modified
233 * options player options changed: repeat mode, etc…
234 * player player state changed: paused, stopped, skip track…
235 * skipped current track skipped
239 ret = self.idle('database', 'playlist', 'player', 'options')
240 except (MPDError, IOError) as err:
241 raise PlayerError("Couldn't init idle: %s" % err)
242 if self._skipped_track(curr):
243 ret.append('skipped')
244 if 'database' in ret:
249 """Clean blocking event (idle) and pending commands
251 if 'idle' in self._pending:
254 self.log.warning('pending commands: %s', self._pending)
256 def add(self, payload):
257 """Overriding MPD's add method to accept Track objects"""
258 if isinstance(payload, Track):
259 super().__getattr__('add')(payload.file)
260 elif isinstance(payload, list):
261 self.command_list_ok_begin()
264 self.command_list_end()
266 self.log.error('Cannot add %s', payload)
268 # ######### Properties #####################
271 return self.currentsong()
276 Override deprecated MPD playlist command
278 return self.playlistinfo()
282 plm = {'repeat': None, 'single': None,
283 'random': None, 'consume': None, }
284 for key, val in self.status().items():
285 if key in plm.keys():
286 plm.update({key: bool(int(val))})
292 curr_position = int(self.current.pos)
294 return [trk for trk in plst if int(trk.pos) > curr_position]
298 """Returns (play|stop|pause)"""
299 return str(self.status().get('state'))
300 # ######### / Properties ###################
302 # #### find_tracks ####
303 def find_tracks(self, what):
304 """Find tracks for a specific artist or album
305 >>> player.find_tracks(Artist('Nirvana'))
306 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
308 :param Artist,Album what: Artist or Album to fetch track from
310 Returns a list of :py:obj:Track objects
312 if isinstance(what, Artist):
313 return self._find_art(what)
314 elif isinstance(what, Album):
315 return self._find_alb(what)
316 elif isinstance(what, str):
317 return self.find_tracks(Artist(name=what))
319 def _find_art(self, artist):
322 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
323 for name in artist.names_sz:
324 tracks |= set(self.find('artist', name))
327 def _find_alb(self, album):
328 if not hasattr(album, 'artist'):
329 raise PlayerError('Album object have no artist attribute')
331 if self.use_mbid and album.mbid:
332 filt = f'(MUSICBRAINZ_ALBUMID == {album.mbid})'
333 albums = self.find(filt)
334 # Now look for album with no MusicBrainzIdentifier
335 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
336 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
337 albums = self.find(filt)
338 if not albums: # Falls back to (album)?artist/album name
339 filt = f"((albumartist == '{album.artist!s}') AND (album == '{album.name_sz}'))"
340 albums = self.find(filt)
342 # #### / find_tracks ##
344 # #### Search Methods #####
346 def search_artist(self, artist):
348 Search artists based on a fuzzy search in the media library
349 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
350 >>> bea = player.search_artist(art)c
352 >>> ['The Beatles', 'Beatles', 'the beatles']
354 :param Artist artist: Artist to look for in MPD music library
356 Returns an Artist object
359 if self.use_mbid and artist.mbid:
360 # look for exact search w/ musicbrainz_artistid
361 found = bool(self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')"))
363 self.log.trace('Found mbid "%r" in library', artist)
364 # Fetches remaining artists for potential match
365 artists = self._cache['nombid_artists']
366 else: # not using MusicBrainzIDs
367 artists = self._cache['artists']
368 match = get_close_matches(artist.name, artists, 50, 0.73)
369 if not match and not found:
372 self.log.debug('found close match for "%s": %s', artist, '/'.join(match))
373 # Forst lowercased comparison
374 for close_art in match:
375 # Regular lowered string comparison
376 if artist.name.lower() == close_art.lower():
377 artist.add_alias(close_art)
379 if artist.name != close_art:
380 self.log.debug('"%s" matches "%s".', close_art, artist)
381 # Does not perform fuzzy matching on short and single word strings
382 # Only lowercased comparison
383 if ' ' not in artist.name and len(artist.name) < 8:
384 self.log.trace('no fuzzy matching for %r', artist)
387 # Now perform fuzzy search
389 if fuzz in artist.names: # Already found in lower cased comparison
391 # SimaStr string __eq__ (not regular string comparison here)
392 if SimaStr(artist.name) == fuzz:
394 artist.add_alias(fuzz)
395 self.log.info('"%s" quite probably matches "%s" (SimaStr)',
399 self.log.debug('Found aliases: %s', '/'.join(artist.names))
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):
430 """Find potential albums for "artist"
432 * Fetch all albums for "AlbumArtist" == artist
433 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
434 * Tries to filter some mutli-artists album
435 For instance an album by Artist_A may have a track by Artist_B. Then
436 looking for albums for Artist_B returns wrongly this album.
438 # First, look for all potential albums
439 self.log.debug('Searching album for "%s"', artist)
441 self.log.debug('Searching album for %s aliases: "%s"',
442 artist, artist.aliases)
443 for name_sz in artist.names_sz:
444 raw_albums = self.list('album', f"( albumartist == '{name_sz}')")
445 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums if a]
448 album_trks = self.find_tracks(album)
449 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
450 if album.artist.names & album_artists:
451 candidates.append(album)
453 if 'Various Artists' in album_artists:
454 self.log.debug('Discarding %s ("Various Artists" set)', album)
456 if album_artists and album.artist.name not in album_artists:
457 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
459 # Attempt to detect false positive
460 # Avoid selecting albums where artist is credited for a single
462 album_trks = self.find(f"(album == '{album.name_sz}')")
463 arts = [trk.artist for trk in album_trks] # Artists in the album
464 # count artist occurences
465 ratio = arts.count(album.artist.name)/len(arts)
467 candidates.append(album)
469 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
470 album, artist, ratio)
473 # #### / Search Methods ###
476 # vim: ai ts=4 sw=4 sts=4 expandtab