def get_quota(self): """Returns a tuple of (number of uploaded tracks, allowed number of uploaded tracks).""" if self.uploader_id is None: raise NotLoggedIn("Not authenticated as an upload device;" " run Musicmanager.login(...perform_upload_auth=True...)" " first.") client_state = self._make_call( musicmanager.GetClientState, self.uploader_id).clientstate_response return (client_state.total_track_count, client_state.locker_track_limit)
def upload(self, filepaths, enable_matching=False, enable_transcoding=True, transcode_quality='320k'): """Uploads the given filepaths. All non-mp3 files will be transcoded before being uploaded. This is a limitation of Google's backend. An available installation of ffmpeg or avconv is required in most cases: see `the installation page <https://unofficial-google-music-api.readthedocs.io/en /latest/usage.html?#installation>`__ for details. Returns a 3-tuple ``(uploaded, matched, not_uploaded)`` of dictionaries, eg:: ( {'<filepath>': '<new server id>'}, # uploaded {'<filepath>': '<new server id>'}, # matched {'<filepath>': '<reason, eg ALREADY_EXISTS>'} # not uploaded ) :param filepaths: a list of filepaths, or a single filepath. :param enable_matching: if ``True``, attempt to use `scan and match <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=2920799&topic=2450455>`__ to avoid uploading every song. This requires ffmpeg or avconv. **WARNING**: currently, mismatched songs can *not* be fixed with the 'Fix Incorrect Match' button nor :py:func:`report_incorrect_match <gmusicapi.clients.Webclient.report_incorrect_match>`. They would have to be deleted and reuploaded with matching disabled (or with the Music Manager). Fixing matches from gmusicapi may be supported in a future release; see issue `#89 <https://github.com/simon-weber/gmusicapi/issues/89>`__. :param enable_transcoding: if ``False``, non-MP3 files that aren't matched using `scan and match <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=2920799&topic=2450455>`__ will not be uploaded. :param transcode_quality: if int, pass to ffmpeg/avconv ``-q:a`` for libmp3lame (`lower-better int, <http://trac.ffmpeg.org/wiki/Encoding%20VBR%20(Variable%20Bit%20Rate)%20mp3%20audio>`__). If string, pass to ffmpeg/avconv ``-b:a`` (eg ``'128k'`` for an average bitrate of 128k). The default is 320kbps cbr (the highest possible quality). All Google-supported filetypes are supported; see `Google's documentation <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=1100462>`__. If ``PERMANENT_ERROR`` is given as a not_uploaded reason, attempts to reupload will never succeed. The file will need to be changed before the server will reconsider it; the easiest way is to change metadata tags (it's not important that the tag be uploaded, just that the contents of the file change somehow). """ if self.uploader_id is None or self.uploader_name is None: raise NotLoggedIn("Not authenticated as an upload device;" " run Api.login(...perform_upload_auth=True...)" " first.") # TODO there is way too much code in this function. # To return. uploaded = {} matched = {} not_uploaded = {} # Gather local information on the files. local_info = {} # {clientid: (path, Track)} for path in filepaths: try: track = musicmanager.UploadMetadata.fill_track_info(path) except BaseException as e: self.logger.exception("problem gathering local info of '%r'", path) user_err_msg = str(e) if 'Non-ASCII strings must be converted to unicode' in str(e): # This is a protobuf-specific error; they require either ascii or unicode. # To keep behavior consistent, make no effort to guess - require users # to decode first. user_err_msg = ( "nonascii bytestrings must be decoded to unicode" " (error: '%s')" % user_err_msg) not_uploaded[path] = user_err_msg else: local_info[track.client_id] = (path, track) if not local_info: return uploaded, matched, not_uploaded # TODO allow metadata faking # Upload metadata; the server tells us what to do next. res = self._make_call(musicmanager.UploadMetadata, [t for (path, t) in local_info.values()], self.uploader_id) # TODO checking for proper contents should be handled in verification md_res = res.metadata_response responses = [r for r in md_res.track_sample_response] sample_requests = [req for req in md_res.signed_challenge_info] # Send scan and match samples if requested. for sample_request in sample_requests: path, track = local_info[ sample_request.challenge_info.client_track_id] bogus_sample = None if not enable_matching: bogus_sample = b'' # just send empty bytes try: res = self._make_call(musicmanager.ProvideSample, path, sample_request, track, self.uploader_id, bogus_sample) except (IOError, ValueError) as e: self.logger.warning( "couldn't create scan and match sample for '%r': %s", path, str(e)) not_uploaded[path] = str(e) else: responses.extend(res.sample_response.track_sample_response) # Read sample responses and prep upload requests. to_upload = {} # {serverid: (path, Track, do_not_rematch?)} for sample_res in responses: path, track = local_info[sample_res.client_track_id] if sample_res.response_code == upload_pb2.TrackSampleResponse.MATCHED: self.logger.info("matched '%r' to sid %s", path, sample_res.server_track_id) matched[path] = sample_res.server_track_id if not enable_matching: self.logger.error( "'%r' was matched without matching enabled", path) elif sample_res.response_code == upload_pb2.TrackSampleResponse.UPLOAD_REQUESTED: to_upload[sample_res.server_track_id] = (path, track, False) else: # there was a problem # report the symbolic name of the response code enum for debugging enum_desc = upload_pb2._TRACKSAMPLERESPONSE.enum_types[0] res_name = enum_desc.values_by_number[ sample_res.response_code].name err_msg = "TrackSampleResponse code %s: %s" % ( sample_res.response_code, res_name) if res_name == 'ALREADY_EXISTS': # include the sid, too # this shouldn't be relied on externally, but I use it in # tests - being surrounded by parens is how it's matched err_msg += "(%s)" % sample_res.server_track_id self.logger.warning("upload of '%r' rejected: %s", path, err_msg) not_uploaded[path] = err_msg # Send upload requests. if to_upload: # TODO reordering requests could avoid wasting time waiting for reup sync self._make_call(musicmanager.UpdateUploadState, 'start', self.uploader_id) for server_id, (path, track, do_not_rematch) in to_upload.items(): # It can take a few tries to get an session. should_retry = True attempts = 0 while should_retry and attempts < 10: session = self._make_call(musicmanager.GetUploadSession, self.uploader_id, len(uploaded), track, path, server_id, do_not_rematch) attempts += 1 got_session, error_details = \ musicmanager.GetUploadSession.process_session(session) if got_session: self.logger.info("got an upload session for '%r'", path) break should_retry, reason, error_code = error_details self.logger.debug( "problem getting upload session: %s\ncode=%s retrying=%s", reason, error_code, should_retry) if error_code == 200 and do_not_rematch: # reupload requests need to wait on a server sync # 200 == already uploaded, so force a retry in this case should_retry = True time.sleep(6) # wait before retrying else: err_msg = "GetUploadSession error %s: %s" % (error_code, reason) self.logger.warning( "giving up on upload session for '%r': %s", path, err_msg) not_uploaded[path] = err_msg continue # to next upload # got a session, do the upload # this terribly inconsistent naming isn't my fault: Google-- session = session['sessionStatus'] external = session['externalFieldTransfers'][0] session_url = external['putInfo']['url'] content_type = external.get('content_type', 'audio/mpeg') if track.original_content_type != locker_pb2.Track.MP3: if enable_transcoding: try: self.logger.info("transcoding '%r' to mp3", path) contents = utils.transcode_to_mp3( path, quality=transcode_quality) except (IOError, ValueError) as e: self.logger.warning("error transcoding %r: %s", path, e) not_uploaded[path] = "transcoding error: %s" % e continue else: not_uploaded[path] = "transcoding disabled" continue else: with open(path, 'rb') as f: contents = f.read() upload_response = self._make_call(musicmanager.UploadFile, session_url, content_type, contents) success = upload_response.get('sessionStatus', {}).get('state') if success: uploaded[path] = server_id else: # 404 == already uploaded? serverside check on clientid? self.logger.debug( "could not finalize upload of '%r'. response: %s", path, upload_response) not_uploaded[ path] = 'could not finalize upload; details in log' self._make_call(musicmanager.UpdateUploadState, 'stopped', self.uploader_id) return uploaded, matched, not_uploaded
def upload(self, filepaths, transcode_quality=3, enable_matching=False): """Uploads the given filepaths. Any non-mp3 files will be `transcoded with avconv <https://github.com/simon-weber/Unofficial-Google-Music-API/ blob/develop/gmusicapi/utils/utils.py#L18>`__ before being uploaded. Return a 3-tuple ``(uploaded, matched, not_uploaded)`` of dictionaries, eg:: ( {'<filepath>': '<new server id>'}, # uploaded {'<filepath>': '<new server id>'}, # matched {'<filepath>': '<reason, eg ALREADY_UPLOADED>'} # not uploaded ) :param filepaths: a list of filepaths, or a single filepath. :param transcode_quality: if int, pass to avconv ``-qscale`` for libmp3lame (lower-better int, roughly corresponding to `hydrogenaudio -vX settings <http://wiki.hydrogenaudio.org/index.php?title=LAME#Recommended_encoder_settings>`__). If string, pass to avconv ``-ab`` (eg ``'128k'`` for an average bitrate of 128k). The default is ~175kbs vbr. :param enable_matching: if ``True``, attempt to use `scan and match <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=2920799&topic=2450455>`__ to avoid uploading every song. **WARNING**: currently, mismatched songs can *not* be fixed with the 'Fix Incorrect Match' button or :func:`report_incorrect_match`. They would have to be deleted and reuploaded with the Music Manager. Fixing matches from gmusicapi will be supported in a future release; see issue `#89 <https://github.com/simon-weber/Unofficial-Google-Music-API/issues/89>`__. All Google-supported filetypes are supported; see `Google's documentation <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=1100462>`__. Unlike Google's Music Manager, this function will currently allow the same song to be uploaded more than once if its tags are changed. This is subject to change in the future. If ``PERMANENT_ERROR`` is given as a not_uploaded reason, attempts to reupload will never succeed. The file will need to be changed before the server will reconsider it; the easiest way is to change metadata tags (it's not important that the tag be uploaded, just that the contents of the file change somehow). """ if self.uploader_id is None or self.uploader_name is None: raise NotLoggedIn("Not authenticated as an upload device;" " run Api.login(...perform_upload_auth=True...)" " first.") #TODO there is way too much code in this function. #To return. uploaded = {} matched = {} not_uploaded = {} #Gather local information on the files. local_info = {} # {clientid: (path, Track)} for path in filepaths: try: track = musicmanager.UploadMetadata.fill_track_info(path) except BaseException as e: log.exception("problem gathering local info of '%r'", path) user_err_msg = str(e) if 'Non-ASCII strings must be converted to unicode' in str(e): #This is a protobuf-specific error; they require either ascii or unicode. #To keep behavior consistent, make no effort to guess - require users # to decode first. user_err_msg = ( "nonascii bytestrings must be decoded to unicode" " (error: '%s')" % err_msg) not_uploaded[path] = user_err_msg else: local_info[track.client_id] = (path, track) if not local_info: return uploaded, matched, not_uploaded #TODO allow metadata faking #Upload metadata; the server tells us what to do next. res = self._make_call(musicmanager.UploadMetadata, [track for (path, track) in local_info.values()], self.uploader_id) #TODO checking for proper contents should be handled in verification md_res = res.metadata_response responses = [r for r in md_res.track_sample_response] sample_requests = [req for req in md_res.signed_challenge_info] #Send scan and match samples if requested. for sample_request in sample_requests: path, track = local_info[ sample_request.challenge_info.client_track_id] try: res = self._make_call(musicmanager.ProvideSample, path, sample_request, track, self.uploader_id) except (IOError, ValueError) as e: log.warning( "couldn't create scan and match sample for '%s': %s", path, str(e)) not_uploaded[path] = str(e) else: responses.extend(res.sample_response.track_sample_response) #Read sample responses and prep upload requests. to_upload = {} # {serverid: (path, Track, do_not_rematch?)} for sample_res in responses: path, track = local_info[sample_res.client_track_id] if sample_res.response_code == upload_pb2.TrackSampleResponse.MATCHED: log.info("matched '%s' to sid %s", path, sample_res.server_track_id) if enable_matching: matched[path] = sample_res.server_track_id else: # request a reupload session (ie, hit 'fix incorrect match'). time.sleep(10) # wait for upload servers to sync try: self._make_call(webclient.ReportBadSongMatch, [sample_res.server_track_id]) #Wait for server to register our request. retries = 0 while retries < 5: jobs = self._make_call(musicmanager.GetUploadJobs, self.uploader_id) matching = [ job for job in jobs.getjobs_response.tracks_to_upload if (job.client_id == sample_res.client_track_id and job.status == upload_pb2.TracksToUpload.FORCE_REUPLOAD) ] if matching: reup_sid = matching[0].server_id break log.debug("wait for reup job (%s)", retries) time.sleep(2) retries += 1 else: raise CallFailure( "could not get reupload/rematch job for '%s'" % path, 'GetUploadJobs') except CallFailure as e: log.exception( "'%s' was matched without matching enabled", path) matched[path] = sample_res.server_track_id else: log.info("will reupload '%s'", path) to_upload[reup_sid] = (path, track, True) elif sample_res.response_code == upload_pb2.TrackSampleResponse.UPLOAD_REQUESTED: to_upload[sample_res.server_track_id] = (path, track, False) else: #Report the symbolic name of the response code enum. enum_desc = upload_pb2._TRACKSAMPLERESPONSE.enum_types[0] res_name = enum_desc.values_by_number[ sample_res.response_code].name err_msg = "TrackSampleResponse code %s: %s" % ( sample_res.response_code, res_name) log.warning("upload of '%s' rejected: %s", path, err_msg) not_uploaded[path] = err_msg #Send upload requests. if to_upload: #TODO reordering requests could avoid wasting time waiting for reup sync self._make_call(musicmanager.UpdateUploadState, 'start', self.uploader_id) for server_id, (path, track, do_not_rematch) in to_upload.items(): #It can take a few tries to get an session. should_retry = True attempts = 0 while should_retry and attempts < 10: session = self._make_call(musicmanager.GetUploadSession, self.uploader_id, len(uploaded), track, path, server_id, do_not_rematch) attempts += 1 got_session, error_details = \ musicmanager.GetUploadSession.process_session(session) if got_session: log.info("got an upload session for '%s'", path) break should_retry, reason, error_code = error_details log.debug( "problem getting upload session: %s\ncode=%s retrying=%s", reason, error_code, should_retry) if error_code == 200 and do_not_rematch: #reupload requests need to wait on a server sync #200 == already uploaded, so force a retry in this case should_retry = True time.sleep(6) # wait before retrying else: err_msg = "GetUploadSession error %s: %s" % (error_code, reason) log.warning("giving up on upload session for '%s': %s", path, err_msg) not_uploaded[path] = err_msg continue # to next upload #got a session, do the upload #this terribly inconsistent naming isn't my fault: Google-- session = session['sessionStatus'] external = session['externalFieldTransfers'][0] session_url = external['putInfo']['url'] content_type = external.get('content_type', 'audio/mpeg') if track.original_content_type != locker_pb2.Track.MP3: try: log.info("transcoding '%s' to mp3", path) contents = utils.transcode_to_mp3( path, quality=transcode_quality) except (IOError, ValueError) as e: log.warning("error transcoding %s: %s", path, e) not_uploaded[path] = "transcoding error: %s" % e continue else: with open(path, 'rb') as f: contents = f.read() upload_response = self._make_call(musicmanager.UploadFile, session_url, content_type, contents) success = upload_response.get('sessionStatus', {}).get('state') if success: uploaded[path] = server_id else: #404 == already uploaded? serverside check on clientid? log.debug( "could not finalize upload of '%s'. response: %s", path, upload_response) not_uploaded[ path] = 'could not finalize upload; details in log' self._make_call(musicmanager.UpdateUploadState, 'stopped', self.uploader_id) return uploaded, matched, not_uploaded