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(Exception):
36 """Fatal error in the player."""
41 def wrapper(*args, **kwargs):
44 return func(*args, **kwargs)
45 results = func(*args, **kwargs)
48 for art in results.names:
51 mbid = cls._find_musicbrainz_artistid(results)
52 artist = Artist(name=art, mbid=mbid)
53 if cls.database.get_bl_artist(artist, add=False):
54 cls.log.debug('Artist in blocklist: %s', artist)
60 def tracks_wrapper(func):
61 """Convert plain track mapping as returned by MPDClient into :py:obj:Track
62 objects. This decorator accepts single track or list of tracks as input.
65 def wrapper(*args, **kwargs):
66 ret = func(*args, **kwargs)
67 if isinstance(ret, dict):
69 return [Track(**t) for t in ret]
76 Player instance inheriting from MPDClient (python-musicpd).
78 Some methods are overridden to format objects as sima.lib.Track for
79 instance, other are calling parent class directly through super().
84 * find methods are looking for exact match of the object provided
85 attributes in MPD music library
86 * search methods are looking for exact match + fuzzy match.
88 needed_cmds = ['status', 'stats', 'add', 'find',
89 'search', 'currentsong', 'ping']
90 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
91 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
92 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
93 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
94 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
95 'Date', 'OriginalDate', 'Composer', 'Performer',
96 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
97 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
98 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
99 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
102 def __init__(self, config):
105 self.log = getLogger('sima')
109 # ######### Overriding MPDClient ###########
110 def __getattr__(self, cmd):
111 """Wrapper around MPDClient calls for abstract overriding"""
112 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
113 if cmd in track_wrapped:
114 return tracks_wrapper(super().__getattr__(cmd))
115 return super().__getattr__(cmd)
117 def disconnect(self):
118 """Overriding explicitly MPDClient.disconnect()"""
123 """Overriding explicitly MPDClient.connect()"""
124 mpd_config = self.config['MPD']
125 # host, port, password
126 host = mpd_config.get('host')
127 port = mpd_config.get('port')
128 password = mpd_config.get('password', fallback=None)
131 super().connect(host, port)
132 # Catch socket errors
133 except IOError as err:
134 raise PlayerError('Could not connect to "%s:%s": %s' %
135 (host, port, err.strerror))
136 # Catch all other possible errors
137 # ConnectionError and ProtocolError are always fatal. Others may not
138 # be, but we don't know how to handle them here, so treat them as if
139 # they are instead of ignoring them.
140 except MPDError as err:
141 raise PlayerError('Could not connect to "%s:%s": %s' %
145 self.password(password)
146 except (MPDError, IOError) as err:
147 raise PlayerError("Could not connect to '%s': %s" % (host, err))
148 # Controls we have sufficient rights
149 available_cmd = self.commands()
150 for cmd in MPD.needed_cmds:
151 if cmd not in available_cmd:
153 raise PlayerError('Could connect to "%s", '
154 'but command "%s" not available' %
156 self.tagtypes('clear')
157 for tag in MPD.needed_tags:
158 self.tagtypes('enable', tag)
159 tt = set(map(str.lower, self.tagtypes()))
160 needed_tags = set(map(str.lower, MPD.needed_tags))
161 if len(needed_tags & tt) != len(MPD.needed_tags):
162 self.log.warning('MPD exposes: %s', tt)
163 self.log.warning('Tags needed: %s', needed_tags)
164 raise PlayerError('Missing mandatory metadata!')
165 for tag in MPD.needed_mbid_tags:
166 self.tagtypes('enable', tag)
167 # Controls use of MusicBrainzIdentifier
168 if self.config.getboolean('sima', 'musicbrainzid'):
169 tt = set(self.tagtypes())
170 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
171 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
172 'is not providing related metadata')
174 self.log.warning('Disabling MusicBrainzIdentifier')
175 self.use_mbid = Meta.use_mbid = False
177 self.log.debug('Available metadata: %s', tt)
178 self.use_mbid = Meta.use_mbid = True
180 self.log.warning('Use of MusicBrainzIdentifier disabled!')
181 self.log.info('Consider using MusicBrainzIdentifier for your music library')
182 self.use_mbid = Meta.use_mbid = False
184 # ######### / Overriding MPDClient #########
186 def _reset_cache(self):
188 Both flushes and instantiates _cache
190 * artists: all artists
191 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
192 * artist_tracks: caching last artist tracks, used in search_track
194 if isinstance(self._cache, dict):
195 self.log.info('Player: Flushing cache!')
197 self.log.info('Player: Initialising cache!')
198 self._cache = {'artists': frozenset(),
199 'nombid_artists': frozenset(),
201 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
203 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
204 self._cache['nombid_artists'] = frozenset(filter(None, artists))
206 def _skipped_track(self, previous):
207 if (self.state == 'stop'
208 or not hasattr(previous, 'id')
209 or not hasattr(self.current, 'id')):
211 return self.current.id != previous.id # pylint: disable=no-member
214 """Monitor player for change
215 Returns a list a events among:
217 * database player media library has changed
218 * playlist playlist modified
219 * options player options changed: repeat mode, etc…
220 * player player state changed: paused, stopped, skip track…
221 * skipped current track skipped
225 ret = self.idle('database', 'playlist', 'player', 'options')
226 except (MPDError, IOError) as err:
227 raise PlayerError("Couldn't init idle: %s" % err)
228 if self._skipped_track(curr):
229 ret.append('skipped')
230 if 'database' in ret:
235 """Clean blocking event (idle) and pending commands
237 if 'idle' in self._pending:
240 self.log.warning('pending commands: %s', self._pending)
242 def add(self, payload):
243 """Overriding MPD's add method to accept Track objects
245 :param Track,list payload: Either a single :py:obj:`Track` or a list of it
247 if isinstance(payload, Track):
248 super().__getattr__('add')(payload.file)
249 elif isinstance(payload, list):
250 self.command_list_ok_begin()
251 map(self.add, payload)
252 self.command_list_end()
254 self.log.error('Cannot add %s', payload)
256 # ######### Properties #####################
259 return self.currentsong()
264 Override deprecated MPD playlist command
266 return self.playlistinfo()
270 plm = {'repeat': None, 'single': None,
271 'random': None, 'consume': None, }
272 for key, val in self.status().items():
273 if key in plm.keys():
274 plm.update({key: bool(int(val))})
280 curr_position = int(self.current.pos)
282 return [trk for trk in plst if int(trk.pos) > curr_position]
286 """Returns (play|stop|pause)"""
287 return str(self.status().get('state'))
288 # ######### / Properties ###################
290 # #### find_tracks ####
291 def find_tracks(self, what):
292 """Find tracks for a specific artist or album
293 >>> player.find_tracks(Artist('Nirvana'))
294 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
296 :param Artist,Album what: Artist or Album to fetch track from
298 Returns a list of :py:obj:Track objects
300 if isinstance(what, Artist):
301 return self._find_art(what)
302 if isinstance(what, Album):
303 return self._find_alb(what)
304 if isinstance(what, str):
305 return self.find_tracks(Artist(name=what))
306 raise PlayerError('Bad input argument')
308 def _find_art(self, artist):
311 if self.database.get_bl_artist(artist, add=False):
312 self.log.info('Artist in blocklist: %s', artist)
315 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
316 for name in artist.names:
317 tracks |= set(self.find('artist', name))
319 albums = {Album(trk.album, mbid=trk.musicbrainz_albumid)
321 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
322 for a in self.database.view_bl() if a.get('album')}
323 if albums & bl_albums:
324 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
325 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
327 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
328 for t in self.database.view_bl() if t.get('title')}
329 if tracks & bl_tracks:
330 self.log.info('Tracks in blocklist for %s: %s',
331 artist, tracks & bl_tracks)
332 tracks = {trk for trk in tracks if trk not in bl_tracks}
335 def _find_alb(self, album):
336 if not hasattr(album, 'artist'):
337 raise PlayerError('Album object have no artist attribute')
338 if self.database.get_bl_album(album, add=False):
339 self.log.info('Album in blocklist: %s', album)
343 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
344 albums = self.find(filt)
345 # Now look for album with no MusicBrainzIdentifier
346 if not albums and album.Artist.mbid: # Use album artist MBID if possible
347 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
348 albums = self.find(filt)
349 if not albums: # Falls back to (album)?artist/album name
350 for artist in album.Artist.names_sz:
351 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
352 albums.extend(self.find(filt))
354 # #### / find_tracks ##
356 # #### Search Methods #####
357 def _find_musicbrainz_artistid(self, artist):
358 if not self.use_mbid:
360 mbids = self.list('MUSICBRAINZ_ARTISTID',
361 f'(artist == "{artist.name_sz}")')
365 self.log.debug("Got multiple MBID for artist: %r", artist)
368 if artist.mbid != mbids[0]:
369 self.log('MBID discrepancy, %s found with %s (instead of %s)',
370 artist.name, mbids[0], artist.mbid)
375 def search_artist(self, artist):
377 Search artists based on a fuzzy search in the media library
378 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
379 >>> bea = player.search_artist(art)
381 >>> ['The Beatles', 'Beatles', 'the beatles']
383 :param Artist artist: Artist to look for in MPD music library
385 Returns an Artist object
389 # look for exact search w/ musicbrainz_artistid
390 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
393 self.log.trace('Found mbid "%r" in library', artist)
394 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
396 self.log.debug('I got "%s" searching for %r', library, artist)
398 if SimaStr(artist.name) == name and name != artist.name:
399 self.log.debug('add alias for %s: %s', artist, name)
400 artist.add_alias(name)
401 elif len(library) == 1 and library[0] != artist.name:
402 new_alias = artist.name
403 self.log.info('Update artist name %s->%s', artist, library[0])
404 self.log.debug('Also add alias for %s: %s', artist, new_alias)
405 artist = Artist(name=library[0], mbid=artist.mbid)
406 artist.add_alias(new_alias)
407 # Fetches remaining artists for potential match
408 artists = self._cache['nombid_artists']
409 else: # not using MusicBrainzIDs
410 artists = self._cache['artists']
411 match = get_close_matches(artist.name, artists, 50, 0.73)
412 if not match and not found:
415 self.log.debug('found close match for "%s": %s',
416 artist, '/'.join(match))
417 # First lowercased comparison
418 for close_art in match:
419 # Regular lowered string comparison
420 if artist.name.lower() == close_art.lower():
421 artist.add_alias(close_art)
423 if artist.name != close_art:
424 self.log.debug('"%s" matches "%s".', close_art, artist)
425 # Does not perform fuzzy matching on short and single word strings
426 # Only lowercased comparison
427 if ' ' not in artist.name and len(artist.name) < 8:
428 self.log.trace('no fuzzy matching for %r', artist)
432 # Now perform fuzzy search
434 if fuzz in artist.names: # Already found in lower cased comparison
436 # SimaStr string __eq__ (not regular string comparison here)
437 if SimaStr(artist.name) == fuzz:
439 artist.add_alias(fuzz)
440 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
444 self.log.info('Found aliases: %s', '/'.join(artist.names))
448 def search_track(self, artist, title):
449 """Fuzzy search of title by an artist
451 cache = self._cache.get('artist_tracks').get(artist)
452 # Retrieve all tracks from artist
453 all_tracks = cache or self.find_tracks(artist)
455 self._cache['artist_tracks'] = {} # clean up
456 self._cache.get('artist_tracks')[artist] = all_tracks
457 # Get all titles (filter missing titles set to 'None')
458 all_artist_titles = frozenset([tr.title for tr in all_tracks
459 if tr.title is not None])
460 match = get_close_matches(title, all_artist_titles, 50, 0.78)
465 leven = levenshtein_ratio(title, mtitle)
467 tracks.extend([t for t in all_tracks if t.title == mtitle])
469 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
470 mtitle, title, leven)
471 tracks.extend([t for t in all_tracks if t.title == mtitle])
473 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
474 mtitle, title, leven)
477 def search_albums(self, artist):
478 """Find potential albums for "artist"
480 * Fetch all albums for "AlbumArtist" == artist
481 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
482 * Tries to filter some mutli-artists album
483 For instance an album by Artist_A may have a track by Artist_B. Then
484 looking for albums for Artist_B wrongly returns this album.
486 # First, look for all potential albums
487 self.log.debug('Searching album for "%r"', artist)
489 self.log.debug('Searching album for %s aliases: "%s"',
490 artist, artist.aliases)
492 if self.use_mbid and artist.mbid:
493 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
494 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
495 for albumid in raw_album_id:
496 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
497 album_name = self.list('album', mpd_filter)
498 if not album_name: # something odd here
500 albums.add(Album(album_name[0], artist=artist.name,
501 Artist=artist, mbid=albumid))
502 for name_sz in artist.names_sz:
503 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
504 raw_albums = self.list('album', mpd_filter)
505 for alb in raw_albums:
506 if alb in [a.name for a in albums]:
511 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
512 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
515 albums.add(Album(alb, artist=artist.name,
516 Artist=artist, mbid=mbid))
519 album_trks = self.find_tracks(album)
520 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
521 if album.Artist.names & album_artists:
522 candidates.append(album)
524 if self.use_mbid and artist.mbid:
525 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
526 candidates.append(album)
529 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid', album, album.Artist)
531 if 'Various Artists' in album_artists:
532 self.log.debug('Discarding %s ("Various Artists" set)', album)
534 if album_artists and album.Artist.name not in album_artists:
535 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
537 # Attempt to detect false positive (especially when no
538 # AlbumArtist/MBIDs tag ar set)
539 # Avoid selecting albums where artist is credited for a single
541 album_trks = self.find(f"(album == '{album.name_sz}')")
542 arts = [trk.artist for trk in album_trks] # Artists in the album
543 # count artist occurences
544 ratio = arts.count(album.Artist.name)/len(arts)
546 candidates.append(album)
548 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
549 album, artist, ratio)
552 if self.database.get_bl_album(album, add=False):
553 candidates.remove(album)
555 # #### / Search Methods ###
558 # vim: ai ts=4 sw=4 sts=4 expandtab