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
242 self.send_idle('database', 'playlist', 'player', 'options')
243 _read, _, _ = select([self], [], [], select_timeout)
244 if _read: # tries to read response
245 ret = self.fetch_idle()
246 if self._skipped_track(curr):
247 ret.append('skipped')
248 if 'database' in ret:
251 # Nothing to read, canceling idle
252 try: # noidle cmd does not go through __getattr__, need to catch OSError then
254 except OSError as err:
255 raise PlayerError(err) from err
258 """Clean blocking event (idle) and pending commands
260 if 'idle' in self._pending:
263 self.log.warning('pending commands: %s', self._pending)
265 def add(self, payload):
266 """Overriding MPD's add method to accept Track objects
268 :param Track,list payload: Either a single track or a list of it
270 if isinstance(payload, Track):
271 super().__getattr__('add')(payload.file)
272 elif isinstance(payload, list):
273 self.command_list_ok_begin()
274 map(self.add, payload)
275 self.command_list_end()
277 self.log.error('Cannot add %s', payload)
279 # ######### Properties #####################
282 return self.currentsong()
287 Override deprecated MPD playlist command
289 return self.playlistinfo()
293 plm = {'repeat': None, 'single': None,
294 'random': None, 'consume': None, }
295 for key, val in self.status().items():
297 plm.update({key: bool(int(val))})
303 curr_position = int(self.current.pos)
305 return [trk for trk in plst if int(trk.pos) > curr_position]
309 """Returns (play|stop|pause)"""
310 return str(self.status().get('state'))
311 # ######### / Properties ###################
313 # #### find_tracks ####
314 def find_tracks(self, what):
315 """Find tracks for a specific artist or album
316 >>> player.find_tracks(Artist('Nirvana'))
317 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
319 :param Artist,Album what: Artist or Album to fetch track from
320 :return: A list of track objects
323 if isinstance(what, Artist):
324 return self._find_art(what)
325 if isinstance(what, Album):
326 return self._find_alb(what)
327 if isinstance(what, str):
328 return self.find_tracks(Artist(name=what))
329 raise PlayerError('Bad input argument')
331 def _find_art(self, artist):
334 if self.database.get_bl_artist(artist, add=False):
335 self.log.info('Artist in blocklist: %s', artist)
338 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
339 for name in artist.names:
340 tracks |= set(self.find('artist', name))
342 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
344 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
345 for a in self.database.view_bl() if a.get('album')}
346 if albums & bl_albums:
347 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
348 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
350 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
351 for t in self.database.view_bl() if t.get('title')}
352 if tracks & bl_tracks:
353 self.log.info('Tracks in blocklist for %s: %s',
354 artist, tracks & bl_tracks)
355 tracks = {trk for trk in tracks if trk not in bl_tracks}
358 def _find_alb(self, album):
359 if not hasattr(album, 'artist'):
360 raise PlayerError('Album object have no artist attribute')
361 if self.database.get_bl_album(album, add=False):
362 self.log.info('Album in blocklist: %s', album)
366 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
367 albums = self.find(filt)
368 # Now look for album with no MusicBrainzIdentifier
369 if not albums and album.Artist.mbid: # Use album artist MBID if possible
370 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
371 albums = self.find(filt)
372 if not albums: # Falls back to (album)?artist/album name
373 for artist in album.Artist.names_sz:
374 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
375 albums.extend(self.find(filt))
377 # #### / find_tracks ##
379 # #### Search Methods #####
380 def _find_musicbrainz_artistid(self, artist):
381 """Find MusicBrainzArtistID when possible.
383 if not self.use_mbid:
386 for name in artist.names_sz:
387 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
388 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
394 self.log.debug("Got multiple MBID for artist: %r", artist)
397 if artist.mbid != mbids[0]:
398 self.log('MBID discrepancy, %s found with %s (instead of %s)',
399 artist.name, mbids[0], artist.mbid)
406 def search_artist(self, artist):
408 Search artists based on a fuzzy search in the media library
409 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
410 >>> bea = player.search_artist(art)
412 >>> ['The Beatles', 'Beatles', 'the beatles']
414 :param Artist artist: Artist to look for in MPD music library
415 :return: Artist object
420 # look for exact search w/ musicbrainz_artistid
421 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
424 self.log.trace('Found mbid "%r" in library', artist)
425 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
427 self.log.debug('I got "%s" searching for %r', library, artist)
429 if SimaStr(artist.name) == name and name != artist.name:
430 self.log.debug('add alias for %s: %s', artist, name)
431 artist.add_alias(name)
432 elif len(library) == 1 and library[0] != artist.name:
433 new_alias = artist.name
434 self.log.info('Update artist name %s->%s', artist, library[0])
435 self.log.debug('Also add alias for %s: %s', artist, new_alias)
436 artist = Artist(name=library[0], mbid=artist.mbid)
437 artist.add_alias(new_alias)
438 # Fetches remaining artists for potential match
439 artists = self._cache['nombid_artists']
440 else: # not using MusicBrainzIDs
441 artists = self._cache['artists']
442 match = get_close_matches(artist.name, artists, 50, 0.73)
443 if not match and not found:
446 self.log.debug('found close match for "%s": %s',
447 artist, '/'.join(match))
448 # First lowercased comparison
449 for close_art in match:
450 # Regular lowered string comparison
451 if artist.name.lower() == close_art.lower():
452 artist.add_alias(close_art)
454 if artist.name != close_art:
455 self.log.debug('"%s" matches "%s".', close_art, artist)
456 # Does not perform fuzzy matching on short and single word strings
457 # Only lowercased comparison
458 if ' ' not in artist.name and len(artist.name) < 8:
459 self.log.trace('no fuzzy matching for %r', artist)
463 # Now perform fuzzy search
465 if fuzz in artist.names: # Already found in lower cased comparison
467 # SimaStr string __eq__ (not regular string comparison here)
468 if SimaStr(artist.name) == fuzz:
470 artist.add_alias(fuzz)
471 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
475 self.log.info('Found aliases: %s', '/'.join(artist.names))
479 def search_track(self, artist, title):
480 """Fuzzy search of title by an artist
482 cache = self._cache.get('artist_tracks').get(artist)
483 # Retrieve all tracks from artist
484 all_tracks = cache or self.find_tracks(artist)
486 self._cache['artist_tracks'] = {} # clean up
487 self._cache.get('artist_tracks')[artist] = all_tracks
488 # Get all titles (filter missing titles set to 'None')
489 all_artist_titles = frozenset([tr.title for tr in all_tracks
490 if tr.title is not None])
491 match = get_close_matches(title, all_artist_titles, 50, 0.78)
496 leven = levenshtein_ratio(title, mtitle)
498 tracks.extend([t for t in all_tracks if t.title == mtitle])
500 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
501 mtitle, title, leven)
502 tracks.extend([t for t in all_tracks if t.title == mtitle])
504 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
505 mtitle, title, leven)
508 def search_albums(self, artist):
509 """Find potential albums for "artist"
511 * Fetch all albums for "AlbumArtist" == artist
512 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
513 * Tries to filter some mutli-artists album
514 For instance an album by Artist_A may have a track by Artist_B. Then
515 looking for albums for Artist_B wrongly returns this album.
517 # First, look for all potential albums
518 self.log.debug('Searching album for "%r"', artist)
520 self.log.debug('Searching album for %s aliases: "%s"',
521 artist, artist.aliases)
523 if self.use_mbid and artist.mbid:
524 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
525 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
526 for albumid in raw_album_id:
527 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
528 album_name = self.list('album', mpd_filter)
529 if not album_name: # something odd here
531 albums.add(Album(album_name[0], artist=artist.name,
532 Artist=artist, mbid=albumid))
533 for name_sz in artist.names_sz:
534 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
535 raw_albums = self.list('album', mpd_filter)
536 for alb in raw_albums:
537 if alb in [a.name for a in albums]:
542 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
543 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
546 albums.add(Album(alb, artist=artist.name,
547 Artist=artist, mbid=mbid))
550 album_trks = self.find_tracks(album)
551 if not album_trks: # find_track result can be empty, blocklist applied
553 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
554 if album.Artist.names & album_artists:
555 candidates.append(album)
557 if self.use_mbid and artist.mbid:
558 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
559 candidates.append(album)
561 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid',
564 if 'Various Artists' in album_artists:
565 self.log.debug('Discarding %s ("Various Artists" set)', album)
567 if album_artists and album.Artist.name not in album_artists:
568 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
570 # Attempt to detect false positive (especially when no
571 # AlbumArtist/MBIDs tag ar set)
572 # Avoid selecting albums where artist is credited for a single
574 album_trks = self.find(f"(album == '{album.name_sz}')")
575 arts = [trk.artist for trk in album_trks] # Artists in the album
576 # count artist occurences
577 ratio = arts.count(album.Artist.name)/len(arts)
579 candidates.append(album)
581 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
582 album, artist, ratio)
585 # #### / Search Methods ###
588 # vim: ai ts=4 sw=4 sts=4 expandtab