Skip to content
Open
2 changes: 1 addition & 1 deletion librespot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


class Version:
version_name = "0.0.10"
version_name = "0.0.15"

@staticmethod
def platform() -> Platform:
Expand Down
55 changes: 13 additions & 42 deletions librespot/audio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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!")
Expand All @@ -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],
Expand All @@ -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]:
Expand Down
146 changes: 119 additions & 27 deletions librespot/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),])
Expand All @@ -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

Expand Down Expand Up @@ -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):
"""

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
)

Expand Down
Loading
Loading