X-Git-Url: https://git.kaliko.me/?a=blobdiff_plain;f=sima%2Fclient.py;h=2893d9b52cf52bb5a53266ec2bdedc7f0e05ebcd;hb=c2c435f2d5a6e13d610f91d16764a62ae6cb315a;hp=331c02c29bdef31e686a51ecf4a7c1a277b7d5a5;hpb=c73d03627448ae3015c2143e2c7629971ff5b326;p=mpd-sima.git diff --git a/sima/client.py b/sima/client.py index 331c02c..2893d9b 100644 --- a/sima/client.py +++ b/sima/client.py @@ -1,13 +1,33 @@ -# -* coding: utf-8 -*- +# -*- coding: utf-8 -*- +# Copyright (c) 2013, 2014 Jack Kaliko +# +# This file is part of sima +# +# sima is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# sima is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with sima. If not, see . +# +# """MPD client for Sima 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: @@ -16,8 +36,10 @@ except ImportError as err: sexit(1) # local import -from .lib.player import Player +from .lib.player import Player, blacklist from .lib.track import Track +from .lib.meta import Album, Artist +from .utils.leven import levenshtein_ratio class PlayerError(Exception): @@ -26,9 +48,11 @@ class PlayerError(Exception): class PlayerCommandError(PlayerError): """Command error""" +PlayerUnHandledError = MPDError # pylint: disable=C0103 + class PlayerClient(Player): - """MPC Client + """MPD Client From python-musicpd: _fetch_nothing … _fetch_item single str @@ -40,14 +64,17 @@ class PlayerClient(Player): _fetch_songs list of dict, especially tracks _fetch_plugins, TODO: handle exception in command not going through _client_wrapper() (ie. - find_aa, remove…) + remove…) """ + database = None # sima database (history, blacklist) + 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 @@ -58,7 +85,7 @@ class PlayerClient(Player): 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 +103,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,11 +120,63 @@ 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')) + + @blacklist(track=True) 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) + tracks = set() + for name in artist.names: + if title: + tracks |= set(self.find('artist', name, 'title', title)) + else: + tracks |= set(self.find('artist', name)) + if artist.mbid: + if title: + tracks |= set(self.find('musicbrainz_artistid', artist.mbid)) + else: + tracks |= set(self.find('musicbrainz_artistid', artist.mbid, + 'title', title)) + return list(tracks) + + def fuzzy_find_track(self, artist, title): + # Retrieve all tracks from artist + all_tracks = self.find_track(artist, title) + # Get all titles (filter missing titles set to 'None') + all_artist_titles = frozenset([tr.title for tr in all_tracks + if tr.title is not None]) + match = get_close_matches(title, all_artist_titles, 50, 0.78) + if not match: + return [] + for title_ in match: + leven = levenshtein_ratio(title.lower(), title_.lower()) + if leven == 1: + pass + elif leven >= 0.79: # PARAM + self.log.debug('title: "%s" should match "%s" (lr=%1.3f)' % + (title_, title, leven)) + else: + self.log.debug('title: "%s" does not match "%s" (lr=%1.3f)' % + (title_, title, leven)) + return [] + return self.find('artist', artist, 'title', title_) def find_album(self, artist, album): """ @@ -107,25 +188,82 @@ class PlayerClient(Player): return alb_art_search return self.find('artist', artist, 'album', album) + @blacklist(album=True) + def search_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 = [] + for name in artist.aliases: + self.log.debug('Searching album for {}'.format(name)) + kwalbart = {'albumartist':name, 'artist':name} + for album in self.list('album', 'albumartist', artist): + if album and album not in albums: + albums.append(Album(name=album, **kwalbart)) + for album in self.list('album', 'artist', artist): + album_trks = [trk for trk in self.find('album', album)] + if 'Various Artists' in [tr.albumartist for tr in album_trks]: + self.log.debug('Discarding {0} ("Various Artists" set)'.format(album)) + continue + arts = set([trk.artist for trk in album_trks]) + if len(set(arts)) < 2: # TODO: better heuristic, use a ratio instead + if album not in albums: + albums.append(Album(name=album, albumartist=artist)) + elif album and album not in albums: + self.log.debug('"{0}" probably not an album of "{1}"'.format( + album, artist) + '({0})'.format('/'.join(arts))) + return albums + def monitor(self): + curr = self.current try: - self._client.send_idle('database', 'playlist', 'player', 'options') + self.send_idle('database', 'playlist', 'player', 'options') select([self._client], [], [], 60) - return self._client.fetch_idle() + ret = self.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 clean(self): + """Clean blocking event (idle) and pending commands + """ + if 'idle' in self._client._pending: + self._client.noidle() + elif self._client._pending: + self.log.warning('pending commands: {}'.format(self._client._pending)) + def remove(self, position=0): - self._client.delete(position) + self.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')) + return str(self.status().get('state')) @property 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): """ @@ -134,14 +272,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 @@ -149,24 +288,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'] @@ -176,11 +315,25 @@ class PlayerClient(Player): self.disconnect() raise PlayerError('Could connect to "%s", ' 'but command "%s" not available' % - (self._host, nddcmd)) + (host, nddcmd)) + + # Controls use of MusicBrainzIdentifier + if Artist.use_mbid: + if 'MUSICBRAINZ_ARTISTID' not in self._client.tagtypes(): + self.log.warning('Use of MusicBrainzIdentifier is set but MPD is ' + 'not providing related metadata') + self.log.info(self._client.tagtypes()) + self.log.warning('Disabling MusicBrainzIdentifier') + Artist.use_mbid = False + else: + self.log.warning('Use of MusicBrainzIdentifier disabled!') + self.log.info('Consider using MusicBrainzIdentifier for your music library') + self._flush_cache() def disconnect(self): # Try to tell MPD we're closing the connection first try: + self._client.noidle() self._client.close() # If that fails, don't worry, just ignore it and disconnect except (MPDError, IOError):