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 as PlayerError
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
38 def wrapper(*args, **kwargs):
41 return func(*args, **kwargs)
42 result = func(*args, **kwargs)
45 for art in result.names:
46 artist = Artist(name=art, mbid=result.mbid)
47 if cls.database.get_bl_artist(artist, add=False):
48 cls.log.debug('Artist in blocklist: %s', artist)
54 def set_artist_mbid(func):
55 def wrapper(*args, **kwargs):
57 result = func(*args, **kwargs)
59 if result and not result.mbid:
60 mbid = cls._find_musicbrainz_artistid(result)
61 artist = Artist(name=result.name, mbid=mbid)
62 artist.add_alias(result)
68 def tracks_wrapper(func):
69 """Convert plain track mapping as returned by MPDClient into :py:obj:`sima.lib.track.Track`
70 objects. This decorator accepts single track or list of tracks as input.
73 def wrapper(*args, **kwargs):
74 ret = func(*args, **kwargs)
75 if isinstance(ret, dict):
77 return [Track(**t) for t in ret]
84 Player instance inheriting from MPDClient (python-musicpd).
86 Some methods are overridden to format objects as :py:obj:`sima.lib.track.Track` for
87 instance, other are calling parent class directly through super().
92 * find methods are looking for exact match of the object provided
93 attributes in MPD music library
94 * search methods are looking for exact match + fuzzy match.
96 needed_cmds = ['status', 'stats', 'add', 'find',
97 'search', 'currentsong', 'ping']
98 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
99 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
100 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
101 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
102 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
103 'Date', 'OriginalDate', 'Composer', 'Performer',
104 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
105 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
106 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
107 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
110 def __init__(self, config):
112 self.socket_timeout = 10
114 self.log = getLogger('sima')
118 # ######### Overriding MPDClient ###########
119 def __getattr__(self, cmd):
120 """Wrapper around MPDClient calls for abstract overriding"""
121 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
123 if cmd in track_wrapped:
124 return tracks_wrapper(super().__getattr__(cmd))
125 return super().__getattr__(cmd)
126 except OSError as err: # socket errors
127 raise PlayerError(err) from err
129 def disconnect(self):
130 """Overriding explicitly MPDClient.disconnect()"""
135 """Overriding explicitly MPDClient.connect()"""
136 mpd_config = self.config['MPD']
137 # host, port, password
138 host = mpd_config.get('host')
139 port = mpd_config.get('port')
140 password = mpd_config.get('password', fallback=None)
143 super().connect(host, port)
144 # Catch socket errors
145 except OSError as err:
146 raise PlayerError(f'Could not connect to "{host}:{port}": {err.strerror}'
148 # Catch all other possible errors
149 # ConnectionError and ProtocolError are always fatal. Others may not
150 # be, but we don't know how to handle them here, so treat them as if
151 # they are instead of ignoring them.
152 except MPDError as err:
153 raise PlayerError(f'Could not connect to "{host}:{port}": {err}') from err
156 self.password(password)
157 except OSError as err:
158 raise PlayerError(f"Could not connect to '{host}': {err}") from err
159 # Controls we have sufficient rights
160 available_cmd = self.commands()
161 for cmd in MPD.needed_cmds:
162 if cmd not in available_cmd:
164 raise PlayerError(f'Could connect to "{host}", but command "{cmd}" not available')
165 self.tagtypes_clear()
166 for tag in MPD.needed_tags:
167 self.tagtypes_enable(tag)
168 ltt = set(map(str.lower, self.tagtypes()))
169 needed_tags = set(map(str.lower, MPD.needed_tags))
170 if len(needed_tags & ltt) != len(MPD.needed_tags):
171 self.log.warning('MPD exposes: %s', ltt)
172 self.log.warning('Tags needed: %s', needed_tags)
173 raise PlayerError('Missing mandatory metadata!')
174 for tag in MPD.needed_mbid_tags:
175 self.tagtypes_enable(tag)
176 # Controls use of MusicBrainzIdentifier
177 if self.config.getboolean('sima', 'musicbrainzid'):
178 ltt = set(self.tagtypes())
179 if len(MPD.needed_mbid_tags & ltt) != len(MPD.needed_mbid_tags):
180 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
181 'is not providing related metadata')
183 self.log.warning('Disabling MusicBrainzIdentifier')
184 self.use_mbid = Meta.use_mbid = False
186 self.log.debug('Available metadata: %s', ltt)
187 self.use_mbid = Meta.use_mbid = True
189 self.log.warning('Use of MusicBrainzIdentifier disabled!')
190 self.log.info('Consider using MusicBrainzIdentifier for your music library')
191 self.use_mbid = Meta.use_mbid = False
193 # ######### / Overriding MPDClient #########
195 def _reset_cache(self):
197 Both flushes and instantiates _cache
199 * artists: all artists
200 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
201 * artist_tracks: caching last artist tracks, used in search_track
203 if isinstance(self._cache, dict):
204 self.log.info('Player: Flushing cache!')
206 self.log.info('Player: Initialising cache!')
207 self._cache = {'artists': frozenset(),
208 'nombid_artists': frozenset(),
210 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
212 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
213 self._cache['nombid_artists'] = frozenset(filter(None, artists))
215 def _skipped_track(self, previous):
216 if (self.state == 'stop'
217 or not hasattr(previous, 'id')
218 or not hasattr(self.current, 'id')):
220 return self.current.id != previous.id # pylint: disable=no-member
223 """Monitor player for change
224 Returns a list a events among:
226 * database player media library has changed
227 * playlist playlist modified
228 * options player options changed: repeat mode, etc…
229 * player player state changed: paused, stopped, skip track…
230 * skipped current track skipped
234 try: # noidle cmd does not go through __getattr__, need to catch OSError then
236 self.send_idle('database', 'playlist', 'player', 'options')
237 _read, _, _ = select([self], [], [], select_timeout)
238 if _read: # tries to read response
239 ret = self.fetch_idle()
240 if self._skipped_track(curr):
241 ret.append('skipped')
242 if 'database' in ret:
245 # Nothing to read, canceling idle
247 except OSError as err:
248 raise PlayerError(err) from err
251 """Clean blocking event (idle) and pending commands
253 if 'idle' in self._pending:
256 self.log.warning('pending commands: %s', self._pending)
258 def add(self, payload):
259 """Overriding MPD's add method to accept Track objects
261 :param Track,list payload: Either a single track or a list of it
263 if isinstance(payload, Track):
264 super().__getattr__('add')(payload.file)
265 elif isinstance(payload, list):
266 self.command_list_ok_begin()
267 map(self.add, payload)
268 self.command_list_end()
270 self.log.error('Cannot add %s', payload)
272 # ######### Properties #####################
275 return self.currentsong()
280 Override deprecated MPD playlist command
282 return self.playlistinfo()
286 plm = {'repeat': None, 'single': None,
287 'random': None, 'consume': None, }
288 for key, val in self.status().items():
290 plm.update({key: bool(int(val))})
296 curr_position = int(self.current.pos)
298 return [trk for trk in plst if int(trk.pos) > curr_position]
302 """Returns (play|stop|pause)"""
303 return str(self.status().get('state'))
304 # ######### / Properties ###################
306 # #### find_tracks ####
307 def find_tracks(self, what):
308 """Find tracks for a specific artist or album
309 >>> player.find_tracks(Artist('Nirvana'))
310 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
312 :param Artist,Album what: Artist or Album to fetch track from
313 :return: A list of track objects
316 if isinstance(what, Artist):
317 return self._find_art(what)
318 if isinstance(what, Album):
319 return self._find_alb(what)
320 if isinstance(what, str):
321 return self.find_tracks(Artist(name=what))
322 raise PlayerError('Bad input argument')
324 def _find_art(self, artist):
327 if self.database.get_bl_artist(artist, add=False):
328 self.log.info('Artist in blocklist: %s', artist)
331 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
332 for name in artist.names:
333 tracks |= set(self.find('artist', name))
335 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
337 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
338 for a in self.database.view_bl() if a.get('album')}
339 if albums & bl_albums:
340 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
341 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
343 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
344 for t in self.database.view_bl() if t.get('title')}
345 if tracks & bl_tracks:
346 self.log.info('Tracks in blocklist for %s: %s',
347 artist, tracks & bl_tracks)
348 tracks = {trk for trk in tracks if trk not in bl_tracks}
351 def _find_alb(self, album):
352 if not hasattr(album, 'artist'):
353 raise PlayerError('Album object have no artist attribute')
354 if self.database.get_bl_album(album, add=False):
355 self.log.info('Album in blocklist: %s', album)
359 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
360 albums = self.find(filt)
361 # Now look for album with no MusicBrainzIdentifier
362 if not albums and album.Artist.mbid: # Use album artist MBID if possible
363 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
364 albums = self.find(filt)
365 if not albums: # Falls back to (album)?artist/album name
366 for artist in album.Artist.names_sz:
367 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
368 albums.extend(self.find(filt))
370 # #### / find_tracks ##
372 # #### Search Methods #####
373 def _find_musicbrainz_artistid(self, artist):
374 """Find MusicBrainzArtistID when possible.
376 if not self.use_mbid:
379 for name in artist.names_sz:
380 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
381 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
387 self.log.debug("Got multiple MBID for artist: %r", artist)
390 if artist.mbid != mbids[0]:
391 self.log('MBID discrepancy, %s found with %s (instead of %s)',
392 artist.name, mbids[0], artist.mbid)
399 def search_artist(self, artist):
401 Search artists based on a fuzzy search in the media library
402 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
403 >>> bea = player.search_artist(art)
405 >>> ['The Beatles', 'Beatles', 'the beatles']
407 :param Artist artist: Artist to look for in MPD music library
408 :return: Artist object
413 # look for exact search w/ musicbrainz_artistid
414 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
417 self.log.trace('Found mbid "%r" in library', artist)
418 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
420 self.log.debug('I got "%s" searching for %r', library, artist)
422 if SimaStr(artist.name) == name and name != artist.name:
423 self.log.debug('add alias for %s: %s', artist, name)
424 artist.add_alias(name)
425 # Fetches remaining artists for potential match
426 artists = self._cache['nombid_artists']
427 else: # not using MusicBrainzIDs
428 artists = self._cache['artists']
429 match = get_close_matches(artist.name, artists, 50, 0.73)
430 if not match and not found:
433 self.log.debug('found close match for "%s": %s',
434 artist, '/'.join(match))
435 # First lowercased comparison
436 for close_art in match:
437 # Regular lowered string comparison
438 if artist.name.lower() == close_art.lower():
439 artist.add_alias(close_art)
441 if artist.name != close_art:
442 self.log.debug('"%s" matches "%s".', close_art, artist)
443 # Does not perform fuzzy matching on short and single word strings
444 # Only lowercased comparison
445 if ' ' not in artist.name and len(artist.name) < 8:
446 self.log.trace('no fuzzy matching for %r', artist)
450 # Now perform fuzzy search
452 if fuzz in artist.names: # Already found in lower cased comparison
454 # SimaStr string __eq__ (not regular string comparison here)
455 if SimaStr(artist.name) == fuzz:
457 artist.add_alias(fuzz)
458 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
462 self.log.info('Found aliases: %s', '/'.join(artist.names))
466 def search_track(self, artist, title):
467 """Fuzzy search of title by an artist
469 cache = self._cache.get('artist_tracks').get(artist)
470 # Retrieve all tracks from artist
471 all_tracks = cache or self.find_tracks(artist)
473 self._cache['artist_tracks'] = {} # clean up
474 self._cache.get('artist_tracks')[artist] = all_tracks
475 # Get all titles (filter missing titles set to 'None')
476 all_artist_titles = frozenset([tr.title for tr in all_tracks
477 if tr.title is not None])
478 match = get_close_matches(title, all_artist_titles, 50, 0.78)
483 leven = levenshtein_ratio(title, mtitle)
485 tracks.extend([t for t in all_tracks if t.title == mtitle])
487 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
488 mtitle, title, leven)
489 tracks.extend([t for t in all_tracks if t.title == mtitle])
491 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
492 mtitle, title, leven)
495 def search_albums(self, artist):
496 """Find potential albums for "artist"
498 * Fetch all albums for "AlbumArtist" == artist
499 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
500 * Tries to filter some mutli-artists album
501 For instance an album by Artist_A may have a track by Artist_B. Then
502 looking for albums for Artist_B wrongly returns this album.
504 # First, look for all potential albums
505 self.log.debug('Searching album for "%r"', artist)
507 self.log.debug('Searching album for %s aliases: "%s"',
508 artist, artist.aliases)
510 if self.use_mbid and artist.mbid:
511 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
512 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
513 for albumid in raw_album_id:
514 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
515 album_name = self.list('album', mpd_filter)
516 if not album_name: # something odd here
518 albums.add(Album(album_name[0], artist=artist.name,
519 Artist=artist, mbid=albumid))
520 for name_sz in artist.names_sz:
521 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
522 raw_albums = self.list('album', mpd_filter)
523 for alb in raw_albums:
524 if alb in [a.name for a in albums]:
529 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
530 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
533 albums.add(Album(alb, artist=artist.name,
534 Artist=artist, mbid=mbid))
537 album_trks = self.find_tracks(album)
538 if not album_trks: # find_track result can be empty, blocklist applied
540 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
541 if album.Artist.names & album_artists:
542 candidates.append(album)
544 if self.use_mbid and artist.mbid:
545 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
546 candidates.append(album)
548 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid',
551 if 'Various Artists' in album_artists:
552 self.log.debug('Discarding %s ("Various Artists" set)', album)
554 if album_artists and album.Artist.name not in album_artists:
555 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
557 # Attempt to detect false positive (especially when no
558 # AlbumArtist/MBIDs tag ar set)
559 # Avoid selecting albums where artist is credited for a single
561 album_trks = self.find(f"(album == '{album.name_sz}')")
562 arts = [trk.artist for trk in album_trks] # Artists in the album
563 # count artist occurences
564 ratio = arts.count(album.Artist.name)/len(arts)
566 candidates.append(album)
568 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
569 album, artist, ratio)
572 # #### / Search Methods ###
575 # vim: ai ts=4 sw=4 sts=4 expandtab