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: # socket errors
132 raise PlayerError(err) from err
133 except MPDError as err: # hight level MPD client errors
134 raise PlayerError(err) from err
136 def disconnect(self):
137 """Overriding explicitly MPDClient.disconnect()"""
142 """Overriding explicitly MPDClient.connect()"""
143 mpd_config = self.config['MPD']
144 # host, port, password
145 host = mpd_config.get('host')
146 port = mpd_config.get('port')
147 password = mpd_config.get('password', fallback=None)
150 super().connect(host, port)
151 # Catch socket errors
152 except OSError as err:
153 raise PlayerError(f'Could not connect to "{host}:{port}": {err.strerror}'
155 # Catch all other possible errors
156 # ConnectionError and ProtocolError are always fatal. Others may not
157 # be, but we don't know how to handle them here, so treat them as if
158 # they are instead of ignoring them.
159 except MPDError as err:
160 raise PlayerError(f'Could not connect to "{host}:{port}": {err}') from err
163 self.password(password)
164 except (MPDError, OSError) as err:
165 raise PlayerError(f"Could not connect to '{host}': {err}") from err
166 # Controls we have sufficient rights
167 available_cmd = self.commands()
168 for cmd in MPD.needed_cmds:
169 if cmd not in available_cmd:
171 raise PlayerError(f'Could connect to "{host}", but command "{cmd}" not available')
172 self.tagtypes('clear')
173 for tag in MPD.needed_tags:
174 self.tagtypes('enable', tag)
175 ltt = set(map(str.lower, self.tagtypes()))
176 needed_tags = set(map(str.lower, MPD.needed_tags))
177 if len(needed_tags & ltt) != len(MPD.needed_tags):
178 self.log.warning('MPD exposes: %s', ltt)
179 self.log.warning('Tags needed: %s', needed_tags)
180 raise PlayerError('Missing mandatory metadata!')
181 for tag in MPD.needed_mbid_tags:
182 self.tagtypes('enable', tag)
183 # Controls use of MusicBrainzIdentifier
184 if self.config.getboolean('sima', 'musicbrainzid'):
185 ltt = set(self.tagtypes())
186 if len(MPD.needed_mbid_tags & ltt) != len(MPD.needed_mbid_tags):
187 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
188 'is not providing related metadata')
190 self.log.warning('Disabling MusicBrainzIdentifier')
191 self.use_mbid = Meta.use_mbid = False
193 self.log.debug('Available metadata: %s', ltt)
194 self.use_mbid = Meta.use_mbid = True
196 self.log.warning('Use of MusicBrainzIdentifier disabled!')
197 self.log.info('Consider using MusicBrainzIdentifier for your music library')
198 self.use_mbid = Meta.use_mbid = False
200 # ######### / Overriding MPDClient #########
202 def _reset_cache(self):
204 Both flushes and instantiates _cache
206 * artists: all artists
207 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
208 * artist_tracks: caching last artist tracks, used in search_track
210 if isinstance(self._cache, dict):
211 self.log.info('Player: Flushing cache!')
213 self.log.info('Player: Initialising cache!')
214 self._cache = {'artists': frozenset(),
215 'nombid_artists': frozenset(),
217 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
219 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
220 self._cache['nombid_artists'] = frozenset(filter(None, artists))
222 def _skipped_track(self, previous):
223 if (self.state == 'stop'
224 or not hasattr(previous, 'id')
225 or not hasattr(self.current, 'id')):
227 return self.current.id != previous.id # pylint: disable=no-member
230 """Monitor player for change
231 Returns a list a events among:
233 * database player media library has changed
234 * playlist playlist modified
235 * options player options changed: repeat mode, etc…
236 * player player state changed: paused, stopped, skip track…
237 * skipped current track skipped
241 try: # noidle cmd does not go through __getattr__, need to catch OSError then
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
254 except OSError as err:
255 raise PlayerError(err) from err
256 except MPDError as err: # hight level MPD client errors
257 raise PlayerError(err) from err
260 """Clean blocking event (idle) and pending commands
262 if 'idle' in self._pending:
265 self.log.warning('pending commands: %s', self._pending)
267 def add(self, payload):
268 """Overriding MPD's add method to accept Track objects
270 :param Track,list payload: Either a single track or a list of it
272 if isinstance(payload, Track):
273 super().__getattr__('add')(payload.file)
274 elif isinstance(payload, list):
275 self.command_list_ok_begin()
276 map(self.add, payload)
277 self.command_list_end()
279 self.log.error('Cannot add %s', payload)
281 # ######### Properties #####################
284 return self.currentsong()
289 Override deprecated MPD playlist command
291 return self.playlistinfo()
295 plm = {'repeat': None, 'single': None,
296 'random': None, 'consume': None, }
297 for key, val in self.status().items():
299 plm.update({key: bool(int(val))})
305 curr_position = int(self.current.pos)
307 return [trk for trk in plst if int(trk.pos) > curr_position]
311 """Returns (play|stop|pause)"""
312 return str(self.status().get('state'))
313 # ######### / Properties ###################
315 # #### find_tracks ####
316 def find_tracks(self, what):
317 """Find tracks for a specific artist or album
318 >>> player.find_tracks(Artist('Nirvana'))
319 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
321 :param Artist,Album what: Artist or Album to fetch track from
322 :return: A list of track objects
325 if isinstance(what, Artist):
326 return self._find_art(what)
327 if isinstance(what, Album):
328 return self._find_alb(what)
329 if isinstance(what, str):
330 return self.find_tracks(Artist(name=what))
331 raise PlayerError('Bad input argument')
333 def _find_art(self, artist):
336 if self.database.get_bl_artist(artist, add=False):
337 self.log.info('Artist in blocklist: %s', artist)
340 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
341 for name in artist.names:
342 tracks |= set(self.find('artist', name))
344 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
346 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
347 for a in self.database.view_bl() if a.get('album')}
348 if albums & bl_albums:
349 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
350 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
352 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
353 for t in self.database.view_bl() if t.get('title')}
354 if tracks & bl_tracks:
355 self.log.info('Tracks in blocklist for %s: %s',
356 artist, tracks & bl_tracks)
357 tracks = {trk for trk in tracks if trk not in bl_tracks}
360 def _find_alb(self, album):
361 if not hasattr(album, 'artist'):
362 raise PlayerError('Album object have no artist attribute')
363 if self.database.get_bl_album(album, add=False):
364 self.log.info('Album in blocklist: %s', album)
368 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
369 albums = self.find(filt)
370 # Now look for album with no MusicBrainzIdentifier
371 if not albums and album.Artist.mbid: # Use album artist MBID if possible
372 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
373 albums = self.find(filt)
374 if not albums: # Falls back to (album)?artist/album name
375 for artist in album.Artist.names_sz:
376 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
377 albums.extend(self.find(filt))
379 # #### / find_tracks ##
381 # #### Search Methods #####
382 def _find_musicbrainz_artistid(self, artist):
383 """Find MusicBrainzArtistID when possible.
385 if not self.use_mbid:
388 for name in artist.names_sz:
389 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
390 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
396 self.log.debug("Got multiple MBID for artist: %r", artist)
399 if artist.mbid != mbids[0]:
400 self.log('MBID discrepancy, %s found with %s (instead of %s)',
401 artist.name, mbids[0], artist.mbid)
408 def search_artist(self, artist):
410 Search artists based on a fuzzy search in the media library
411 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
412 >>> bea = player.search_artist(art)
414 >>> ['The Beatles', 'Beatles', 'the beatles']
416 :param Artist artist: Artist to look for in MPD music library
417 :return: Artist object
422 # look for exact search w/ musicbrainz_artistid
423 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
426 self.log.trace('Found mbid "%r" in library', artist)
427 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
429 self.log.debug('I got "%s" searching for %r', library, artist)
431 if SimaStr(artist.name) == name and name != artist.name:
432 self.log.debug('add alias for %s: %s', artist, name)
433 artist.add_alias(name)
434 elif len(library) == 1 and library[0] != artist.name:
435 new_alias = artist.name
436 self.log.info('Update artist name %s->%s', artist, library[0])
437 self.log.debug('Also add alias for %s: %s', artist, new_alias)
438 artist = Artist(name=library[0], mbid=artist.mbid)
439 artist.add_alias(new_alias)
440 # Fetches remaining artists for potential match
441 artists = self._cache['nombid_artists']
442 else: # not using MusicBrainzIDs
443 artists = self._cache['artists']
444 match = get_close_matches(artist.name, artists, 50, 0.73)
445 if not match and not found:
448 self.log.debug('found close match for "%s": %s',
449 artist, '/'.join(match))
450 # First lowercased comparison
451 for close_art in match:
452 # Regular lowered string comparison
453 if artist.name.lower() == close_art.lower():
454 artist.add_alias(close_art)
456 if artist.name != close_art:
457 self.log.debug('"%s" matches "%s".', close_art, artist)
458 # Does not perform fuzzy matching on short and single word strings
459 # Only lowercased comparison
460 if ' ' not in artist.name and len(artist.name) < 8:
461 self.log.trace('no fuzzy matching for %r', artist)
465 # Now perform fuzzy search
467 if fuzz in artist.names: # Already found in lower cased comparison
469 # SimaStr string __eq__ (not regular string comparison here)
470 if SimaStr(artist.name) == fuzz:
472 artist.add_alias(fuzz)
473 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
477 self.log.info('Found aliases: %s', '/'.join(artist.names))
481 def search_track(self, artist, title):
482 """Fuzzy search of title by an artist
484 cache = self._cache.get('artist_tracks').get(artist)
485 # Retrieve all tracks from artist
486 all_tracks = cache or self.find_tracks(artist)
488 self._cache['artist_tracks'] = {} # clean up
489 self._cache.get('artist_tracks')[artist] = all_tracks
490 # Get all titles (filter missing titles set to 'None')
491 all_artist_titles = frozenset([tr.title for tr in all_tracks
492 if tr.title is not None])
493 match = get_close_matches(title, all_artist_titles, 50, 0.78)
498 leven = levenshtein_ratio(title, mtitle)
500 tracks.extend([t for t in all_tracks if t.title == mtitle])
502 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
503 mtitle, title, leven)
504 tracks.extend([t for t in all_tracks if t.title == mtitle])
506 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
507 mtitle, title, leven)
510 def search_albums(self, artist):
511 """Find potential albums for "artist"
513 * Fetch all albums for "AlbumArtist" == artist
514 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
515 * Tries to filter some mutli-artists album
516 For instance an album by Artist_A may have a track by Artist_B. Then
517 looking for albums for Artist_B wrongly returns this album.
519 # First, look for all potential albums
520 self.log.debug('Searching album for "%r"', artist)
522 self.log.debug('Searching album for %s aliases: "%s"',
523 artist, artist.aliases)
525 if self.use_mbid and artist.mbid:
526 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
527 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
528 for albumid in raw_album_id:
529 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
530 album_name = self.list('album', mpd_filter)
531 if not album_name: # something odd here
533 albums.add(Album(album_name[0], artist=artist.name,
534 Artist=artist, mbid=albumid))
535 for name_sz in artist.names_sz:
536 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
537 raw_albums = self.list('album', mpd_filter)
538 for alb in raw_albums:
539 if alb in [a.name for a in albums]:
544 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
545 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
548 albums.add(Album(alb, artist=artist.name,
549 Artist=artist, mbid=mbid))
552 album_trks = self.find_tracks(album)
553 if not album_trks: # find_track result can be empty, blocklist applied
555 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
556 if album.Artist.names & album_artists:
557 candidates.append(album)
559 if self.use_mbid and artist.mbid:
560 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
561 candidates.append(album)
563 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid',
566 if 'Various Artists' in album_artists:
567 self.log.debug('Discarding %s ("Various Artists" set)', album)
569 if album_artists and album.Artist.name not in album_artists:
570 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
572 # Attempt to detect false positive (especially when no
573 # AlbumArtist/MBIDs tag ar set)
574 # Avoid selecting albums where artist is credited for a single
576 album_trks = self.find(f"(album == '{album.name_sz}')")
577 arts = [trk.artist for trk in album_trks] # Artists in the album
578 # count artist occurences
579 ratio = arts.count(album.Artist.name)/len(arts)
581 candidates.append(album)
583 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
584 album, artist, ratio)
587 # #### / Search Methods ###
590 # vim: ai ts=4 sw=4 sts=4 expandtab