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