Format code with yapf
This commit fixes the style issues introduced in 2790f48
according to the output
from yapf.
Details: https://deepsource.io/gh/kokarare1212/librespot-python/transform/04a80a03-ea85-44b3-9159-2580bda68c1c/
This commit is contained in:
parent
2790f484c8
commit
0741dbdd43
|
@ -25,9 +25,7 @@ class Version:
|
|||
|
||||
@staticmethod
|
||||
def standard_build_info() -> BuildInfo:
|
||||
return BuildInfo(
|
||||
product=Product.PRODUCT_CLIENT,
|
||||
product_flags=[ProductFlags.PRODUCT_FLAG_NONE],
|
||||
platform=Version.platform(),
|
||||
version=112800721
|
||||
)
|
||||
return BuildInfo(product=Product.PRODUCT_CLIENT,
|
||||
product_flags=[ProductFlags.PRODUCT_FLAG_NONE],
|
||||
platform=Version.platform(),
|
||||
version=112800721)
|
||||
|
|
|
@ -190,7 +190,6 @@ class AbsChunkedInputStream(io.BytesIO, HaltListener):
|
|||
buffer.seek(0)
|
||||
return buffer.read()
|
||||
|
||||
|
||||
def notify_chunk_available(self, index: int) -> None:
|
||||
self.available_chunks()[index] = True
|
||||
self.__decoded_length += len(self.buffer()[index])
|
||||
|
@ -250,7 +249,10 @@ class AudioKeyManager(PacketsReceiver, Closeable):
|
|||
"Couldn't handle packet, cmd: {}, length: {}".format(
|
||||
packet.cmd, len(packet.payload)))
|
||||
|
||||
def get_audio_key(self, gid: bytes, file_id: bytes, retry: bool = True) -> bytes:
|
||||
def get_audio_key(self,
|
||||
gid: bytes,
|
||||
file_id: bytes,
|
||||
retry: bool = True) -> bytes:
|
||||
seq: int
|
||||
with self.__seq_holder_lock:
|
||||
seq = self.__seq_holder
|
||||
|
@ -294,14 +296,16 @@ class AudioKeyManager(PacketsReceiver, Closeable):
|
|||
self.__reference_lock.notify_all()
|
||||
|
||||
def error(self, code: int) -> None:
|
||||
self.__audio_key_manager.logger.fatal("Audio key error, code: {}".format(code))
|
||||
self.__audio_key_manager.logger.fatal(
|
||||
"Audio key error, code: {}".format(code))
|
||||
with self.__reference_lock:
|
||||
self.__reference.put(None)
|
||||
self.__reference_lock.notify_all()
|
||||
|
||||
def wait_response(self) -> bytes:
|
||||
with self.__reference_lock:
|
||||
self.__reference_lock.wait(AudioKeyManager.audio_key_request_timeout)
|
||||
self.__reference_lock.wait(
|
||||
AudioKeyManager.audio_key_request_timeout)
|
||||
return self.__reference.get(block=False)
|
||||
|
||||
|
||||
|
@ -313,9 +317,11 @@ class CdnFeedHelper:
|
|||
return random.choice(resp.cdnurl)
|
||||
|
||||
@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) -> PlayableContentFeeder.LoadedStream:
|
||||
def load_track(
|
||||
session: Session, track: Metadata.Track, file: Metadata.AudioFile,
|
||||
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse,
|
||||
str], preload: bool,
|
||||
halt_listener: HaltListener) -> PlayableContentFeeder.LoadedStream:
|
||||
if type(resp_or_url) is str:
|
||||
url = resp_or_url
|
||||
else:
|
||||
|
@ -333,15 +339,14 @@ class CdnFeedHelper:
|
|||
track,
|
||||
streamer,
|
||||
normalization_data,
|
||||
PlayableContentFeeder.Metrics(
|
||||
file.file_id, preload, -1 if preload else audio_key_time),
|
||||
PlayableContentFeeder.Metrics(file.file_id, preload,
|
||||
-1 if preload else audio_key_time),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def load_episode_external(
|
||||
session: Session, episode: Metadata.Episode,
|
||||
halt_listener: HaltListener
|
||||
) -> PlayableContentFeeder.LoadedStream:
|
||||
session: Session, episode: Metadata.Episode,
|
||||
halt_listener: HaltListener) -> PlayableContentFeeder.LoadedStream:
|
||||
resp = session.client().head(episode.external_url)
|
||||
|
||||
if resp.status_code != 200:
|
||||
|
@ -385,8 +390,7 @@ class CdnFeedHelper:
|
|||
episode,
|
||||
streamer,
|
||||
normalization_data,
|
||||
PlayableContentFeeder.Metrics(
|
||||
file.file_id, False, audio_key_time),
|
||||
PlayableContentFeeder.Metrics(file.file_id, False, audio_key_time),
|
||||
)
|
||||
|
||||
|
||||
|
@ -408,7 +412,9 @@ class CdnManager:
|
|||
raise IOError("Response body is empty!")
|
||||
return body
|
||||
|
||||
def stream_external_episode(self, episode: Metadata.Episode, external_url: str, halt_listener: HaltListener):
|
||||
def stream_external_episode(self, episode: Metadata.Episode,
|
||||
external_url: str,
|
||||
halt_listener: HaltListener):
|
||||
return CdnManager.Streamer(
|
||||
self.__session,
|
||||
StreamId(episode),
|
||||
|
@ -419,7 +425,8 @@ class CdnManager:
|
|||
halt_listener,
|
||||
)
|
||||
|
||||
def stream_file(self, file: Metadata.AudioFile, key: bytes, url: str, halt_listener: HaltListener):
|
||||
def stream_file(self, file: Metadata.AudioFile, key: bytes, url: str,
|
||||
halt_listener: HaltListener):
|
||||
return CdnManager.Streamer(
|
||||
self.__session,
|
||||
StreamId(file),
|
||||
|
@ -442,9 +449,11 @@ class CdnManager:
|
|||
proto.ParseFromString(body)
|
||||
if proto.result == StorageResolve.StorageResolveResponse.Result.CDN:
|
||||
url = random.choice(proto.cdnurl)
|
||||
self.logger.debug("Fetched CDN url for {}: {}".format(util.bytes_to_hex(file_id), url))
|
||||
self.logger.debug("Fetched CDN url for {}: {}".format(
|
||||
util.bytes_to_hex(file_id), url))
|
||||
return url
|
||||
raise CdnManager.CdnException("Could not retrieve CDN url! result: {}".format(proto.result))
|
||||
raise CdnManager.CdnException(
|
||||
"Could not retrieve CDN url! result: {}".format(proto.result))
|
||||
|
||||
class CdnException(Exception):
|
||||
pass
|
||||
|
@ -463,7 +472,8 @@ class CdnManager:
|
|||
__expiration: int
|
||||
url: str
|
||||
|
||||
def __init__(self, cdn_manager, file_id: typing.Union[bytes, None], url: str):
|
||||
def __init__(self, cdn_manager, file_id: typing.Union[bytes, None],
|
||||
url: str):
|
||||
self.__cdn_manager: CdnManager = cdn_manager
|
||||
self.__file_id = file_id
|
||||
self.set_url(url)
|
||||
|
@ -498,7 +508,8 @@ class CdnManager:
|
|||
break
|
||||
if expire_at is None:
|
||||
self.__expiration = -1
|
||||
self.__cdn_manager.logger.warning("Invalid __token__ in CDN url: {}".format(url))
|
||||
self.__cdn_manager.logger.warning(
|
||||
"Invalid __token__ in CDN url: {}".format(url))
|
||||
return
|
||||
self.__expiration = expire_at * 1000
|
||||
else:
|
||||
|
@ -529,16 +540,18 @@ class CdnManager:
|
|||
__session: Session
|
||||
__stream_id: StreamId
|
||||
|
||||
def __init__(self, session: Session, stream_id: StreamId, audio_format: SuperAudioFormat,
|
||||
cdn_url: CdnManager.CdnUrl, cache: CacheManager, audio_decrypt: AudioDecrypt,
|
||||
halt_listener: HaltListener):
|
||||
def __init__(self, session: Session, stream_id: StreamId,
|
||||
audio_format: SuperAudioFormat,
|
||||
cdn_url: CdnManager.CdnUrl, cache: CacheManager,
|
||||
audio_decrypt: AudioDecrypt, halt_listener: HaltListener):
|
||||
self.__session = session
|
||||
self.__stream_id = stream_id
|
||||
self.__audio_format = audio_format
|
||||
self.__audio_decrypt = audio_decrypt
|
||||
self.__cdn_url = cdn_url
|
||||
self.halt_listener = halt_listener
|
||||
response = self.request(range_start=0, range_end=ChannelManager.chunk_size - 1)
|
||||
response = self.request(range_start=0,
|
||||
range_end=ChannelManager.chunk_size - 1)
|
||||
content_range = response.headers.get("Content-Range")
|
||||
if content_range is None:
|
||||
raise IOError("Missing Content-Range header!")
|
||||
|
@ -549,7 +562,8 @@ class CdnManager:
|
|||
self.available = [False for _ in range(self.chunks)]
|
||||
self.requested = [False for _ in range(self.chunks)]
|
||||
self.buffer = [b"" for _ in range(self.chunks)]
|
||||
self.__internal_stream = CdnManager.Streamer.InternalStream(self, False)
|
||||
self.__internal_stream = CdnManager.Streamer.InternalStream(
|
||||
self, False)
|
||||
self.requested[0] = True
|
||||
self.write_chunk(first_chunk, 0, False)
|
||||
|
||||
|
@ -557,9 +571,11 @@ class CdnManager:
|
|||
cached: bool) -> None:
|
||||
if self.__internal_stream.is_closed():
|
||||
return
|
||||
self.__session.logger.debug("Chunk {}/{} completed, cached: {}, stream: {}"
|
||||
.format(chunk_index + 1, self.chunks, cached, self.describe()))
|
||||
self.buffer[chunk_index] = self.__audio_decrypt.decrypt_chunk(chunk_index, chunk)
|
||||
self.__session.logger.debug(
|
||||
"Chunk {}/{} completed, cached: {}, stream: {}".format(
|
||||
chunk_index + 1, self.chunks, cached, self.describe()))
|
||||
self.buffer[chunk_index] = self.__audio_decrypt.decrypt_chunk(
|
||||
chunk_index, chunk)
|
||||
self.__internal_stream.notify_chunk_available(chunk_index)
|
||||
|
||||
def stream(self) -> AbsChunkedInputStream:
|
||||
|
@ -590,7 +606,9 @@ class CdnManager:
|
|||
range_end = (chunk + 1) * ChannelManager.chunk_size - 1
|
||||
response = self.__session.client().get(
|
||||
self.__cdn_url.url,
|
||||
headers={"Range": "bytes={}-{}".format(range_start, range_end)},
|
||||
headers={
|
||||
"Range": "bytes={}-{}".format(range_start, range_end)
|
||||
},
|
||||
)
|
||||
if response.status_code != 206:
|
||||
raise IOError(response.status_code)
|
||||
|
@ -650,8 +668,9 @@ class NormalizationData:
|
|||
self.album_gain_db = album_gain_db
|
||||
self.album_peak = album_peak
|
||||
|
||||
self._LOGGER.debug("Loaded normalization data, track_gain: {}, track_peak: {}, album_gain: {}, album_peak: {}"
|
||||
.format(track_gain_db, track_peak, album_gain_db, album_peak))
|
||||
self._LOGGER.debug(
|
||||
"Loaded normalization data, track_gain: {}, track_peak: {}, album_gain: {}, album_peak: {}"
|
||||
.format(track_gain_db, track_peak, album_gain_db, album_peak))
|
||||
|
||||
@staticmethod
|
||||
def read(input_stream: io.BytesIO) -> NormalizationData:
|
||||
|
@ -659,11 +678,15 @@ class NormalizationData:
|
|||
data = input_stream.read(4 * 4)
|
||||
input_stream.seek(16)
|
||||
buffer = io.BytesIO(data)
|
||||
return NormalizationData(struct.unpack("<f", buffer.read(4))[0], struct.unpack("<f", buffer.read(4))[0],
|
||||
struct.unpack("<f", buffer.read(4))[0], struct.unpack("<f", buffer.read(4))[0])
|
||||
return NormalizationData(
|
||||
struct.unpack("<f", buffer.read(4))[0],
|
||||
struct.unpack("<f", buffer.read(4))[0],
|
||||
struct.unpack("<f", buffer.read(4))[0],
|
||||
struct.unpack("<f", buffer.read(4))[0])
|
||||
|
||||
def get_factor(self, normalisation_pregain) -> float:
|
||||
normalisation_factor = float(math.pow(10, (self.track_gain_db + normalisation_pregain) / 20))
|
||||
normalisation_factor = float(
|
||||
math.pow(10, (self.track_gain_db + normalisation_pregain) / 20))
|
||||
if normalisation_factor * self.track_peak > 1:
|
||||
self._LOGGER \
|
||||
.warning("Reducing normalisation factor to prevent clipping. Please add negative pregain to avoid.")
|
||||
|
@ -680,19 +703,23 @@ class PlayableContentFeeder:
|
|||
def __init__(self, session: Session):
|
||||
self.__session = session
|
||||
|
||||
def load(self, playable_id: PlayableId, audio_quality_picker: AudioQualityPicker,
|
||||
preload: bool, halt_listener: typing.Union[HaltListener, None]):
|
||||
def load(self, playable_id: PlayableId,
|
||||
audio_quality_picker: AudioQualityPicker, preload: bool,
|
||||
halt_listener: typing.Union[HaltListener, None]):
|
||||
if type(playable_id) is TrackId:
|
||||
return self.load_track(playable_id, audio_quality_picker, preload, halt_listener)
|
||||
return self.load_track(playable_id, audio_quality_picker, preload,
|
||||
halt_listener)
|
||||
|
||||
def load_stream(self, file: Metadata.AudioFile, track: Metadata.Track,
|
||||
episode: Metadata.Episode, preload: bool, halt_lister: HaltListener):
|
||||
episode: Metadata.Episode, preload: bool,
|
||||
halt_lister: HaltListener):
|
||||
if track is None and episode is None:
|
||||
raise RuntimeError()
|
||||
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_track(self.__session, track, file,
|
||||
response, preload, halt_lister)
|
||||
return CdnFeedHelper.load_episode(self.__session, episode, file,
|
||||
response, preload, halt_lister)
|
||||
elif response.result == StorageResolve.StorageResolveResponse.Result.STORAGE:
|
||||
|
@ -705,11 +732,13 @@ class PlayableContentFeeder:
|
|||
else:
|
||||
raise RuntimeError("Unknown result: {}".format(response.result))
|
||||
|
||||
def load_track(self, track_id_or_track: typing.Union[TrackId, Metadata.Track],
|
||||
def load_track(self, track_id_or_track: typing.Union[TrackId,
|
||||
Metadata.Track],
|
||||
audio_quality_picker: AudioQualityPicker, preload: bool,
|
||||
halt_listener: HaltListener):
|
||||
if type(track_id_or_track) is TrackId:
|
||||
original = self.__session.api().get_metadata_4_track(track_id_or_track)
|
||||
original = self.__session.api().get_metadata_4_track(
|
||||
track_id_or_track)
|
||||
track = self.pick_alternative_if_necessary(original)
|
||||
if track is None:
|
||||
raise RuntimeError("Cannot get alternative track")
|
||||
|
@ -717,10 +746,12 @@ class PlayableContentFeeder:
|
|||
track = track_id_or_track
|
||||
file = audio_quality_picker.get_file(track.file)
|
||||
if file is None:
|
||||
self.logger.fatal("Couldn't find any suitable audio file, available")
|
||||
self.logger.fatal(
|
||||
"Couldn't find any suitable audio file, available")
|
||||
return self.load_stream(file, track, None, preload, halt_listener)
|
||||
|
||||
def pick_alternative_if_necessary(self, track: Metadata.Track) -> typing.Union[Metadata.Track, None]:
|
||||
def pick_alternative_if_necessary(
|
||||
self, track: Metadata.Track) -> typing.Union[Metadata.Track, None]:
|
||||
if len(track.file) > 0:
|
||||
return track
|
||||
for alt in track.alternative:
|
||||
|
@ -752,8 +783,11 @@ class PlayableContentFeeder:
|
|||
preload: bool) -> StorageResolve.StorageResolveResponse:
|
||||
resp = self.__session.api().send(
|
||||
"GET",
|
||||
(self.storage_resolve_interactive_prefetch if preload else self.storage_resolve_interactive)
|
||||
.format(util.bytes_to_hex(file_id)), None, None,
|
||||
(self.storage_resolve_interactive_prefetch
|
||||
if preload else self.storage_resolve_interactive).format(
|
||||
util.bytes_to_hex(file_id)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(resp.status_code)
|
||||
|
@ -771,8 +805,10 @@ class PlayableContentFeeder:
|
|||
normalization_data: NormalizationData
|
||||
metrics: PlayableContentFeeder.Metrics
|
||||
|
||||
def __init__(self, track_or_episode: typing.Union[Metadata.Track, Metadata.Episode],
|
||||
input_stream: GeneralAudioStream, normalization_data: typing.Union[NormalizationData, None],
|
||||
def __init__(self, track_or_episode: typing.Union[Metadata.Track,
|
||||
Metadata.Episode],
|
||||
input_stream: GeneralAudioStream,
|
||||
normalization_data: typing.Union[NormalizationData, None],
|
||||
metrics: PlayableContentFeeder.Metrics):
|
||||
if type(track_or_episode) is Metadata.Track:
|
||||
self.track = track_or_episode
|
||||
|
@ -791,9 +827,10 @@ class PlayableContentFeeder:
|
|||
preloaded_audio_key: bool
|
||||
audio_key_time: int
|
||||
|
||||
def __init__(self, file_id: typing.Union[bytes, None], preloaded_audio_key: bool,
|
||||
audio_key_time: int):
|
||||
self.file_id = None if file_id is None else util.bytes_to_hex(file_id)
|
||||
def __init__(self, file_id: typing.Union[bytes, None],
|
||||
preloaded_audio_key: bool, audio_key_time: int):
|
||||
self.file_id = None if file_id is None else util.bytes_to_hex(
|
||||
file_id)
|
||||
self.preloaded_audio_key = preloaded_audio_key
|
||||
self.audio_key_time = audio_key_time
|
||||
if preloaded_audio_key and audio_key_time != -1:
|
||||
|
|
|
@ -27,9 +27,11 @@ class AudioQuality(enum.Enum):
|
|||
return AudioQuality.VERY_HIGH
|
||||
raise RuntimeError("Unknown format: {}".format(format))
|
||||
|
||||
def get_matches(self, files: typing.List[AudioFile]) -> typing.List[AudioFile]:
|
||||
def get_matches(self,
|
||||
files: typing.List[AudioFile]) -> typing.List[AudioFile]:
|
||||
file_list = []
|
||||
for file in files:
|
||||
if hasattr(file, "format") and AudioQuality.get_quality(file.format) == self:
|
||||
if hasattr(file, "format") and AudioQuality.get_quality(
|
||||
file.format) == self:
|
||||
file_list.append(file)
|
||||
return file_list
|
||||
|
|
|
@ -24,14 +24,16 @@ class AesAudioDecrypt(AudioDecrypt):
|
|||
iv = self.iv_int + int(ChannelManager.chunk_size * chunk_index / 16)
|
||||
start = time.time_ns()
|
||||
for i in range(0, len(buffer), 4096):
|
||||
cipher = AES.new(key=self.key, mode=AES.MODE_CTR,
|
||||
cipher = AES.new(key=self.key,
|
||||
mode=AES.MODE_CTR,
|
||||
counter=Counter.new(128, initial_value=iv))
|
||||
count = min(4096, len(buffer) - i)
|
||||
decrypted_buffer = cipher.decrypt(buffer[i:i + count])
|
||||
new_buffer.write(decrypted_buffer)
|
||||
if count != len(decrypted_buffer):
|
||||
raise RuntimeError("Couldn't process all data, actual: {}, expected: {}"
|
||||
.format(len(decrypted_buffer), count))
|
||||
raise RuntimeError(
|
||||
"Couldn't process all data, actual: {}, expected: {}".
|
||||
format(len(decrypted_buffer), count))
|
||||
iv += self.iv_diff
|
||||
self.decrypt_total_time += time.time_ns() - start
|
||||
self.decrypt_count += 1
|
||||
|
@ -39,4 +41,5 @@ class AesAudioDecrypt(AudioDecrypt):
|
|||
return new_buffer.read()
|
||||
|
||||
def decrypt_time_ms(self):
|
||||
return 0 if self.decrypt_count == 0 else int((self.decrypt_total_time / self.decrypt_count) / 1000000)
|
||||
return 0 if self.decrypt_count == 0 else int(
|
||||
(self.decrypt_total_time / self.decrypt_count) / 1000000)
|
||||
|
|
|
@ -50,21 +50,25 @@ class ChannelManager(Closeable, PacketsReceiver):
|
|||
chunk_id = struct.unpack(">H", payload.read(2))[0]
|
||||
channel = self.channels.get(chunk_id)
|
||||
if channel is None:
|
||||
self.logger.warning("Couldn't find channel, id: {}, received: {}"
|
||||
.format(chunk_id, len(packet.payload)))
|
||||
self.logger.warning(
|
||||
"Couldn't find channel, id: {}, received: {}".format(
|
||||
chunk_id, len(packet.payload)))
|
||||
return
|
||||
channel.add_to_queue(payload)
|
||||
elif packet.is_cmd(Packet.Type.channel_error):
|
||||
chunk_id = struct.unpack(">H", payload.read(2))[0]
|
||||
channel = self.channels.get(chunk_id)
|
||||
if channel is None:
|
||||
self.logger.warning("Dropping channel error, id: {}, code: {}"
|
||||
.format(chunk_id, struct.unpack(">H", payload.read(2))[0]))
|
||||
self.logger.warning(
|
||||
"Dropping channel error, id: {}, code: {}".format(
|
||||
chunk_id,
|
||||
struct.unpack(">H", payload.read(2))[0]))
|
||||
return
|
||||
channel.stream_error(struct.unpack(">H", payload.read(2))[0])
|
||||
else:
|
||||
self.logger.warning("Couldn't handle packet, cmd: {}, payload: {}"
|
||||
.format(packet.cmd, util.bytes_to_hex(packet.payload)))
|
||||
self.logger.warning(
|
||||
"Couldn't handle packet, cmd: {}, payload: {}".format(
|
||||
packet.cmd, util.bytes_to_hex(packet.payload)))
|
||||
|
||||
def close(self) -> None:
|
||||
self.executor_service.shutdown()
|
||||
|
@ -86,14 +90,16 @@ class ChannelManager(Closeable, PacketsReceiver):
|
|||
with self.channel_manager.seq_holder_lock:
|
||||
self.chunk_id = self.channel_manager.seq_holder
|
||||
self.channel_manager.seq_holder += 1
|
||||
self.channel_manager.executor_service.submit(lambda: ChannelManager.Channel.Handler(self))
|
||||
self.channel_manager.executor_service.submit(
|
||||
lambda: ChannelManager.Channel.Handler(self))
|
||||
|
||||
def _handle(self, payload: bytes) -> bool:
|
||||
if len(payload) == 0:
|
||||
if not self.__header:
|
||||
self.__file.write_chunk(payload, self.__chunk_index, False)
|
||||
return True
|
||||
self.channel_manager.logger.debug("Received empty chunk, skipping.")
|
||||
self.channel_manager.logger.debug(
|
||||
"Received empty chunk, skipping.")
|
||||
return False
|
||||
if self.__header:
|
||||
length: int
|
||||
|
@ -123,7 +129,10 @@ class ChannelManager(Closeable, PacketsReceiver):
|
|||
self.__channel = channel
|
||||
|
||||
def run(self) -> None:
|
||||
self.__channel.channel_manager.logger.debug("ChannelManager.Handler is starting")
|
||||
self.__channel.channel_manager.logger.debug(
|
||||
"ChannelManager.Handler is starting")
|
||||
with self.__channel.q.all_tasks_done:
|
||||
self.__channel.channel_manager.channels.pop(self.__channel.chunk_id)
|
||||
self.__channel.channel_manager.logger.debug("ChannelManager.Handler is shutting down")
|
||||
self.__channel.channel_manager.channels.pop(
|
||||
self.__channel.chunk_id)
|
||||
self.__channel.channel_manager.logger.debug(
|
||||
"ChannelManager.Handler is shutting down")
|
||||
|
|
|
@ -41,36 +41,51 @@ class ApiClient(Closeable):
|
|||
self.__session = session
|
||||
self.__base_url = "https://{}".format(ApResolver.get_random_spclient())
|
||||
|
||||
def build_request(self, method: str, suffix: str, headers: typing.Union[None, typing.Dict[str, str]],
|
||||
body: typing.Union[None, bytes]) -> requests.PreparedRequest:
|
||||
def build_request(
|
||||
self, method: str, suffix: str,
|
||||
headers: typing.Union[None, typing.Dict[str, str]],
|
||||
body: typing.Union[None, bytes]) -> requests.PreparedRequest:
|
||||
request = requests.PreparedRequest()
|
||||
request.method = method
|
||||
request.data = body
|
||||
request.headers = {}
|
||||
if headers is not None:
|
||||
request.headers = headers
|
||||
request.headers["Authorization"] = "Bearer {}".format(self.__session.tokens().get("playlist-read"))
|
||||
request.headers["Authorization"] = "Bearer {}".format(
|
||||
self.__session.tokens().get("playlist-read"))
|
||||
request.url = self.__base_url + suffix
|
||||
return request
|
||||
|
||||
def send(self, method: str, suffix: str, headers: typing.Union[None, typing.Dict[str, str]],
|
||||
body: typing.Union[None, bytes]) -> requests.Response:
|
||||
response = self.__session.client().send(self.build_request(method, suffix, headers, body))
|
||||
def send(self, method: str, suffix: str,
|
||||
headers: typing.Union[None, typing.Dict[str, str]],
|
||||
body: typing.Union[None, bytes]) -> requests.Response:
|
||||
response = self.__session.client().send(
|
||||
self.build_request(method, suffix, headers, body))
|
||||
return response
|
||||
|
||||
def put_connect_state(self, connection_id: str, proto: Connect.PutStateRequest) -> None:
|
||||
def put_connect_state(self, connection_id: str,
|
||||
proto: Connect.PutStateRequest) -> None:
|
||||
response = self.send(
|
||||
"PUT", "/connect-state/v1/devices/{}".format(self.__session.device_id()),
|
||||
{"Content-Type": "application/protobuf", "X-Spotify-Connection-Id": connection_id},
|
||||
"PUT",
|
||||
"/connect-state/v1/devices/{}".format(self.__session.device_id()),
|
||||
{
|
||||
"Content-Type": "application/protobuf",
|
||||
"X-Spotify-Connection-Id": connection_id
|
||||
},
|
||||
proto.SerializeToString(),
|
||||
)
|
||||
if response.status_code == 413:
|
||||
self.logger.warning("PUT state payload is too large: {} bytes uncompressed.".format(len(proto.SerializeToString())))
|
||||
self.logger.warning(
|
||||
"PUT state payload is too large: {} bytes uncompressed.".
|
||||
format(len(proto.SerializeToString())))
|
||||
elif response.status_code != 200:
|
||||
self.logger.warning("PUT state returned {}. headers: {}".format(response.status_code, response.headers))
|
||||
self.logger.warning("PUT state returned {}. headers: {}".format(
|
||||
response.status_code, response.headers))
|
||||
|
||||
def get_metadata_4_track(self, track: TrackId) -> Metadata.Track:
|
||||
response = self.send("GET", "/metadata/4/track/{}".format(track.hex_id()), None, None)
|
||||
response = self.send("GET",
|
||||
"/metadata/4/track/{}".format(track.hex_id()),
|
||||
None, None)
|
||||
ApiClient.StatusCodeException.check_status(response)
|
||||
body = response.content
|
||||
if body is None:
|
||||
|
@ -80,7 +95,9 @@ class ApiClient(Closeable):
|
|||
return proto
|
||||
|
||||
def get_metadata_4_episode(self, episode: EpisodeId) -> Metadata.Episode:
|
||||
response = self.send("GET", "/metadata/4/episode/{}".format(episode.hex_id()), None, None)
|
||||
response = self.send("GET",
|
||||
"/metadata/4/episode/{}".format(episode.hex_id()),
|
||||
None, None)
|
||||
ApiClient.StatusCodeException.check_status(response)
|
||||
body = response.content
|
||||
if body is None:
|
||||
|
@ -90,7 +107,9 @@ class ApiClient(Closeable):
|
|||
return proto
|
||||
|
||||
def get_metadata_4_album(self, album: AlbumId) -> Metadata.Album:
|
||||
response = self.send("GET", "/metadata/4/album/{}".format(album.hex_id()), None, None)
|
||||
response = self.send("GET",
|
||||
"/metadata/4/album/{}".format(album.hex_id()),
|
||||
None, None)
|
||||
ApiClient.StatusCodeException.check_status(response)
|
||||
|
||||
body = response.content
|
||||
|
@ -101,7 +120,9 @@ class ApiClient(Closeable):
|
|||
return proto
|
||||
|
||||
def get_metadata_4_artist(self, artist: ArtistId) -> Metadata.Artist:
|
||||
response = self.send("GET", "/metadata/4/artist/{}".format(artist.hex_id()), None, None)
|
||||
response = self.send("GET",
|
||||
"/metadata/4/artist/{}".format(artist.hex_id()),
|
||||
None, None)
|
||||
ApiClient.StatusCodeException.check_status(response)
|
||||
body = response.content
|
||||
if body is None:
|
||||
|
@ -111,7 +132,9 @@ class ApiClient(Closeable):
|
|||
return proto
|
||||
|
||||
def get_metadata_4_show(self, show: ShowId) -> Metadata.Show:
|
||||
response = self.send("GET", "/metadata/4/show/{}".format(show.hex_id()), None, None)
|
||||
response = self.send("GET",
|
||||
"/metadata/4/show/{}".format(show.hex_id()), None,
|
||||
None)
|
||||
ApiClient.StatusCodeException.check_status(response)
|
||||
body = response.content
|
||||
if body is None:
|
||||
|
@ -145,7 +168,8 @@ class ApResolver:
|
|||
Returns:
|
||||
The resulting object will be returned
|
||||
"""
|
||||
response = requests.get("{}?type={}".format(ApResolver.base_url, service_type))
|
||||
response = requests.get("{}?type={}".format(ApResolver.base_url,
|
||||
service_type))
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
|
@ -203,14 +227,19 @@ class EventService(Closeable):
|
|||
try:
|
||||
body = event_builder.to_array()
|
||||
resp = self.__session.mercury().send_sync(
|
||||
RawMercuryRequest.Builder().set_uri("hm://event-service/v1/events")
|
||||
.set_method("POST").add_user_field("Accept-Language", "en")
|
||||
.add_user_field("X-ClientTimeStamp", int(time.time() * 1000)).add_payload_part(body).build())
|
||||
self.logger.debug("Event sent. body: {}, result: {}".format(body, resp.status_code))
|
||||
RawMercuryRequest.Builder().set_uri(
|
||||
"hm://event-service/v1/events").set_method("POST").
|
||||
add_user_field("Accept-Language", "en").add_user_field(
|
||||
"X-ClientTimeStamp",
|
||||
int(time.time() * 1000)).add_payload_part(body).build())
|
||||
self.logger.debug("Event sent. body: {}, result: {}".format(
|
||||
body, resp.status_code))
|
||||
except IOError as ex:
|
||||
self.logger.error("Failed sending event: {} {}".format(event_builder, ex))
|
||||
self.logger.error("Failed sending event: {} {}".format(
|
||||
event_builder, ex))
|
||||
|
||||
def send_event(self, event_or_builder: typing.Union[GenericEvent, EventBuilder]):
|
||||
def send_event(self, event_or_builder: typing.Union[GenericEvent,
|
||||
EventBuilder]):
|
||||
if type(event_or_builder) is EventService.GenericEvent:
|
||||
builder = event_or_builder.build()
|
||||
elif type(event_or_builder) is EventService.EventBuilder:
|
||||
|
@ -258,7 +287,9 @@ class EventService(Closeable):
|
|||
s = ""
|
||||
self.body.write(s.encode())
|
||||
|
||||
def append(self, c: int = None, s: str = None) -> EventService.EventBuilder:
|
||||
def append(self,
|
||||
c: int = None,
|
||||
s: str = None) -> EventService.EventBuilder:
|
||||
if c is None and s is None or c is not None and s is not None:
|
||||
raise TypeError()
|
||||
if c is not None:
|
||||
|
@ -326,7 +357,8 @@ class Session(Closeable, SubListener):
|
|||
self.connection = Session.ConnectionHolder.create(address, None)
|
||||
self.__inner = inner
|
||||
self.__keys = DiffieHellman()
|
||||
self.logger.info("Created new session! device_id: {}, ap: {}".format(inner.device_id, address))
|
||||
self.logger.info("Created new session! device_id: {}, ap: {}".format(
|
||||
inner.device_id, address))
|
||||
|
||||
def api(self) -> ApiClient:
|
||||
self.__wait_auth_lock()
|
||||
|
@ -346,7 +378,8 @@ class Session(Closeable, SubListener):
|
|||
raise RuntimeError("Session isn't authenticated!")
|
||||
return self.__audio_key_manager
|
||||
|
||||
def authenticate(self, credential: Authentication.LoginCredentials) -> None:
|
||||
def authenticate(self,
|
||||
credential: Authentication.LoginCredentials) -> None:
|
||||
"""
|
||||
Log in to Spotify
|
||||
Args:
|
||||
|
@ -365,7 +398,8 @@ class Session(Closeable, SubListener):
|
|||
self.__event_service = EventService(self)
|
||||
self.__auth_lock_bool = False
|
||||
self.__auth_lock.notify_all()
|
||||
self.logger.info("Authenticated as {}!".format(self.__ap_welcome.canonical_username))
|
||||
self.logger.info("Authenticated as {}!".format(
|
||||
self.__ap_welcome.canonical_username))
|
||||
self.mercury().interested_in("spotify:user:attributes:update", self)
|
||||
|
||||
def cache(self) -> CacheManager:
|
||||
|
@ -393,7 +427,8 @@ class Session(Closeable, SubListener):
|
|||
"""
|
||||
Close instance
|
||||
"""
|
||||
self.logger.info("Closing session. device_id: {}".format(self.__inner.device_id))
|
||||
self.logger.info("Closing session. device_id: {}".format(
|
||||
self.__inner.device_id))
|
||||
self.__closing = True
|
||||
if self.__audio_key_manager is not None:
|
||||
self.__audio_key_manager = None
|
||||
|
@ -416,7 +451,8 @@ class Session(Closeable, SubListener):
|
|||
self.__ap_welcome = None
|
||||
self.cipher_pair = None
|
||||
self.__closed = True
|
||||
self.logger.info("Closed session. device_id: {}".format(self.__inner.device_id))
|
||||
self.logger.info("Closed session. device_id: {}".format(
|
||||
self.__inner.device_id))
|
||||
|
||||
def connect(self) -> None:
|
||||
"""
|
||||
|
@ -433,10 +469,7 @@ class Session(Closeable, SubListener):
|
|||
],
|
||||
login_crypto_hello=Keyexchange.LoginCryptoHelloUnion(
|
||||
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello(
|
||||
gc=self.__keys.public_key_bytes(),
|
||||
server_keys_known=1
|
||||
),
|
||||
),
|
||||
gc=self.__keys.public_key_bytes(), server_keys_known=1), ),
|
||||
padding=b"\x1e",
|
||||
)
|
||||
client_hello_bytes = client_hello_proto.SerializeToString()
|
||||
|
@ -450,22 +483,24 @@ class Session(Closeable, SubListener):
|
|||
# Read APResponseMessage
|
||||
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)
|
||||
ap_response_message_bytes = self.connection.read(
|
||||
ap_response_message_length - 4)
|
||||
acc.write(ap_response_message_bytes)
|
||||
ap_response_message_proto = Keyexchange.APResponseMessage()
|
||||
ap_response_message_proto.ParseFromString(ap_response_message_bytes)
|
||||
shared_key = util.int_to_bytes(
|
||||
self.__keys.compute_shared_key(
|
||||
ap_response_message_proto.challenge.login_crypto_challenge.diffie_hellman.gs
|
||||
)
|
||||
)
|
||||
ap_response_message_proto.challenge.login_crypto_challenge.
|
||||
diffie_hellman.gs))
|
||||
# Check gs_signature
|
||||
rsa = RSA.construct((int.from_bytes(self.__server_key, "big"), 65537))
|
||||
pkcs1_v1_5 = PKCS1_v1_5.new(rsa)
|
||||
sha1 = SHA1.new()
|
||||
sha1.update(ap_response_message_proto.challenge.login_crypto_challenge.diffie_hellman.gs)
|
||||
sha1.update(ap_response_message_proto.challenge.login_crypto_challenge.
|
||||
diffie_hellman.gs)
|
||||
if not pkcs1_v1_5.verify(
|
||||
sha1, ap_response_message_proto.challenge.login_crypto_challenge.diffie_hellman.gs_signature):
|
||||
sha1, ap_response_message_proto.challenge.
|
||||
login_crypto_challenge.diffie_hellman.gs_signature):
|
||||
raise RuntimeError("Failed signature check!")
|
||||
# Solve challenge
|
||||
buffer = io.BytesIO()
|
||||
|
@ -481,11 +516,12 @@ class Session(Closeable, SubListener):
|
|||
client_response_plaintext_proto = Keyexchange.ClientResponsePlaintext(
|
||||
crypto_response=Keyexchange.CryptoResponseUnion(),
|
||||
login_crypto_response=Keyexchange.LoginCryptoResponseUnion(
|
||||
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(hmac=challenge)
|
||||
),
|
||||
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(
|
||||
hmac=challenge)),
|
||||
pow_response=Keyexchange.PoWResponseUnion(),
|
||||
)
|
||||
client_response_plaintext_bytes = client_response_plaintext_proto.SerializeToString()
|
||||
client_response_plaintext_bytes = client_response_plaintext_proto.SerializeToString(
|
||||
)
|
||||
self.connection.write_int(4 + len(client_response_plaintext_bytes))
|
||||
self.connection.write(client_response_plaintext_bytes)
|
||||
self.connection.flush()
|
||||
|
@ -493,7 +529,8 @@ class Session(Closeable, SubListener):
|
|||
self.connection.set_timeout(1)
|
||||
scrap = self.connection.read(4)
|
||||
if len(scrap) == 4:
|
||||
payload = self.connection.read(struct.unpack(">i", scrap)[0] - 4)
|
||||
payload = self.connection.read(
|
||||
struct.unpack(">i", scrap)[0] - 4)
|
||||
failed = Keyexchange.APResponseMessage()
|
||||
failed.ParseFromString(payload)
|
||||
raise RuntimeError(failed)
|
||||
|
@ -522,7 +559,8 @@ class Session(Closeable, SubListener):
|
|||
return self.__inner.device_id
|
||||
|
||||
def get_user_attribute(self, key: str, fallback: str = None) -> str:
|
||||
return self.__user_attributes.get(key) if self.__user_attributes.get(key) is not None else fallback
|
||||
return self.__user_attributes.get(key) if self.__user_attributes.get(
|
||||
key) is not None else fallback
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
if self.__closed:
|
||||
|
@ -550,7 +588,8 @@ class Session(Closeable, SubListener):
|
|||
return
|
||||
for i in range(len(product)):
|
||||
self.__user_attributes[product[i].tag] = product[i].text
|
||||
self.logger.debug("Parsed product info: {}".format(self.__user_attributes))
|
||||
self.logger.debug("Parsed product info: {}".format(
|
||||
self.__user_attributes))
|
||||
|
||||
def reconnect(self) -> None:
|
||||
"""
|
||||
|
@ -559,7 +598,8 @@ class Session(Closeable, SubListener):
|
|||
if self.connection is not None:
|
||||
self.connection.close()
|
||||
self.__receiver.stop()
|
||||
self.connection = Session.ConnectionHolder.create(ApResolver.get_random_accesspoint(), self.__inner.conf)
|
||||
self.connection = Session.ConnectionHolder.create(
|
||||
ApResolver.get_random_accesspoint(), self.__inner.conf)
|
||||
self.connect()
|
||||
self.__authenticate_partial(
|
||||
Authentication.LoginCredentials(
|
||||
|
@ -569,7 +609,8 @@ class Session(Closeable, SubListener):
|
|||
),
|
||||
True,
|
||||
)
|
||||
self.logger.info("Re-authenticated as {}!".format(self.__ap_welcome.canonical_username))
|
||||
self.logger.info("Re-authenticated as {}!".format(
|
||||
self.__ap_welcome.canonical_username))
|
||||
|
||||
def reconnecting(self) -> bool:
|
||||
return not self.__closing and not self.__closed and self.connection is None
|
||||
|
@ -597,7 +638,9 @@ class Session(Closeable, SubListener):
|
|||
raise RuntimeError("Session isn't authenticated!")
|
||||
return self.__token_provider
|
||||
|
||||
def __authenticate_partial(self, credential: Authentication.LoginCredentials, remove_lock: bool) -> None:
|
||||
def __authenticate_partial(self,
|
||||
credential: Authentication.LoginCredentials,
|
||||
remove_lock: bool) -> None:
|
||||
"""
|
||||
Login to Spotify
|
||||
Args:
|
||||
|
@ -615,7 +658,9 @@ class Session(Closeable, SubListener):
|
|||
),
|
||||
version_string=Version.version_string(),
|
||||
)
|
||||
self.__send_unchecked(Packet.Type.login, client_response_encrypted_proto.SerializeToString())
|
||||
self.__send_unchecked(
|
||||
Packet.Type.login,
|
||||
client_response_encrypted_proto.SerializeToString())
|
||||
packet = self.cipher_pair.receive_encoded(self.connection)
|
||||
if packet.is_cmd(Packet.Type.ap_welcome):
|
||||
self.__ap_welcome = Authentication.APWelcome()
|
||||
|
@ -624,9 +669,11 @@ class Session(Closeable, SubListener):
|
|||
bytes0x0f = Random.get_random_bytes(0x14)
|
||||
self.__send_unchecked(Packet.Type.unknown_0x0f, bytes0x0f)
|
||||
preferred_locale = io.BytesIO()
|
||||
preferred_locale.write(b"\x00\x00\x10\x00\x02preferred-locale" + self.__inner.preferred_locale.encode())
|
||||
preferred_locale.write(b"\x00\x00\x10\x00\x02preferred-locale" +
|
||||
self.__inner.preferred_locale.encode())
|
||||
preferred_locale.seek(0)
|
||||
self.__send_unchecked(Packet.Type.preferred_locale, preferred_locale.read())
|
||||
self.__send_unchecked(Packet.Type.preferred_locale,
|
||||
preferred_locale.read())
|
||||
if remove_lock:
|
||||
with self.__auth_lock:
|
||||
self.__auth_lock_bool = False
|
||||
|
@ -636,13 +683,15 @@ class Session(Closeable, SubListener):
|
|||
reusable_type = Authentication.AuthenticationType.Name(
|
||||
self.__ap_welcome.reusable_auth_credentials_type)
|
||||
if self.__inner.conf.stored_credentials_file is None:
|
||||
raise TypeError("The file path to be saved is not specified")
|
||||
raise TypeError(
|
||||
"The file path to be saved is not specified")
|
||||
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,
|
||||
}, f)
|
||||
json.dump(
|
||||
{
|
||||
"username": self.__ap_welcome.canonical_username,
|
||||
"credentials": base64.b64encode(reusable).decode(),
|
||||
"type": reusable_type,
|
||||
}, f)
|
||||
|
||||
elif packet.is_cmd(Packet.Type.auth_failure):
|
||||
ap_login_failed = Keyexchange.APLoginFailed()
|
||||
|
@ -746,7 +795,8 @@ class Session(Closeable, SubListener):
|
|||
"""
|
||||
pass
|
||||
|
||||
def stored_file(self, stored_credentials: str = None) -> Session.Builder:
|
||||
def stored_file(self,
|
||||
stored_credentials: str = None) -> Session.Builder:
|
||||
"""
|
||||
Create credential from stored file
|
||||
Args:
|
||||
|
@ -765,7 +815,8 @@ class Session(Closeable, SubListener):
|
|||
else:
|
||||
try:
|
||||
self.login_credentials = Authentication.LoginCredentials(
|
||||
typ=Authentication.AuthenticationType.Value(obj["type"]),
|
||||
typ=Authentication.AuthenticationType.Value(
|
||||
obj["type"]),
|
||||
username=obj["username"],
|
||||
auth_data=base64.b64decode(obj["credentials"]),
|
||||
)
|
||||
|
@ -834,20 +885,20 @@ class Session(Closeable, SubListener):
|
|||
retry_on_chunk_error: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# proxy_enabled: bool,
|
||||
# proxy_type: Proxy.Type,
|
||||
# proxy_address: str,
|
||||
# proxy_port: int,
|
||||
# proxy_auth: bool,
|
||||
# proxy_username: str,
|
||||
# proxy_password: str,
|
||||
cache_enabled: bool,
|
||||
cache_dir: str,
|
||||
do_cache_clean_up: bool,
|
||||
store_credentials: bool,
|
||||
stored_credentials_file: str,
|
||||
retry_on_chunk_error: bool,
|
||||
self,
|
||||
# proxy_enabled: bool,
|
||||
# proxy_type: Proxy.Type,
|
||||
# proxy_address: str,
|
||||
# proxy_port: int,
|
||||
# proxy_auth: bool,
|
||||
# proxy_username: str,
|
||||
# proxy_password: str,
|
||||
cache_enabled: bool,
|
||||
cache_dir: str,
|
||||
do_cache_clean_up: bool,
|
||||
store_credentials: bool,
|
||||
stored_credentials_file: str,
|
||||
retry_on_chunk_error: bool,
|
||||
):
|
||||
# self.proxyEnabled = proxy_enabled
|
||||
# self.proxyType = proxy_type
|
||||
|
@ -880,7 +931,8 @@ class Session(Closeable, SubListener):
|
|||
|
||||
# Stored credentials
|
||||
store_credentials: bool = True
|
||||
stored_credentials_file: str = os.path.join(os.getcwd(), "credentials.json")
|
||||
stored_credentials_file: str = os.path.join(
|
||||
os.getcwd(), "credentials.json")
|
||||
|
||||
# Fetching
|
||||
retry_on_chunk_error: bool = True
|
||||
|
@ -919,7 +971,9 @@ class Session(Closeable, SubListener):
|
|||
# self.proxyPassword = proxy_password
|
||||
# return self
|
||||
|
||||
def set_cache_enabled(self, cache_enabled: bool) -> Session.Configuration.Builder:
|
||||
def set_cache_enabled(
|
||||
self,
|
||||
cache_enabled: bool) -> Session.Configuration.Builder:
|
||||
"""
|
||||
Set cache_enabled
|
||||
Args:
|
||||
|
@ -930,7 +984,8 @@ class Session(Closeable, SubListener):
|
|||
self.cache_enabled = cache_enabled
|
||||
return self
|
||||
|
||||
def set_cache_dir(self, cache_dir: str) -> Session.Configuration.Builder:
|
||||
def set_cache_dir(self,
|
||||
cache_dir: str) -> Session.Configuration.Builder:
|
||||
"""
|
||||
Set cache_dir
|
||||
Args:
|
||||
|
@ -941,7 +996,9 @@ class Session(Closeable, SubListener):
|
|||
self.cache_dir = cache_dir
|
||||
return self
|
||||
|
||||
def set_do_cache_clean_up(self, do_cache_clean_up: bool) -> Session.Configuration.Builder:
|
||||
def set_do_cache_clean_up(
|
||||
self,
|
||||
do_cache_clean_up: bool) -> Session.Configuration.Builder:
|
||||
"""
|
||||
Set do_cache_clean_up
|
||||
Args:
|
||||
|
@ -952,7 +1009,9 @@ class Session(Closeable, SubListener):
|
|||
self.do_cache_clean_up = do_cache_clean_up
|
||||
return self
|
||||
|
||||
def set_store_credentials(self, store_credentials: bool) -> Session.Configuration.Builder:
|
||||
def set_store_credentials(
|
||||
self,
|
||||
store_credentials: bool) -> Session.Configuration.Builder:
|
||||
"""
|
||||
Set store_credentials
|
||||
Args:
|
||||
|
@ -963,7 +1022,9 @@ class Session(Closeable, SubListener):
|
|||
self.store_credentials = store_credentials
|
||||
return self
|
||||
|
||||
def set_stored_credential_file(self, stored_credential_file: str) -> Session.Configuration.Builder:
|
||||
def set_stored_credential_file(
|
||||
self, stored_credential_file: str
|
||||
) -> Session.Configuration.Builder:
|
||||
"""
|
||||
Set stored_credential_file
|
||||
Args:
|
||||
|
@ -974,7 +1035,9 @@ class Session(Closeable, SubListener):
|
|||
self.stored_credentials_file = stored_credential_file
|
||||
return self
|
||||
|
||||
def set_retry_on_chunk_error(self, retry_on_chunk_error: bool) -> Session.Configuration.Builder:
|
||||
def set_retry_on_chunk_error(
|
||||
self, retry_on_chunk_error: bool
|
||||
) -> Session.Configuration.Builder:
|
||||
"""
|
||||
Set retry_on_chunk_error
|
||||
Args:
|
||||
|
@ -1111,18 +1174,19 @@ class Session(Closeable, SubListener):
|
|||
preferred_locale: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_type: Connect.DeviceType,
|
||||
device_name: str,
|
||||
preferred_locale: str,
|
||||
conf: Session.Configuration,
|
||||
device_id: str = None,
|
||||
self,
|
||||
device_type: Connect.DeviceType,
|
||||
device_name: str,
|
||||
preferred_locale: str,
|
||||
conf: Session.Configuration,
|
||||
device_id: str = None,
|
||||
):
|
||||
self.preferred_locale = preferred_locale
|
||||
self.conf = conf
|
||||
self.device_type = device_type
|
||||
self.device_name = device_name
|
||||
self.device_id = (device_id if device_id is not None else util.random_hex_string(40))
|
||||
self.device_id = (device_id if device_id is not None else
|
||||
util.random_hex_string(40))
|
||||
|
||||
class Receiver:
|
||||
__session: Session
|
||||
|
@ -1148,65 +1212,79 @@ class Session(Closeable, SubListener):
|
|||
packet: Packet
|
||||
cmd: bytes
|
||||
try:
|
||||
packet = self.__session.cipher_pair.receive_encoded(self.__session.connection)
|
||||
packet = self.__session.cipher_pair.receive_encoded(
|
||||
self.__session.connection)
|
||||
cmd = Packet.Type.parse(packet.cmd)
|
||||
if cmd is None:
|
||||
self.__session.logger.info(
|
||||
"Skipping unknown command cmd: 0x{}, payload: {}".
|
||||
format(util.bytes_to_hex(packet.cmd), packet.payload))
|
||||
format(util.bytes_to_hex(packet.cmd),
|
||||
packet.payload))
|
||||
continue
|
||||
except RuntimeError as ex:
|
||||
if self.__running:
|
||||
self.__session.logger.fatal("Failed reading packet! {}".format(ex))
|
||||
self.__session.logger.fatal(
|
||||
"Failed reading packet! {}".format(ex))
|
||||
self.__session.reconnect()
|
||||
break
|
||||
if not self.__running:
|
||||
break
|
||||
if cmd == Packet.Type.ping:
|
||||
if self.__session.scheduled_reconnect is not None:
|
||||
self.__session.scheduler.cancel(self.__session.scheduled_reconnect)
|
||||
self.__session.scheduler.cancel(
|
||||
self.__session.scheduled_reconnect)
|
||||
|
||||
def anonymous():
|
||||
self.__session.logger.warning("Socket timed out. Reconnecting...")
|
||||
self.__session.logger.warning(
|
||||
"Socket timed out. Reconnecting...")
|
||||
self.__session.reconnect()
|
||||
|
||||
self.__session.scheduled_reconnect = self.__session.scheduler.enter(2 * 60 + 5, 1, anonymous)
|
||||
self.__session.scheduled_reconnect = self.__session.scheduler.enter(
|
||||
2 * 60 + 5, 1, anonymous)
|
||||
self.__session.send(Packet.Type.pong, packet.payload)
|
||||
elif cmd == Packet.Type.pong_ack:
|
||||
continue
|
||||
elif cmd == Packet.Type.country_code:
|
||||
self.__session.country_code = packet.payload.decode()
|
||||
self.__session.logger.info("Received country_code: {}".format(self.__session.country_code))
|
||||
self.__session.logger.info(
|
||||
"Received country_code: {}".format(
|
||||
self.__session.country_code))
|
||||
elif cmd == Packet.Type.license_version:
|
||||
license_version = io.BytesIO(packet.payload)
|
||||
license_id = struct.unpack(">h", license_version.read(2))[0]
|
||||
license_id = struct.unpack(">h",
|
||||
license_version.read(2))[0]
|
||||
if license_id != 0:
|
||||
buffer = license_version.read()
|
||||
self.__session.logger.info(
|
||||
"Received license_version: {}, {}".format(license_id, buffer.decode()))
|
||||
"Received license_version: {}, {}".format(
|
||||
license_id, buffer.decode()))
|
||||
else:
|
||||
self.__session.logger.info("Received license_version: {}".format(license_id))
|
||||
self.__session.logger.info(
|
||||
"Received license_version: {}".format(license_id))
|
||||
elif cmd == Packet.Type.unknown_0x10:
|
||||
self.__session.logger.debug("Received 0x10: {}".format(util.bytes_to_hex(packet.payload)))
|
||||
self.__session.logger.debug("Received 0x10: {}".format(
|
||||
util.bytes_to_hex(packet.payload)))
|
||||
elif cmd in [
|
||||
Packet.Type.mercury_sub, Packet.Type.mercury_unsub,
|
||||
Packet.Type.mercury_event, Packet.Type.mercury_req
|
||||
Packet.Type.mercury_sub, Packet.Type.mercury_unsub,
|
||||
Packet.Type.mercury_event, Packet.Type.mercury_req
|
||||
]:
|
||||
self.__session.mercury().dispatch(packet)
|
||||
elif cmd in [Packet.Type.aes_key, Packet.Type.aes_key_error]:
|
||||
self.__session.audio_key().dispatch(packet)
|
||||
elif cmd in [
|
||||
Packet.Type.channel_error, Packet.Type.stream_chunk_res
|
||||
Packet.Type.channel_error, Packet.Type.stream_chunk_res
|
||||
]:
|
||||
self.__session.channel().dispatch(packet)
|
||||
elif cmd == Packet.Type.product_info:
|
||||
self.__session.parse_product_info(packet.payload)
|
||||
else:
|
||||
self.__session.logger.info("Skipping {}".format(util.bytes_to_hex(cmd)))
|
||||
self.__session.logger.info("Skipping {}".format(
|
||||
util.bytes_to_hex(cmd)))
|
||||
|
||||
class SpotifyAuthenticationException(Exception):
|
||||
def __init__(self, login_failed: Keyexchange.APLoginFailed):
|
||||
super().__init__(Keyexchange.ErrorCode.Name(login_failed.error_code))
|
||||
super().__init__(
|
||||
Keyexchange.ErrorCode.Name(login_failed.error_code))
|
||||
|
||||
|
||||
class TokenProvider:
|
||||
|
@ -1218,7 +1296,8 @@ class TokenProvider:
|
|||
def __init__(self, session: Session):
|
||||
self._session = session
|
||||
|
||||
def find_token_with_all_scopes(self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
|
||||
def find_token_with_all_scopes(
|
||||
self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
|
||||
for token in self.__tokens:
|
||||
if token.has_scopes(scopes):
|
||||
return token
|
||||
|
@ -1238,11 +1317,15 @@ class TokenProvider:
|
|||
else:
|
||||
return token
|
||||
self.logger.debug(
|
||||
"Token expired or not suitable, requesting again. scopes: {}, old_token: {}".format(scopes, token))
|
||||
"Token expired or not suitable, requesting again. scopes: {}, old_token: {}"
|
||||
.format(scopes, token))
|
||||
response = self._session.mercury().send_sync_json(
|
||||
MercuryRequests.request_token(self._session.device_id(), ",".join(scopes)))
|
||||
MercuryRequests.request_token(self._session.device_id(),
|
||||
",".join(scopes)))
|
||||
token = TokenProvider.StoredToken(response)
|
||||
self.logger.debug("Updated token successfully! scopes: {}, new_token: {}".format(scopes, token))
|
||||
self.logger.debug(
|
||||
"Updated token successfully! scopes: {}, new_token: {}".format(
|
||||
scopes, token))
|
||||
self.__tokens.append(token)
|
||||
return token
|
||||
|
||||
|
@ -1259,8 +1342,10 @@ class TokenProvider:
|
|||
self.scopes = obj["scope"]
|
||||
|
||||
def expired(self) -> bool:
|
||||
return (self.timestamp +
|
||||
(self.expires_in - TokenProvider.token_expire_threshold) * 1000 < int(time.time_ns() / 1000))
|
||||
return (
|
||||
self.timestamp +
|
||||
(self.expires_in - TokenProvider.token_expire_threshold) * 1000
|
||||
< int(time.time_ns() / 1000))
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
for s in self.scopes:
|
||||
|
|
|
@ -22,7 +22,8 @@ class CipherPair:
|
|||
self.__receive_cipher = Shannon()
|
||||
self.__receive_cipher.key(receive_key)
|
||||
|
||||
def send_encoded(self, connection: Session.ConnectionHolder, cmd: bytes, payload: bytes) -> None:
|
||||
def send_encoded(self, connection: Session.ConnectionHolder, cmd: bytes,
|
||||
payload: bytes) -> None:
|
||||
"""
|
||||
Send decrypted data to the socket
|
||||
:param connection:
|
||||
|
@ -57,7 +58,8 @@ class CipherPair:
|
|||
header_bytes = self.__receive_cipher.decrypt(connection.read(3))
|
||||
cmd = struct.pack(">s", bytes([header_bytes[0]]))
|
||||
payload_length = (header_bytes[1] << 8) | (header_bytes[2] & 0xff)
|
||||
payload_bytes = self.__receive_cipher.decrypt(connection.read(payload_length))
|
||||
payload_bytes = self.__receive_cipher.decrypt(
|
||||
connection.read(payload_length))
|
||||
mac = connection.read(4)
|
||||
expected_mac = self.__receive_cipher.finish(4)
|
||||
if mac != expected_mac:
|
||||
|
@ -78,7 +80,8 @@ class DiffieHellman:
|
|||
b'\x13\x9b"QJ\x08y\x8e4\x04\xdd\xef\x95\x19'
|
||||
b'\xb3\xcd:C\x1b0+\nm\xf2_\x147O\xe15mmQ\xc2'
|
||||
b'E\xe4\x85\xb5vb^~\xc6\xf4LB\xe9\xa6:6 \xff'
|
||||
b'\xff\xff\xff\xff\xff\xff\xff', byteorder="big")
|
||||
b'\xff\xff\xff\xff\xff\xff\xff',
|
||||
byteorder="big")
|
||||
__private_key: int
|
||||
__public_key: int
|
||||
|
||||
|
|
|
@ -9,5 +9,3 @@ import typing
|
|||
|
||||
if typing.TYPE_CHECKING:
|
||||
from librespot.core import Session
|
||||
|
||||
|
||||
|
|
|
@ -93,7 +93,8 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
if not dispatched:
|
||||
self.logger.debug(
|
||||
"Couldn't dispatch Mercury event seq: {}, uri: {}, code: {}, payload: {}"
|
||||
.format(seq, header.uri, header.status_code, response.payload))
|
||||
.format(seq, header.uri, header.status_code,
|
||||
response.payload))
|
||||
elif (packet.is_cmd(Packet.Type.mercury_req)
|
||||
or packet.is_cmd(Packet.Type.mercury_sub)
|
||||
or packet.is_cmd(Packet.Type.mercury_sub)):
|
||||
|
@ -103,8 +104,8 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
callback.response(response)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Skipped Mercury response, seq: {}, uri: {}, code: {}"
|
||||
.format(seq, response.uri, response.status_code))
|
||||
"Skipped Mercury response, seq: {}, uri: {}, code: {}".
|
||||
format(seq, response.uri, response.status_code))
|
||||
with self.__remove_callback_lock:
|
||||
self.__remove_callback_lock.notify_all()
|
||||
else:
|
||||
|
@ -113,7 +114,8 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
seq, header.uri, header.status_code))
|
||||
|
||||
def interested_in(self, uri: str, listener: SubListener) -> None:
|
||||
self.__subscriptions.append(MercuryClient.InternalSubListener(uri, listener, False))
|
||||
self.__subscriptions.append(
|
||||
MercuryClient.InternalSubListener(uri, listener, False))
|
||||
|
||||
def not_interested_in(self, listener: SubListener) -> None:
|
||||
try:
|
||||
|
@ -139,7 +141,8 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
seq = self.__seq_holder
|
||||
self.__seq_holder += 1
|
||||
self.logger.debug(
|
||||
"Send Mercury request, seq: {}, uri: {}, method: {}".format(seq, request.header.uri, request.header.method))
|
||||
"Send Mercury request, seq: {}, uri: {}, method: {}".format(
|
||||
seq, request.header.uri, request.header.method))
|
||||
buffer.write(struct.pack(">H", 4))
|
||||
buffer.write(struct.pack(">i", seq))
|
||||
buffer.write(b"\x01")
|
||||
|
@ -169,8 +172,9 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
try:
|
||||
response = callback.wait_response()
|
||||
if response is None:
|
||||
raise IOError("Request timeout out, {} passed, yet no response. seq: {}"
|
||||
.format(self.mercury_request_timeout, seq))
|
||||
raise IOError(
|
||||
"Request timeout out, {} passed, yet no response. seq: {}".
|
||||
format(self.mercury_request_timeout, seq))
|
||||
return response
|
||||
except queue.Empty as e:
|
||||
raise IOError(e)
|
||||
|
@ -195,9 +199,11 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
for payload in response.payload:
|
||||
sub = Pubsub.Subscription()
|
||||
sub.ParseFromString(payload)
|
||||
self.__subscriptions.append(MercuryClient.InternalSubListener(sub.uri, listener, True))
|
||||
self.__subscriptions.append(
|
||||
MercuryClient.InternalSubListener(sub.uri, listener, True))
|
||||
else:
|
||||
self.__subscriptions.append(MercuryClient.InternalSubListener(uri, listener, True))
|
||||
self.__subscriptions.append(
|
||||
MercuryClient.InternalSubListener(uri, listener, True))
|
||||
self.logger.debug("Subscribed successfully to {}!".format(uri))
|
||||
|
||||
def unsubscribe(self, uri) -> None:
|
||||
|
@ -263,7 +269,8 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
payload: typing.List[bytes]
|
||||
status_code: int
|
||||
|
||||
def __init__(self, header: Mercury.Header, payload: typing.List[bytes]):
|
||||
def __init__(self, header: Mercury.Header,
|
||||
payload: typing.List[bytes]):
|
||||
self.uri = header.uri
|
||||
self.status_code = header.status_code
|
||||
self.payload = payload[1:]
|
||||
|
@ -281,7 +288,8 @@ class MercuryClient(Closeable, PacketsReceiver):
|
|||
self.__reference.task_done()
|
||||
|
||||
def wait_response(self) -> typing.Any:
|
||||
return self.__reference.get(timeout=MercuryClient.mercury_request_timeout)
|
||||
return self.__reference.get(
|
||||
timeout=MercuryClient.mercury_request_timeout)
|
||||
|
||||
|
||||
class MercuryRequests:
|
||||
|
@ -298,7 +306,8 @@ class MercuryRequests:
|
|||
return JsonMercuryRequest(
|
||||
RawMercuryRequest.get(
|
||||
"hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}"
|
||||
.format(scope, MercuryRequests.keymaster_client_id, device_id)))
|
||||
.format(scope, MercuryRequests.keymaster_client_id,
|
||||
device_id)))
|
||||
|
||||
|
||||
class RawMercuryRequest:
|
||||
|
@ -311,15 +320,18 @@ class RawMercuryRequest:
|
|||
|
||||
@staticmethod
|
||||
def sub(uri: str):
|
||||
return RawMercuryRequest.new_builder().set_uri(uri).set_method("SUB").build()
|
||||
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||
"SUB").build()
|
||||
|
||||
@staticmethod
|
||||
def unsub(uri: str):
|
||||
return RawMercuryRequest.new_builder().set_uri(uri).set_method("UNSUB").build()
|
||||
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||
"UNSUB").build()
|
||||
|
||||
@staticmethod
|
||||
def get(uri: str):
|
||||
return RawMercuryRequest.new_builder().set_uri(uri).set_method("GET").build()
|
||||
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||
"GET").build()
|
||||
|
||||
@staticmethod
|
||||
def send(uri: str, part: bytes):
|
||||
|
|
|
@ -135,8 +135,7 @@ class ArtistId(SpotifyId):
|
|||
|
||||
@staticmethod
|
||||
def from_base62(base62: str) -> ArtistId:
|
||||
return ArtistId(util.bytes_to_hex(ArtistId.base62.decode(base62,
|
||||
16)))
|
||||
return ArtistId(util.bytes_to_hex(ArtistId.base62.decode(base62, 16)))
|
||||
|
||||
@staticmethod
|
||||
def from_hex(hex_str: str) -> ArtistId:
|
||||
|
@ -205,8 +204,7 @@ class ShowId(SpotifyId):
|
|||
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, 16)))
|
||||
return ShowId(util.bytes_to_hex(ShowId.base62.decode(show_id, 16)))
|
||||
raise TypeError("Not a Spotify show ID: {}".format(uri))
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -85,4 +85,3 @@ class PlayerConfiguration:
|
|||
self.initial_volume,
|
||||
self.volume_steps,
|
||||
)
|
||||
|
||||
|
|
|
@ -17,18 +17,24 @@ class VorbisOnlyAudioQuality(AudioQualityPicker):
|
|||
@staticmethod
|
||||
def get_vorbis_file(files: typing.List[Metadata.AudioFile]):
|
||||
for file in files:
|
||||
if hasattr(file, "format") and SuperAudioFormat.get(file.format) == SuperAudioFormat.VORBIS:
|
||||
if hasattr(file, "format") and SuperAudioFormat.get(
|
||||
file.format) == SuperAudioFormat.VORBIS:
|
||||
return file
|
||||
return None
|
||||
|
||||
def get_file(self, files: typing.List[Metadata.AudioFile]):
|
||||
matches: typing.List[Metadata.AudioFile] = self.preferred.get_matches(files)
|
||||
vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(matches)
|
||||
matches: typing.List[Metadata.AudioFile] = self.preferred.get_matches(
|
||||
files)
|
||||
vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(
|
||||
matches)
|
||||
if vorbis is None:
|
||||
vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(files)
|
||||
vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(
|
||||
files)
|
||||
if vorbis is not None:
|
||||
self.logger.warning("Using {} because preferred {} couldn't be found."
|
||||
.format(vorbis.format, self.preferred))
|
||||
self.logger.warning(
|
||||
"Using {} because preferred {} couldn't be found.".format(
|
||||
vorbis.format, self.preferred))
|
||||
else:
|
||||
self.logger.fatal("Couldn't find any Vorbis file, available: {}")
|
||||
self.logger.fatal(
|
||||
"Couldn't find any Vorbis file, available: {}")
|
||||
return vorbis
|
||||
|
|
|
@ -18,7 +18,8 @@ class AudioDecrypt:
|
|||
|
||||
|
||||
class AudioQualityPicker:
|
||||
def get_file(self, files: typing.List[Metadata.AudioFile]) -> Metadata.AudioFile:
|
||||
def get_file(self,
|
||||
files: typing.List[Metadata.AudioFile]) -> Metadata.AudioFile:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
|
|
|
@ -52,20 +52,24 @@ class Base62:
|
|||
return Base62(Base62.CharacterSets.inverted)
|
||||
|
||||
def encode(self, message: bytes, length: int = -1):
|
||||
indices = self.convert(message, self.standard_base, self.target_base, length)
|
||||
indices = self.convert(message, self.standard_base, self.target_base,
|
||||
length)
|
||||
return self.translate(indices, self.alphabet)
|
||||
|
||||
def decode(self, encoded: bytes, length: int = -1):
|
||||
prepared = self.translate(encoded, self.lookup)
|
||||
return self.convert(prepared, self.target_base, self.standard_base, length)
|
||||
return self.convert(prepared, self.target_base, self.standard_base,
|
||||
length)
|
||||
|
||||
def translate(self, indices: bytes, dictionary: bytes):
|
||||
translation = bytearray(len(indices))
|
||||
for i in range(len(indices)):
|
||||
translation[i] = dictionary[int.from_bytes(indices[i].encode(),"big")]
|
||||
translation[i] = dictionary[int.from_bytes(indices[i].encode(),
|
||||
"big")]
|
||||
return translation
|
||||
|
||||
def convert(self, message: bytes, source_base: int, target_base: int, length: int):
|
||||
def convert(self, message: bytes, source_base: int, target_base: int,
|
||||
length: int):
|
||||
estimated_length = self.estimate_output_length(
|
||||
len(message), source_base, target_base) if length == -1 else length
|
||||
out = b""
|
||||
|
@ -91,9 +95,11 @@ class Base62:
|
|||
return self.reverse(out[:estimated_length])
|
||||
return self.reverse(out)
|
||||
|
||||
def estimate_output_length(self, input_length: int, source_base: int, target_base: int):
|
||||
def estimate_output_length(self, input_length: int, source_base: int,
|
||||
target_base: int):
|
||||
return int(
|
||||
math.ceil((math.log(source_base) / math.log(target_base)) * input_length))
|
||||
math.ceil((math.log(source_base) / math.log(target_base)) *
|
||||
input_length))
|
||||
|
||||
def reverse(self, arr: bytes):
|
||||
length = len(arr)
|
||||
|
|
34
setup.py
34
setup.py
|
@ -1,18 +1,20 @@
|
|||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
name="librespot",
|
||||
version="0.0.1",
|
||||
description="Open Source Spotify Client",
|
||||
long_description=open("README.md").read(),
|
||||
long_description_content_type="text/markdown",
|
||||
author="kokarare1212",
|
||||
url="https://github.com/kokarare1212/librespot-python",
|
||||
license="Apache-2.0",
|
||||
packages=setuptools.find_packages("."),
|
||||
install_requires=["defusedxml", "protobuf", "pycryptodomex", "pyogg", "requests"],
|
||||
classifiers=[
|
||||
"Development Status :: 1 - Planning",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Multimedia :: Sound/Audio"
|
||||
])
|
||||
setuptools.setup(name="librespot",
|
||||
version="0.0.1",
|
||||
description="Open Source Spotify Client",
|
||||
long_description=open("README.md").read(),
|
||||
long_description_content_type="text/markdown",
|
||||
author="kokarare1212",
|
||||
url="https://github.com/kokarare1212/librespot-python",
|
||||
license="Apache-2.0",
|
||||
packages=setuptools.find_packages("."),
|
||||
install_requires=[
|
||||
"defusedxml", "protobuf", "pycryptodomex", "pyogg",
|
||||
"requests"
|
||||
],
|
||||
classifiers=[
|
||||
"Development Status :: 1 - Planning",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Multimedia :: Sound/Audio"
|
||||
])
|
||||
|
|
Loading…
Reference in New Issue