def _wc_call(self, service_name, *args, **kw): """Returns the response of a web client call. :param service_name: the name of the call, eg ``search`` additional positional arguments are passed to ``build_body``for the retrieved protocol. if a 'query_args' key is present in kw, it is assumed to be a dictionary of additional key/val pairs to append to the query string. """ protocol = getattr(self.wc_protocol, service_name) #Always log the request. self.log.debug("wc_call %s %s", service_name, args) body, res_schema = protocol.build_transaction(*args) #Encode the body. It might be None (empty). if body is not None: #body can be {}, which is different from None. {} is falsey. body = "json=" + quote_plus(json.dumps(body)) extra_query_args = None if 'query_args' in kw: extra_query_args = kw['query_args'] res = self.session.open_web_url(protocol.build_url, extra_query_args, body) read = res.read() res = json.loads(read) if protocol.gets_logged: self.log.debug("wc_call response %s", res) else: self.log.debug("wc_call response <suppressed>") #Check if the server reported success. success = utils.call_succeeded(res) if not success: self.log.error("call to %s failed", service_name) self.log.debug("full response: %s", res) if not self.suppress_failure: calling_func_name = inspect.stack()[1][3] raise CallFailure( calling_func_name, res) #normally caused by bad arguments to the server #Calls are not required to have a schema, and # schemas are only for successful calls. if success and res_schema: try: validictory.validate(res, res_schema) except ValueError as details: self.log.warning( "Received an unexpected response from call %s.", service_name) self.log.debug("full response: %s", res) self.log.debug("failed schema: %s", res_schema) self.log.warning("error was: %s", details) return res
def _wc_call(self, service_name, *args, **kw): """Returns the response of a web client call. :param service_name: the name of the call, eg ``search`` additional positional arguments are passed to ``build_body``for the retrieved protocol. if a 'query_args' key is present in kw, it is assumed to be a dictionary of additional key/val pairs to append to the query string. """ protocol = getattr(self.wc_protocol, service_name) #Always log the request. self.log.debug("wc_call %s %s", service_name, args) body, res_schema = protocol.build_transaction(*args) #Encode the body. It might be None (empty). if body is not None: #body can be {}, which is different from None. {} is falsey. body = "json=" + quote_plus(json.dumps(body)) extra_query_args = None if 'query_args' in kw: extra_query_args = kw['query_args'] res = self.session.open_web_url(protocol.build_url, extra_query_args, body) read = res.read() res = json.loads(read) if protocol.gets_logged: self.log.debug("wc_call response %s", res) else: self.log.debug("wc_call response <suppressed>") #Check if the server reported success. success = utils.call_succeeded(res) if not success: self.log.error("call to %s failed", service_name) self.log.debug("full response: %s", res) if not self.suppress_failure: calling_func_name = inspect.stack()[1][3] raise CallFailure(calling_func_name, res) #normally caused by bad arguments to the server #Calls are not required to have a schema, and # schemas are only for successful calls. if success and res_schema: try: validictory.validate(res, res_schema) except ValueError as details: self.log.warning("Received an unexpected response from call %s.", service_name) self.log.debug("full response: %s", res) self.log.debug("failed schema: %s", res_schema) self.log.warning("error was: %s", details) return res
def _wc_call(self, service_name, *args, **kw): """Returns the response of a web client call. :param service_name: the name of the call, eg ``search`` additional positional arguments are passed to ``build_body``for the retrieved protocol. if a 'query_args' key is present in kw, it is assumed to be a dictionary of additional key/val pairs to append to the query string. """ protocol = getattr(self.wc_protocol, service_name) #Always log the request. self.log.debug("wc_call %s %s", service_name, args) body, res_schema = protocol.build_transaction(*args) #Encode the body. It might be None (empty). #This should probably be done in protocol, and an encoded body grabbed here. if body != None: #body can be {}, which is different from None. {} is falsey. body = "json=" + urllib.quote_plus(json.dumps(body)) extra_query_args = None if 'query_args' in kw: extra_query_args = kw['query_args'] if protocol.requires_login: res = self.wc_session.open_authed_https_url(protocol.build_url, extra_query_args, body) else: res = self.wc_session.open_https_url(protocol.build_url, extra_query_args, body) res = json.loads(res.read()) #Calls are not required to have a schema. if res_schema: try: validictory.validate(res, res_schema) except ValueError as details: self.log.warning("Received an unexpected response from call %s.", service_name) self.log.debug("full response: %s", res) self.log.debug("failed schema: %s", res_schema) self.log.warning("error was: %s", details) if protocol.gets_logged: self.log.debug("wc_call response %s", res) else: self.log.debug("wc_call response <suppressed>") #Check if the server reported success. #It's likely a failure will not pass validation, as well. if not utils.call_succeeded(res): self.log.warning("call to %s failed", service_name) self.log.debug("full response: %s", res) return res
def change_playlist(self, playlist_id, desired_playlist, safe=True): """Changes the order and contents of an existing playlist. Returns True on success, False if the playlist could be in an inconsistent state due to a problem. :param playlist_id: the id of the playlist being modified. :param desired_playlist: the desired contents and order as a list of song dictionaries, like is returned from :func:`get_playlist_songs`. :param safe: if True, ensure playlists will not be lost if a problem occurs. This may slow down updates. The server only provides 3 basic (atomic) playlist mutations: addition, deletion, and reordering. This function will automagically use these to apply a list representation of the desired changes. However, this might involve multiple calls to the server, and if a call fails, the playlist will be left in an inconsistent state. The `safe` option makes a backup of the playlist before doing anything, so it can be rolled back if a problem occurs. This is enabled by default. Note that this might slow down updates of very large playlists. There will always be a warning logged if a problem occurs, even if `safe` is False. """ #We'll be modifying the entries in the playlist, and need to copy it. #Copying ensures two things: # 1. the user won't see our changes # 2. changing a key for one entry won't change it for another - which would be the case # if the user appended the same song twice, for example. desired_playlist = [copy.deepcopy(t) for t in desired_playlist] server_tracks = self.get_playlist_songs(playlist_id) ##Make the backup. if safe: #The backup is stored on the server as a new playlist with "_gmusicapi_backup" appended to the backed up name. #We can't just store the backup here, since when rolling back we'd be relying on this function - which just failed. names_to_ids = self.get_playlists(always_id_lists=True)['user'] playlist_name = (ni_pair[0] for ni_pair in names_to_ids.iteritems() if playlist_id in ni_pair[1]).next() backup_id = self.copy_playlist(playlist_id, playlist_name + "_gmusicapi_backup") ##Try to change. try: #Counter, Counter, and set of id pairs to delete, add, and keep. to_del, to_add, to_keep = tools.find_playlist_changes(server_tracks, desired_playlist) ##Delete unwanted entries. to_del_eids = [pair[1] for pair in to_del.elements()] if to_del_eids and not utils.call_succeeded(self._remove_entries_from_playlist(playlist_id, to_del_eids)): raise PlaylistModificationError ##Add new entries. to_add_sids = [pair[0] for pair in to_add.elements()] if to_add_sids: res = self.add_songs_to_playlist(playlist_id, to_add_sids) if not utils.call_succeeded(res): raise PlaylistModificationError ##Update desired tracks with added tracks server-given eids. #Map new sid -> [eids] new_sid_to_eids = {} for sid, eid in ((s["songId"], s["playlistEntryId"]) for s in res["songIds"]): if not sid in new_sid_to_eids: new_sid_to_eids[sid] = [] new_sid_to_eids[sid].append(eid) for d_t in desired_playlist: if d_t["id"] in new_sid_to_eids: #Found a matching sid. match = d_t sid = match["id"] eid = match.get("playlistEntryId") pair = (sid, eid) if pair in to_keep: to_keep.remove(pair) #only keep one of the to_keep eids. else: match["playlistEntryId"] = new_sid_to_eids[sid].pop() if len(new_sid_to_eids[sid]) == 0: del new_sid_to_eids[sid] ##Now, the right eids are in the playlist. ##Set the order of the tracks: #The web client has no way to dictate the order without block insertion, # but the api actually supports setting the order to a given list. #For whatever reason, though, it needs to be set backwards; might be # able to get around this by messing with afterEntry and beforeEntry parameters. sids, eids = zip(*tools.get_id_pairs(desired_playlist[::-1])) if sids and not utils.call_succeeded(self._wc_call("changeplaylistorder", playlist_id, sids, eids)): raise PlaylistModificationError ##Clean up the backup. #Nothing to do if this fails (retry?), so assume it succeeds. if safe: self.delete_playlist(backup_id) return True except PlaylistModificationError: self.log.warning("a subcall of change_playlist failed - playlist %s is in an inconsistent state", playlist_id) reverted = False if safe: self.log.warning("attempting to revert changes from playlist '%s_gmusicapi_backup'", playlist_name) if all(map(utils.call_succeeded, [self.delete_playlist(playlist_id), self.change_playlist_name(backup_id, playlist_name)])): reverted = True if reverted: self.log.warning("reverted changes safely; playlist id of '%s' is now '%s'", playlist_name, backup_id) return reverted