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 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
367 self.log.trace('Found mbid "%r" in library', artist)
368 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
370 self.log.warning('I got "%s" searching for %r', library, artist)
371 elif len(library) == 1 and library[0] != artist.name:
372 self.log.debug('Update artist name %s->%s', artist, library[0])
373 artist = Artist(name=library[0], mbid=artist.mbid)
374 # Fetches remaining artists for potential match
375 artists = self._cache['nombid_artists']
376 else: # not using MusicBrainzIDs
377 artists = self._cache['artists']
378 match = get_close_matches(artist.name, artists, 50, 0.73)
379 if not match and not found:
382 self.log.debug('found close match for "%s": %s', artist, '/'.join(match))
383 # Forst lowercased comparison
384 for close_art in match:
385 # Regular lowered string comparison
386 if artist.name.lower() == close_art.lower():
387 artist.add_alias(close_art)
389 if artist.name != close_art:
390 self.log.debug('"%s" matches "%s".', close_art, artist)
391 # Does not perform fuzzy matching on short and single word strings
392 # Only lowercased comparison
393 if ' ' not in artist.name and len(artist.name) < 8:
394 self.log.trace('no fuzzy matching for %r', artist)
398 # Now perform fuzzy search
400 if fuzz in artist.names: # Already found in lower cased comparison
402 # SimaStr string __eq__ (not regular string comparison here)
403 if SimaStr(artist.name) == fuzz:
405 artist.add_alias(fuzz)
406 self.log.info('"%s" quite probably matches "%s" (SimaStr)',
410 self.log.debug('Found aliases: %s', '/'.join(artist.names))
414 @blacklist(track=True)
415 def search_track(self, artist, title):
416 """Fuzzy search of title by an artist
418 # Retrieve all tracks from artist
419 all_tracks = self.find_tracks(artist)
420 # Get all titles (filter missing titles set to 'None')
421 all_artist_titles = frozenset([tr.title for tr in all_tracks
422 if tr.title is not None])
423 match = get_close_matches(title, all_artist_titles, 50, 0.78)
428 leven = levenshtein_ratio(title, mtitle)
430 tracks.extend([t for t in all_tracks if t.title == mtitle])
432 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
433 mtitle, title, leven)
434 tracks.extend([t for t in all_tracks if t.title == mtitle])
436 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
437 mtitle, title, leven)
440 @blacklist(album=True)
441 def search_albums(self, artist):
442 """Find potential albums for "artist"
444 * Fetch all albums for "AlbumArtist" == artist
445 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
446 * Tries to filter some mutli-artists album
447 For instance an album by Artist_A may have a track by Artist_B. Then
448 looking for albums for Artist_B returns wrongly this album.
450 # First, look for all potential albums
451 self.log.debug('Searching album for "%s"', artist)
453 self.log.debug('Searching album for %s aliases: "%s"',
454 artist, artist.aliases)
455 for name_sz in artist.names_sz:
456 raw_albums = self.list('album', f"( albumartist == '{name_sz}')")
457 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums if a]
460 album_trks = self.find_tracks(album)
461 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
462 if album.artist.names & album_artists:
463 candidates.append(album)
465 if 'Various Artists' in album_artists:
466 self.log.debug('Discarding %s ("Various Artists" set)', album)
468 if album_artists and album.artist.name not in album_artists:
469 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
471 # Attempt to detect false positive
472 # Avoid selecting albums where artist is credited for a single
474 album_trks = self.find(f"(album == '{album.name_sz}')")
475 arts = [trk.artist for trk in album_trks] # Artists in the album
476 # count artist occurences
477 ratio = arts.count(album.artist.name)/len(arts)
479 candidates.append(album)
481 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
482 album, artist, ratio)
485 # #### / Search Methods ###
488 # vim: ai ts=4 sw=4 sts=4 expandtab