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 * artists: all artists
211 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
212 * artist_tracks: caching last artist tracks, used in search_track
214 if isinstance(self._cache, dict):
215 self.log.info('Player: Flushing cache!')
217 self.log.info('Player: Initialising cache!')
218 self._cache = {'artists': frozenset(),
219 'nombid_artists': frozenset(),
221 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
223 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
224 self._cache['nombid_artists'] = frozenset(filter(None, artists))
226 def _skipped_track(self, previous):
227 if (self.state == 'stop'
228 or not hasattr(previous, 'id')
229 or not hasattr(self.current, 'id')):
231 return self.current.id != previous.id # pylint: disable=no-member
234 """Monitor player for change
235 Returns a list a events among:
237 * database player media library has changed
238 * playlist playlist modified
239 * options player options changed: repeat mode, etc…
240 * player player state changed: paused, stopped, skip track…
241 * skipped current track skipped
245 ret = self.idle('database', 'playlist', 'player', 'options')
246 except (MPDError, IOError) as err:
247 raise PlayerError("Couldn't init idle: %s" % err)
248 if self._skipped_track(curr):
249 ret.append('skipped')
250 if 'database' in ret:
255 """Clean blocking event (idle) and pending commands
257 if 'idle' in self._pending:
260 self.log.warning('pending commands: %s', self._pending)
262 def add(self, payload):
263 """Overriding MPD's add method to accept Track objects
265 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
267 if isinstance(payload, Track):
268 super().__getattr__('add')(payload.file)
269 elif isinstance(payload, list):
270 self.command_list_ok_begin()
271 map(self.add, payload)
272 self.command_list_end()
274 self.log.error('Cannot add %s', payload)
276 # ######### Properties #####################
279 return self.currentsong()
284 Override deprecated MPD playlist command
286 return self.playlistinfo()
290 plm = {'repeat': None, 'single': None,
291 'random': None, 'consume': None, }
292 for key, val in self.status().items():
293 if key in plm.keys():
294 plm.update({key: bool(int(val))})
300 curr_position = int(self.current.pos)
302 return [trk for trk in plst if int(trk.pos) > curr_position]
306 """Returns (play|stop|pause)"""
307 return str(self.status().get('state'))
308 # ######### / Properties ###################
310 # #### find_tracks ####
311 def find_tracks(self, what):
312 """Find tracks for a specific artist or album
313 >>> player.find_tracks(Artist('Nirvana'))
314 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
316 :param Artist,Album what: Artist or Album to fetch track from
318 Returns a list of :py:obj:Track objects
320 if isinstance(what, Artist):
321 return self._find_art(what)
322 if isinstance(what, Album):
323 return self._find_alb(what)
324 if isinstance(what, str):
325 return self.find_tracks(Artist(name=what))
326 raise PlayerError('Bad input argument')
328 def _find_art(self, artist):
331 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
332 for name in artist.names_sz:
333 tracks |= set(self.find('artist', name))
336 def _find_alb(self, album):
337 if not hasattr(album, 'artist'):
338 raise PlayerError('Album object have no artist attribute')
340 if self.use_mbid and album.mbid:
341 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
342 albums = self.find(filt)
343 # Now look for album with no MusicBrainzIdentifier
344 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
345 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
346 albums = self.find(filt)
347 if not albums: # Falls back to (album)?artist/album name
348 for artist in album.artist.names_sz:
349 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
350 albums.extend(self.find(filt))
352 # #### / find_tracks ##
354 # #### Search Methods #####
356 def search_artist(self, artist):
358 Search artists based on a fuzzy search in the media library
359 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
360 >>> bea = player.search_artist(art)
362 >>> ['The Beatles', 'Beatles', 'the beatles']
364 :param Artist artist: Artist to look for in MPD music library
366 Returns an Artist object
369 if self.use_mbid and artist.mbid:
370 # look for exact search w/ musicbrainz_artistid
371 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
374 self.log.trace('Found mbid "%r" in library', artist)
375 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
377 self.log.debug('I got "%s" searching for %r', library, artist)
378 elif len(library) == 1 and library[0] != artist.name:
379 self.log.info('Update artist name %s->%s', artist, library[0])
380 artist = Artist(name=library[0], mbid=artist.mbid)
381 # Fetches remaining artists for potential match
382 artists = self._cache['nombid_artists']
383 else: # not using MusicBrainzIDs
384 artists = self._cache['artists']
385 match = get_close_matches(artist.name, artists, 50, 0.73)
386 if not match and not found:
389 self.log.debug('found close match for "%s": %s',
390 artist, '/'.join(match))
391 # First lowercased comparison
392 for close_art in match:
393 # Regular lowered string comparison
394 if artist.name.lower() == close_art.lower():
395 artist.add_alias(close_art)
397 if artist.name != close_art:
398 self.log.debug('"%s" matches "%s".', close_art, artist)
399 # Does not perform fuzzy matching on short and single word strings
400 # Only lowercased comparison
401 if ' ' not in artist.name and len(artist.name) < 8:
402 self.log.trace('no fuzzy matching for %r', artist)
406 # Now perform fuzzy search
408 if fuzz in artist.names: # Already found in lower cased comparison
410 # SimaStr string __eq__ (not regular string comparison here)
411 if SimaStr(artist.name) == fuzz:
413 artist.add_alias(fuzz)
414 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
418 self.log.info('Found aliases: %s', '/'.join(artist.names))
422 @blacklist(track=True)
423 def search_track(self, artist, title):
424 """Fuzzy search of title by an artist
426 cache = self._cache.get('artist_tracks').get(artist)
427 # Retrieve all tracks from artist
428 all_tracks = cache or self.find_tracks(artist)
430 self._cache['artist_tracks'] = {} # clean up
431 self._cache.get('artist_tracks')[artist] = all_tracks
432 # Get all titles (filter missing titles set to 'None')
433 all_artist_titles = frozenset([tr.title for tr in all_tracks
434 if tr.title is not None])
435 match = get_close_matches(title, all_artist_titles, 50, 0.78)
440 leven = levenshtein_ratio(title, mtitle)
442 tracks.extend([t for t in all_tracks if t.title == mtitle])
444 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
445 mtitle, title, leven)
446 tracks.extend([t for t in all_tracks if t.title == mtitle])
448 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
449 mtitle, title, leven)
452 @blacklist(album=True)
453 def search_albums(self, artist):
454 """Find potential albums for "artist"
456 * Fetch all albums for "AlbumArtist" == artist
457 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
458 * Tries to filter some mutli-artists album
459 For instance an album by Artist_A may have a track by Artist_B. Then
460 looking for albums for Artist_B returns wrongly this album.
462 # First, look for all potential albums
463 self.log.debug('Searching album for "%s"', artist)
465 self.log.debug('Searching album for %s aliases: "%s"',
466 artist, artist.aliases)
467 for name_sz in artist.names_sz:
468 raw_albums = self.list('album', f"( albumartist == '{name_sz}')")
469 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums if a]
472 album_trks = self.find_tracks(album)
473 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
474 if album.artist.names & album_artists:
475 candidates.append(album)
477 if 'Various Artists' in album_artists:
478 self.log.debug('Discarding %s ("Various Artists" set)', album)
480 if album_artists and album.artist.name not in album_artists:
481 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
483 # Attempt to detect false positive
484 # Avoid selecting albums where artist is credited for a single
486 album_trks = self.find(f"(album == '{album.name_sz}')")
487 arts = [trk.artist for trk in album_trks] # Artists in the album
488 # count artist occurences
489 ratio = arts.count(album.artist.name)/len(arts)
491 candidates.append(album)
493 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
494 album, artist, ratio)
497 # #### / Search Methods ###
500 # vim: ai ts=4 sw=4 sts=4 expandtab