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:
127 raise PlayerError(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('Could not connect to "%s:%s": %s' %
147 (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 PlayerError as err:
153 raise PlayerError('Could not connect to "%s:%s": %s' %
157 self.password(password)
158 except (PlayerError, OSError) as err:
159 raise PlayerError("Could not connect to '%s': %s" % (host, err))
160 # Controls we have sufficient rights
161 available_cmd = self.commands()
162 for cmd in MPD.needed_cmds:
163 if cmd not in available_cmd:
165 raise PlayerError('Could connect to "%s", '
166 'but command "%s" not available' %
168 self.tagtypes('clear')
169 for tag in MPD.needed_tags:
170 self.tagtypes('enable', tag)
171 tt = set(map(str.lower, self.tagtypes()))
172 needed_tags = set(map(str.lower, MPD.needed_tags))
173 if len(needed_tags & tt) != len(MPD.needed_tags):
174 self.log.warning('MPD exposes: %s', tt)
175 self.log.warning('Tags needed: %s', needed_tags)
176 raise PlayerError('Missing mandatory metadata!')
177 for tag in MPD.needed_mbid_tags:
178 self.tagtypes('enable', tag)
179 # Controls use of MusicBrainzIdentifier
180 if self.config.getboolean('sima', 'musicbrainzid'):
181 tt = set(self.tagtypes())
182 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
183 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
184 'is not providing related metadata')
186 self.log.warning('Disabling MusicBrainzIdentifier')
187 self.use_mbid = Meta.use_mbid = False
189 self.log.debug('Available metadata: %s', tt)
190 self.use_mbid = Meta.use_mbid = True
192 self.log.warning('Use of MusicBrainzIdentifier disabled!')
193 self.log.info('Consider using MusicBrainzIdentifier for your music library')
194 self.use_mbid = Meta.use_mbid = False
196 # ######### / Overriding MPDClient #########
198 def _reset_cache(self):
200 Both flushes and instantiates _cache
202 * artists: all artists
203 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
204 * artist_tracks: caching last artist tracks, used in search_track
206 if isinstance(self._cache, dict):
207 self.log.info('Player: Flushing cache!')
209 self.log.info('Player: Initialising cache!')
210 self._cache = {'artists': frozenset(),
211 'nombid_artists': frozenset(),
213 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
215 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
216 self._cache['nombid_artists'] = frozenset(filter(None, artists))
218 def _skipped_track(self, previous):
219 if (self.state == 'stop'
220 or not hasattr(previous, 'id')
221 or not hasattr(self.current, 'id')):
223 return self.current.id != previous.id # pylint: disable=no-member
226 """Monitor player for change
227 Returns a list a events among:
229 * database player media library has changed
230 * playlist playlist modified
231 * options player options changed: repeat mode, etc…
232 * player player state changed: paused, stopped, skip track…
233 * skipped current track skipped
238 self.send_idle('database', 'playlist', 'player', 'options')
239 _read, _, _ = select([self], [], [], select_timeout)
240 if _read: # tries to read response
241 ret = self.fetch_idle()
242 if self._skipped_track(curr):
243 ret.append('skipped')
244 if 'database' in ret:
248 try: # noidle cmd does not go through __getattr__, need to catch OSError then
250 except OSError as err:
251 raise PlayerError(err)
254 """Clean blocking event (idle) and pending commands
256 if 'idle' in self._pending:
259 self.log.warning('pending commands: %s', self._pending)
261 def add(self, payload):
262 """Overriding MPD's add method to accept Track objects
264 :param Track,list payload: Either a single track or a list of it
266 if isinstance(payload, Track):
267 super().__getattr__('add')(payload.file)
268 elif isinstance(payload, list):
269 self.command_list_ok_begin()
270 map(self.add, payload)
271 self.command_list_end()
273 self.log.error('Cannot add %s', payload)
275 # ######### Properties #####################
278 return self.currentsong()
283 Override deprecated MPD playlist command
285 return self.playlistinfo()
289 plm = {'repeat': None, 'single': None,
290 'random': None, 'consume': None, }
291 for key, val in self.status().items():
292 if key in plm.keys():
293 plm.update({key: bool(int(val))})
299 curr_position = int(self.current.pos)
301 return [trk for trk in plst if int(trk.pos) > curr_position]
305 """Returns (play|stop|pause)"""
306 return str(self.status().get('state'))
307 # ######### / Properties ###################
309 # #### find_tracks ####
310 def find_tracks(self, what):
311 """Find tracks for a specific artist or album
312 >>> player.find_tracks(Artist('Nirvana'))
313 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
315 :param Artist,Album what: Artist or Album to fetch track from
316 :return: A list of track objects
319 if isinstance(what, Artist):
320 return self._find_art(what)
321 if isinstance(what, Album):
322 return self._find_alb(what)
323 if isinstance(what, str):
324 return self.find_tracks(Artist(name=what))
325 raise PlayerError('Bad input argument')
327 def _find_art(self, artist):
330 if self.database.get_bl_artist(artist, add=False):
331 self.log.info('Artist in blocklist: %s', artist)
334 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
335 for name in artist.names:
336 tracks |= set(self.find('artist', name))
338 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
340 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
341 for a in self.database.view_bl() if a.get('album')}
342 if albums & bl_albums:
343 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
344 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
346 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
347 for t in self.database.view_bl() if t.get('title')}
348 if tracks & bl_tracks:
349 self.log.info('Tracks in blocklist for %s: %s',
350 artist, tracks & bl_tracks)
351 tracks = {trk for trk in tracks if trk not in bl_tracks}
354 def _find_alb(self, album):
355 if not hasattr(album, 'artist'):
356 raise PlayerError('Album object have no artist attribute')
357 if self.database.get_bl_album(album, add=False):
358 self.log.info('Album in blocklist: %s', album)
362 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
363 albums = self.find(filt)
364 # Now look for album with no MusicBrainzIdentifier
365 if not albums and album.Artist.mbid: # Use album artist MBID if possible
366 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
367 albums = self.find(filt)
368 if not albums: # Falls back to (album)?artist/album name
369 for artist in album.Artist.names_sz:
370 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
371 albums.extend(self.find(filt))
373 # #### / find_tracks ##
375 # #### Search Methods #####
376 def _find_musicbrainz_artistid(self, artist):
377 """Find MusicBrainzArtistID when possible.
379 if not self.use_mbid:
382 for name in artist.names_sz:
383 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
384 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
390 self.log.debug("Got multiple MBID for artist: %r", artist)
393 if artist.mbid != mbids[0]:
394 self.log('MBID discrepancy, %s found with %s (instead of %s)',
395 artist.name, mbids[0], artist.mbid)
401 def search_artist(self, artist):
403 Search artists based on a fuzzy search in the media library
404 >>> art = Artist(name='the beatles', mbid=<UUID4>) # mbid optional
405 >>> bea = player.search_artist(art)
407 >>> ['The Beatles', 'Beatles', 'the beatles']
409 :param Artist artist: Artist to look for in MPD music library
410 :return: Artist object
415 # look for exact search w/ musicbrainz_artistid
416 library = self.list('artist', f"(MUSICBRAINZ_ARTISTID == '{artist.mbid}')")
419 self.log.trace('Found mbid "%r" in library', artist)
420 # library could fetch several artist name for a single MUSICBRAINZ_ARTISTID
422 self.log.debug('I got "%s" searching for %r', library, artist)
424 if SimaStr(artist.name) == name and name != artist.name:
425 self.log.debug('add alias for %s: %s', artist, name)
426 artist.add_alias(name)
427 elif len(library) == 1 and library[0] != artist.name:
428 new_alias = artist.name
429 self.log.info('Update artist name %s->%s', artist, library[0])
430 self.log.debug('Also add alias for %s: %s', artist, new_alias)
431 artist = Artist(name=library[0], mbid=artist.mbid)
432 artist.add_alias(new_alias)
433 # Fetches remaining artists for potential match
434 artists = self._cache['nombid_artists']
435 else: # not using MusicBrainzIDs
436 artists = self._cache['artists']
437 match = get_close_matches(artist.name, artists, 50, 0.73)
438 if not match and not found:
441 self.log.debug('found close match for "%s": %s',
442 artist, '/'.join(match))
443 # First lowercased comparison
444 for close_art in match:
445 # Regular lowered string comparison
446 if artist.name.lower() == close_art.lower():
447 artist.add_alias(close_art)
449 if artist.name != close_art:
450 self.log.debug('"%s" matches "%s".', close_art, artist)
451 # Does not perform fuzzy matching on short and single word strings
452 # Only lowercased comparison
453 if ' ' not in artist.name and len(artist.name) < 8:
454 self.log.trace('no fuzzy matching for %r', artist)
458 # Now perform fuzzy search
460 if fuzz in artist.names: # Already found in lower cased comparison
462 # SimaStr string __eq__ (not regular string comparison here)
463 if SimaStr(artist.name) == fuzz:
465 artist.add_alias(fuzz)
466 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
470 self.log.info('Found aliases: %s', '/'.join(artist.names))
474 def search_track(self, artist, title):
475 """Fuzzy search of title by an artist
477 cache = self._cache.get('artist_tracks').get(artist)
478 # Retrieve all tracks from artist
479 all_tracks = cache or self.find_tracks(artist)
481 self._cache['artist_tracks'] = {} # clean up
482 self._cache.get('artist_tracks')[artist] = all_tracks
483 # Get all titles (filter missing titles set to 'None')
484 all_artist_titles = frozenset([tr.title for tr in all_tracks
485 if tr.title is not None])
486 match = get_close_matches(title, all_artist_titles, 50, 0.78)
491 leven = levenshtein_ratio(title, mtitle)
493 tracks.extend([t for t in all_tracks if t.title == mtitle])
495 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
496 mtitle, title, leven)
497 tracks.extend([t for t in all_tracks if t.title == mtitle])
499 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
500 mtitle, title, leven)
503 def search_albums(self, artist):
504 """Find potential albums for "artist"
506 * Fetch all albums for "AlbumArtist" == artist
507 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
508 * Tries to filter some mutli-artists album
509 For instance an album by Artist_A may have a track by Artist_B. Then
510 looking for albums for Artist_B wrongly returns this album.
512 # First, look for all potential albums
513 self.log.debug('Searching album for "%r"', artist)
515 self.log.debug('Searching album for %s aliases: "%s"',
516 artist, artist.aliases)
518 if self.use_mbid and artist.mbid:
519 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
520 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
521 for albumid in raw_album_id:
522 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
523 album_name = self.list('album', mpd_filter)
524 if not album_name: # something odd here
526 albums.add(Album(album_name[0], artist=artist.name,
527 Artist=artist, mbid=albumid))
528 for name_sz in artist.names_sz:
529 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
530 raw_albums = self.list('album', mpd_filter)
531 for alb in raw_albums:
532 if alb in [a.name for a in albums]:
537 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
538 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
541 albums.add(Album(alb, artist=artist.name,
542 Artist=artist, mbid=mbid))
545 album_trks = self.find_tracks(album)
546 if not album_trks: # find_track result can be empty, blocklist applied
548 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
549 if album.Artist.names & album_artists:
550 candidates.append(album)
552 if self.use_mbid and artist.mbid:
553 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
554 candidates.append(album)
557 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid', album, album.Artist)
559 if 'Various Artists' in album_artists:
560 self.log.debug('Discarding %s ("Various Artists" set)', album)
562 if album_artists and album.Artist.name not in album_artists:
563 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
565 # Attempt to detect false positive (especially when no
566 # AlbumArtist/MBIDs tag ar set)
567 # Avoid selecting albums where artist is credited for a single
569 album_trks = self.find(f"(album == '{album.name_sz}')")
570 arts = [trk.artist for trk in album_trks] # Artists in the album
571 # count artist occurences
572 ratio = arts.count(album.Artist.name)/len(arts)
574 candidates.append(album)
576 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
577 album, artist, ratio)
580 # #### / Search Methods ###
583 # vim: ai ts=4 sw=4 sts=4 expandtab