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 logging import getLogger
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(MPDError):
36 """Fatal error in the player."""
41 def wrapper(*args, **kwargs):
44 return func(*args, **kwargs)
45 result = func(*args, **kwargs)
48 for art in result.names:
49 artist = Artist(name=art, mbid=result.mbid)
50 if cls.database.get_bl_artist(artist, add=False):
51 cls.log.debug('Artist in blocklist: %s', artist)
56 def set_artist_mbid(func):
57 def wrapper(*args, **kwargs):
59 result = func(*args, **kwargs)
61 if result and not result.mbid:
62 result.mbid = cls._find_musicbrainz_artistid(result)
66 def tracks_wrapper(func):
67 """Convert plain track mapping as returned by MPDClient into :py:obj:Track
68 objects. This decorator accepts single track or list of tracks as input.
71 def wrapper(*args, **kwargs):
72 ret = func(*args, **kwargs)
73 if isinstance(ret, dict):
75 return [Track(**t) for t in ret]
82 Player instance inheriting from MPDClient (python-musicpd).
84 Some methods are overridden to format objects as sima.lib.Track for
85 instance, other are calling parent class directly through super().
90 * find methods are looking for exact match of the object provided
91 attributes in MPD music library
92 * search methods are looking for exact match + fuzzy match.
94 needed_cmds = ['status', 'stats', 'add', 'find',
95 'search', 'currentsong', 'ping']
96 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
97 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
98 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
99 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
100 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
101 'Date', 'OriginalDate', 'Composer', 'Performer',
102 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
103 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
104 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
105 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
108 def __init__(self, config):
111 self.log = getLogger('sima')
115 # ######### Overriding MPDClient ###########
116 def __getattr__(self, cmd):
117 """Wrapper around MPDClient calls for abstract overriding"""
118 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
119 if cmd in track_wrapped:
120 return tracks_wrapper(super().__getattr__(cmd))
121 return super().__getattr__(cmd)
123 def disconnect(self):
124 """Overriding explicitly MPDClient.disconnect()"""
129 """Overriding explicitly MPDClient.connect()"""
130 mpd_config = self.config['MPD']
131 # host, port, password
132 host = mpd_config.get('host')
133 port = mpd_config.get('port')
134 password = mpd_config.get('password', fallback=None)
137 super().connect(host, port)
138 # Catch socket errors
139 except IOError as err:
140 raise PlayerError('Could not connect to "%s:%s": %s' %
141 (host, port, err.strerror))
142 # Catch all other possible errors
143 # ConnectionError and ProtocolError are always fatal. Others may not
144 # be, but we don't know how to handle them here, so treat them as if
145 # they are instead of ignoring them.
146 except MPDError as err:
147 raise PlayerError('Could not connect to "%s:%s": %s' %
151 self.password(password)
152 except (MPDError, IOError) as err:
153 raise PlayerError("Could not connect to '%s': %s" % (host, err))
154 # Controls we have sufficient rights
155 available_cmd = self.commands()
156 for cmd in MPD.needed_cmds:
157 if cmd not in available_cmd:
159 raise PlayerError('Could connect to "%s", '
160 'but command "%s" not available' %
162 self.tagtypes('clear')
163 for tag in MPD.needed_tags:
164 self.tagtypes('enable', tag)
165 tt = set(map(str.lower, self.tagtypes()))
166 needed_tags = set(map(str.lower, MPD.needed_tags))
167 if len(needed_tags & tt) != len(MPD.needed_tags):
168 self.log.warning('MPD exposes: %s', tt)
169 self.log.warning('Tags needed: %s', needed_tags)
170 raise PlayerError('Missing mandatory metadata!')
171 for tag in MPD.needed_mbid_tags:
172 self.tagtypes('enable', tag)
173 # Controls use of MusicBrainzIdentifier
174 if self.config.getboolean('sima', 'musicbrainzid'):
175 tt = set(self.tagtypes())
176 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
177 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
178 'is not providing related metadata')
180 self.log.warning('Disabling MusicBrainzIdentifier')
181 self.use_mbid = Meta.use_mbid = False
183 self.log.debug('Available metadata: %s', tt)
184 self.use_mbid = Meta.use_mbid = True
186 self.log.warning('Use of MusicBrainzIdentifier disabled!')
187 self.log.info('Consider using MusicBrainzIdentifier for your music library')
188 self.use_mbid = Meta.use_mbid = False
190 # ######### / Overriding MPDClient #########
192 def _reset_cache(self):
194 Both flushes and instantiates _cache
196 * artists: all artists
197 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
198 * artist_tracks: caching last artist tracks, used in search_track
200 if isinstance(self._cache, dict):
201 self.log.info('Player: Flushing cache!')
203 self.log.info('Player: Initialising cache!')
204 self._cache = {'artists': frozenset(),
205 'nombid_artists': frozenset(),
207 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
209 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
210 self._cache['nombid_artists'] = frozenset(filter(None, artists))
212 def _skipped_track(self, previous):
213 if (self.state == 'stop'
214 or not hasattr(previous, 'id')
215 or not hasattr(self.current, 'id')):
217 return self.current.id != previous.id # pylint: disable=no-member
220 """Monitor player for change
221 Returns a list a events among:
223 * database player media library has changed
224 * playlist playlist modified
225 * options player options changed: repeat mode, etc…
226 * player player state changed: paused, stopped, skip track…
227 * skipped current track skipped
231 ret = self.idle('database', 'playlist', 'player', 'options')
232 except (MPDError, IOError) as err:
233 raise PlayerError("Couldn't init idle: %s" % err)
234 if self._skipped_track(curr):
235 ret.append('skipped')
236 if 'database' in ret:
241 """Clean blocking event (idle) and pending commands
243 if 'idle' in self._pending:
246 self.log.warning('pending commands: %s', self._pending)
248 def add(self, payload):
249 """Overriding MPD's add method to accept Track objects
251 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
253 if isinstance(payload, Track):
254 super().__getattr__('add')(payload.file)
255 elif isinstance(payload, list):
256 self.command_list_ok_begin()
257 map(self.add, payload)
258 self.command_list_end()
260 self.log.error('Cannot add %s', payload)
262 # ######### Properties #####################
265 return self.currentsong()
270 Override deprecated MPD playlist command
272 return self.playlistinfo()
276 plm = {'repeat': None, 'single': None,
277 'random': None, 'consume': None, }
278 for key, val in self.status().items():
279 if key in plm.keys():
280 plm.update({key: bool(int(val))})
286 curr_position = int(self.current.pos)
288 return [trk for trk in plst if int(trk.pos) > curr_position]
292 """Returns (play|stop|pause)"""
293 return str(self.status().get('state'))
294 # ######### / Properties ###################
296 # #### find_tracks ####
297 def find_tracks(self, what):
298 """Find tracks for a specific artist or album
299 >>> player.find_tracks(Artist('Nirvana'))
300 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
302 :param Artist,Album what: Artist or Album to fetch track from
304 Returns a list of :py:obj:Track objects
306 if isinstance(what, Artist):
307 return self._find_art(what)
308 if isinstance(what, Album):
309 return self._find_alb(what)
310 if isinstance(what, str):
311 return self.find_tracks(Artist(name=what))
312 raise PlayerError('Bad input argument')
314 def _find_art(self, artist):
317 if self.database.get_bl_artist(artist, add=False):
318 self.log.info('Artist in blocklist: %s', artist)
321 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
322 for name in artist.names:
323 tracks |= set(self.find('artist', name))
325 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
327 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
328 for a in self.database.view_bl() if a.get('album')}
329 if albums & bl_albums:
330 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
331 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
333 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
334 for t in self.database.view_bl() if t.get('title')}
335 if tracks & bl_tracks:
336 self.log.info('Tracks in blocklist for %s: %s',
337 artist, tracks & bl_tracks)
338 tracks = {trk for trk in tracks if trk not in bl_tracks}
341 def _find_alb(self, album):
342 if not hasattr(album, 'artist'):
343 raise PlayerError('Album object have no artist attribute')
344 if self.database.get_bl_album(album, add=False):
345 self.log.info('Album in blocklist: %s', album)
349 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
350 albums = self.find(filt)
351 # Now look for album with no MusicBrainzIdentifier
352 if not albums and album.Artist.mbid: # Use album artist MBID if possible
353 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
354 albums = self.find(filt)
355 if not albums: # Falls back to (album)?artist/album name
356 for artist in album.Artist.names_sz:
357 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
358 albums.extend(self.find(filt))
360 # #### / find_tracks ##
362 # #### Search Methods #####
363 def _find_musicbrainz_artistid(self, artist):
364 """Find MusicBrainzArtistID when possible.
365 For artist with aliases having a mbid but not the main name, no mbid is
367 Searching for Artist('Russian Circls') do not reslove the MBID
369 if not self.use_mbid:
371 mbids = self.list('MUSICBRAINZ_ARTISTID',
372 f'(artist == "{artist.name_sz}")')
376 self.log.debug("Got multiple MBID for artist: %r", artist)
379 if artist.mbid != mbids[0]:
380 self.log('MBID discrepancy, %s found with %s (instead of %s)',
381 artist.name, mbids[0], artist.mbid)
387 def search_artist(self, artist):
389 Search artists based on a fuzzy search in the media library
390 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
391 >>> bea = player.search_artist(art)
393 >>> ['The Beatles', 'Beatles', 'the beatles']
395 :param Artist artist: Artist to look for in MPD music library
397 Returns an Artist object
401 # look for exact search w/ musicbrainz_artistid
402 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
405 self.log.trace('Found mbid "%r" in library', artist)
406 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
408 self.log.debug('I got "%s" searching for %r', library, artist)
410 if SimaStr(artist.name) == name and name != artist.name:
411 self.log.debug('add alias for %s: %s', artist, name)
412 artist.add_alias(name)
413 elif len(library) == 1 and library[0] != artist.name:
414 new_alias = artist.name
415 self.log.info('Update artist name %s->%s', artist, library[0])
416 self.log.debug('Also add alias for %s: %s', artist, new_alias)
417 artist = Artist(name=library[0], mbid=artist.mbid)
418 artist.add_alias(new_alias)
419 # Fetches remaining artists for potential match
420 artists = self._cache['nombid_artists']
421 else: # not using MusicBrainzIDs
422 artists = self._cache['artists']
423 match = get_close_matches(artist.name, artists, 50, 0.73)
424 if not match and not found:
427 self.log.debug('found close match for "%s": %s',
428 artist, '/'.join(match))
429 # First lowercased comparison
430 for close_art in match:
431 # Regular lowered string comparison
432 if artist.name.lower() == close_art.lower():
433 artist.add_alias(close_art)
435 if artist.name != close_art:
436 self.log.debug('"%s" matches "%s".', close_art, artist)
437 # Does not perform fuzzy matching on short and single word strings
438 # Only lowercased comparison
439 if ' ' not in artist.name and len(artist.name) < 8:
440 self.log.trace('no fuzzy matching for %r', artist)
444 # Now perform fuzzy search
446 if fuzz in artist.names: # Already found in lower cased comparison
448 # SimaStr string __eq__ (not regular string comparison here)
449 if SimaStr(artist.name) == fuzz:
451 artist.add_alias(fuzz)
452 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
456 self.log.info('Found aliases: %s', '/'.join(artist.names))
460 def search_track(self, artist, title):
461 """Fuzzy search of title by an artist
463 cache = self._cache.get('artist_tracks').get(artist)
464 # Retrieve all tracks from artist
465 all_tracks = cache or self.find_tracks(artist)
467 self._cache['artist_tracks'] = {} # clean up
468 self._cache.get('artist_tracks')[artist] = all_tracks
469 # Get all titles (filter missing titles set to 'None')
470 all_artist_titles = frozenset([tr.title for tr in all_tracks
471 if tr.title is not None])
472 match = get_close_matches(title, all_artist_titles, 50, 0.78)
477 leven = levenshtein_ratio(title, mtitle)
479 tracks.extend([t for t in all_tracks if t.title == mtitle])
481 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
482 mtitle, title, leven)
483 tracks.extend([t for t in all_tracks if t.title == mtitle])
485 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
486 mtitle, title, leven)
489 def search_albums(self, artist):
490 """Find potential albums for "artist"
492 * Fetch all albums for "AlbumArtist" == artist
493 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
494 * Tries to filter some mutli-artists album
495 For instance an album by Artist_A may have a track by Artist_B. Then
496 looking for albums for Artist_B wrongly returns this album.
498 # First, look for all potential albums
499 self.log.debug('Searching album for "%r"', artist)
501 self.log.debug('Searching album for %s aliases: "%s"',
502 artist, artist.aliases)
504 if self.use_mbid and artist.mbid:
505 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
506 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
507 for albumid in raw_album_id:
508 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
509 album_name = self.list('album', mpd_filter)
510 if not album_name: # something odd here
512 albums.add(Album(album_name[0], artist=artist.name,
513 Artist=artist, mbid=albumid))
514 for name_sz in artist.names_sz:
515 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
516 raw_albums = self.list('album', mpd_filter)
517 for alb in raw_albums:
518 if alb in [a.name for a in albums]:
523 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
524 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
527 albums.add(Album(alb, artist=artist.name,
528 Artist=artist, mbid=mbid))
531 album_trks = self.find_tracks(album)
532 if not album_trks: # find_track result can be empty, blocklist applied
534 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
535 if album.Artist.names & album_artists:
536 candidates.append(album)
538 if self.use_mbid and artist.mbid:
539 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
540 candidates.append(album)
543 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid', album, album.Artist)
545 if 'Various Artists' in album_artists:
546 self.log.debug('Discarding %s ("Various Artists" set)', album)
548 if album_artists and album.Artist.name not in album_artists:
549 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
551 # Attempt to detect false positive (especially when no
552 # AlbumArtist/MBIDs tag ar set)
553 # Avoid selecting albums where artist is credited for a single
555 album_trks = self.find(f"(album == '{album.name_sz}')")
556 arts = [trk.artist for trk in album_trks] # Artists in the album
557 # count artist occurences
558 ratio = arts.count(album.Artist.name)/len(arts)
560 candidates.append(album)
562 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
563 album, artist, ratio)
566 # #### / Search Methods ###
569 # vim: ai ts=4 sw=4 sts=4 expandtab