]> kaliko git repositories - mpd-sima.git/blobdiff - sima/client.py
Some refactoring around player
[mpd-sima.git] / sima / client.py
index e19a5aafdabbdce12ad18e747eb5d8c16e5f83ea..9724357c3c521acc87689a6143e91910903f33d9 100644 (file)
@@ -3,11 +3,13 @@
 
 This client is built above python-musicpd a fork of python-mpd
 """
+#  pylint: disable=C0111
 
-# standart library import
+# standard library import
+from difflib import get_close_matches
 from select import select
 
-# third parties componants
+# third parties components
 try:
     from musicpd import (MPDClient, MPDError, CommandError)
 except ImportError as err:
@@ -18,6 +20,7 @@ except ImportError as err:
 # local import
 from .lib.player import Player
 from .lib.track import Track
+from .lib.simastr import SimaStr
 
 
 class PlayerError(Exception):
@@ -26,6 +29,8 @@ class PlayerError(Exception):
 class PlayerCommandError(PlayerError):
     """Command error"""
 
+PlayerUnHandledError = MPDError  # pylint: disable=C0103
+
 
 class PlayerClient(Player):
     """MPC Client
@@ -43,22 +48,27 @@ class PlayerClient(Player):
           find_aa, removeā€¦)
     """
     def __init__(self, host="localhost", port="6600", password=None):
-        self._host = host
-        self._port = port
-        self._password = password
+        super().__init__()
+        self._comm = self._args = None
+        self._mpd = host, port, password
         self._client = MPDClient()
         self._client.iterate = True
+        self._cache = None
 
     def __getattr__(self, attr):
         command = attr
         wrapper = self._execute
         return lambda *args: wrapper(command, args)
 
+    def __del__(self):
+        """Avoid hanging sockets"""
+        self.disconnect()
+
     def _execute(self, command, args):
         self._write_command(command, args)
         return self._client_wrapper()
 
-    def _write_command(self, command, args=[]):
+    def _write_command(self, command, args=None):
         self._comm = command
         self._args = list()
         for arg in args:
@@ -76,13 +86,15 @@ class PlayerClient(Player):
         return self._track_format(ans)
 
     def _track_format(self, ans):
+        """
+        unicode_obj = ["idle", "listplaylist", "list", "sticker list",
+                "commands", "notcommands", "tagtypes", "urlhandlers",]
+        """
         # TODO: ain't working for "sticker find" and "sticker list"
         tracks_listing = ["playlistfind", "playlistid", "playlistinfo",
                 "playlistsearch", "plchanges", "listplaylistinfo", "find",
                 "search", "sticker find",]
         track_obj = ['currentsong']
-        unicode_obj = ["idle", "listplaylist", "list", "sticker list",
-                "commands", "notcommands", "tagtypes", "urlhandlers",]
         if self._comm in tracks_listing + track_obj:
             #  pylint: disable=w0142
             if isinstance(ans, list):
@@ -91,12 +103,80 @@ class PlayerClient(Player):
                 return Track(**ans)
         return ans
 
+    def __skipped_track(self, old_curr):
+        if (self.state == 'stop'
+            or not hasattr(old_curr, 'id')
+            or not hasattr(self.current, 'id')):
+            return False
+        return (self.current.id != old_curr.id)  # pylint: disable=no-member
+
+    def _flush_cache(self):
+        """
+        Both flushes and instantiates _cache
+        """
+        if isinstance(self._cache, dict):
+            self.log.info('Player: Flushing cache!')
+        else:
+            self.log.info('Player: Initialising cache!')
+        self._cache = {
+                'artists': None,
+                }
+        self._cache['artists'] = frozenset(self._client.list('artist'))
+
     def find_track(self, artist, title=None):
         #return getattr(self, 'find')('artist', artist, 'title', title)
         if title:
             return self.find('artist', artist, 'title', title)
         return self.find('artist', artist)
 
+    def fuzzy_find_artist(self, art):
+        """
+        Controls presence of artist in music library.
+        Crosschecking artist names with SimaStr objects / difflib / levenshtein
+
+        TODO: proceed crosschecking even when an artist matched !!!
+              Not because we found "The Doors" as "The Doors" that there is no
+              remaining entries as "Doors" :/
+              not straight forward, need probably heavy refactoring.
+        """
+        matching_artists = list()
+        artist = SimaStr(art)
+
+        # Check against the actual string in artist list
+        if artist.orig in self.artists:
+            self.log.debug('found exact match for "%s"' % artist)
+            return [artist]
+        # Then proceed with fuzzy matching if got nothing
+        match = get_close_matches(artist.orig, self.artists, 50, 0.73)
+        if not match:
+            return []
+        self.log.debug('found close match for "%s": %s' %
+                       (artist, '/'.join(match)))
+        # Does not perform fuzzy matching on short and single word strings
+        # Only lowercased comparison
+        if ' ' not in artist.orig and len(artist) < 8:
+            for fuzz_art in match:
+                # Regular string comparison SimaStr().lower is regular string
+                if artist.lower() == fuzz_art.lower():
+                    matching_artists.append(fuzz_art)
+                    self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
+            return matching_artists
+        for fuzz_art in match:
+            # Regular string comparison SimaStr().lower is regular string
+            if artist.lower() == fuzz_art.lower():
+                matching_artists.append(fuzz_art)
+                self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
+                return matching_artists
+            # SimaStr string __eq__ (not regular string comparison here)
+            if artist == fuzz_art:
+                matching_artists.append(fuzz_art)
+                self.log.info('"%s" quite probably matches "%s" (SimaStr)' %
+                              (fuzz_art, artist))
+            else:
+                self.log.debug('FZZZ: "%s" does not match "%s"' %
+                               (fuzz_art, artist))
+        return matching_artists
+
     def find_album(self, artist, album):
         """
         Special wrapper around album search:
@@ -107,25 +187,49 @@ class PlayerClient(Player):
             return alb_art_search
         return self.find('artist', artist, 'album', album)
 
-    def monitor(self):
-        try:
-            self._client.send_idle('database', 'playlist', 'player', 'options')
-            select([self._client], [], [], 60)
-            return self._client.fetch_idle()
-        except (MPDError, IOError) as err:
-            raise PlayerError("Couldn't init idle: %s" % err)
+    def find_albums(self, artist):
+        """
+        Fetch all albums for "AlbumArtist" == artist
+        Filter albums returned for "artist" == artist since MPD returns any
+               album containing at least a single track for artist
+        """
+        albums = set()
+        albums.update(self.list('album', 'albumartist', artist))
+        for album in self.list('album', 'artist', artist):
+            arts = set([trk.artist for trk in self.find('album', album)])
+            if len(arts) < 2:
+                albums.add(album)
+            else:
+                self.log.debug('"{0}" probably not an album of "{1}"'.format(
+                             album, artist) + '({0})'.format('/'.join(arts)))
+        return albums
 
-    def idle(self):
+    def monitor(self):
+        curr = self.current
         try:
             self._client.send_idle('database', 'playlist', 'player', 'options')
             select([self._client], [], [], 60)
-            return self._client.fetch_idle()
+            ret = self._client.fetch_idle()
+            if self.__skipped_track(curr):
+                ret.append('skipped')
+            if 'database' in ret:
+                self._flush_cache()
+            return ret
         except (MPDError, IOError) as err:
             raise PlayerError("Couldn't init idle: %s" % err)
 
     def remove(self, position=0):
         self._client.delete(position)
 
+    def add(self, track):
+        """Overriding MPD's add method to accept add signature with a Track
+        object"""
+        self._client.add(track.file)
+
+    @property
+    def artists(self):
+        return self._cache.get('artists')
+
     @property
     def state(self):
         return str(self._client.status().get('state'))
@@ -134,6 +238,13 @@ class PlayerClient(Player):
     def current(self):
         return self.currentsong()
 
+    @property
+    def queue(self):
+        plst = self.playlist
+        plst.reverse()
+        return [ trk for trk in plst if int(trk.pos) > int(self.current.pos)]
+
+    @property
     def playlist(self):
         """
         Override deprecated MPD playlist command
@@ -141,14 +252,15 @@ class PlayerClient(Player):
         return self.playlistinfo()
 
     def connect(self):
+        host, port, password = self._mpd
         self.disconnect()
         try:
-            self._client.connect(self._host, self._port)
+            self._client.connect(host, port)
 
         # Catch socket errors
         except IOError as err:
             raise PlayerError('Could not connect to "%s:%s": %s' %
-                              (self._host, self._port, err.strerror))
+                              (host, port, err.strerror))
 
         # Catch all other possible errors
         # ConnectionError and ProtocolError are always fatal.  Others may not
@@ -156,24 +268,24 @@ class PlayerClient(Player):
         # they are instead of ignoring them.
         except MPDError as err:
             raise PlayerError('Could not connect to "%s:%s": %s' %
-                              (self._host, self._port, err))
+                              (host, port, err))
 
-        if self._password:
+        if password:
             try:
-                self._client.password(self._password)
+                self._client.password(password)
 
             # Catch errors with the password command (e.g., wrong password)
             except CommandError as err:
                 raise PlayerError("Could not connect to '%s': "
                                   "password command failed: %s" %
-                                  (self._host, err))
+                                  (host, err))
 
             # Catch all other possible errors
             except (MPDError, IOError) as err:
                 raise PlayerError("Could not connect to '%s': "
                                   "error with password command: %s" %
-                                  (self._host, err))
-        # Controls we have sufficient rights for MPD_sima
+                                  (host, err))
+        # Controls we have sufficient rights
         needed_cmds = ['status', 'stats', 'add', 'find', \
                        'search', 'currentsong', 'ping']
 
@@ -183,7 +295,8 @@ class PlayerClient(Player):
                 self.disconnect()
                 raise PlayerError('Could connect to "%s", '
                                   'but command "%s" not available' %
-                                  (self._host, nddcmd))
+                                  (host, nddcmd))
+        self._flush_cache()
 
     def disconnect(self):
         # Try to tell MPD we're closing the connection first
@@ -192,7 +305,6 @@ class PlayerClient(Player):
         # If that fails, don't worry, just ignore it and disconnect
         except (MPDError, IOError):
             pass
-
         try:
             self._client.disconnect()
         # Disconnecting failed, so use a new client object instead