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
23 from select import select
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
34 from .utils.utils import MPDSimaException
37 class PlayerError(MPDSimaException):
38 """Fatal error in the player."""
43 def wrapper(*args, **kwargs):
46 return func(*args, **kwargs)
47 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 in blocklist: %s', artist)
59 def set_artist_mbid(func):
60 def wrapper(*args, **kwargs):
62 result = func(*args, **kwargs)
64 if result and not result.mbid:
65 mbid = cls._find_musicbrainz_artistid(result)
66 artist = Artist(name=result.name, mbid=mbid)
67 artist.add_alias(result)
73 def tracks_wrapper(func):
74 """Convert plain track mapping as returned by MPDClient into :py:obj:`sima.lib.track.Track`
75 objects. This decorator accepts single track or list of tracks as input.
78 def wrapper(*args, **kwargs):
79 ret = func(*args, **kwargs)
80 if isinstance(ret, dict):
82 return [Track(**t) for t in ret]
89 Player instance inheriting from MPDClient (python-musicpd).
91 Some methods are overridden to format objects as :py:obj:`sima.lib.track.Track` for
92 instance, other are calling parent class directly through super().
97 * find methods are looking for exact match of the object provided
98 attributes in MPD music library
99 * search methods are looking for exact match + fuzzy match.
101 needed_cmds = ['status', 'stats', 'add', 'find',
102 'search', 'currentsong', 'ping']
103 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
104 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
105 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
106 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
107 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
108 'Date', 'OriginalDate', 'Composer', 'Performer',
109 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
110 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
111 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
112 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
115 def __init__(self, config):
117 self.socket_timeout = 10
119 self.log = getLogger('sima')
123 # ######### Overriding MPDClient ###########
124 def __getattr__(self, cmd):
125 """Wrapper around MPDClient calls for abstract overriding"""
126 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
128 if cmd in track_wrapped:
129 return tracks_wrapper(super().__getattr__(cmd))
130 return super().__getattr__(cmd)
131 except OSError as err:
132 raise PlayerError(err) from err
134 def disconnect(self):
135 """Overriding explicitly MPDClient.disconnect()"""
140 """Overriding explicitly MPDClient.connect()"""
141 mpd_config = self.config['MPD']
142 # host, port, password
143 host = mpd_config.get('host')
144 port = mpd_config.get('port')
145 password = mpd_config.get('password', fallback=None)
148 super().connect(host, port)
149 # Catch socket errors
150 except OSError as err:
151 raise PlayerError(f'Could not connect to "{host}:{port}": {err.strerror}'
153 # Catch all other possible errors
154 # ConnectionError and ProtocolError are always fatal. Others may not
155 # be, but we don't know how to handle them here, so treat them as if
156 # they are instead of ignoring them.
157 except MPDError as err:
158 raise PlayerError(f'Could not connect to "{host}:{port}": {err}') from err
161 self.password(password)
162 except (MPDError, OSError) as err:
163 raise PlayerError(f"Could not connect to '{host}': {err}") from err
164 # Controls we have sufficient rights
165 available_cmd = self.commands()
166 for cmd in MPD.needed_cmds:
167 if cmd not in available_cmd:
169 raise PlayerError(f'Could connect to "{host}", but command "{cmd}" not available')
170 self.tagtypes('clear')
171 for tag in MPD.needed_tags:
172 self.tagtypes('enable', tag)
173 ltt = set(map(str.lower, self.tagtypes()))
174 needed_tags = set(map(str.lower, MPD.needed_tags))
175 if len(needed_tags & ltt) != len(MPD.needed_tags):
176 self.log.warning('MPD exposes: %s', ltt)
177 self.log.warning('Tags needed: %s', needed_tags)
178 raise PlayerError('Missing mandatory metadata!')
179 for tag in MPD.needed_mbid_tags:
180 self.tagtypes('enable', tag)
181 # Controls use of MusicBrainzIdentifier
182 if self.config.getboolean('sima', 'musicbrainzid'):
183 ltt = set(self.tagtypes())
184 if len(MPD.needed_mbid_tags & ltt) != len(MPD.needed_mbid_tags):
185 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
186 'is not providing related metadata')
188 self.log.warning('Disabling MusicBrainzIdentifier')
189 self.use_mbid = Meta.use_mbid = False
191 self.log.debug('Available metadata: %s', ltt)
192 self.use_mbid = Meta.use_mbid = True
194 self.log.warning('Use of MusicBrainzIdentifier disabled!')
195 self.log.info('Consider using MusicBrainzIdentifier for your music library')
196 self.use_mbid = Meta.use_mbid = False
198 # ######### / Overriding MPDClient #########
200 def _reset_cache(self):
202 Both flushes and instantiates _cache
204 * artists: all artists
205 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
206 * artist_tracks: caching last artist tracks, used in search_track
208 if isinstance(self._cache, dict):
209 self.log.info('Player: Flushing cache!')
211 self.log.info('Player: Initialising cache!')
212 self._cache = {'artists': frozenset(),
213 'nombid_artists': frozenset(),
215 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
217 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
218 self._cache['nombid_artists'] = frozenset(filter(None, artists))
220 def _skipped_track(self, previous):
221 if (self.state == 'stop'
222 or not hasattr(previous, 'id')
223 or not hasattr(self.current, 'id')):
225 return self.current.id != previous.id # pylint: disable=no-member
228 """Monitor player for change
229 Returns a list a events among:
231 * database player media library has changed
232 * playlist playlist modified
233 * options player options changed: repeat mode, etc…
234 * player player state changed: paused, stopped, skip track…
235 * skipped current track skipped
240 self.send_idle('database', 'playlist', 'player', 'options')
241 _read, _, _ = select([self], [], [], select_timeout)
242 if _read: # tries to read response
243 ret = self.fetch_idle()
244 if self._skipped_track(curr):
245 ret.append('skipped')
246 if 'database' in ret:
249 # Nothing to read, canceling idle
250 try: # noidle cmd does not go through __getattr__, need to catch OSError then
252 except OSError as err:
253 raise PlayerError(err) from err
256 """Clean blocking event (idle) and pending commands
258 if 'idle' in self._pending:
261 self.log.warning('pending commands: %s', self._pending)
263 def add(self, payload):
264 """Overriding MPD's add method to accept Track objects
266 :param Track,list payload: Either a single track or a list of it
268 if isinstance(payload, Track):
269 super().__getattr__('add')(payload.file)
270 elif isinstance(payload, list):
271 self.command_list_ok_begin()
272 map(self.add, payload)
273 self.command_list_end()
275 self.log.error('Cannot add %s', payload)
277 # ######### Properties #####################
280 return self.currentsong()
285 Override deprecated MPD playlist command
287 return self.playlistinfo()
291 plm = {'repeat': None, 'single': None,
292 'random': None, 'consume': None, }
293 for key, val in self.status().items():
295 plm.update({key: bool(int(val))})
301 curr_position = int(self.current.pos)
303 return [trk for trk in plst if int(trk.pos) > curr_position]
307 """Returns (play|stop|pause)"""
308 return str(self.status().get('state'))
309 # ######### / Properties ###################
311 # #### find_tracks ####
312 def find_tracks(self, what):
313 """Find tracks for a specific artist or album
314 >>> player.find_tracks(Artist('Nirvana'))
315 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
317 :param Artist,Album what: Artist or Album to fetch track from
318 :return: A list of track objects
321 if isinstance(what, Artist):
322 return self._find_art(what)
323 if isinstance(what, Album):
324 return self._find_alb(what)
325 if isinstance(what, str):
326 return self.find_tracks(Artist(name=what))
327 raise PlayerError('Bad input argument')
329 def _find_art(self, artist):
332 if self.database.get_bl_artist(artist, add=False):
333 self.log.info('Artist in blocklist: %s', artist)
336 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
337 for name in artist.names:
338 tracks |= set(self.find('artist', name))
340 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
342 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
343 for a in self.database.view_bl() if a.get('album')}
344 if albums & bl_albums:
345 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
346 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
348 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
349 for t in self.database.view_bl() if t.get('title')}
350 if tracks & bl_tracks:
351 self.log.info('Tracks in blocklist for %s: %s',
352 artist, tracks & bl_tracks)
353 tracks = {trk for trk in tracks if trk not in bl_tracks}
356 def _find_alb(self, album):
357 if not hasattr(album, 'artist'):
358 raise PlayerError('Album object have no artist attribute')
359 if self.database.get_bl_album(album, add=False):
360 self.log.info('Album in blocklist: %s', album)
364 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
365 albums = self.find(filt)
366 # Now look for album with no MusicBrainzIdentifier
367 if not albums and album.Artist.mbid: # Use album artist MBID if possible
368 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
369 albums = self.find(filt)
370 if not albums: # Falls back to (album)?artist/album name
371 for artist in album.Artist.names_sz:
372 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
373 albums.extend(self.find(filt))
375 # #### / find_tracks ##
377 # #### Search Methods #####
378 def _find_musicbrainz_artistid(self, artist):
379 """Find MusicBrainzArtistID when possible.
381 if not self.use_mbid:
384 for name in artist.names_sz:
385 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
386 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
392 self.log.debug("Got multiple MBID for artist: %r", artist)
395 if artist.mbid != mbids[0]:
396 self.log('MBID discrepancy, %s found with %s (instead of %s)',
397 artist.name, mbids[0], artist.mbid)
404 def search_artist(self, artist):
406 Search artists based on a fuzzy search in the media library
407 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
408 >>> bea = player.search_artist(art)
410 >>> ['The Beatles', 'Beatles', 'the beatles']
412 :param Artist artist: Artist to look for in MPD music library
413 :return: Artist object
418 # look for exact search w/ musicbrainz_artistid
419 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
422 self.log.trace('Found mbid "%r" in library', artist)
423 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
425 self.log.debug('I got "%s" searching for %r', library, artist)
427 if SimaStr(artist.name) == name and name != artist.name:
428 self.log.debug('add alias for %s: %s', artist, name)
429 artist.add_alias(name)
430 elif len(library) == 1 and library[0] != artist.name:
431 new_alias = artist.name
432 self.log.info('Update artist name %s->%s', artist, library[0])
433 self.log.debug('Also add alias for %s: %s', artist, new_alias)
434 artist = Artist(name=library[0], mbid=artist.mbid)
435 artist.add_alias(new_alias)
436 # Fetches remaining artists for potential match
437 artists = self._cache['nombid_artists']
438 else: # not using MusicBrainzIDs
439 artists = self._cache['artists']
440 match = get_close_matches(artist.name, artists, 50, 0.73)
441 if not match and not found:
444 self.log.debug('found close match for "%s": %s',
445 artist, '/'.join(match))
446 # First lowercased comparison
447 for close_art in match:
448 # Regular lowered string comparison
449 if artist.name.lower() == close_art.lower():
450 artist.add_alias(close_art)
452 if artist.name != close_art:
453 self.log.debug('"%s" matches "%s".', close_art, artist)
454 # Does not perform fuzzy matching on short and single word strings
455 # Only lowercased comparison
456 if ' ' not in artist.name and len(artist.name) < 8:
457 self.log.trace('no fuzzy matching for %r', artist)
461 # Now perform fuzzy search
463 if fuzz in artist.names: # Already found in lower cased comparison
465 # SimaStr string __eq__ (not regular string comparison here)
466 if SimaStr(artist.name) == fuzz:
468 artist.add_alias(fuzz)
469 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
473 self.log.info('Found aliases: %s', '/'.join(artist.names))
477 def search_track(self, artist, title):
478 """Fuzzy search of title by an artist
480 cache = self._cache.get('artist_tracks').get(artist)
481 # Retrieve all tracks from artist
482 all_tracks = cache or self.find_tracks(artist)
484 self._cache['artist_tracks'] = {} # clean up
485 self._cache.get('artist_tracks')[artist] = all_tracks
486 # Get all titles (filter missing titles set to 'None')
487 all_artist_titles = frozenset([tr.title for tr in all_tracks
488 if tr.title is not None])
489 match = get_close_matches(title, all_artist_titles, 50, 0.78)
494 leven = levenshtein_ratio(title, mtitle)
496 tracks.extend([t for t in all_tracks if t.title == mtitle])
498 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
499 mtitle, title, leven)
500 tracks.extend([t for t in all_tracks if t.title == mtitle])
502 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
503 mtitle, title, leven)
506 def search_albums(self, artist):
507 """Find potential albums for "artist"
509 * Fetch all albums for "AlbumArtist" == artist
510 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
511 * Tries to filter some mutli-artists album
512 For instance an album by Artist_A may have a track by Artist_B. Then
513 looking for albums for Artist_B wrongly returns this album.
515 # First, look for all potential albums
516 self.log.debug('Searching album for "%r"', artist)
518 self.log.debug('Searching album for %s aliases: "%s"',
519 artist, artist.aliases)
521 if self.use_mbid and artist.mbid:
522 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
523 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
524 for albumid in raw_album_id:
525 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
526 album_name = self.list('album', mpd_filter)
527 if not album_name: # something odd here
529 albums.add(Album(album_name[0], artist=artist.name,
530 Artist=artist, mbid=albumid))
531 for name_sz in artist.names_sz:
532 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
533 raw_albums = self.list('album', mpd_filter)
534 for alb in raw_albums:
535 if alb in [a.name for a in albums]:
540 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
541 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
544 albums.add(Album(alb, artist=artist.name,
545 Artist=artist, mbid=mbid))
548 album_trks = self.find_tracks(album)
549 if not album_trks: # find_track result can be empty, blocklist applied
551 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
552 if album.Artist.names & album_artists:
553 candidates.append(album)
555 if self.use_mbid and artist.mbid:
556 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
557 candidates.append(album)
559 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid',
562 if 'Various Artists' in album_artists:
563 self.log.debug('Discarding %s ("Various Artists" set)', album)
565 if album_artists and album.Artist.name not in album_artists:
566 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
568 # Attempt to detect false positive (especially when no
569 # AlbumArtist/MBIDs tag ar set)
570 # Avoid selecting albums where artist is credited for a single
572 album_trks = self.find(f"(album == '{album.name_sz}')")
573 arts = [trk.artist for trk in album_trks] # Artists in the album
574 # count artist occurences
575 ratio = arts.count(album.Artist.name)/len(arts)
577 candidates.append(album)
579 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
580 album, artist, ratio)
583 # #### / Search Methods ###
586 # vim: ai ts=4 sw=4 sts=4 expandtab