1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2021 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
23 from logging import getLogger
26 from musicpd import MPDClient, MPDError
30 from .lib.meta import Meta, Artist, Album
31 from .lib.track import Track
32 from .lib.simastr import SimaStr
33 from .utils.leven import levenshtein_ratio
36 class PlayerError(Exception):
37 """Fatal error in the player."""
42 def wrapper(*args, **kwargs):
45 return func(*args, **kwargs)
46 result = func(*args, **kwargs)
50 for art in result.names:
51 artist = Artist(name=art, mbid=result.mbid)
52 if cls.database.get_bl_artist(artist, add=False):
53 cls.log.debug('Artist "%s" in blocklist!', artist)
58 resp = Artist(name=names.pop(), mbid=result.mbid)
65 def tracks_wrapper(func):
66 """Convert plain track mapping as returned by MPDClient into :py:obj:Track
67 objects. This decorator accepts single track or list of tracks as input.
70 def wrapper(*args, **kwargs):
71 ret = func(*args, **kwargs)
72 if isinstance(ret, dict):
74 return [Track(**t) for t in ret]
79 def blocklist(album=False, track=False):
80 # pylint: disable=C0111,W0212
81 field = (album, track)
84 def wrapper(*args, **kwargs):
85 if not args[0].database:
86 return func(*args, **kwargs)
88 boolgen = (bl for bl in field)
89 bl_fun = (cls.database.get_bl_album,
90 cls.database.get_bl_track,)
91 #bl_getter = next(fn for fn, bl in zip(bl_fun, boolgen) if bl is True)
92 bl_getter = next(dropwhile(lambda _: not next(boolgen), bl_fun))
93 #cls.log.debug('using {0} as bl filter'.format(bl_getter.__name__))
95 for elem in func(*args, **kwargs):
96 if bl_getter(elem, add=False):
97 #cls.log.debug('Blacklisted "{0}"'.format(elem))
99 if track and cls.database.get_bl_album(elem, add=False):
100 # filter album as well in track mode
101 # (artist have already been)
102 cls.log.debug('Album "%s" in blocklist', elem)
110 class MPD(MPDClient):
112 Player instance inheriting from MPDClient (python-musicpd).
114 Some methods are overridden to format objects as sima.lib.Track for
115 instance, other are calling parent class directly through super().
120 * find methods are looking for exact match of the object provided
121 attributes in MPD music library
122 * search methods are looking for exact match + fuzzy match.
124 needed_cmds = ['status', 'stats', 'add', 'find',
125 'search', 'currentsong', 'ping']
126 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
127 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
128 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
129 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
130 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
131 'Date', 'OriginalDate', 'Composer', 'Performer',
132 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
133 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
134 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
135 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
138 def __init__(self, config):
141 self.log = getLogger('sima')
145 # ######### Overriding MPDClient ###########
146 def __getattr__(self, cmd):
147 """Wrapper around MPDClient calls for abstract overriding"""
148 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
149 if cmd in track_wrapped:
150 return tracks_wrapper(super().__getattr__(cmd))
151 return super().__getattr__(cmd)
153 def disconnect(self):
154 """Overriding explicitly MPDClient.disconnect()"""
159 """Overriding explicitly MPDClient.connect()"""
160 mpd_config = self.config['MPD']
161 # host, port, password
162 host = mpd_config.get('host')
163 port = mpd_config.get('port')
164 password = mpd_config.get('password', fallback=None)
167 super().connect(host, port)
168 # Catch socket errors
169 except IOError as err:
170 raise PlayerError('Could not connect to "%s:%s": %s' %
171 (host, port, err.strerror))
172 # Catch all other possible errors
173 # ConnectionError and ProtocolError are always fatal. Others may not
174 # be, but we don't know how to handle them here, so treat them as if
175 # they are instead of ignoring them.
176 except MPDError as err:
177 raise PlayerError('Could not connect to "%s:%s": %s' %
181 self.password(password)
182 except (MPDError, IOError) as err:
183 raise PlayerError("Could not connect to '%s': %s" % (host, err))
184 # Controls we have sufficient rights
185 available_cmd = self.commands()
186 for cmd in MPD.needed_cmds:
187 if cmd not in available_cmd:
189 raise PlayerError('Could connect to "%s", '
190 'but command "%s" not available' %
192 self.tagtypes('clear')
193 for tag in MPD.needed_tags:
194 self.tagtypes('enable', tag)
195 tt = set(map(str.lower, self.tagtypes()))
196 needed_tags = set(map(str.lower, MPD.needed_tags))
197 if len(needed_tags & tt) != len(MPD.needed_tags):
198 self.log.warning('MPD exposes: %s', tt)
199 self.log.warning('Tags needed: %s', needed_tags)
200 raise PlayerError('Missing mandatory metadata!')
201 for tag in MPD.needed_mbid_tags:
202 self.tagtypes('enable', tag)
203 # Controls use of MusicBrainzIdentifier
204 if self.config.getboolean('sima', 'musicbrainzid'):
205 tt = set(self.tagtypes())
206 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
207 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
208 'is not providing related metadata')
210 self.log.warning('Disabling MusicBrainzIdentifier')
211 self.use_mbid = Meta.use_mbid = False
213 self.log.debug('Available metadata: %s', tt)
214 self.use_mbid = Meta.use_mbid = True
216 self.log.warning('Use of MusicBrainzIdentifier disabled!')
217 self.log.info('Consider using MusicBrainzIdentifier for your music library')
218 self.use_mbid = Meta.use_mbid = False
220 # ######### / Overriding MPDClient #########
222 def _reset_cache(self):
224 Both flushes and instantiates _cache
226 * artists: all artists
227 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
228 * artist_tracks: caching last artist tracks, used in search_track
230 if isinstance(self._cache, dict):
231 self.log.info('Player: Flushing cache!')
233 self.log.info('Player: Initialising cache!')
234 self._cache = {'artists': frozenset(),
235 'nombid_artists': frozenset(),
237 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
239 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
240 self._cache['nombid_artists'] = frozenset(filter(None, artists))
242 def _skipped_track(self, previous):
243 if (self.state == 'stop'
244 or not hasattr(previous, 'id')
245 or not hasattr(self.current, 'id')):
247 return self.current.id != previous.id # pylint: disable=no-member
250 """Monitor player for change
251 Returns a list a events among:
253 * database player media library has changed
254 * playlist playlist modified
255 * options player options changed: repeat mode, etc…
256 * player player state changed: paused, stopped, skip track…
257 * skipped current track skipped
261 ret = self.idle('database', 'playlist', 'player', 'options')
262 except (MPDError, IOError) as err:
263 raise PlayerError("Couldn't init idle: %s" % err)
264 if self._skipped_track(curr):
265 ret.append('skipped')
266 if 'database' in ret:
271 """Clean blocking event (idle) and pending commands
273 if 'idle' in self._pending:
276 self.log.warning('pending commands: %s', self._pending)
278 def add(self, payload):
279 """Overriding MPD's add method to accept Track objects
281 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
283 if isinstance(payload, Track):
284 super().__getattr__('add')(payload.file)
285 elif isinstance(payload, list):
286 self.command_list_ok_begin()
287 map(self.add, payload)
288 self.command_list_end()
290 self.log.error('Cannot add %s', payload)
292 # ######### Properties #####################
295 return self.currentsong()
300 Override deprecated MPD playlist command
302 return self.playlistinfo()
306 plm = {'repeat': None, 'single': None,
307 'random': None, 'consume': None, }
308 for key, val in self.status().items():
309 if key in plm.keys():
310 plm.update({key: bool(int(val))})
316 curr_position = int(self.current.pos)
318 return [trk for trk in plst if int(trk.pos) > curr_position]
322 """Returns (play|stop|pause)"""
323 return str(self.status().get('state'))
324 # ######### / Properties ###################
326 # #### find_tracks ####
327 def find_tracks(self, what):
328 """Find tracks for a specific artist or album
329 >>> player.find_tracks(Artist('Nirvana'))
330 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
332 :param Artist,Album what: Artist or Album to fetch track from
334 Returns a list of :py:obj:Track objects
336 if isinstance(what, Artist):
337 return self._find_art(what)
338 if isinstance(what, Album):
339 return self._find_alb(what)
340 if isinstance(what, str):
341 return self.find_tracks(Artist(name=what))
342 raise PlayerError('Bad input argument')
344 def _find_art(self, artist):
347 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
348 for name in artist.names:
349 tracks |= set(self.find('artist', name))
352 def _find_alb(self, album):
353 if not hasattr(album, 'artist'):
354 raise PlayerError('Album object have no artist attribute')
356 if self.use_mbid and album.mbid:
357 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
358 albums = self.find(filt)
359 # Now look for album with no MusicBrainzIdentifier
360 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
361 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
362 albums = self.find(filt)
363 if not albums: # Falls back to (album)?artist/album name
364 for artist in album.artist.names_sz:
365 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
366 albums.extend(self.find(filt))
368 # #### / find_tracks ##
370 # #### Search Methods #####
372 def search_artist(self, artist):
374 Search artists based on a fuzzy search in the media library
375 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
376 >>> bea = player.search_artist(art)
378 >>> ['The Beatles', 'Beatles', 'the beatles']
380 :param Artist artist: Artist to look for in MPD music library
382 Returns an Artist object
385 if self.use_mbid and artist.mbid:
386 # look for exact search w/ musicbrainz_artistid
387 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
390 self.log.trace('Found mbid "%r" in library', artist)
391 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
393 self.log.debug('I got "%s" searching for %r', library, artist)
394 elif len(library) == 1 and library[0] != artist.name:
395 new_alias = artist.name
396 self.log.info('Update artist name %s->%s', artist, library[0])
397 self.log.debug('Also add alias for %s: %s', artist, new_alias)
398 artist = Artist(name=library[0], mbid=artist.mbid)
399 artist.add_alias(new_alias)
400 # Fetches remaining artists for potential match
401 artists = self._cache['nombid_artists']
402 else: # not using MusicBrainzIDs
403 artists = self._cache['artists']
404 match = get_close_matches(artist.name, artists, 50, 0.73)
405 if not match and not found:
408 self.log.debug('found close match for "%s": %s',
409 artist, '/'.join(match))
410 # First lowercased comparison
411 for close_art in match:
412 # Regular lowered string comparison
413 if artist.name.lower() == close_art.lower():
414 artist.add_alias(close_art)
416 if artist.name != close_art:
417 self.log.debug('"%s" matches "%s".', close_art, artist)
418 # Does not perform fuzzy matching on short and single word strings
419 # Only lowercased comparison
420 if ' ' not in artist.name and len(artist.name) < 8:
421 self.log.trace('no fuzzy matching for %r', artist)
425 # Now perform fuzzy search
427 if fuzz in artist.names: # Already found in lower cased comparison
429 # SimaStr string __eq__ (not regular string comparison here)
430 if SimaStr(artist.name) == fuzz:
432 artist.add_alias(fuzz)
433 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
437 self.log.info('Found aliases: %s', '/'.join(artist.names))
441 @blocklist(track=True)
442 def search_track(self, artist, title):
443 """Fuzzy search of title by an artist
445 cache = self._cache.get('artist_tracks').get(artist)
446 # Retrieve all tracks from artist
447 all_tracks = cache or self.find_tracks(artist)
449 self._cache['artist_tracks'] = {} # clean up
450 self._cache.get('artist_tracks')[artist] = all_tracks
451 # Get all titles (filter missing titles set to 'None')
452 all_artist_titles = frozenset([tr.title for tr in all_tracks
453 if tr.title is not None])
454 match = get_close_matches(title, all_artist_titles, 50, 0.78)
459 leven = levenshtein_ratio(title, mtitle)
461 tracks.extend([t for t in all_tracks if t.title == mtitle])
463 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
464 mtitle, title, leven)
465 tracks.extend([t for t in all_tracks if t.title == mtitle])
467 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
468 mtitle, title, leven)
471 @blocklist(album=True)
472 def search_albums(self, artist):
473 """Find potential albums for "artist"
475 * Fetch all albums for "AlbumArtist" == artist
476 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
477 * Tries to filter some mutli-artists album
478 For instance an album by Artist_A may have a track by Artist_B. Then
479 looking for albums for Artist_B returns wrongly this album.
481 # First, look for all potential albums
482 self.log.debug('Searching album for "%r"', artist)
484 self.log.debug('Searching album for %s aliases: "%s"',
485 artist, artist.aliases)
486 for name_sz in artist.names_sz:
487 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
488 raw_albums = self.list('album', mpd_filter)
489 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums]
492 album_trks = self.find_tracks(album)
493 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
494 if album.artist.names & album_artists:
495 candidates.append(album)
497 if 'Various Artists' in album_artists:
498 self.log.debug('Discarding %s ("Various Artists" set)', album)
500 if album_artists and album.artist.name not in album_artists:
501 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
503 # Attempt to detect false positive
504 # Avoid selecting albums where artist is credited for a single
506 album_trks = self.find(f"(album == '{album.name_sz}')")
507 arts = [trk.artist for trk in album_trks] # Artists in the album
508 # count artist occurences
509 ratio = arts.count(album.artist.name)/len(arts)
511 candidates.append(album)
513 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
514 album, artist, ratio)
517 # #### / Search Methods ###
520 # vim: ai ts=4 sw=4 sts=4 expandtab