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
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 the player."""
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_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
125 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
126 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
127 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
128 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
129 'Date', 'OriginalDate', 'Composer', 'Performer',
130 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
131 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
132 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
133 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
136 def __init__(self, daemon):
140 self.log = daemon.log
141 self.config = self.daemon.config['MPD']
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 # host, port, password
160 host = self.config.get('host')
161 port = self.config.get('port')
162 password = self.config.get('password', fallback=None)
165 super().connect(host, port)
166 # Catch socket errors
167 except IOError as err:
168 raise PlayerError('Could not connect to "%s:%s": %s' %
169 (host, port, err.strerror))
170 # Catch all other possible errors
171 # ConnectionError and ProtocolError are always fatal. Others may not
172 # be, but we don't know how to handle them here, so treat them as if
173 # they are instead of ignoring them.
174 except MPDError as err:
175 raise PlayerError('Could not connect to "%s:%s": %s' %
179 self.password(password)
180 except (MPDError, IOError) as err:
181 raise PlayerError("Could not connect to '%s': %s" % (host, err))
182 # Controls we have sufficient rights
183 available_cmd = self.commands()
184 for cmd in MPD.needed_cmds:
185 if cmd not in available_cmd:
187 raise PlayerError('Could connect to "%s", '
188 'but command "%s" not available' %
190 self.tagtypes('clear')
191 for tag in MPD.needed_tags:
192 self.tagtypes('enable', tag)
193 tt = set(map(str.lower, self.tagtypes()))
194 needed_tags = set(map(str.lower, MPD.needed_tags))
195 if len(needed_tags & tt) != len(MPD.needed_tags):
196 self.log.warning('MPD exposes: %s', tt)
197 self.log.warning('Tags needed: %s', needed_tags)
198 raise PlayerError('Missing mandatory metadata!')
199 for tag in MPD.needed_mbid_tags:
200 self.tagtypes('enable', tag)
201 # Controls use of MusicBrainzIdentifier
202 if self.daemon.config.get('sima', 'musicbrainzid'):
203 tt = set(self.tagtypes())
204 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
205 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
206 'is not providing related metadata')
208 self.log.warning('Disabling MusicBrainzIdentifier')
209 self.use_mbid = Meta.use_mbid = False
211 self.log.debug('Available metadata: %s', tt)
212 self.use_mbid = Meta.use_mbid = True
214 self.log.warning('Use of MusicBrainzIdentifier disabled!')
215 self.log.info('Consider using MusicBrainzIdentifier for your music library')
216 self.use_mbid = Meta.use_mbid = False
218 # ######### / Overriding MPDClient #########
220 def _reset_cache(self):
222 Both flushes and instantiates _cache
224 * artists: all artists
225 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
226 * artist_tracks: caching last artist tracks, used in search_track
228 if isinstance(self._cache, dict):
229 self.log.info('Player: Flushing cache!')
231 self.log.info('Player: Initialising cache!')
232 self._cache = {'artists': frozenset(),
233 'nombid_artists': frozenset(),
235 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
237 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
238 self._cache['nombid_artists'] = frozenset(filter(None, artists))
240 def _skipped_track(self, previous):
241 if (self.state == 'stop'
242 or not hasattr(previous, 'id')
243 or not hasattr(self.current, 'id')):
245 return self.current.id != previous.id # pylint: disable=no-member
248 """Monitor player for change
249 Returns a list a events among:
251 * database player media library has changed
252 * playlist playlist modified
253 * options player options changed: repeat mode, etc…
254 * player player state changed: paused, stopped, skip track…
255 * skipped current track skipped
259 ret = self.idle('database', 'playlist', 'player', 'options')
260 except (MPDError, IOError) as err:
261 raise PlayerError("Couldn't init idle: %s" % err)
262 if self._skipped_track(curr):
263 ret.append('skipped')
264 if 'database' in ret:
269 """Clean blocking event (idle) and pending commands
271 if 'idle' in self._pending:
274 self.log.warning('pending commands: %s', self._pending)
276 def add(self, payload):
277 """Overriding MPD's add method to accept Track objects
279 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
281 if isinstance(payload, Track):
282 super().__getattr__('add')(payload.file)
283 elif isinstance(payload, list):
284 self.command_list_ok_begin()
285 map(self.add, payload)
286 self.command_list_end()
288 self.log.error('Cannot add %s', payload)
290 # ######### Properties #####################
293 return self.currentsong()
298 Override deprecated MPD playlist command
300 return self.playlistinfo()
304 plm = {'repeat': None, 'single': None,
305 'random': None, 'consume': None, }
306 for key, val in self.status().items():
307 if key in plm.keys():
308 plm.update({key: bool(int(val))})
314 curr_position = int(self.current.pos)
316 return [trk for trk in plst if int(trk.pos) > curr_position]
320 """Returns (play|stop|pause)"""
321 return str(self.status().get('state'))
322 # ######### / Properties ###################
324 # #### find_tracks ####
325 def find_tracks(self, what):
326 """Find tracks for a specific artist or album
327 >>> player.find_tracks(Artist('Nirvana'))
328 >>> player.find_tracks(Album('In Utero', artist=(Artist('Nirvana'))
330 :param Artist,Album what: Artist or Album to fetch track from
332 Returns a list of :py:obj:Track objects
334 if isinstance(what, Artist):
335 return self._find_art(what)
336 if isinstance(what, Album):
337 return self._find_alb(what)
338 if isinstance(what, str):
339 return self.find_tracks(Artist(name=what))
340 raise PlayerError('Bad input argument')
342 def _find_art(self, artist):
345 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
346 for name in artist.names:
347 tracks |= set(self.find('artist', name))
350 def _find_alb(self, album):
351 if not hasattr(album, 'artist'):
352 raise PlayerError('Album object have no artist attribute')
354 if self.use_mbid and album.mbid:
355 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
356 albums = self.find(filt)
357 # Now look for album with no MusicBrainzIdentifier
358 if not albums and album.artist.mbid and self.use_mbid: # Use album artist MBID if possible
359 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.artist.mbid}') AND (album == '{album.name_sz}'))"
360 albums = self.find(filt)
361 if not albums: # Falls back to (album)?artist/album name
362 for artist in album.artist.names_sz:
363 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
364 albums.extend(self.find(filt))
366 # #### / find_tracks ##
368 # #### Search Methods #####
370 def search_artist(self, artist):
372 Search artists based on a fuzzy search in the media library
373 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
374 >>> bea = player.search_artist(art)
376 >>> ['The Beatles', 'Beatles', 'the beatles']
378 :param Artist artist: Artist to look for in MPD music library
380 Returns an Artist object
383 if self.use_mbid and artist.mbid:
384 # look for exact search w/ musicbrainz_artistid
385 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
388 self.log.trace('Found mbid "%r" in library', artist)
389 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
391 self.log.debug('I got "%s" searching for %r', library, artist)
392 elif len(library) == 1 and library[0] != artist.name:
393 new_alias = artist.name
394 self.log.info('Update artist name %s->%s', artist, library[0])
395 self.log.debug('Also add alias for %s: %s', artist, new_alias)
396 artist = Artist(name=library[0], mbid=artist.mbid)
397 artist.add_alias(new_alias)
398 # Fetches remaining artists for potential match
399 artists = self._cache['nombid_artists']
400 else: # not using MusicBrainzIDs
401 artists = self._cache['artists']
402 match = get_close_matches(artist.name, artists, 50, 0.73)
403 if not match and not found:
406 self.log.debug('found close match for "%s": %s',
407 artist, '/'.join(match))
408 # First lowercased comparison
409 for close_art in match:
410 # Regular lowered string comparison
411 if artist.name.lower() == close_art.lower():
412 artist.add_alias(close_art)
414 if artist.name != close_art:
415 self.log.debug('"%s" matches "%s".', close_art, artist)
416 # Does not perform fuzzy matching on short and single word strings
417 # Only lowercased comparison
418 if ' ' not in artist.name and len(artist.name) < 8:
419 self.log.trace('no fuzzy matching for %r', artist)
423 # Now perform fuzzy search
425 if fuzz in artist.names: # Already found in lower cased comparison
427 # SimaStr string __eq__ (not regular string comparison here)
428 if SimaStr(artist.name) == fuzz:
430 artist.add_alias(fuzz)
431 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
435 self.log.info('Found aliases: %s', '/'.join(artist.names))
439 @blacklist(track=True)
440 def search_track(self, artist, title):
441 """Fuzzy search of title by an artist
443 cache = self._cache.get('artist_tracks').get(artist)
444 # Retrieve all tracks from artist
445 all_tracks = cache or self.find_tracks(artist)
447 self._cache['artist_tracks'] = {} # clean up
448 self._cache.get('artist_tracks')[artist] = all_tracks
449 # Get all titles (filter missing titles set to 'None')
450 all_artist_titles = frozenset([tr.title for tr in all_tracks
451 if tr.title is not None])
452 match = get_close_matches(title, all_artist_titles, 50, 0.78)
457 leven = levenshtein_ratio(title, mtitle)
459 tracks.extend([t for t in all_tracks if t.title == mtitle])
461 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
462 mtitle, title, leven)
463 tracks.extend([t for t in all_tracks if t.title == mtitle])
465 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
466 mtitle, title, leven)
469 @blacklist(album=True)
470 def search_albums(self, artist):
471 """Find potential albums for "artist"
473 * Fetch all albums for "AlbumArtist" == artist
474 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
475 * Tries to filter some mutli-artists album
476 For instance an album by Artist_A may have a track by Artist_B. Then
477 looking for albums for Artist_B returns wrongly this album.
479 # First, look for all potential albums
480 self.log.debug('Searching album for "%r"', artist)
482 self.log.debug('Searching album for %s aliases: "%s"',
483 artist, artist.aliases)
484 for name_sz in artist.names_sz:
485 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
486 raw_albums = self.list('album', mpd_filter)
487 albums = [Album(a, albumartist=artist.name, artist=artist) for a in raw_albums]
490 album_trks = self.find_tracks(album)
491 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
492 if album.artist.names & album_artists:
493 candidates.append(album)
495 if 'Various Artists' in album_artists:
496 self.log.debug('Discarding %s ("Various Artists" set)', album)
498 if album_artists and album.artist.name not in album_artists:
499 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.artist)
501 # Attempt to detect false positive
502 # Avoid selecting albums where artist is credited for a single
504 album_trks = self.find(f"(album == '{album.name_sz}')")
505 arts = [trk.artist for trk in album_trks] # Artists in the album
506 # count artist occurences
507 ratio = arts.count(album.artist.name)/len(arts)
509 candidates.append(album)
511 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
512 album, artist, ratio)
515 # #### / Search Methods ###
518 # vim: ai ts=4 sw=4 sts=4 expandtab