class Api:
    def __init__(self):
        self.wc_session = WC_Session()
        self.wc_protocol = WC_Protocol()

        self.mm_session = MM_Session()
        self.mm_protocol = MM_Protocol()

        self.log = LogController().get_logger(__name__ + "." + self.__class__.__name__)

    # ---
    #   Authentication:
    # ---

    def is_authenticated(self):
        """Returns whether the api is logged in."""

        return self.wc_session.logged_in and not (self.mm_session.sid == None)

    def login(self, email, password):
        """Authenticates the api with the given credentials.
        Returns True on success, False on failure.

        :param email: eg `[email protected]`
        :param password: plaintext password. It will not be stored and is sent over ssl."""

        self.wc_session.login(email, password)
        self.mm_session.login(email, password)

        if self.is_authenticated():
            # Need some extra init for upload authentication.
            self._mm_pb_call("upload_auth")
            self.log.info("logged in")
        else:
            self.log.info("failed to log in")

        return self.is_authenticated()

    def logout(self):
        """Logs out of the api.
        Returns True on success, False on failure."""

        self.wc_session.logout()
        self.mm_session.logout()

        self.log.info("logged out")

        return True

    # ---
    #   Api features supported by the web client interface:
    # ---

    @utils.accept_singleton(basestring, 2)  # can also accept a single string in pos 2 (song_ids)
    def add_songs_to_playlist(self, playlist_id, song_ids):
        """Adds songs to a playlist.

        :param playlist_id: id of the playlist to add to.
        :param song_ids: a list of song ids, or a single song id.
        """

        return self._wc_call("addtoplaylist", playlist_id, song_ids)

    def change_playlist_name(self, playlist_id, new_name):
        """Changes the name of a playlist.

        :param playlist_id: id of the playlist to rename.
        :param new_title: desired title.
        """

        return self._wc_call("modifyplaylist", playlist_id, new_name)

    @utils.accept_singleton(dict)
    def change_song_metadata(self, songs):
        """Changes the metadata for songs.
        Songs are presumed to be in GM dictionary format.

        :param songs: a list of song dictionaries, or a single song dictionary.
        """

        return self._wc_call("modifyentries", songs)

    def create_playlist(self, name):
        """Creates a new playlist.

        :param title: the title of the playlist to create.
        """

        return self._wc_call("addplaylist", name)

    def delete_playlist(self, playlist_id):
        """Deletes a playlist.

        :param playlist_id: id of the playlist to delete.
        """

        return self._wc_call("deleteplaylist", playlist_id)

    @utils.accept_singleton(basestring)
    def delete_song(self, song_ids):
        """Deletes songs from the entire library.

        :param song_ids: a list of song ids, or a single song id.
        """

        return self._wc_call("deletesong", song_ids)

    def get_all_songs(self):
        """Returns a list of song dictionaries."""

        library = []

        lib_chunk = self._wc_call("loadalltracks")

        while "continuationToken" in lib_chunk:
            library += lib_chunk["playlist"]  # misleading name; this is the entire chunk

            lib_chunk = self._wc_call("loadalltracks", lib_chunk["continuationToken"])

        library += lib_chunk["playlist"]

        return library

    def get_playlist_songs(self, playlist_id):
        """Returns a list of song dictionaries, which include entryIds keys for the given playlist.

        :param playlist_id: id of the playlist to load.
        """

        return self._wc_call("loadplaylist", playlist_id)["playlist"]

    def get_playlists(self):
        """Returns a dictionary which maps playlist name to id for all user-defined playlists.
        The dictionary does not include autoplaylists.
        """

        # Playlists are built in to the markup server-side.
        # There's a lot of html; rather than parse, it's easier to just cut
        # out the playlists ul, then use a regex.
        res = self.wc_session.open_https_url("https://music.google.com/music/listen?u=0")
        markup = res.read()

        # Get the playlists ul.
        markup = markup[markup.index(r'<ul id="playlists" class="playlistContainer">') :]
        markup = markup[: markup.index(r"</ul>") + 5]

        id_name = re.findall(r'<li id="(.*?)" class="nav-item-container".*?title="(.*?)">', markup)

        playlists = {}

        for p_id, p_name in id_name:
            playlists[p_name] = p_id

        return playlists

    def get_song_download_info(self, song_id):
        """Returns a tuple of (download url, download_count).
        GM allows 2 downloads per song.

        :param song_id: a single song id.
        """

        # The protocol expects a list of songs - could extend with accept_singleton
        info = self._wc_call("multidownload", [song_id])

        return (info["url"], info["downloadCounts"][song_id])

    def get_stream_url(self, song_id):
        """Returns a url that points to the audio file for this song. Reading the file does not require authentication.
        *This is only intended for streaming*. The streamed audio does not contain metadata. Use :func:`get_song_download_info` to download complete files.
        
        :param song_id: a single song id.
        """

        # This call is strange. The body is empty, and the songid is passed in the querystring.
        res = self._wc_call("play", query_args={"songid": song_id})

        return res["url"]

    @utils.accept_singleton(basestring)
    def remove_song_from_playlist(self, song_ids, playlist_id):
        """Removes songs from a playlist.

        :param song_ids: a list of song ids, or a single song id.
        """

        # Not as easy as just calling deletesong with the playlist;
        # we need the entryIds for the songs with the playlist as well.

        playlist_tracks = self.get_playlist_songs(playlist_id)

        entry_ids = []

        for sid in song_ids:
            matched_eids = [t["playlistEntryId"] for t in playlist_tracks if t["id"] == sid]

            if len(matched_eids) < 1:
                self.log.warning("could not match song id %s to any entryIds")
            else:
                entry_ids.extend(matched_eids)

        return self._wc_call("deletesong", song_ids, entry_ids, playlist_id)

    def search(self, query):
        """Searches for songs, artists and albums.
        GM ignores punctuation.

        :param query: the search query.
        """

        return self._wc_call("search", query)

    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.
        """

        # TODO check if we're logged in!
        protocol = getattr(self.wc_protocol, service_name)

        if protocol.gets_logged:
            self.log.debug("wc_call: %s(%s)", service_name, str(args))

        url_builder = protocol.build_url
        body = protocol.build_body(*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"]

        res = self.wc_session.open_https_url(url_builder, extra_query_args, body)

        res = json.loads(res.read())

        if protocol.gets_logged:
            self.log.debug("wc_call response: %s", res)

        return res

    # ---
    #   Api features supported by the Music Manager interface:
    # ---

    # This works, but the protocol isn't quite right.
    # For now, you're better off just taking len(get_all_songs)
    # to get a count of songs in the library.

    # def get_quota(self):
    #     """Returns a tuple of (allowed number of tracks, total tracks, available tracks)."""

    #     quota = self._mm_pb_call("client_state").quota

    #     #protocol incorrect here...
    #     return (quota.maximumTracks, quota.totalTracks, quota.availableTracks)

    @utils.accept_singleton(basestring)
    def upload(self, filenames):
        """Uploads the MP3s stored in the given filenames.
        Returns a mapping of filename: GM song id for each successful upload.
        Returns an empty dictionary if all uploads fail.

        :param filenames: a list of filenames, or a single filename
        """

        # filename -> GM song id
        fn_sid_map = {}

        # Form and send the metadata request.
        metadata_request, cid_map = self.mm_protocol.make_metadata_request(filenames)
        metadataresp = self._mm_pb_call("metadata", metadata_request)

        # Form upload session requests (for songs GM wants).
        session_requests = self.mm_protocol.make_upload_session_requests(cid_map, metadataresp)

        for filename, server_id, payload in session_requests:

            post_data = json.dumps(payload)

            success = False
            attempts = 0
            while not success and attempts < 3:

                # Pull this out with the below call when it makes sense to.
                res = json.loads(self.mm_session.jumper_post("/uploadsj/rupio", post_data).read())

                if "sessionStatus" in res:
                    self.log.info("got a session. full response: %s", str(res))
                    success = True
                    break

                elif "errorMessage" in res:
                    self.log.warning("got an error from the GM upload server. full response: %s", str(res))
                else:
                    self.log.warning("could not interpret upload session resonse. full response: %s", str(res))

                time.sleep(3)
                self.log.info("trying again for a session.")
                attempts += 1

                # print "Waiting for servers to sync..."

            if success:
                # Got a session; upload the actual file.
                up = res["sessionStatus"]["externalFieldTransfers"][0]
                self.log.info("uploading file. sid: %s", server_id)
                # print "Uploading a file... this may take a while"

                res = json.loads(
                    self.mm_session.jumper_post(
                        up["putInfo"]["url"], open(filename), {"Content-Type": up["content_type"]}
                    ).read()
                )

                # if options.verbose: print res
                if res["sessionStatus"]["state"] == "FINALIZED":
                    fn_sid_map[filename] = server_id
                    self.log.info("successfully uploaded sid %s", server_id)
            else:
                self.log.warning("could not get an upload session for sid %s", server_id)

        return fn_sid_map

    def _mm_pb_call(self, service_name, req=None):
        """Returns the protobuff response of a call to a predefined Music Manager protobuff service."""

        self.log.debug("mm_pb_call: %s(%s)", service_name, str(req))

        res = self.mm_protocol.make_pb(service_name + "_response")

        if not req:
            try:
                req = self.mm_protocol.make_pb(service_name + "_request")
            except AttributeError:
                req = self.mm_protocol.make_pb(service_name)  # some request types don't have _request appended.

        url = self.mm_protocol.pb_services[service_name]

        res.ParseFromString(self.mm_session.protopost(url, req))

        self.log.debug("mm_pb_call response: [%s]", str(res))

        return res
Esempio n. 2
0
class Api:
    """ Contains abstractions of API calls."""


    def __init__(self):
        self.wc_session = WC_Session()
        self.wc_protocol = WC_Protocol()

        self.mm_session = MM_Session()
        self.mm_protocol = MM_Protocol()

        self.log = LogController().get_logger(__name__ + "." + self.__class__.__name__)


    #---
    #   Authentication:
    #---

    def is_authenticated(self):
        """Returns whether the api is logged in."""

        return self.wc_session.logged_in and not (self.mm_session.sid == None)

    def login(self, email, password):
        """Authenticates the api with the given credentials.
        Returns True on success, False on failure.

        :param email: eg [email protected]
        :param password
        """

        self.wc_session.login(email, password)
        self.mm_session.login(email, password)


        if self.is_authenticated():
            #Need some extra init for upload authentication.
            self._mm_pb_call("upload_auth")
            self.log.info("logged in")
        else:
            self.log.info("failed to log in")

        return self.is_authenticated()

    def logout(self):
        """Log out of the Api.
        Returns True on success, False on failure."""

        self.wc_session.logout()
        self.mm_session.logout()

        self.log.info("logged out")

        return True


    #---
    #   Api features supported by the web client interface:
    #---



    @utils.accept_singleton(basestring, 2) #can also accept a single string in pos 2 (song_ids)
    def add_songs_to_playlist(self, playlist_id, song_ids):
        """Adds songs to a playlist.

        :param playlist_id: id of the playlist to add to.
        :param song_ids: a list of song ids, or a single song id.
        """

        return self._wc_call("addtoplaylist", playlist_id, song_ids)

    def change_playlist_name(self, playlist_id, new_name):
        """Changes the name of a playlist.

        :param playlist_id: id of the playlist to rename.
        :param new_title: desired title.
        """

        return self._wc_call("modifyplaylist", playlist_id, new_name)

    @utils.accept_singleton(dict)
    def change_song_metadata(self, songs):
        """Change the metadata for some songs.
        Songs are presumed to be in GM dictionary format.

        :param songs: a list of songs, or a single song.
        """

        #Warn about metadata changes that may cause problems.
        #If you change the interface, you can warn about changing bad categories, too.
        #Something like safelychange(song, entries) where entries are only those you want to change.
        limited_md = self.wc_protocol.limited_md
        for song_md in songs:
            for key in limited_md:
                if key in song_md and song_md[key] not in limited_md[key]:
                    self.log.warning("setting id (%s)[%s] to a dangerous value. Check metadata expectations in protocol.py", song_md["id"], key)

        return self._wc_call("modifyentries", songs)
        
    def create_playlist(self, name):
        """Creates a new playlist.

        :param title: the title of the playlist to create.
        """

        return self._wc_call("addplaylist", name)

    def delete_playlist(self, playlist_id):
        """Deletes a playlist.

        :param playlist_id: id of the playlist to delete.
        """

        return self._wc_call("deleteplaylist", playlist_id)

    @utils.accept_singleton(basestring) #position defaults to 1
    def delete_song(self, song_ids):
        """Deletes songs from the entire library.

        :param song_ids: a list of song ids, or a single song id.
        """

        return self._wc_call("deletesong", song_ids)

    def get_all_songs(self):
        """Returns a list of song metadata dictionaries.
        """

        library = []

        lib_chunk = self._wc_call("loadalltracks")
    
        while 'continuationToken' in lib_chunk:
            library += lib_chunk['playlist'] #misleading name; this is the entire chunk
            
            lib_chunk = self._wc_call("loadalltracks", lib_chunk['continuationToken'])

        library += lib_chunk['playlist']

        return library

    def get_playlist_songs(self, playlist_id):
        """Returns a list of track dictionaries, which include enttryIds for the playlist.

        :playlist_id: id of the playlist to load
        """

        return self._wc_call("loadplaylist", playlist_id)["playlist"]

    def get_playlists(self):
        """Returns a dictionary mapping playlist name to id for all user playlists.
        The dictionary does not include autoplaylists.
        """
        
        #Playlists are built in to the markup server-side.
        #There's a lot of html; rather than parse, it's easier to just cut
        # out the playlists ul, then use a regex.
        res = self.wc_session.open_https_url("https://music.google.com/music/listen?u=0")
        markup = res.read()

        #Get the playlists ul.
        markup = markup[markup.index(r'<ul id="playlists" class="playlistContainer">'):]
        markup = markup[:markup.index(r'</ul>') + 5]


        id_name = re.findall(r'<li id="(.*?)" class="nav-item-container".*?title="(.*?)">', markup)
        
        playlists = {}
        
        for p_id, p_name in id_name:
            playlists[p_name] = p_id

        return playlists

    def get_song_download_info(self, song_id):
        """Returns a tuple of (download url, download_count).

        :param song_id: a single song id.
        """

        #The protocol expects a list of songs - could extend with accept_singleton
        info = self._wc_call("multidownload", [song_id])

        return (info["url"], info["downloadCounts"][song_id])

    @utils.accept_singleton(basestring)
    def remove_song_from_playlist(self, song_ids, playlist_id):
        """Removes songs from a playlist.

        :param song_ids: a list of song ids, or a single song id
        """

        #Not as easy as just calling deletesong with the playlist;
        # we need the entryIds for the songs with the playlist as well.

        playlist_tracks = self.get_playlist_songs(playlist_id)

        entry_ids = []

        for sid in song_ids:
            matched_eids = [t["playlistEntryId"] for t in playlist_tracks if t["id"] == sid]
            
            if len(matched_eids) < 1:
                self.log.warning("could not match song id %s to any entryIds")
            else:
                entry_ids.extend(matched_eids)


        return self._wc_call("deletesong", song_ids, entry_ids, playlist_id)

    def search(self, query):
        """Searches for songs, artists and albums.
        GM ignores punctuation.

        :param query: the search query.
        """

        return self._wc_call("search", query)


    def _wc_call(self, service_name, *args, **kw):
        """Returns the response of a call with the web client session and protocol."""

        #Pull these suppressed calls out somewhere.
        if service_name != "loadalltracks":
            self.log.debug("wc_call: %s(%s)", service_name, str(args))

        protocol_builder = getattr(self.wc_protocol, service_name)

        res = self.wc_session.make_request(service_name, 
                                           protocol_builder(*args, **kw))
        
        res = json.loads(res.read())

        if service_name != "loadalltracks":
            self.log.debug("wc_call response: %s", res)

        return res


    #---
    #   Api features supported by the Music Manager interface:
    #---


    #This works, but the protocol isn't quite right.
    #For now, you're better of just taking len(get_all_songs)
    # to get a count of songs in the library.

    # def get_quota(self):
    #     """Returns a tuple of (allowed number of tracks, total tracks, available tracks)."""

    #     quota = self._mm_pb_call("client_state").quota

    #     #protocol incorrect here...
    #     return (quota.maximumTracks, quota.totalTracks, quota.availableTracks)

    @utils.accept_singleton(basestring)
    def upload(self, filenames):
        """Uploads the MP3s stored in the given filenames.
        Returns a mapping of filename: GM song id for each successful upload.
        Returns an empty dictionary if all uploads fail.

        :param filenames: a list of filenames, or a single filename
        """

        #filename -> GM song id
        fn_sid_map = {}

        #Form and send the metadata request.
        metadata_request, cid_map = self.mm_protocol.make_metadata_request(filenames)
        metadataresp = self._mm_pb_call("metadata", metadata_request)

        #Form upload session requests (for songs GM wants).
        session_requests = self.mm_protocol.make_upload_session_requests(cid_map, metadataresp)

    
        for filename, server_id, payload in session_requests:

            post_data = json.dumps(payload)

            success = False
            attempts = 0
            while not success and attempts < 3:
                
                #Pull this out with the below call when it makes sense to.
                res = json.loads(
                    self.mm_session.jumper_post(
                        "/uploadsj/rupio", 
                        post_data).read())

                if 'sessionStatus' in res:
                    self.log.info("got a session. full response: %s", str(res))
                    success = True
                    break
                
                elif 'errorMessage' in res:
                    self.log.warning("got an error from the GM upload server. full response: %s", str(res))
                else:
                    self.log.warning("could not interpret upload session resonse. full response: %s", str(res))
                    
                    
                    
                time.sleep(3)
                self.log.info("trying again for a session.")
                attempts += 1
                
                #print "Waiting for servers to sync..."

            if success:
                #Got a session; upload the actual file.
                up = res['sessionStatus']['externalFieldTransfers'][0]
                self.log.info("uploading file. sid: %s", server_id)
                #print "Uploading a file... this may take a while"

                res = json.loads(
                    self.mm_session.jumper_post( 
                        up['putInfo']['url'], 
                        open(filename), 
                        {'Content-Type': up['content_type']}).read())


                #if options.verbose: print res
                if res['sessionStatus']['state'] == 'FINALIZED':
                    fn_sid_map[filename] = server_id
                    self.log.info("successfully uploaded sid %s", server_id)
            else:
                self.log.warning("could not get an upload session for sid %s", server_id)


        
        return fn_sid_map


    def _mm_pb_call(self, service_name, req = None):
        """Returns the protobuff response of a call to a predefined Music Manager protobuff service."""

        self.log.debug("mm_pb_call: %s(%s)", service_name, str(req))

        res = self.mm_protocol.make_pb(service_name + "_response")

        if not req:
            try:
                req = self.mm_protocol.make_pb(service_name + "_request")
            except AttributeError:
                req = self.mm_protocol.make_pb(service_name) #some request types don't have _request appended.

        url = self.mm_protocol.pb_services[service_name]

        res.ParseFromString(self.mm_session.protopost(url, req))

        self.log.debug("mm_pb_call response: [%s]", str(res))

        return res