diff --git a/librespot/__init__.py b/librespot/__init__.py index 8f688cf..ff1ec64 100644 --- a/librespot/__init__.py +++ b/librespot/__init__.py @@ -6,7 +6,7 @@ class Version: - version_name = "0.0.10" + version_name = "0.0.15" @staticmethod def platform() -> Platform: diff --git a/librespot/audio/__init__.py b/librespot/audio/__init__.py index 6f0f067..5bb15ef 100644 --- a/librespot/audio/__init__.py +++ b/librespot/audio/__init__.py @@ -326,32 +326,6 @@ def get_url(resp: StorageResolve.StorageResolveResponse) -> str: selected_url = random.choice(resp.cdnurl) return selected_url - @staticmethod - def load_track( - session: Session, track: Metadata.Track, file: Metadata.AudioFile, - resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, - str], preload: bool, - halt_listener: HaltListener) -> LoadedStream: - if type(resp_or_url) is str: - url = resp_or_url - else: - url = CdnFeedHelper.get_url(resp_or_url) - start = int(time.time() * 1000) - key = session.audio_key().get_audio_key(track.gid, file.file_id) - audio_key_time = int(time.time() * 1000) - start - - streamer = session.cdn().stream_file(file, key, url, halt_listener) - input_stream = streamer.stream() - normalization_data = NormalizationData.read(input_stream) - if input_stream.skip(0xA7) != 0xA7: - raise IOError("Couldn't skip 0xa7 bytes!") - return LoadedStream( - track, - streamer, - normalization_data, - file.file_id, preload, audio_key_time - ) - @staticmethod def load_episode_external( session: Session, episode: Metadata.Episode, @@ -375,9 +349,9 @@ def load_episode_external( ) @staticmethod - def load_episode( + def load_content( session: Session, - episode: Metadata.Episode, + track_or_episode: typing.Union[Metadata.Track, Metadata.Episode], file: Metadata.AudioFile, resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str], preload: bool, @@ -388,7 +362,7 @@ def load_episode( else: url = CdnFeedHelper.get_url(resp_or_url) start = int(time.time() * 1000) - key = session.audio_key().get_audio_key(episode.gid, file.file_id) + key = session.audio_key().get_audio_key(track_or_episode.gid, file.file_id) audio_key_time = int(time.time() * 1000) - start streamer = session.cdn().stream_file(file, key, url, halt_listener) @@ -397,7 +371,7 @@ def load_episode( if input_stream.skip(0xA7) != 0xA7: raise IOError("Couldn't skip 0xa7 bytes!") return LoadedStream( - episode, + track_or_episode, streamer, normalization_data, file.file_id, preload, audio_key_time @@ -742,22 +716,19 @@ def load(self, playable_id: PlayableId, preload, halt_listener) raise TypeError("Unknown content: {}".format(playable_id)) - def load_stream(self, file: Metadata.AudioFile, track: Metadata.Track, - episode: Metadata.Episode, preload: bool, - halt_lister: HaltListener): - if track is None and episode is None: + def load_stream(self, file: Metadata.AudioFile, + track_or_episode: typing.Union[Metadata.Track, Metadata.Episode], + preload: bool, halt_lister: HaltListener): + if track_or_episode is None: raise RuntimeError("No content passed!") elif file is None: raise RuntimeError("Content has no audio file!") response = self.resolve_storage_interactive(file.file_id, preload) if response.result == StorageResolve.StorageResolveResponse.Result.CDN: - if track is not None: - return CdnFeedHelper.load_track(self.__session, track, file, - response, preload, halt_lister) - return CdnFeedHelper.load_episode(self.__session, episode, file, - response, preload, halt_lister) + return CdnFeedHelper.load_content(self.__session, track_or_episode, file, + response, preload, halt_lister) if response.result == StorageResolve.StorageResolveResponse.Result.STORAGE: - if track is None: + if track_or_episode is None: pass elif response.result == StorageResolve.StorageResolveResponse.Result.RESTRICTED: raise RuntimeError("Content is restricted!") @@ -779,7 +750,7 @@ def load_episode(self, episode_id: EpisodeId, "Couldn't find any suitable audio file, available: {}".format( episode.audio)) raise FeederException("Cannot find suitable audio file") - return self.load_stream(file, None, episode, preload, halt_listener) + return self.load_stream(file, episode, preload, halt_listener) def load_track(self, track_id_or_track: typing.Union[TrackId, Metadata.Track], @@ -799,7 +770,7 @@ def load_track(self, track_id_or_track: typing.Union[TrackId, "Couldn't find any suitable audio file, available: {}".format( track.file)) raise FeederException("Cannot find suitable audio file") - return self.load_stream(file, track, None, preload, halt_listener) + return self.load_stream(file, track, preload, halt_listener) def pick_alternative_if_necessary( self, track: Metadata.Track) -> typing.Union[Metadata.Track, None]: diff --git a/librespot/core.py b/librespot/core.py index 68df881..2c56814 100644 --- a/librespot/core.py +++ b/librespot/core.py @@ -43,6 +43,7 @@ from librespot.mercury import MercuryClient from librespot.mercury import MercuryRequests from librespot.mercury import RawMercuryRequest +from librespot.metadata import Id from librespot.metadata import AlbumId from librespot.metadata import ArtistId from librespot.metadata import EpisodeId @@ -192,6 +193,64 @@ def put_connect_state(self, connection_id: str, self.logger.warning("PUT state returned {}. headers: {}".format( response.status_code, response.headers)) + def get_ext_metadata_batched(self, extension_kinds: list[ExtensionKind], uris: list[str]) -> list[typing.Optional[bytes]]: + reqs = [] + for ext, uri in zip(extension_kinds, uris): + reqs.append(EntityRequest(entity_uri=uri, query=[ExtensionQuery(extension_kind=ext),])) + + headers = CaseInsensitiveDict({"content-type": "application/x-protobuf"}) + response = self.send("POST", "/extended-metadata/v0/extended-metadata", + headers, BatchedEntityRequest(entity_request=reqs).SerializeToString()) + ApiClient.StatusCodeException.check_status(response) + + body = response.content + if body is None: + raise ConnectionError("Extended Metadata request for batch failed: No response body") + + proto = BatchedExtensionResponse() + proto.ParseFromString(body) + + mdbs: list[bytes] = [None]*len(uris) + for extension_kind in proto.extended_metadata: + for query_resp in extension_kind.extension_data: + uri = query_resp.entity_uri + status_code = query_resp.header.status_code + if status_code != 200: + # raise ConnectionError("Extended Metadata request for {} failed: Status code {}".format(uri, status_code)) + continue + mdbs[uris.index(uri)] = query_resp.extension_data.value + return mdbs + + def get_metadata_4_multiple(self, items: list[Id]) -> list: + if any(isinstance(item, PlaylistId) for item in items): + raise NotImplementedError("PlaylsitId cannot retreive metadata via batched endpoint") + + extension_kind_map = { + TrackId: ExtensionKind.TRACK_V4, + EpisodeId: ExtensionKind.EPISODE_V4, + AlbumId: ExtensionKind.ALBUM_V4, + ArtistId: ExtensionKind.ARTIST_V4, + ShowId: ExtensionKind.SHOW_V4, + } + + extension_kinds = [extension_kind_map[item.__class__] for item in items] + item_uris = [item.to_spotify_uri() for item in items] + mdbs = self.get_ext_metadata_batched(extension_kinds, item_uris) + + metadata_kind_map = { + TrackId: Metadata.Track, + EpisodeId: Metadata.Episode, + AlbumId: Metadata.Album, + ArtistId: Metadata.Artist, + ShowId: Metadata.Show, + } + + mds = [metadata_kind_map[item.__class__]() for item in items] + for md, mdb in zip(mds, mdbs): + if mdb: + md.ParseFromString(mdb) + return mds + def get_ext_metadata(self, extension_kind: ExtensionKind, uri: str): headers = CaseInsensitiveDict({"content-type": "application/x-protobuf"}) req = EntityRequest(entity_uri=uri, query=[ExtensionQuery(extension_kind=extension_kind),]) @@ -202,13 +261,13 @@ def get_ext_metadata(self, extension_kind: ExtensionKind, uri: str): body = response.content if body is None: - raise ConnectionError("Extended Metadata request failed: No response body") + raise ConnectionError("Extended Metadata request for {} failed: No response body".format(uri)) proto = BatchedExtensionResponse() proto.ParseFromString(body) entityextd = proto.extended_metadata.pop().extension_data.pop() if entityextd.header.status_code != 200: - raise ConnectionError("Extended Metadata request failed: Status code {}".format(entityextd.header.status_code)) + raise ConnectionError("Extended Metadata request for {} failed: Status code {}".format(uri, entityextd.header.status_code)) mdb: bytes = entityextd.extension_data.value return mdb @@ -267,24 +326,52 @@ def get_metadata_4_show(self, show: ShowId) -> Metadata.Show: md.ParseFromString(mdb) return md - def get_playlist(self, - _id: PlaylistId) -> Playlist4External.SelectedListContent: + def get_playlist(self, playlist: PlaylistId) -> Playlist4External.SelectedListContent: """ - :param _id: PlaylistId: + :param playlist: PlaylistId: """ - response = self.send("GET", - "/playlist/v2/playlist/{}".format(_id.id()), None, - None) + response = self.send("GET", "/playlist/v2/playlist/{}".format(playlist.id()), + None, None) ApiClient.StatusCodeException.check_status(response) + body = response.content if body is None: - raise IOError() + raise ConnectionError("Playlist Metadata request for {} failed: No response body".format(playlist.to_spotify_uri())) + proto = Playlist4External.SelectedListContent() proto.ParseFromString(body) return proto + def get_user_profile(self, username: str, playlist_limit: int = None, artist_limit: int = None) -> dict[str, typing.Any]: + """ + + :param user: str: + :param playlist_limit: int: (Default value = None) + :param artist_limit: int: (Default value = None) + + """ + + suffix = "/user-profile-view/v3/profile/{}".format(username) + if playlist_limit is not None or artist_limit is not None: + suffix += "?" + if playlist_limit is not None: + suffix += "playlist_limit={}".format(playlist_limit) + if artist_limit is not None: + suffix += "&" + if artist_limit is not None: + suffix += "artist_limit={}".format(artist_limit) + + response = self.send("GET", suffix, None, None) + ApiClient.StatusCodeException.check_status(response) + + body = response.content + if body is None: + raise ConnectionError("User Profile request for {} failed: No response body".format(username)) + + return response.json() + def set_client_token(self, client_token): """ @@ -1065,7 +1152,11 @@ def connect(self) -> None: acc.write_int(2 + 4 + len(client_hello_bytes)) acc.write(client_hello_bytes) # Read APResponseMessage - ap_response_message_length = self.connection.read_int() + try: + ap_response_message_length = self.connection.read_int() + except struct.error: + time.sleep(1) + ap_response_message_length = self.connection.read_int() acc.write_int(ap_response_message_length) ap_response_message_bytes = self.connection.read( ap_response_message_length - 4) @@ -1147,6 +1238,20 @@ def create_client(conf: Configuration) -> requests.Session: client = requests.Session() return client + def credentials(self) -> dict: + ap_welcome = self.ap_welcome() + reusable = ap_welcome.reusable_auth_credentials + reusable_type = Authentication.AuthenticationType.Name( + ap_welcome.reusable_auth_credentials_type) + return { + "username": + ap_welcome.canonical_username, + "credentials": + base64.b64encode(reusable).decode(), + "type": + reusable_type, + } + def dealer(self) -> DealerClient: """ """ self.__wait_auth_lock() @@ -1343,28 +1448,15 @@ def __authenticate_partial(self, self.__auth_lock_bool = False self.__auth_lock.notify_all() if self.__inner.conf.store_credentials: - reusable = self.__ap_welcome.reusable_auth_credentials - reusable_type = Authentication.AuthenticationType.Name( - self.__ap_welcome.reusable_auth_credentials_type) + self.__stored_str = base64.b64encode( + json.dumps(self.credentials()).encode() + ).decode() if self.__inner.conf.stored_credentials_file is None: raise TypeError( "The file path to be saved is not specified") - self.__stored_str = base64.b64encode( - json.dumps({ - "username": - self.__ap_welcome.canonical_username, - "credentials": - base64.b64encode(reusable).decode(), - "type": - reusable_type, - }).encode()).decode() with open(self.__inner.conf.stored_credentials_file, "w") as f: json.dump( - { - "username": self.__ap_welcome.canonical_username, - "credentials": base64.b64encode(reusable).decode(), - "type": reusable_type, - }, + self.credentials(), f, ) diff --git a/librespot/metadata.py b/librespot/metadata.py index a5e01e9..7b5cda5 100644 --- a/librespot/metadata.py +++ b/librespot/metadata.py @@ -5,279 +5,121 @@ import re -class SpotifyId: - STATIC_FROM_URI = "fromUri" - STATIC_FROM_BASE62 = "fromBase62" - STATIC_FROM_HEX = "fromHex" +class Id: + b62 = Base62.create_instance_with_inverted_character_set() + uri_pattern: str = None + mercury_pattern: str = None - @staticmethod - def from_base62(base62: str): - raise NotImplementedError - - @staticmethod - def from_hex(hex_str: str): - raise NotImplementedError - - @staticmethod - def from_uri(uri: str): - raise NotImplementedError - - def to_spotify_uri(self) -> str: - raise NotImplementedError - - class SpotifyIdParsingException(Exception): - pass - - -class PlayableId: - base62 = Base62.create_instance_with_inverted_character_set() - - @staticmethod - def from_uri(uri: str) -> PlayableId: - if not PlayableId.is_supported(uri): - return UnsupportedId(uri) - if TrackId.pattern.search(uri) is not None: - return TrackId.from_uri(uri) - if EpisodeId.pattern.search(uri) is not None: - return EpisodeId.from_uri(uri) - raise TypeError("Unknown uri: {}".format(uri)) - - @staticmethod - def is_supported(uri: str): - return (not uri.startswith("spotify:local:") - and not uri == "spotify:delimiter" - and not uri == "spotify:meta:delimiter") - - @staticmethod - def should_play(track: ContextTrack): - return track.metadata_or_default - - def get_gid(self) -> bytes: - raise NotImplementedError - - def hex_id(self) -> str: - raise NotImplementedError - - def to_spotify_uri(self) -> str: - raise NotImplementedError + def __init__(self, _id: str): + self.__id = _id + @classmethod + def from_base62(cls, base62: str) -> Id: + return cls(base62) -class PlaylistId(SpotifyId): - base62 = Base62.create_instance_with_inverted_character_set() - pattern = re.compile(r"spotify:playlist:(.{22})") - __id: str + @classmethod + def from_hex(cls, hex_str: str) -> Id: + return cls(cls.b62.encode(util.hex_to_bytes(hex_str)).decode()) - def __init__(self, _id: str): - self.__id = _id + @classmethod + def match_uri(cls, uri: str): + if not cls.uri_pattern: + raise NotImplementedError + return re.search(cls.uri_pattern + r":(.{22})", uri) - @staticmethod - def from_uri(uri: str) -> PlaylistId: - matcher = PlaylistId.pattern.search(uri) + @classmethod + def from_uri(cls, uri: str) -> Id: + matcher = cls.match_uri(uri) if matcher is not None: - playlist_id = matcher.group(1) - return PlaylistId(playlist_id) - raise TypeError("Not a Spotify playlist ID: {}.".format(uri)) + return cls(matcher.group(1)) + raise TypeError("Not a Spotify ID: {}.".format(uri)) def id(self) -> str: return self.__id - def to_spotify_uri(self) -> str: - return "spotify:playlist:" + self.__id - - -class UnsupportedId(PlayableId): - uri: str - - def __init__(self, uri: str): - self.uri = uri - def get_gid(self) -> bytes: - raise TypeError() + return self.b62.decode(self.__id.encode(), 16) def hex_id(self) -> str: - raise TypeError() + return util.bytes_to_hex(self.get_gid()).lower() def to_spotify_uri(self) -> str: - return self.uri - - -class AlbumId(SpotifyId): - base62 = Base62.create_instance_with_inverted_character_set() - pattern = re.compile(r"spotify:album:(.{22})") - __hex_id: str - - def __init__(self, hex_id: str): - self.__hex_id = hex_id.lower() - - @staticmethod - def from_uri(uri: str) -> AlbumId: - matcher = AlbumId.pattern.search(uri) - if matcher is not None: - album_id = matcher.group(1) - return AlbumId(util.bytes_to_hex(AlbumId.base62.decode(album_id.encode(), 16))) - raise TypeError("Not a Spotify album ID: {}.".format(uri)) - - @staticmethod - def from_base62(base62: str) -> AlbumId: - return AlbumId(util.bytes_to_hex(AlbumId.base62.decode(base62.encode(), 16))) - - @staticmethod - def from_hex(hex_str: str) -> AlbumId: - return AlbumId(hex_str) + if not self.uri_pattern: + raise NotImplementedError + return self.uri_pattern + ":" + self.__id def to_mercury_uri(self) -> str: - return "hm://metadata/4/album/{}".format(self.__hex_id) - - def hex_id(self) -> str: - return self.__hex_id - - def to_spotify_uri(self) -> str: - return "spotify:album:{}".format( - AlbumId.base62.encode(util.hex_to_bytes(self.__hex_id)).decode()) - - -class ArtistId(SpotifyId): - base62 = Base62.create_instance_with_inverted_character_set() - pattern = re.compile("spotify:artist:(.{22})") - __hex_id: str - - def __init__(self, hex_id: str): - self.__hex_id = hex_id.lower() + if not self.mercury_pattern: + raise NotImplementedError + return self.mercury_pattern + "/" + self.hex_id() - @staticmethod - def from_uri(uri: str) -> ArtistId: - matcher = ArtistId.pattern.search(uri) - if matcher is not None: - artist_id = matcher.group(1) - return ArtistId( - util.bytes_to_hex(ArtistId.base62.decode(artist_id.encode(), 16))) - raise TypeError("Not a Spotify artist ID: {}".format(uri)) - @staticmethod - def from_base62(base62: str) -> ArtistId: - return ArtistId(util.bytes_to_hex(ArtistId.base62.decode(base62.encode(), 16))) +class PlaylistId(Id): + uri_pattern = r"spotify:playlist" - @staticmethod - def from_hex(hex_str: str) -> ArtistId: - return ArtistId(hex_str) - def to_mercury_uri(self) -> str: - return "hm://metadata/4/artist/{}".format(self.__hex_id) +class AlbumId(Id): + uri_pattern = r"spotify:album" + mercury_pattern = "hm://metadata/4/album" - def to_spotify_uri(self) -> str: - return "spotify:artist:{}".format( - ArtistId.base62.encode(util.hex_to_bytes(self.__hex_id)).decode()) - def hex_id(self) -> str: - return self.__hex_id +class ArtistId(Id): + uri_pattern = r"spotify:artist" + mercury_pattern = "hm://metadata/4/artist" -class EpisodeId(SpotifyId, PlayableId): - pattern = re.compile(r"spotify:episode:(.{22})") - __hex_id: str +class ShowId(Id): + uri_pattern = r"spotify:show" + mercury_pattern = "hm://metadata/4/show" - def __init__(self, hex_id: str): - self.__hex_id = hex_id.lower() +class PlayableId: @staticmethod - def from_uri(uri: str) -> EpisodeId: - matcher = EpisodeId.pattern.search(uri) - if matcher is not None: - episode_id = matcher.group(1) - return EpisodeId( - util.bytes_to_hex(PlayableId.base62.decode(episode_id.encode(), 16))) - raise TypeError("Not a Spotify episode ID: {}".format(uri)) + def from_uri(uri: str) -> PlayableId: + if not PlayableId.is_supported(uri): + return UnsupportedId(uri) + if TrackId.match_uri(uri) is not None: + return TrackId.from_uri(uri) + if EpisodeId.match_uri(uri) is not None: + return EpisodeId.from_uri(uri) + raise TypeError("Unknown uri: {}".format(uri)) @staticmethod - def from_base62(base62: str) -> EpisodeId: - return EpisodeId( - util.bytes_to_hex(PlayableId.base62.decode(base62.encode(), 16))) + def is_supported(uri: str) -> bool: + return (not uri.startswith("spotify:local:") + and not uri == "spotify:delimiter" + and not uri == "spotify:meta:delimiter") @staticmethod - def from_hex(hex_str: str) -> EpisodeId: - return EpisodeId(hex_str) + def should_play(track: ContextTrack): + return track.metadata_or_default - def to_mercury_uri(self) -> str: - return "hm://metadata/4/episode/{}".format(self.__hex_id) - def to_spotify_uri(self) -> str: - return "Spotify:episode:{}".format( - PlayableId.base62.encode(util.hex_to_bytes(self.__hex_id)).decode()) +class UnsupportedId(PlayableId): + def __init__(self, uri: str): + self.uri = uri - def hex_id(self) -> str: - return self.__hex_id + def id(self) -> str: + raise TypeError() def get_gid(self) -> bytes: - return util.hex_to_bytes(self.__hex_id) - - -class ShowId(SpotifyId): - base62 = Base62.create_instance_with_inverted_character_set() - pattern = re.compile("spotify:show:(.{22})") - __hex_id: str - - def __init__(self, hex_id: str): - self.__hex_id = hex_id - - @staticmethod - def from_uri(uri: str) -> ShowId: - matcher = ShowId.pattern.search(uri) - if matcher is not None: - show_id = matcher.group(1) - return ShowId(util.bytes_to_hex(ShowId.base62.decode(show_id.encode(), 16))) - raise TypeError("Not a Spotify show ID: {}".format(uri)) - - @staticmethod - def from_base62(base62: str) -> ShowId: - return ShowId(util.bytes_to_hex(ShowId.base62.decode(base62.encode(), 16))) - - @staticmethod - def from_hex(hex_str: str) -> ShowId: - return ShowId(hex_str) - - def to_mercury_uri(self) -> str: - return "hm://metadata/4/show/{}".format(self.__hex_id) - - def to_spotify_uri(self) -> str: - return "spotify:show:{}".format( - ShowId.base62.encode(util.hex_to_bytes(self.__hex_id)).decode()) + raise TypeError() def hex_id(self) -> str: - return self.__hex_id - - -class TrackId(PlayableId, SpotifyId): - pattern = re.compile("spotify:track:(.{22})") - __hex_id: str - - def __init__(self, hex_id: str): - self.__hex_id = hex_id.lower() - - @staticmethod - def from_uri(uri: str) -> TrackId: - search = TrackId.pattern.search(uri) - if search is not None: - track_id = search.group(1) - return TrackId( - util.bytes_to_hex(PlayableId.base62.decode(track_id.encode(), 16))) - raise RuntimeError("Not a Spotify track ID: {}".format(uri)) - - @staticmethod - def from_base62(base62: str) -> TrackId: - return TrackId(util.bytes_to_hex(PlayableId.base62.decode(base62.encode(), 16))) + raise TypeError() - @staticmethod - def from_hex(hex_str: str) -> TrackId: - return TrackId(hex_str) + def to_spotify_uri(self) -> str: + return self.uri def to_mercury_uri(self) -> str: - return "hm://metadata/4/track/{}".format(self.__hex_id) + raise TypeError() - def to_spotify_uri(self) -> str: - return "spotify:track:{}".format(TrackId.base62.encode(util.hex_to_bytes(self.__hex_id)).decode()) - def hex_id(self) -> str: - return self.__hex_id +class TrackId(Id, PlayableId): + uri_pattern = r"spotify:track" + mercury_pattern = "hm://metadata/4/track" - def get_gid(self) -> bytes: - return util.hex_to_bytes(self.__hex_id) + +class EpisodeId(Id, PlayableId): + uri_pattern = r"spotify:episode" + mercury_pattern = "hm://metadata/4/episode" diff --git a/librespot/oauth.py b/librespot/oauth.py index 5b83f08..b80e961 100644 --- a/librespot/oauth.py +++ b/librespot/oauth.py @@ -31,12 +31,13 @@ class OAuth: __oauth_url_callback = None __success_page_content = None __listen_all_interfaces = False + __server_timeout = None def __init__(self, client_id, redirect_url, oauth_url_callback): self.__client_id = client_id self.__redirect_url = redirect_url self.__oauth_url_callback = oauth_url_callback - + def set_success_page_content(self, content): self.__success_page_content = content return self @@ -71,9 +72,14 @@ def set_listen_all(self, listen_all: bool): self.__listen_all_interfaces = listen_all return self + def set_timeout(self, timeout: int): + self.__server_timeout = timeout + return self + def ingest_token_response(self, result): self.__token = result["access_token"] - self.__refresh_token = result["refresh_token"] + if "refresh_token" in result: + self.__refresh_token = result["refresh_token"] if "expires_in" in result: self.__token_expires_at = datetime.now() + timedelta(seconds=result["expires_in"]) elif "expires_at" in result: @@ -121,6 +127,9 @@ def refresh_token(self): raise RuntimeError("Received status code %d: %s" % (response.status_code, response.reason)) return self.ingest_token_response(response.json()) + def has_token(self): + return bool(self.__token) + def token(self): if not self.__token: raise RuntimeError("You need to request a token bore!") @@ -150,11 +159,15 @@ def get_credentials(self): class CallbackServer(HTTPServer): callback_path = None - def __init__(self, server_address, RequestHandlerClass, callback_path, set_code, success_page_content): + def __init__(self, server_address, RequestHandlerClass, callback_path, set_code, success_page_content, timeout = None): self.callback_path = callback_path self.set_code = set_code self.success_page_content = success_page_content + self.timeout = timeout super().__init__(server_address, RequestHandlerClass) + + def handle_timeout(self): + raise TimeoutError(f"OAuth: Callback server timed out after {self.timeout} seconds") class CallbackRequestHandler(BaseHTTPRequestHandler): def do_GET(self): @@ -195,6 +208,7 @@ def run_callback_server(self): url.path, self.set_code, self.__success_page_content, + self.__server_timeout ) logging.info("OAuth: Waiting for callback on %s", url.hostname + ":" + str(url.port)) self.__start_server() @@ -205,6 +219,6 @@ def flow(self): self.request_token() return self.get_credentials() - def __close(self): + def close(self): if self.__server: self.__server.shutdown() diff --git a/setup.py b/setup.py index 1f3c5b5..b247e61 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ import setuptools setuptools.setup(name="librespot", - version="0.0.10", + version="0.0.15", description="Open Source Spotify Client", long_description=open("README.md").read(), long_description_content_type="text/markdown",