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('Could not connect to "%s:%s": %s' %
152 (host, port, err.strerror)) from err
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('Could not connect to "%s:%s": %s' %
159 (host, port, err)) from err
162 self.password(password)
163 except (MPDError, OSError) as err:
164 raise PlayerError("Could not connect to '%s': %s" % (host, err)) from err
165 # Controls we have sufficient rights
166 available_cmd = self.commands()
167 for cmd in MPD.needed_cmds:
168 if cmd not in available_cmd:
170 raise PlayerError('Could connect to "%s", '
171 'but command "%s" not available' %
173 self.tagtypes('clear')
174 for tag in MPD.needed_tags:
175 self.tagtypes('enable', tag)
176 ltt = set(map(str.lower, self.tagtypes()))
177 needed_tags = set(map(str.lower, MPD.needed_tags))
178 if len(needed_tags & ltt) != len(MPD.needed_tags):
179 self.log.warning('MPD exposes: %s', ltt)
180 self.log.warning('Tags needed: %s', needed_tags)
181 raise PlayerError('Missing mandatory metadata!')
182 for tag in MPD.needed_mbid_tags:
183 self.tagtypes('enable', tag)
184 # Controls use of MusicBrainzIdentifier
185 if self.config.getboolean('sima', 'musicbrainzid'):
186 ltt = set(self.tagtypes())
187 if len(MPD.needed_mbid_tags & ltt) != len(MPD.needed_mbid_tags):
188 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
189 'is not providing related metadata')
191 self.log.warning('Disabling MusicBrainzIdentifier')
192 self.use_mbid = Meta.use_mbid = False
194 self.log.debug('Available metadata: %s', ltt)
195 self.use_mbid = Meta.use_mbid = True
197 self.log.warning('Use of MusicBrainzIdentifier disabled!')
198 self.log.info('Consider using MusicBrainzIdentifier for your music library')
199 self.use_mbid = Meta.use_mbid = False
201 # ######### / Overriding MPDClient #########
203 def _reset_cache(self):
205 Both flushes and instantiates _cache
207 * artists: all artists
208 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
209 * artist_tracks: caching last artist tracks, used in search_track
211 if isinstance(self._cache, dict):
212 self.log.info('Player: Flushing cache!')
214 self.log.info('Player: Initialising cache!')
215 self._cache = {'artists': frozenset(),
216 'nombid_artists': frozenset(),
218 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
220 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
221 self._cache['nombid_artists'] = frozenset(filter(None, artists))
223 def _skipped_track(self, previous):
224 if (self.state == 'stop'
225 or not hasattr(previous, 'id')
226 or not hasattr(self.current, 'id')):
228 return self.current.id != previous.id # pylint: disable=no-member
231 """Monitor player for change
232 Returns a list a events among:
234 * database player media library has changed
235 * playlist playlist modified
236 * options player options changed: repeat mode, etc…
237 * player player state changed: paused, stopped, skip track…
238 * skipped current track skipped
243 self.send_idle('database', 'playlist', 'player', 'options')
244 _read, _, _ = select([self], [], [], select_timeout)
245 if _read: # tries to read response
246 ret = self.fetch_idle()
247 if self._skipped_track(curr):
248 ret.append('skipped')
249 if 'database' in ret:
252 # Nothing to read, canceling idle
253 try: # noidle cmd does not go through __getattr__, need to catch OSError then
255 except OSError as err:
256 raise PlayerError(err) from err
259 """Clean blocking event (idle) and pending commands
261 if 'idle' in self._pending:
264 self.log.warning('pending commands: %s', self._pending)
266 def add(self, payload):
267 """Overriding MPD's add method to accept Track objects
269 :param Track,list payload: Either a single track or a list of it
271 if isinstance(payload, Track):
272 super().__getattr__('add')(payload.file)
273 elif isinstance(payload, list):
274 self.command_list_ok_begin()
275 map(self.add, payload)
276 self.command_list_end()
278 self.log.error('Cannot add %s', payload)
280 # ######### Properties #####################
283 return self.currentsong()
288 Override deprecated MPD playlist command
290 return self.playlistinfo()
294 plm = {'repeat': None, 'single': None,
295 'random': None, 'consume': None, }
296 for key, val in self.status().items():
297 if key in plm.keys():
298 plm.update({key: bool(int(val))})
304 curr_position = int(self.current.pos)
306 return [trk for trk in plst if int(trk.pos) > curr_position]
310 """Returns (play|stop|pause)"""
311 return str(self.status().get('state'))
312 # ######### / Properties ###################
314 # #### find_tracks ####
315 def find_tracks(self, what):
316 """Find tracks for a specific artist or album
317 >>> player.find_tracks(Artist('Nirvana'))
318 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
320 :param Artist,Album what: Artist or Album to fetch track from
321 :return: A list of track objects
324 if isinstance(what, Artist):
325 return self._find_art(what)
326 if isinstance(what, Album):
327 return self._find_alb(what)
328 if isinstance(what, str):
329 return self.find_tracks(Artist(name=what))
330 raise PlayerError('Bad input argument')
332 def _find_art(self, artist):
335 if self.database.get_bl_artist(artist, add=False):
336 self.log.info('Artist in blocklist: %s', artist)
339 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
340 for name in artist.names:
341 tracks |= set(self.find('artist', name))
343 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
345 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
346 for a in self.database.view_bl() if a.get('album')}
347 if albums & bl_albums:
348 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
349 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
351 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
352 for t in self.database.view_bl() if t.get('title')}
353 if tracks & bl_tracks:
354 self.log.info('Tracks in blocklist for %s: %s',
355 artist, tracks & bl_tracks)
356 tracks = {trk for trk in tracks if trk not in bl_tracks}
359 def _find_alb(self, album):
360 if not hasattr(album, 'artist'):
361 raise PlayerError('Album object have no artist attribute')
362 if self.database.get_bl_album(album, add=False):
363 self.log.info('Album in blocklist: %s', album)
367 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
368 albums = self.find(filt)
369 # Now look for album with no MusicBrainzIdentifier
370 if not albums and album.Artist.mbid: # Use album artist MBID if possible
371 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
372 albums = self.find(filt)
373 if not albums: # Falls back to (album)?artist/album name
374 for artist in album.Artist.names_sz:
375 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
376 albums.extend(self.find(filt))
378 # #### / find_tracks ##
380 # #### Search Methods #####
381 def _find_musicbrainz_artistid(self, artist):
382 """Find MusicBrainzArtistID when possible.
384 if not self.use_mbid:
387 for name in artist.names_sz:
388 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
389 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
395 self.log.debug("Got multiple MBID for artist: %r", artist)
398 if artist.mbid != mbids[0]:
399 self.log('MBID discrepancy, %s found with %s (instead of %s)',
400 artist.name, mbids[0], artist.mbid)
407 def search_artist(self, artist):
409 Search artists based on a fuzzy search in the media library
410 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
411 >>> bea = player.search_artist(art)
413 >>> ['The Beatles', 'Beatles', 'the beatles']
415 :param Artist artist: Artist to look for in MPD music library
416 :return: Artist object
421 # look for exact search w/ musicbrainz_artistid
422 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
425 self.log.trace('Found mbid "%r" in library', artist)
426 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
428 self.log.debug('I got "%s" searching for %r', library, artist)
430 if SimaStr(artist.name) == name and name != artist.name:
431 self.log.debug('add alias for %s: %s', artist, name)
432 artist.add_alias(name)
433 elif len(library) == 1 and library[0] != artist.name:
434 new_alias = artist.name
435 self.log.info('Update artist name %s->%s', artist, library[0])
436 self.log.debug('Also add alias for %s: %s', artist, new_alias)
437 artist = Artist(name=library[0], mbid=artist.mbid)
438 artist.add_alias(new_alias)
439 # Fetches remaining artists for potential match
440 artists = self._cache['nombid_artists']
441 else: # not using MusicBrainzIDs
442 artists = self._cache['artists']
443 match = get_close_matches(artist.name, artists, 50, 0.73)
444 if not match and not found:
447 self.log.debug('found close match for "%s": %s',
448 artist, '/'.join(match))
449 # First lowercased comparison
450 for close_art in match:
451 # Regular lowered string comparison
452 if artist.name.lower() == close_art.lower():
453 artist.add_alias(close_art)
455 if artist.name != close_art:
456 self.log.debug('"%s" matches "%s".', close_art, artist)
457 # Does not perform fuzzy matching on short and single word strings
458 # Only lowercased comparison
459 if ' ' not in artist.name and len(artist.name) < 8:
460 self.log.trace('no fuzzy matching for %r', artist)
464 # Now perform fuzzy search
466 if fuzz in artist.names: # Already found in lower cased comparison
468 # SimaStr string __eq__ (not regular string comparison here)
469 if SimaStr(artist.name) == fuzz:
471 artist.add_alias(fuzz)
472 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
476 self.log.info('Found aliases: %s', '/'.join(artist.names))
480 def search_track(self, artist, title):
481 """Fuzzy search of title by an artist
483 cache = self._cache.get('artist_tracks').get(artist)
484 # Retrieve all tracks from artist
485 all_tracks = cache or self.find_tracks(artist)
487 self._cache['artist_tracks'] = {} # clean up
488 self._cache.get('artist_tracks')[artist] = all_tracks
489 # Get all titles (filter missing titles set to 'None')
490 all_artist_titles = frozenset([tr.title for tr in all_tracks
491 if tr.title is not None])
492 match = get_close_matches(title, all_artist_titles, 50, 0.78)
497 leven = levenshtein_ratio(title, mtitle)
499 tracks.extend([t for t in all_tracks if t.title == mtitle])
501 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
502 mtitle, title, leven)
503 tracks.extend([t for t in all_tracks if t.title == mtitle])
505 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
506 mtitle, title, leven)
509 def search_albums(self, artist):
510 """Find potential albums for "artist"
512 * Fetch all albums for "AlbumArtist" == artist
513 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
514 * Tries to filter some mutli-artists album
515 For instance an album by Artist_A may have a track by Artist_B. Then
516 looking for albums for Artist_B wrongly returns this album.
518 # First, look for all potential albums
519 self.log.debug('Searching album for "%r"', artist)
521 self.log.debug('Searching album for %s aliases: "%s"',
522 artist, artist.aliases)
524 if self.use_mbid and artist.mbid:
525 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
526 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
527 for albumid in raw_album_id:
528 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
529 album_name = self.list('album', mpd_filter)
530 if not album_name: # something odd here
532 albums.add(Album(album_name[0], artist=artist.name,
533 Artist=artist, mbid=albumid))
534 for name_sz in artist.names_sz:
535 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
536 raw_albums = self.list('album', mpd_filter)
537 for alb in raw_albums:
538 if alb in [a.name for a in albums]:
543 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
544 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
547 albums.add(Album(alb, artist=artist.name,
548 Artist=artist, mbid=mbid))
551 album_trks = self.find_tracks(album)
552 if not album_trks: # find_track result can be empty, blocklist applied
554 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
555 if album.Artist.names & album_artists:
556 candidates.append(album)
558 if self.use_mbid and artist.mbid:
559 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
560 candidates.append(album)
562 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid',
565 if 'Various Artists' in album_artists:
566 self.log.debug('Discarding %s ("Various Artists" set)', album)
568 if album_artists and album.Artist.name not in album_artists:
569 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
571 # Attempt to detect false positive (especially when no
572 # AlbumArtist/MBIDs tag ar set)
573 # Avoid selecting albums where artist is credited for a single
575 album_trks = self.find(f"(album == '{album.name_sz}')")
576 arts = [trk.artist for trk in album_trks] # Artists in the album
577 # count artist occurences
578 ratio = arts.count(album.Artist.name)/len(arts)
580 candidates.append(album)
582 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
583 album, artist, ratio)
586 # #### / Search Methods ###
589 # vim: ai ts=4 sw=4 sts=4 expandtab