def build_transaction(cls, songs):
            """:param songs: a list of dictionary representations of songs."""

            #Warn about metadata changes that may cause problems.
            #If you change the interface in api, you can warn about changing bad categories, too.
            #Something like safelychange(song, entries) where entries are only those you want to change.

            for song in songs:
                for key in song:
                    allowed_values = Metadata_Expectations.get_expectation(
                        key).allowed_values
                    if allowed_values and song[key] not in allowed_values:
                        LogController.get_logger("modifyentries").warning(
                            "setting key {0} to unallowed value {1} for id {2}. Check metadata expectations in protocol.py"
                            .format(key, song[key], song["id"]))

            req = {"entries": songs}

            res = {
                "type": "object",
                "properties": {
                    "success": {
                        "type": "boolean"
                    },
                    "songs": WC_Protocol.song_array
                },
                "additionalProperties": False
            }
            return (req, res)
        def build_transaction(cls, songs):
            """:param songs: a list of dictionary representations of songs."""

            #Warn about metadata changes that may cause problems.
            #If you change the interface in api, you can warn about changing bad categories, too.
            #Something like safelychange(song, entries) where entries are only those you want to change.

            for song in songs:
                for key in song:
                    allowed_values = Metadata_Expectations.get_expectation(key).allowed_values
                    if allowed_values and song[key] not in allowed_values:
                        LogController.get_logger("modifyentries").warning(
                            "setting key %s to unallowed value %s for id "
                            "%s. Check metadata expectations in "
                            "protocol.py" % (key, song[key], song["id"]))


            req = {"entries": songs}

            res = {"type": "object",
                   "properties":{
                       "success": {"type":"boolean"},
                       "songs":WC_Protocol.song_array
                       },
                   "additionalProperties":False
                   }
            return (req, res)
    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__)
Beispiel #4
0
    def get_expectation(cls, key, warn_on_unknown=True):
        """Get the Expectation associated with the given key name.
        If no Expectation exists for that name, an immutable Expectation of any type is returned."""

        mangle = False
        if not hasattr(cls,key):
            mangle = True

        expt_name = "gm_" + key if mangle else key

        try:
            expt = getattr(cls,expt_name)
            if not issubclass(expt, _Metadata_Expectation):
                raise TypeError
            return expt
        except (AttributeError, TypeError):
            if warn_on_unknown: LogController.get_logger("get_expectation").warning("unknown metadata type '%s'", key)

            return UnknownExpectation
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

                #Think 503 == syncing
                #200 == already uploaded
                #404 == bad request

                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