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)
53 def set_artist_mbid(func):
54 def wrapper(*args, **kwargs):
56 result = func(*args, **kwargs)
58 if result and not result.mbid:
59 mbid = cls._find_musicbrainz_artistid(result)
60 artist = Artist(name=result.name, mbid=mbid)
61 artist.add_alias(result)
66 def tracks_wrapper(func):
67 """Convert plain track mapping as returned by MPDClient into :py:obj:`sima.lib.track.Track`
68 objects. This decorator accepts single track or list of tracks as input.
71 def wrapper(*args, **kwargs):
72 ret = func(*args, **kwargs)
73 if isinstance(ret, dict):
75 return [Track(**t) for t in ret]
82 Player instance inheriting from MPDClient (python-musicpd).
84 Some methods are overridden to format objects as :py:obj:`sima.lib.track.Track` for
85 instance, other are calling parent class directly through super().
90 * find methods are looking for exact match of the object provided
91 attributes in MPD music library
92 * search methods are looking for exact match + fuzzy match.
94 needed_cmds = ['status', 'stats', 'add', 'find',
95 'search', 'currentsong', 'ping']
96 needed_tags = {'Artist', 'Album', 'AlbumArtist', 'Title', 'Track'}
97 needed_mbid_tags = {'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
98 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID'}
99 MPD_supported_tags = {'Artist', 'ArtistSort', 'Album', 'AlbumSort', 'AlbumArtist',
100 'AlbumArtistSort', 'Title', 'Track', 'Name', 'Genre',
101 'Date', 'OriginalDate', 'Composer', 'Performer',
102 'Conductor', 'Work', 'Grouping', 'Disc', 'Label',
103 'MUSICBRAINZ_ARTISTID', 'MUSICBRAINZ_ALBUMID',
104 'MUSICBRAINZ_ALBUMARTISTID', 'MUSICBRAINZ_TRACKID',
105 'MUSICBRAINZ_RELEASETRACKID', 'MUSICBRAINZ_WORKID'}
108 def __init__(self, config):
110 self.socket_timeout = 10
112 self.log = getLogger('sima')
116 # ######### Overriding MPDClient ###########
117 def __getattr__(self, cmd):
118 """Wrapper around MPDClient calls for abstract overriding"""
119 track_wrapped = {'currentsong', 'find', 'playlistinfo', }
121 if cmd in track_wrapped:
122 return tracks_wrapper(super().__getattr__(cmd))
123 return super().__getattr__(cmd)
124 except OSError as err:
125 raise PlayerError(err)
127 def disconnect(self):
128 """Overriding explicitly MPDClient.disconnect()"""
133 """Overriding explicitly MPDClient.connect()"""
134 mpd_config = self.config['MPD']
135 # host, port, password
136 host = mpd_config.get('host')
137 port = mpd_config.get('port')
138 password = mpd_config.get('password', fallback=None)
141 super().connect(host, port)
142 # Catch socket errors
143 except OSError as err:
144 raise PlayerError('Could not connect to "%s:%s": %s' %
145 (host, port, err.strerror))
146 # Catch all other possible errors
147 # ConnectionError and ProtocolError are always fatal. Others may not
148 # be, but we don't know how to handle them here, so treat them as if
149 # they are instead of ignoring them.
150 except PlayerError as err:
151 raise PlayerError('Could not connect to "%s:%s": %s' %
155 self.password(password)
156 except (PlayerError, OSError) as err:
157 raise PlayerError("Could not connect to '%s': %s" % (host, err))
158 # Controls we have sufficient rights
159 available_cmd = self.commands()
160 for cmd in MPD.needed_cmds:
161 if cmd not in available_cmd:
163 raise PlayerError('Could connect to "%s", '
164 'but command "%s" not available' %
166 self.tagtypes('clear')
167 for tag in MPD.needed_tags:
168 self.tagtypes('enable', tag)
169 tt = set(map(str.lower, self.tagtypes()))
170 needed_tags = set(map(str.lower, MPD.needed_tags))
171 if len(needed_tags & tt) != len(MPD.needed_tags):
172 self.log.warning('MPD exposes: %s', tt)
173 self.log.warning('Tags needed: %s', needed_tags)
174 raise PlayerError('Missing mandatory metadata!')
175 for tag in MPD.needed_mbid_tags:
176 self.tagtypes('enable', tag)
177 # Controls use of MusicBrainzIdentifier
178 if self.config.getboolean('sima', 'musicbrainzid'):
179 tt = set(self.tagtypes())
180 if len(MPD.needed_mbid_tags & tt) != len(MPD.needed_mbid_tags):
181 self.log.warning('Use of MusicBrainzIdentifier is set but MPD '
182 'is not providing related metadata')
184 self.log.warning('Disabling MusicBrainzIdentifier')
185 self.use_mbid = Meta.use_mbid = False
187 self.log.debug('Available metadata: %s', tt)
188 self.use_mbid = Meta.use_mbid = True
190 self.log.warning('Use of MusicBrainzIdentifier disabled!')
191 self.log.info('Consider using MusicBrainzIdentifier for your music library')
192 self.use_mbid = Meta.use_mbid = False
194 # ######### / Overriding MPDClient #########
196 def _reset_cache(self):
198 Both flushes and instantiates _cache
200 * artists: all artists
201 * nombid_artists: artists with no mbid (set only when self.use_mbid is True)
202 * artist_tracks: caching last artist tracks, used in search_track
204 if isinstance(self._cache, dict):
205 self.log.info('Player: Flushing cache!')
207 self.log.info('Player: Initialising cache!')
208 self._cache = {'artists': frozenset(),
209 'nombid_artists': frozenset(),
211 self._cache['artists'] = frozenset(filter(None, self.list('artist')))
213 artists = self.list('artist', "(MUSICBRAINZ_ARTISTID == '')")
214 self._cache['nombid_artists'] = frozenset(filter(None, artists))
216 def _skipped_track(self, previous):
217 if (self.state == 'stop'
218 or not hasattr(previous, 'id')
219 or not hasattr(self.current, 'id')):
221 return self.current.id != previous.id # pylint: disable=no-member
224 """Monitor player for change
225 Returns a list a events among:
227 * database player media library has changed
228 * playlist playlist modified
229 * options player options changed: repeat mode, etc…
230 * player player state changed: paused, stopped, skip track…
231 * skipped current track skipped
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:
246 try: # noidle cmd does not go through __getattr__, need to catch OSError then
248 except OSError as err:
249 raise PlayerError(err)
252 """Clean blocking event (idle) and pending commands
254 if 'idle' in self._pending:
257 self.log.warning('pending commands: %s', self._pending)
259 def add(self, payload):
260 """Overriding MPD's add method to accept Track objects
262 :param Track,list payload: Either a single track or a list of it
264 if isinstance(payload, Track):
265 super().__getattr__('add')(payload.file)
266 elif isinstance(payload, list):
267 self.command_list_ok_begin()
268 map(self.add, payload)
269 self.command_list_end()
271 self.log.error('Cannot add %s', payload)
273 # ######### Properties #####################
276 return self.currentsong()
281 Override deprecated MPD playlist command
283 return self.playlistinfo()
287 plm = {'repeat': None, 'single': None,
288 'random': None, 'consume': None, }
289 for key, val in self.status().items():
290 if key in plm.keys():
291 plm.update({key: bool(int(val))})
297 curr_position = int(self.current.pos)
299 return [trk for trk in plst if int(trk.pos) > curr_position]
303 """Returns (play|stop|pause)"""
304 return str(self.status().get('state'))
305 # ######### / Properties ###################
307 # #### find_tracks ####
308 def find_tracks(self, what):
309 """Find tracks for a specific artist or album
310 >>> player.find_tracks(Artist('Nirvana'))
311 >>> player.find_tracks(Album('In Utero', artist=Artist('Nirvana'))
313 :param Artist,Album what: Artist or Album to fetch track from
314 :return: A list of track objects
317 if isinstance(what, Artist):
318 return self._find_art(what)
319 if isinstance(what, Album):
320 return self._find_alb(what)
321 if isinstance(what, str):
322 return self.find_tracks(Artist(name=what))
323 raise PlayerError('Bad input argument')
325 def _find_art(self, artist):
328 if self.database.get_bl_artist(artist, add=False):
329 self.log.info('Artist in blocklist: %s', artist)
332 tracks |= set(self.find('musicbrainz_artistid', artist.mbid))
333 for name in artist.names:
334 tracks |= set(self.find('artist', name))
336 albums = {Album(trk.Album.name, mbid=trk.musicbrainz_albumid)
338 bl_albums = {Album(a.get('album'), mbid=a.get('musicbrainz_album'))
339 for a in self.database.view_bl() if a.get('album')}
340 if albums & bl_albums:
341 self.log.info('Albums in blocklist for %s: %s', artist, albums & bl_albums)
342 tracks = {trk for trk in tracks if trk.Album not in bl_albums}
344 bl_tracks = {Track(title=t.get('title'), file=t.get('file'))
345 for t in self.database.view_bl() if t.get('title')}
346 if tracks & bl_tracks:
347 self.log.info('Tracks in blocklist for %s: %s',
348 artist, tracks & bl_tracks)
349 tracks = {trk for trk in tracks if trk not in bl_tracks}
352 def _find_alb(self, album):
353 if not hasattr(album, 'artist'):
354 raise PlayerError('Album object have no artist attribute')
355 if self.database.get_bl_album(album, add=False):
356 self.log.info('Album in blocklist: %s', album)
360 filt = f"(MUSICBRAINZ_ALBUMID == '{album.mbid}')"
361 albums = self.find(filt)
362 # Now look for album with no MusicBrainzIdentifier
363 if not albums and album.Artist.mbid: # Use album artist MBID if possible
364 filt = f"((MUSICBRAINZ_ALBUMARTISTID == '{album.Artist.mbid}') AND (album == '{album.name_sz}'))"
365 albums = self.find(filt)
366 if not albums: # Falls back to (album)?artist/album name
367 for artist in album.Artist.names_sz:
368 filt = f"((albumartist == '{artist}') AND (album == '{album.name_sz}'))"
369 albums.extend(self.find(filt))
371 # #### / find_tracks ##
373 # #### Search Methods #####
374 def _find_musicbrainz_artistid(self, artist):
375 """Find MusicBrainzArtistID when possible.
377 if not self.use_mbid:
380 for name in artist.names_sz:
381 filt = f'((artist == "{name}") AND (MUSICBRAINZ_ARTISTID != ""))'
382 mbids = self.list('MUSICBRAINZ_ARTISTID', filt)
388 self.log.debug("Got multiple MBID for artist: %r", artist)
391 if artist.mbid != mbids[0]:
392 self.log('MBID discrepancy, %s found with %s (instead of %s)',
393 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 elif len(library) == 1 and library[0] != artist.name:
426 new_alias = artist.name
427 self.log.info('Update artist name %s->%s', artist, library[0])
428 self.log.debug('Also add alias for %s: %s', artist, new_alias)
429 artist = Artist(name=library[0], mbid=artist.mbid)
430 artist.add_alias(new_alias)
431 # Fetches remaining artists for potential match
432 artists = self._cache['nombid_artists']
433 else: # not using MusicBrainzIDs
434 artists = self._cache['artists']
435 match = get_close_matches(artist.name, artists, 50, 0.73)
436 if not match and not found:
439 self.log.debug('found close match for "%s": %s',
440 artist, '/'.join(match))
441 # First lowercased comparison
442 for close_art in match:
443 # Regular lowered string comparison
444 if artist.name.lower() == close_art.lower():
445 artist.add_alias(close_art)
447 if artist.name != close_art:
448 self.log.debug('"%s" matches "%s".', close_art, artist)
449 # Does not perform fuzzy matching on short and single word strings
450 # Only lowercased comparison
451 if ' ' not in artist.name and len(artist.name) < 8:
452 self.log.trace('no fuzzy matching for %r', artist)
456 # Now perform fuzzy search
458 if fuzz in artist.names: # Already found in lower cased comparison
460 # SimaStr string __eq__ (not regular string comparison here)
461 if SimaStr(artist.name) == fuzz:
463 artist.add_alias(fuzz)
464 self.log.debug('"%s" quite probably matches "%s" (SimaStr)',
468 self.log.info('Found aliases: %s', '/'.join(artist.names))
472 def search_track(self, artist, title):
473 """Fuzzy search of title by an artist
475 cache = self._cache.get('artist_tracks').get(artist)
476 # Retrieve all tracks from artist
477 all_tracks = cache or self.find_tracks(artist)
479 self._cache['artist_tracks'] = {} # clean up
480 self._cache.get('artist_tracks')[artist] = all_tracks
481 # Get all titles (filter missing titles set to 'None')
482 all_artist_titles = frozenset([tr.title for tr in all_tracks
483 if tr.title is not None])
484 match = get_close_matches(title, all_artist_titles, 50, 0.78)
489 leven = levenshtein_ratio(title, mtitle)
491 tracks.extend([t for t in all_tracks if t.title == mtitle])
493 self.log.debug('title: "%s" should match "%s" (lr=%1.3f)',
494 mtitle, title, leven)
495 tracks.extend([t for t in all_tracks if t.title == mtitle])
497 self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)',
498 mtitle, title, leven)
501 def search_albums(self, artist):
502 """Find potential albums for "artist"
504 * Fetch all albums for "AlbumArtist" == artist
505 → falls back to "Artist" == artist when no "AlbumArtist" tag is set
506 * Tries to filter some mutli-artists album
507 For instance an album by Artist_A may have a track by Artist_B. Then
508 looking for albums for Artist_B wrongly returns this album.
510 # First, look for all potential albums
511 self.log.debug('Searching album for "%r"', artist)
513 self.log.debug('Searching album for %s aliases: "%s"',
514 artist, artist.aliases)
516 if self.use_mbid and artist.mbid:
517 mpd_filter = f"((musicbrainz_albumartistid == '{artist.mbid}') AND ( album != ''))"
518 raw_album_id = self.list('musicbrainz_albumid', mpd_filter)
519 for albumid in raw_album_id:
520 mpd_filter = f"((musicbrainz_albumid == '{albumid}') AND ( album != ''))"
521 album_name = self.list('album', mpd_filter)
522 if not album_name: # something odd here
524 albums.add(Album(album_name[0], artist=artist.name,
525 Artist=artist, mbid=albumid))
526 for name_sz in artist.names_sz:
527 mpd_filter = f"((albumartist == '{name_sz}') AND ( album != ''))"
528 raw_albums = self.list('album', mpd_filter)
529 for alb in raw_albums:
530 if alb in [a.name for a in albums]:
535 mpd_filter = f"((albumartist == '{artist.name_sz}') AND ( album == '{_.name_sz}'))"
536 mbids = self.list('MUSICBRAINZ_ALBUMID', mpd_filter)
539 albums.add(Album(alb, artist=artist.name,
540 Artist=artist, mbid=mbid))
543 album_trks = self.find_tracks(album)
544 if not album_trks: # find_track result can be empty, blocklist applied
546 album_artists = {tr.albumartist for tr in album_trks if tr.albumartist}
547 if album.Artist.names & album_artists:
548 candidates.append(album)
550 if self.use_mbid and artist.mbid:
551 if artist.mbid == album_trks[0].musicbrainz_albumartistid:
552 candidates.append(album)
555 self.log.debug('Discarding "%s", "%r" not set as musicbrainz_albumartistid', album, album.Artist)
557 if 'Various Artists' in album_artists:
558 self.log.debug('Discarding %s ("Various Artists" set)', album)
560 if album_artists and album.Artist.name not in album_artists:
561 self.log.debug('Discarding "%s", "%s" not set as albumartist', album, album.Artist)
563 # Attempt to detect false positive (especially when no
564 # AlbumArtist/MBIDs tag ar set)
565 # Avoid selecting albums where artist is credited for a single
567 album_trks = self.find(f"(album == '{album.name_sz}')")
568 arts = [trk.artist for trk in album_trks] # Artists in the album
569 # count artist occurences
570 ratio = arts.count(album.Artist.name)/len(arts)
572 candidates.append(album)
574 self.log.debug('"%s" probably not an album of "%s" (ratio=%.2f)',
575 album, artist, ratio)
578 # #### / Search Methods ###
581 # vim: ai ts=4 sw=4 sts=4 expandtab