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 if cls.database.get_bl_artist(art, add_not=True):
52 cls.log.debug('Blacklisted "%s"', art)
57 resp = Artist(name=names.pop(), mbid=result.mbid)
64 def tracks_wrapper(func):
65 """Convert plain track mapping as returned by MPDClient into :py:obj:Track
66 objects. This decorator accepts single track or list of tracks as input.
69 def wrapper(*args, **kwargs):
70 ret = func(*args, **kwargs)
71 if isinstance(ret, dict):
73 return [Track(**t) for t in ret]
78 def blacklist(artist=False, album=False, track=False):
79 # pylint: disable=C0111,W0212
80 field = (album, track)
83 def wrapper(*args, **kwargs):
84 if not args[0].database:
85 return func(*args, **kwargs)
87 boolgen = (bl for bl in field)
88 bl_fun = (cls.database.get_bl_album,
89 cls.database.get_bl_track,)
90 #bl_getter = next(fn for fn, bl in zip(bl_fun, boolgen) if bl is True)
91 bl_getter = next(dropwhile(lambda _: not next(boolgen), bl_fun))
92 #cls.log.debug('using {0} as bl filter'.format(bl_getter.__name__))
94 for elem in func(*args, **kwargs):
95 if bl_getter(elem, add_not=True):
96 #cls.log.debug('Blacklisted "{0}"'.format(elem))
98 if track and cls.database.get_bl_album(elem, add_not=True):
99 # filter album as well in track mode
100 # (artist have already been)
101 cls.log.debug('Blacklisted alb. "%s"', elem)
109 class MPD(MPDClient):
111 Player instance inheriting from MPDClient (python-musicpd).
113 Some methods are overridden to format objects as sima.lib.Track for
114 instance, other are calling parent class directly through super().
119 * find methods are looking for exact match of the object provided
120 attributes in MPD music library
121 * search methods are looking for exact match + fuzzy match.
123 needed_cmds = ['status', 'stats', 'add', 'find',
124 'search', 'currentsong', 'ping']
125 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
126 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
127 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
128 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
129 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
130 'Date', 'OriginalDate', 'Composer', 'Performer',
131 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
132 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
133 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
134 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
137 def __init__(self, config):
140 self.log = getLogger('sima')
144 # ######### Overriding MPDClient ###########
145 def __getattr__(self, cmd):
146 """Wrapper around MPDClient calls for abstract overriding"""
147 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
148 if cmd in track_wrapped:
149 return tracks_wrapper(super().__getattr__(cmd))
150 return super().__getattr__(cmd)
152 def disconnect(self):
153 """Overriding explicitly MPDClient.disconnect()"""
158 """Overriding explicitly MPDClient.connect()"""
159 mpd_config = self.config['MPD']
160 # host, port, password
161 host = mpd_config.get('host')
162 port = mpd_config.get('port')
163 password = mpd_config.get('password', fallback=None)
166 super().connect(host, port)
167 # Catch socket errors
168 except IOError as err:
169 raise PlayerError('Could not connect to "%s:%s": %s' %
170 (host, port, err.strerror))
171 # Catch all other possible errors
172 # ConnectionError and ProtocolError are always fatal. Others may not
173 # be, but we don't know how to handle them here, so treat them as if
174 # they are instead of ignoring them.
175 except MPDError as err:
176 raise PlayerError('Could not connect to "%s:%s": %s' %
180 self.password(password)
181 except (MPDError, IOError) as err:
182 raise PlayerError("Could not connect to '%s': %s" % (host, err))
183 # Controls we have sufficient rights
184 available_cmd = self.commands()
185 for cmd in MPD.needed_cmds:
186 if cmd not in available_cmd:
188 raise PlayerError('Could connect to "%s", '
189 'but command "%s" not available' %
191 self.tagtypes('clear')
192 for tag in MPD.needed_tags:
193 self.tagtypes('enable', tag)
194 tt = set(map(str.lower, self.tagtypes()))
195 needed_tags = set(map(str.lower, MPD.needed_tags))
196 if len(needed_tags & tt) != len(MPD.needed_tags):
197 self.log.warning('MPD exposes: %s', tt)
198 self.log.warning('Tags needed: %s', needed_tags)
199 raise PlayerError('Missing mandatory metadata!')
200 for tag in MPD.needed_mbid_tags:
201 self.tagtypes('enable', tag)
202 # Controls use of MusicBrainzIdentifier
203 if self.config.getboolean('sima', 'musicbrainzid'):
204 tt = set(self.tagtypes())
205 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
206 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
207 'is not providing related metadata')
209 self.log.warning('Disabling MusicBrainzIdentifier')
210 self.use_mbid = Meta.use_mbid = False
212 self.log.debug('Available metadata: %s', tt)
213 self.use_mbid = Meta.use_mbid = True
215 self.log.warning('Use of MusicBrainzIdentifier disabled!')
216 self.log.info('Consider using MusicBrainzIdentifier for your music library')
217 self.use_mbid = Meta.use_mbid = False
219 # ######### / Overriding MPDClient #########
221 def _reset_cache(self):
223 Both flushes and instantiates _cache
225 * artists: all artists
226 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
227 * artist_tracks: caching last artist tracks, used in search_track
229 if isinstance(self._cache, dict):
230 self.log.info('Player: Flushing cache!')
232 self.log.info('Player: Initialising cache!')
233 self._cache = {'artists': frozenset(),
234 'nombid_artists': frozenset(),
236 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
238 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
239 self._cache['nombid_artists'] = frozenset(filter(None, artists))
241 def _skipped_track(self, previous):
242 if (self.state == 'stop'
243 or not hasattr(previous, 'id')
244 or not hasattr(self.current, 'id')):
246 return self.current.id != previous.id # pylint: disable=no-member
249 """Monitor player for change
250 Returns a list a events among:
252 * database player media library has changed
253 * playlist playlist modified
254 * options player options changed: repeat mode, etc…
255 * player player state changed: paused, stopped, skip track…
256 * skipped current track skipped
260 ret = self.idle('database', 'playlist', 'player', 'options')
261 except (MPDError, IOError) as err:
262 raise PlayerError("Couldn't init idle: %s" % err)
263 if self._skipped_track(curr):
264 ret.append('skipped')
265 if 'database' in ret:
270 """Clean blocking event (idle) and pending commands
272 if 'idle' in self._pending:
275 self.log.warning('pending commands: %s', self._pending)
277 def add(self, payload):
278 """Overriding MPD's add method to accept Track objects
280 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
282 if isinstance(payload, Track):
283 super().__getattr__('add')(payload.file)
284 elif isinstance(payload, list):
285 self.command_list_ok_begin()
286 map(self.add, payload)
287 self.command_list_end()
289 self.log.error('Cannot add %s', payload)
291 # ######### Properties #####################
294 return self.currentsong()
299 Override deprecated MPD playlist command
301 return self.playlistinfo()
305 plm = {'repeat': None, 'single': None,
306 'random': None, 'consume': None, }
307 for key, val in self.status().items():
308 if key in plm.keys():
309 plm.update({key: bool(int(val))})
315 curr_position = int(self.current.pos)
317 return [trk for trk in plst if int(trk.pos) > curr_position]
321 """Returns (play|stop|pause)"""
322 return str(self.status().get('state'))
323 # ######### / Properties ###################
325 # #### find_tracks ####
326 def find_tracks(self, what):
327 """Find tracks for a specific artist or album
328 >>> player.find_tracks(Artist('Nirvana'))
329 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
331 :param Artist,Album what: Artist or Album to fetch track from
333 Returns a list of :py:obj:Track objects
335 if isinstance(what, Artist):
336 return self._find_art(what)
337 if isinstance(what, Album):
338 return self._find_alb(what)
339 if isinstance(what, str):
340 return self.find_tracks(Artist(name=what))
341 raise PlayerError('Bad input argument')
343 def _find_art(self, artist):
346 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
347 for name in artist.names:
348 tracks |= set(self.find('artist', name))
351 def _find_alb(self, album):
352 if not hasattr(album, 'artist'):
353 raise PlayerError('Album object have no artist attribute')
355 if self.use_mbid and album.mbid:
356 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
357 albums = self.find(filt)
358 # Now look for album with no MusicBrainzIdentifier
359 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
360 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
361 albums = self.find(filt)
362 if not albums: # Falls back to (album)?artist/album name
363 for artist in album.artist.names_sz:
364 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
365 albums.extend(self.find(filt))
367 # #### / find_tracks ##
369 # #### Search Methods #####
371 def search_artist(self, artist):
373 Search artists based on a fuzzy search in the media library
374 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
375 >>> bea = player.search_artist(art)
377 >>> ['The Beatles', 'Beatles', 'the beatles']
379 :param Artist artist: Artist to look for in MPD music library
381 Returns an Artist object
384 if self.use_mbid and artist.mbid:
385 # look for exact search w/ musicbrainz_artistid
386 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
389 self.log.trace('Found mbid "%r" in library', artist)
390 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
392 self.log.debug('I got "%s" searching for %r', library, artist)
393 elif len(library) == 1 and library[0] != artist.name:
394 new_alias = artist.name
395 self.log.info('Update artist name %s->%s', artist, library[0])
396 self.log.debug('Also add alias for %s: %s', artist, new_alias)
397 artist = Artist(name=library[0], mbid=artist.mbid)
398 artist.add_alias(new_alias)
399 # Fetches remaining artists for potential match
400 artists = self._cache['nombid_artists']
401 else: # not using MusicBrainzIDs
402 artists = self._cache['artists']
403 match = get_close_matches(artist.name, artists, 50, 0.73)
404 if not match and not found:
407 self.log.debug('found close match for "%s": %s',
408 artist, '/'.join(match))
409 # First lowercased comparison
410 for close_art in match:
411 # Regular lowered string comparison
412 if artist.name.lower() == close_art.lower():
413 artist.add_alias(close_art)
415 if artist.name != close_art:
416 self.log.debug('"%s" matches "%s".', close_art, artist)
417 # Does not perform fuzzy matching on short and single word strings
418 # Only lowercased comparison
419 if ' ' not in artist.name and len(artist.name) < 8:
420 self.log.trace('no fuzzy matching for %r', artist)
424 # Now perform fuzzy search
426 if fuzz in artist.names: # Already found in lower cased comparison
428 # SimaStr string __eq__ (not regular string comparison here)
429 if SimaStr(artist.name) == fuzz:
431 artist.add_alias(fuzz)
432 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
436 self.log.info('Found aliases: %s', '/'.join(artist.names))
440 @blacklist(track=True)
441 def search_track(self, artist, title):
442 """Fuzzy search of title by an artist
444 cache = self._cache.get('artist_tracks').get(artist)
445 # Retrieve all tracks from artist
446 all_tracks = cache or self.find_tracks(artist)
448 self._cache['artist_tracks'] = {} # clean up
449 self._cache.get('artist_tracks')[artist] = all_tracks
450 # Get all titles (filter missing titles set to 'None')
451 all_artist_titles = frozenset([tr.title for tr in all_tracks
452 if tr.title is not None])
453 match = get_close_matches(title, all_artist_titles, 50, 0.78)
458 leven = levenshtein_ratio(title, mtitle)
460 tracks.extend([t for t in all_tracks if t.title == mtitle])
462 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
463 mtitle, title, leven)
464 tracks.extend([t for t in all_tracks if t.title == mtitle])
466 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
467 mtitle, title, leven)
470 @blacklist(album=True)
471 def search_albums(self, artist):
472 """Find potential albums for "artist"
474 * Fetch all albums for "AlbumArtist" == artist
475 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
476 * Tries to filter some mutli-artists album
477 For instance an album by Artist_A may have a track by Artist_B. Then
478 looking for albums for Artist_B returns wrongly this album.
480 # First, look for all potential albums
481 self.log.debug('Searching album for "%r"', artist)
483 self.log.debug('Searching album for %s aliases: "%s"',
484 artist, artist.aliases)
485 for name_sz in artist.names_sz:
486 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
487 raw_albums = self.list('album', mpd_filter)
488 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums]
491 album_trks = self.find_tracks(album)
492 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
493 if album.artist.names & album_artists:
494 candidates.append(album)
496 if 'Various Artists' in album_artists:
497 self.log.debug('Discarding %s ("Various Artists" set)', album)
499 if album_artists and album.artist.name not in album_artists:
500 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
502 # Attempt to detect false positive
503 # Avoid selecting albums where artist is credited for a single
505 album_trks = self.find(f"(album == '{album.name_sz}')")
506 arts = [trk.artist for trk in album_trks] # Artists in the album
507 # count artist occurences
508 ratio = arts.count(album.artist.name)/len(arts)
510 candidates.append(album)
512 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
513 album, artist, ratio)
516 # #### / Search Methods ###
519 # vim: ai ts=4 sw=4 sts=4 expandtab