diff --git a/examples/player/main.py b/examples/player/main.py deleted file mode 100644 index 384806e..0000000 --- a/examples/player/main.py +++ /dev/null @@ -1,154 +0,0 @@ -import os -import platform -import re -import subprocess -import time - -import requests - -from librespot.audio.decoders import AudioQuality -from librespot.core import Session -from librespot.metadata import TrackId -from librespot.player.codecs import VorbisOnlyAudioQuality - -quality: AudioQuality = AudioQuality.VERY_HIGH -session: Session = None - - -def clear(): - if platform.system() == "Windows": - os.system("cls") - else: - os.system("clear") - - -def client(): - global quality, session - while True: - clear() - splash() - cmd = input("Player >>> ") - args = cmd.split(" ") - if args[0] == "exit" or args[0] == "quit": - return - if (args[0] == "p" or args[0] == "play") and len(args) == 2: - track_uri_search = re.search( - r"^spotify:track:(?P[0-9a-zA-Z]{22})$", args[1]) - track_url_search = re.search( - r"^(https?://)?open\.spotify\.com/track/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$", - args[1], - ) - if track_uri_search is not None or track_url_search is not None: - track_id_str = (track_uri_search - if track_uri_search is not None else - track_url_search).group("TrackID") - play(track_id_str) - wait() - if args[0] == "q" or args[0] == "quality": - if len(args) == 1: - print("Current Quality: " + quality.name) - wait() - elif len(args) == 2: - if args[1] == "normal" or args[1] == "96": - quality = AudioQuality.NORMAL - elif args[1] == "high" or args[1] == "160": - quality = AudioQuality.HIGH - elif args[1] == "veryhigh" or args[1] == "320": - quality = AudioQuality.VERY_HIGH - print("Set Quality to %s" % quality.name) - wait() - if (args[0] == "s" or args[0] == "search") and len(args) >= 2: - token = session.tokens().get("user-read-email") - resp = requests.get( - "https://api.spotify.com/v1/search", - { - "limit": "5", - "offset": "0", - "q": cmd[2:], - "type": "track" - }, - headers={"Authorization": "Bearer %s" % token}, - ) - i = 1 - tracks = resp.json()["tracks"]["items"] - for track in tracks: - print("%d, %s | %s" % ( - i, - track["name"], - ",".join([artist["name"] for artist in track["artists"]]), - )) - i += 1 - position = -1 - while True: - num_str = input("Select [1-5]: ") - if num_str == "exit" or num_str == "quit": - return - try: - num = int(num_str) - except ValueError: - continue - if num in range(1, 5, 1): - position = num - 1 - break - play(tracks[position]["id"]) - wait() - - -def login(): - global session - - if os.path.isfile("credentials.json"): - try: - session = Session.Builder().stored_file().create() - return - except RuntimeError: - pass - while True: - user_name = input("UserName: ") - password = input("Password: ") - try: - session = Session.Builder().user_pass(user_name, password).create() - return - except RuntimeError: - pass - - -def play(track_id_str: str): - track_id = TrackId.from_base62(track_id_str) - stream = session.content_feeder().load(track_id, - VorbisOnlyAudioQuality(quality), - False, None) - ffplay = subprocess.Popen( - ["ffplay", "-"], - stdin=subprocess.PIPE, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - while True: - byte = stream.input_stream.stream().read() - if byte == -1: - return - ffplay.stdin.write(bytes([byte])) - - -def splash(): - print("=================================\n" - "| Librespot-Python Player |\n" - "| |\n" - "| by kokarare1212 |\n" - "=================================\n\n\n") - - -def main(): - login() - client() - - -def wait(seconds: int = 3): - for i in range(seconds)[::-1]: - print("\rWait for %d second(s)..." % (i + 1), end="") - time.sleep(1) - - -if __name__ == "__main__": - main() diff --git a/examples/server/main.py b/examples/server/main.py deleted file mode 100644 index f026335..0000000 --- a/examples/server/main.py +++ /dev/null @@ -1,122 +0,0 @@ -import os -import re -import socket -import threading - -from librespot.audio.decoders import AudioQuality -from librespot.core import Session -from librespot.metadata import TrackId -from librespot.player.codecs import VorbisOnlyAudioQuality - -session: Session -sock: socket - - -def handler(client: socket.socket, address: str): - req_raw = client.recv(1024 * 1024) - if len(req_raw) == 0: - return - req_arr = req_raw.split(b"\r\n") - req_http_raw = req_arr[0] - req_header_str = req_raw.split(b"\r\n\r\n")[0] - req_body_str = req_raw.split(b"\r\n\r\n")[1] - req_http_arr = req_http_raw.split(b" ") - req_method = req_http_arr[0] - req_uri = req_http_arr[1] - req_http_version = req_http_arr[2] - req_header = {} - for header in req_header_str.split(b"\r\n"): - try: - key, value = header.split(b": ") - except ValueError: - continue - else: - req_header[key.decode().lower()] = value.decode() - status, headers, content, manually = response(client, req_uri.decode(), - req_header, req_body_str) - if not manually: - client.send(req_http_version + b" " + status.encode() + b"\r\n") - client.send(b"Access-Control-Allow-Origin: *\r\n") - for header in headers: - client.send(header.encode() + "\r\n") - client.send(b"\r\n") - client.send(content) - client.close() - - -class HttpCode: - http_200 = "200 OK" - http_204 = "204 No Content" - http_400 = "400 Bad Request" - http_403 = "403 Forbidden" - http_404 = "404 Not Found" - http_500 = "500 Internal Server Error" - - -def main(): - global session, sock - session = None - if os.path.isfile("credentials.json"): - try: - session = Session.Builder().stored_file().create() - except RuntimeError: - pass - if session is None or not session.is_valid(): - username = input("Username: ") - password = input("Password: ") - session = Session.Builder().user_pass(username, password).create() - if not session.is_valid(): - return - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(("127.0.0.1", 8080)) - sock.listen(5) - while True: - threading.Thread(target=handler, args=sock.accept()).start() - - -def response(client: socket.socket, uri: str, header: dict, - body: bytes) -> tuple[str, list, bytes, bool]: - if re.search(r"^/audio/track/([0-9a-zA-Z]{22})$", uri) is not None: - track_id_search = re.search( - r"^/audio/track/(?P[0-9a-zA-Z]{22})$", uri) - track_id_str = track_id_search.group("TrackID") - track_id = TrackId.from_base62(track_id_str) - stream = session.content_feeder().load( - track_id, VorbisOnlyAudioQuality(AudioQuality.VERY_HIGH), False, - None) - start = 0 - end = stream.input_stream.stream().size() - if header.get("range") is not None: - range_search = re.search( - "^bytes=(?P[0-9]+?)-(?P[0-9]+?)$", - header.get("range")) - if range_search is not None: - start = int(range_search.group("start")) - end = (int(range_search.group("end")) - if int(range_search.group("end")) <= - stream.input_stream.stream().size() else - stream.input_stream.stream().size()) - stream.input_stream.stream().skip(start) - client.send(b"HTTP/1.0 200 OK\r\n") - client.send(b"Access-Control-Allow-Origin: *\r\n") - client.send(b"Content-Length: " + - (str(stream.input_stream.stream().size()).encode() if - stream.input_stream.stream().size() == end else "{}-{}/{}" - .format(start, end, - stream.input_stream.stream().size()).encode()) + - b"\r\n") - client.send(b"Content-Type: audio/ogg\r\n") - client.send(b"\r\n") - while True: - if (stream.input_stream.stream().pos() >= - stream.input_stream.stream().size()): - break - byte = stream.input_stream.stream().read() - client.send(bytes([byte])) - return "", [], b"", True - else: - return HttpCode.http_404, [], HttpCode.http_404.encode(), False - - -if __name__ == "__main__": - main() diff --git a/librespot/Version.py b/librespot/Version.py index 56615c7..4eb54c1 100644 --- a/librespot/Version.py +++ b/librespot/Version.py @@ -3,7 +3,7 @@ import platform class Version: - version = "0.0.1" + version_name = "0.0.1" @staticmethod def platform() -> Platform: @@ -15,16 +15,19 @@ class Version: @staticmethod def version_string(): - return "librespot-python " + Version.version + return "librespot-python " + Version.version_name @staticmethod def system_info_string(): - return Version.version_string( - ) + "; Python " + platform.python_version() + "; " + platform.system() + return Version.version_string() + \ + "; Python " + platform.python_version() + \ + "; " + platform.system() @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 + ) diff --git a/librespot/ZeroconfServer.py b/librespot/ZeroconfServer.py deleted file mode 100644 index 19f111f..0000000 --- a/librespot/ZeroconfServer.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import concurrent.futures -import random -import socket - -from zeroconf import ServiceBrowser -from zeroconf import ServiceInfo -from zeroconf import Zeroconf - -from librespot.common import Utils -from librespot.core import Session -from librespot.crypto import DiffieHellman -from librespot.proto import Connect_pb2 as Connect -from librespot.standard import Closeable -from librespot.standard import Runnable - - -class ZeroconfServer(Closeable): - SERVICE = "spotify-connect" - __MAX_PORT = 65536 - __MIN_PORT = 1024 - __EOL = "\r\n" - __keys: DiffieHellman - __inner: ZeroconfServer.Inner - - def __init__(self, inner: ZeroconfServer.Inner, listen_port: int, - listen_all: bool): - self.__inner = inner - self.__keys = DiffieHellman() - - if listen_port == -1: - listen_port = random.randint(self.__MIN_PORT, self.__MAX_PORT) - - class Builder(Session.AbsBuilder): - __listenAll = False - __listenPort = -1 - - def __init__(self, conf: Session.Configuration): - super().__init__(conf) - - def set_listen_all(self, listen_all: bool) -> ZeroconfServer.Builder: - self.__listenAll = listen_all - return self - - def set_listen_port(self, listen_port: int) -> ZeroconfServer.Builder: - self.__listenPort = listen_port - return self - - def create(self) -> ZeroconfServer: - return ZeroconfServer( - ZeroconfServer.Inner( - self.device_type, - self.device_name, - self.preferred_locale, - self.conf, - self.device_id, - ), - self.__listenPort, - self.__listenAll, - ) - - class Inner: - device_type: Connect.DeviceType - device_name: str - device_id: str - preferred_locale: str - conf = None - - def __init__( - 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 - Utils.random_hex_string(40)) - - class HttpRunner(Runnable, Closeable): - __sock: socket - __executorService: concurrent.futures.ThreadPoolExecutor = ( - concurrent.futures.ThreadPoolExecutor()) - __shouldStop: bool = False - - def __init__(self, port: int): - self.__sock = socket.socket() - self.__sock.bind(("0.0.0.0", port)) - self.__sock.listen(1) - - def run(self) -> None: - while not self.__shouldStop: - client, address = self.__sock.accept() - - def anonymous(): - self.__handle(client) - client.close() - - self.__executorService.submit(anonymous) - - def __handle(self, client: socket.socket): - client.recv(1) - - def close(self) -> None: - super().close() diff --git a/librespot/__init__.py b/librespot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/librespot/api/__init__.py b/librespot/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/librespot/audio/AbsChunkedInputStream.py b/librespot/audio/AbsChunkedInputStream.py deleted file mode 100644 index a69bd7e..0000000 --- a/librespot/audio/AbsChunkedInputStream.py +++ /dev/null @@ -1,227 +0,0 @@ -import math -import threading -import time -import typing - -from librespot.audio.HaltListener import HaltListener -from librespot.standard.InputStream import InputStream - - -class AbsChunkedInputStream(InputStream, HaltListener): - preload_ahead: typing.Final[int] = 3 - preload_chunk_retries: typing.Final[int] = 2 - max_chunk_tries: typing.Final[int] = 128 - wait_lock: threading.Condition = threading.Condition() - retries: typing.List[int] - retry_on_chunk_error: bool - chunk_exception = None - wait_for_chunk: int = -1 - _pos: int = 0 - _mark: int = 0 - closed: bool = False - _decoded_length: int = 0 - - def __init__(self, retry_on_chunk_error: bool): - self.retries: typing.Final[typing.List[int]] = [ - 0 for _ in range(self.chunks()) - ] - self.retry_on_chunk_error = retry_on_chunk_error - - def is_closed(self) -> bool: - return self.closed - - def buffer(self) -> typing.List[bytearray]: - raise NotImplementedError() - - def size(self) -> int: - raise NotImplementedError() - - def close(self) -> None: - self.closed = True - - with self.wait_lock: - self.wait_lock.notify_all() - - def available(self): - return self.size() - self._pos - - def mark_supported(self) -> bool: - return True - - def mark(self, read_ahead_limit: int) -> None: - self._mark = self._pos - - def reset(self) -> None: - self._pos = self._mark - - def pos(self) -> int: - return self._pos - - def seek(self, where: int) -> None: - if where < 0: - raise TypeError() - if self.closed: - raise IOError("Stream is closed!") - self._pos = where - - self.check_availability(int(self._pos / (128 * 1024)), False, False) - - def skip(self, n: int) -> int: - if n < 0: - raise TypeError() - if self.closed: - raise IOError("Stream is closed!") - - k = self.size() - self._pos - if n < k: - k = n - self._pos += k - - chunk = int(self._pos / (128 * 1024)) - self.check_availability(chunk, False, False) - - return k - - def requested_chunks(self) -> typing.List[bool]: - raise NotImplementedError() - - def available_chunks(self) -> typing.List[bool]: - raise NotImplementedError() - - def chunks(self) -> int: - raise NotImplementedError() - - def request_chunk_from_stream(self, index: int) -> None: - raise NotImplementedError() - - def should_retry(self, chunk: int) -> bool: - if self.retries[chunk] < 1: - return True - if self.retries[chunk] > self.max_chunk_tries: - return False - return self.retry_on_chunk_error - - def check_availability(self, chunk: int, wait: bool, halted: bool) -> None: - if halted and not wait: - raise TypeError() - - if not self.requested_chunks()[chunk]: - self.request_chunk_from_stream(chunk) - self.requested_chunks()[chunk] = True - - for i in range(chunk + 1, - min(self.chunks() - 1, chunk + self.preload_ahead) + 1): - if (self.requested_chunks()[i] - and self.retries[i] < self.preload_chunk_retries): - self.request_chunk_from_stream(i) - self.requested_chunks()[chunk] = True - - if wait: - if self.available_chunks()[chunk]: - return - - retry = False - with self.wait_lock: - if not halted: - self.stream_read_halted(chunk, int(time.time() * 1000)) - - self.chunk_exception = None - self.wait_for_chunk = chunk - self.wait_lock.wait() - - if self.closed: - return - - if self.chunk_exception is not None: - if self.should_retry(chunk): - retry = True - else: - raise AbsChunkedInputStream.ChunkException - - if not retry: - self.stream_read_halted(chunk, int(time.time() * 1000)) - - if retry: - time.sleep(math.log10(self.retries[chunk])) - - self.check_availability(chunk, True, True) - - def read(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> int: - if b is None and offset is None and length is None: - return self.internal_read() - if not (b is not None and offset is not None and length is not None): - raise TypeError() - - if self.closed: - raise IOError("Stream is closed!") - - if offset < 0 or length < 0 or length > len(b) - offset: - raise IndexError("offset: {}, length: {}, buffer: {}".format( - offset, length, len(b))) - elif length == 0: - return 0 - - if self._pos >= self.size(): - return -1 - - i = 0 - while True: - chunk = int(self._pos / (128 * 1024)) - chunk_off = int(self._pos % (128 * 1024)) - - self.check_availability(chunk, True, False) - - copy = min(len(self.buffer()[chunk]) - chunk_off, length - i) - b[offset + 0:copy] = self.buffer()[chunk][chunk_off:chunk_off + - copy] - i += copy - self._pos += copy - - if i == length or self._pos >= self.size(): - return i - - def internal_read(self) -> int: - if self.closed: - raise IOError("Stream is closed!") - - if self._pos >= self.size(): - return -1 - - chunk = int(self._pos / (128 * 1024)) - self.check_availability(chunk, True, False) - - b = self.buffer()[chunk][self._pos % (128 * 1024)] - self._pos = self._pos + 1 - return b - - def notify_chunk_available(self, index: int) -> None: - self.available_chunks()[index] = True - self._decoded_length += len(self.buffer()[index]) - - with self.wait_lock: - if index == self.wait_for_chunk and not self.closed: - self.wait_for_chunk = -1 - self.wait_lock.notify_all() - - def notify_chunk_error(self, index: int, ex): - self.available_chunks()[index] = False - self.requested_chunks()[index] = False - self.retries[index] += 1 - - with self.wait_lock: - if index == self.wait_for_chunk and not self.closed: - self.chunk_exception = ex - self.wait_for_chunk = -1 - self.wait_lock.notify_all() - - def decoded_length(self): - return self._decoded_length - - class ChunkException(IOError): - @staticmethod - def from_stream_error(stream_error: int): - return AbsChunkedInputStream.ChunkException( - "Failed due to stream error, code: {}".format(stream_error)) diff --git a/librespot/audio/AudioKeyManager.py b/librespot/audio/AudioKeyManager.py deleted file mode 100644 index 2a92207..0000000 --- a/librespot/audio/AudioKeyManager.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import logging -import queue -import struct -import threading -import typing - -from librespot.common import Utils -from librespot.core import Session -from librespot.core.PacketsReceiver import PacketsReceiver -from librespot.crypto import Packet -from librespot.standard import ByteArrayOutputStream -from librespot.standard import BytesInputStream - - -class AudioKeyManager(PacketsReceiver): - _ZERO_SHORT: bytes = bytes([0, 0]) - _LOGGER: logging = logging.getLogger(__name__) - _AUDIO_KEY_REQUEST_TIMEOUT: int = 20 - _seqHolder: int = 0 - _seqHolderLock: threading.Condition = threading.Condition() - _callbacks: typing.Dict[int, AudioKeyManager.Callback] = {} - _session: Session = None - - def __init__(self, session: Session): - self._session = session - - def get_audio_key(self, - gid: bytes, - file_id: bytes, - retry: bool = True) -> bytes: - seq: int - with self._seqHolderLock: - seq = self._seqHolder - self._seqHolder += 1 - - out = ByteArrayOutputStream() - out.write(buffer=bytearray(file_id)) - out.write(buffer=bytearray(gid)) - out.write(buffer=bytearray(struct.pack(">i", seq))) - out.write(buffer=bytearray(self._ZERO_SHORT)) - - self._session.send(Packet.Type.request_key, out.to_bytes()) - - callback = AudioKeyManager.SyncCallback(self) - self._callbacks[seq] = callback - - key = callback.wait_response() - if key is None: - if retry: - return self.get_audio_key(gid, file_id, False) - raise RuntimeError( - "Failed fetching audio key! gid: {}, fileId: {}".format( - Utils.bytes_to_hex(gid), Utils.bytes_to_hex(file_id))) - - return key - - def dispatch(self, packet: Packet) -> None: - payload = BytesInputStream(packet.payload) - seq = payload.read_int() - - callback = self._callbacks.get(seq) - if callback is None: - self._LOGGER.warning( - "Couldn't find callback for seq: {}".format(seq)) - return - - if packet.is_cmd(Packet.Type.aes_key): - key = payload.read(16) - callback.key(key) - elif packet.is_cmd(Packet.Type.aes_key_error): - code = payload.read_short() - callback.error(code) - else: - self._LOGGER.warning( - "Couldn't handle packet, cmd: {}, length: {}".format( - packet.cmd, len(packet.payload))) - - class Callback: - def key(self, key: bytes) -> None: - pass - - def error(self, code: int) -> None: - pass - - class SyncCallback(Callback): - _audioKeyManager: AudioKeyManager - reference = queue.Queue() - reference_lock = threading.Condition() - - def __init__(self, audio_key_manager: AudioKeyManager): - self._audioKeyManager = audio_key_manager - - def key(self, key: bytes) -> None: - with self.reference_lock: - self.reference.put(key) - self.reference_lock.notify_all() - - def error(self, code: int) -> None: - self._audioKeyManager._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) - return self.reference.get(block=False) - - class AesKeyException(IOError): - pass diff --git a/librespot/audio/GeneralAudioStream.py b/librespot/audio/GeneralAudioStream.py deleted file mode 100644 index 7e5f088..0000000 --- a/librespot/audio/GeneralAudioStream.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations -import typing - -if typing.TYPE_CHECKING: - from librespot.audio.AbsChunkedInputStream import AbsChunkedInputStream - from librespot.audio.format import SuperAudioFormat - - -class GeneralAudioStream: - def stream(self) -> AbsChunkedInputStream: - pass - - def codec(self) -> SuperAudioFormat: - pass - - def describe(self) -> str: - pass - - def decrypt_time_ms(self) -> int: - pass diff --git a/librespot/audio/GeneralWritableStream.py b/librespot/audio/GeneralWritableStream.py deleted file mode 100644 index e723c1b..0000000 --- a/librespot/audio/GeneralWritableStream.py +++ /dev/null @@ -1,3 +0,0 @@ -class GeneralWritableStream: - def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool): - pass diff --git a/librespot/audio/HaltListener.py b/librespot/audio/HaltListener.py deleted file mode 100644 index f492053..0000000 --- a/librespot/audio/HaltListener.py +++ /dev/null @@ -1,6 +0,0 @@ -class HaltListener: - def stream_read_halted(self, chunk: int, _time: int) -> None: - pass - - def stream_read_resumed(self, chunk: int, _time: int): - pass diff --git a/librespot/audio/NormalizationData.py b/librespot/audio/NormalizationData.py deleted file mode 100644 index 87c28fb..0000000 --- a/librespot/audio/NormalizationData.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations -from librespot.standard import BytesInputStream, DataInputStream, InputStream -import logging -import math - - -class NormalizationData: - _LOGGER: logging = logging.getLogger(__name__) - track_gain_db: float - track_peak: float - album_gain_db: float - album_peak: float - - def __init__(self, track_gain_db: float, track_peak: float, - album_gain_db: float, album_peak: float): - self.track_gain_db = track_gain_db - self.track_peak = track_peak - 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)) - - @staticmethod - def read(input_stream: InputStream) -> NormalizationData: - data_input = DataInputStream(input_stream) - data_input.mark(16) - skip_bytes = data_input.skip_bytes(144) - if skip_bytes != 144: - raise IOError() - - data = bytearray(4 * 4) - data_input.read_fully(data) - data_input.reset() - - buffer = BytesInputStream(data, "<") - return NormalizationData(buffer.read_float(), buffer.read_float(), - buffer.read_float(), buffer.read_float()) - - def get_factor(self, normalisation_pregain) -> float: - 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." - ) - normalisation_factor = 1 / self.track_peak - - return normalisation_factor diff --git a/librespot/audio/PlayableContentFeeder.py b/librespot/audio/PlayableContentFeeder.py deleted file mode 100644 index 8307a36..0000000 --- a/librespot/audio/PlayableContentFeeder.py +++ /dev/null @@ -1,182 +0,0 @@ -from __future__ import annotations - -import logging -import typing - -from librespot.audio import GeneralAudioStream -from librespot.audio import HaltListener -from librespot.audio import NormalizationData -from librespot.audio.cdn import CdnFeedHelper -from librespot.audio.format import AudioQualityPicker -from librespot.common.Utils import Utils -from librespot.core import Session -from librespot.metadata import PlayableId -from librespot.metadata import TrackId -from librespot.proto import Metadata_pb2 as Metadata -from librespot.proto import StorageResolve_pb2 as StorageResolve - - -class PlayableContentFeeder: - _LOGGER: logging = logging.getLogger(__name__) - STORAGE_RESOLVE_INTERACTIVE: str = "/storage-resolve/files/audio/interactive/{}" - STORAGE_RESOLVE_INTERACTIVE_PREFETCH: str = ( - "/storage-resolve/files/audio/interactive_prefetch/{}") - session: Session - - def __init__(self, session: Session): - self.session = session - - def pick_alternative_if_necessary(self, track: Metadata.Track): - if len(track.file) > 0: - return track - - for alt in track.alternative: - if len(alt.file) > 0: - return Metadata.Track( - gid=track.gid, - name=track.name, - album=track.album, - artist=track.artist, - number=track.number, - disc_number=track.disc_number, - duration=track.duration, - popularity=track.popularity, - explicit=track.explicit, - external_id=track.external_id, - restriction=track.restriction, - file=alt.file, - sale_period=track.sale_period, - preview=track.preview, - tags=track.tags, - earliest_live_timestamp=track.earliest_live_timestamp, - has_lyrics=track.has_lyrics, - availability=track.availability, - licensor=track.licensor) - - return None - - def load( - self, - playable_id: PlayableId, - audio_quality_picker: AudioQualityPicker, - preload: bool, - halt_listener: HaltListener.HaltListener, - ): - if type(playable_id) is TrackId: - return self.load_track(playable_id, audio_quality_picker, preload, - halt_listener) - - def resolve_storage_interactive( - self, file_id: bytes, - preload: bool) -> StorageResolve.StorageResolveResponse: - resp = self.session.api().send( - "GET", - (self.STORAGE_RESOLVE_INTERACTIVE_PREFETCH - if preload else self.STORAGE_RESOLVE_INTERACTIVE).format( - Utils.bytes_to_hex(file_id)), - None, - None, - ) - if resp.status_code != 200: - raise RuntimeError(resp.status_code) - - body = resp.content - if body is None: - RuntimeError("Response body is empty!") - - storage_resolve_response = StorageResolve.StorageResolveResponse() - storage_resolve_response.ParseFromString(body) - return storage_resolve_response - - def load_track( - self, - track_id_or_track: typing.Union[TrackId, Metadata.Track], - audio_quality_picker: AudioQualityPicker, - preload: bool, - halt_listener: HaltListener.HaltListener, - ): - if type(track_id_or_track) is TrackId: - original = self.session.api().get_metadata_4_track( - track_id_or_track) - track = self.pick_alternative_if_necessary(original) - if track is None: - raise - else: - 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") - raise - - return self.load_stream(file, track, None, preload, halt_listener) - - def load_stream( - self, - file: Metadata.AudioFile, - track: Metadata.Track, - episode: Metadata.Episode, - preload: bool, - halt_lister: HaltListener.HaltListener, - ): - if track is None and episode is None: - raise RuntimeError() - - resp = self.resolve_storage_interactive(file.file_id, preload) - if resp.result == StorageResolve.StorageResolveResponse.Result.CDN: - if track is not None: - return CdnFeedHelper.load_track(self.session, track, file, - resp, preload, halt_lister) - return CdnFeedHelper.load_episode(self.session, episode, file, - resp, preload, halt_lister) - elif resp.result == StorageResolve.StorageResolveResponse.Result.STORAGE: - if track is None: - # return StorageFeedHelper - pass - elif resp.result == StorageResolve.StorageResolveResponse.Result.RESTRICTED: - raise RuntimeError("Content is restricted!") - elif resp.result == StorageResolve.StorageResolveResponse.Response.UNRECOGNIZED: - raise RuntimeError("Content is unrecognized!") - else: - raise RuntimeError("Unknown result: {}".format(resp.result)) - - class LoadedStream: - episode: Metadata.Episode - track: Metadata.Track - input_stream: GeneralAudioStream.GeneralAudioStream - normalization_data: NormalizationData.NormalizationData - metrics: PlayableContentFeeder.Metrics - - def __init__( - self, - track_or_episode: typing.Union[Metadata.Track, Metadata.Episode], - input_stream: GeneralAudioStream.GeneralAudioStream, - normalization_data: NormalizationData.NormalizationData, - metrics: PlayableContentFeeder.Metrics, - ): - if type(track_or_episode) is Metadata.Track: - self.track = track_or_episode - self.episode = None - elif type(track_or_episode) is Metadata.Episode: - self.track = None - self.episode = track_or_episode - else: - raise TypeError() - self.input_stream = input_stream - self.normalization_data = normalization_data - self.metrics = metrics - - class Metrics: - file_id: str - preloaded_audio_key: bool - audio_key_time: int - - def __init__(self, file_id: bytes, preloaded_audio_key: bool, - audio_key_time: int): - self.file_id = None if file_id is None else Utils.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: - raise RuntimeError() diff --git a/librespot/audio/StreamId.py b/librespot/audio/StreamId.py deleted file mode 100644 index 1116e20..0000000 --- a/librespot/audio/StreamId.py +++ /dev/null @@ -1,30 +0,0 @@ -from librespot.common.Utils import Utils -from librespot.proto import Metadata_pb2 as Metadata - - -class StreamId: - file_id: bytes = None - episode_gid: bytes = None - - def __init__(self, - file: Metadata.AudioFile = None, - episode: Metadata.Episode = None): - if file is None and episode is None: - return - if file is not None: - self.file_id = file.file_id - if episode is not None: - self.episode_gid = episode.gid - - def get_file_id(self): - if self.file_id is None: - raise RuntimeError("Not a file!") - return Utils.bytes_to_hex(self.file_id) - - def is_episode(self): - return self.episode_gid is not None - - def get_episode_gid(self): - if self.episode_gid is None: - raise RuntimeError("Not an episode!") - return Utils.bytes_to_hex(self.episode_gid) diff --git a/librespot/audio/__init__.py b/librespot/audio/__init__.py index 9b162bc..09b8567 100644 --- a/librespot/audio/__init__.py +++ b/librespot/audio/__init__.py @@ -1,8 +1,826 @@ -from librespot.audio import AbsChunkedInputStream -from librespot.audio import AudioKeyManager -from librespot.audio import GeneralAudioStream -from librespot.audio import GeneralWritableStream -from librespot.audio import HaltListener -from librespot.audio import NormalizationData -from librespot.audio import PlayableContentFeeder -from librespot.audio import StreamId +from __future__ import annotations +from librespot import util +from librespot.audio.decrypt import AesAudioDecrypt +from librespot.audio.format import SuperAudioFormat +from librespot.audio.storage import ChannelManager +from librespot.cache import CacheManager +from librespot.crypto import Packet +from librespot.metadata import PlayableId, TrackId +from librespot.proto import Metadata_pb2 as Metadata, StorageResolve_pb2 as StorageResolve +from librespot.structure import AudioDecrypt, AudioQualityPicker, Closeable, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver +import concurrent.futures +import io +import logging +import math +import queue +import random +import struct +import threading +import time +import typing +import urllib.parse + +if typing.TYPE_CHECKING: + from librespot.core import Session + from librespot.crypto import Packet + + +class AbsChunkedInputStream(io.BytesIO, HaltListener): + chunk_exception = None + closed = False + max_chunk_tries = 128 + preload_ahead = 3 + preload_chunk_retries = 2 + retries: typing.List[int] + retry_on_chunk_error: bool + wait_lock: threading.Condition = threading.Condition() + wait_for_chunk = -1 + __decoded_length = 0 + __mark = 0 + __pos = 0 + + def __init__(self, retry_on_chunk_error: bool): + super().__init__() + self.retries = [0] * self.chunks() + self.retry_on_chunk_error = retry_on_chunk_error + + def is_closed(self) -> bool: + return self.closed + + def buffer(self) -> typing.List[bytes]: + raise NotImplementedError() + + def size(self) -> int: + raise NotImplementedError() + + def close(self) -> None: + self.closed = True + with self.wait_lock: + self.wait_lock.notify_all() + + def available(self): + return self.size() - self.__pos + + def mark_supported(self) -> bool: + return True + + def mark(self, read_ahead_limit: int) -> None: + self.__mark = self.__pos + + def reset(self) -> None: + self.__pos = self.__mark + + def pos(self) -> int: + return self.__pos + + def seek(self, where: int, **kwargs) -> None: + if where < 0: + raise TypeError() + if self.closed: + raise IOError("Stream is closed!") + self.__pos = where + self.check_availability(int(self.__pos / (128 * 1024)), False, False) + + def skip(self, n: int) -> int: + if n < 0: + raise TypeError() + if self.closed: + raise IOError("Stream is closed!") + k = self.size() - self.__pos + if n < k: + k = n + self.__pos += k + chunk = int(self.__pos / (128 * 1024)) + self.check_availability(chunk, False, False) + return k + + def requested_chunks(self) -> typing.List[bool]: + raise NotImplementedError() + + def available_chunks(self) -> typing.List[bool]: + raise NotImplementedError() + + def chunks(self) -> int: + raise NotImplementedError() + + def request_chunk_from_stream(self, index: int) -> None: + raise NotImplementedError() + + def should_retry(self, chunk: int) -> bool: + if self.retries[chunk] < 1: + return True + if self.retries[chunk] > self.max_chunk_tries: + return False + return self.retry_on_chunk_error + + def check_availability(self, chunk: int, wait: bool, halted: bool) -> None: + if halted and not wait: + raise TypeError() + if not self.requested_chunks()[chunk]: + self.request_chunk_from_stream(chunk) + self.requested_chunks()[chunk] = True + for i in range(chunk + 1, + min(self.chunks() - 1, chunk + self.preload_ahead) + 1): + if (self.requested_chunks()[i] + and self.retries[i] < self.preload_chunk_retries): + self.request_chunk_from_stream(i) + self.requested_chunks()[chunk] = True + if wait: + if self.available_chunks()[chunk]: + return + retry = False + with self.wait_lock: + if not halted: + self.stream_read_halted(chunk, int(time.time() * 1000)) + self.chunk_exception = None + self.wait_for_chunk = chunk + self.wait_lock.wait() + if self.closed: + return + if self.chunk_exception is not None: + if self.should_retry(chunk): + retry = True + else: + raise AbsChunkedInputStream.ChunkException + if not retry: + self.stream_read_halted(chunk, int(time.time() * 1000)) + if retry: + time.sleep(math.log10(self.retries[chunk])) + self.check_availability(chunk, True, True) + + def read(self, __size: int = 0) -> bytes: + if self.closed: + raise IOError("Stream is closed!") + if __size <= 0: + if self.__pos == self.size(): + return b"" + buffer = io.BytesIO() + total_size = self.size() + chunk = int(self.__pos / (128 * 1024)) + chunk_off = int(self.__pos % (128 * 1024)) + chunk_total = int(math.ceil(total_size / (128 * 1024))) + self.check_availability(chunk, True, False) + buffer.write(self.buffer()[chunk][chunk_off:]) + chunk += 1 + while chunk <= chunk_total - 1: + self.check_availability(chunk, True, False) + buffer.write(self.buffer()[chunk]) + chunk += 1 + buffer.seek(0) + return buffer.read() + else: + buffer = io.BytesIO() + chunk = int(self.__pos / (128 * 1024)) + chunk_off = int(self.__pos % (128 * 1024)) + chunk_end = int(__size / (128 * 1024)) + chunk_end_off = int(__size % (128 * 1024)) + if chunk_end > self.size(): + chunk_end = int(self.size() / (128 * 1024)) + chunk_end_off = int(self.size() % (128 * 1024)) + self.check_availability(chunk, True, False) + buffer.write(self.buffer()[chunk][chunk_off:]) + chunk += 1 + while chunk <= chunk_end: + self.check_availability(chunk, True, False) + if chunk == chunk_end: + buffer.write(self.buffer()[chunk][:chunk_end_off]) + else: + buffer.write(self.buffer()[chunk]) + chunk += 1 + 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]) + with self.wait_lock: + if index == self.wait_for_chunk and not self.closed: + self.wait_for_chunk = -1 + self.wait_lock.notify_all() + + def notify_chunk_error(self, index: int, ex): + self.available_chunks()[index] = False + self.requested_chunks()[index] = False + self.retries[index] += 1 + with self.wait_lock: + if index == self.wait_for_chunk and not self.closed: + self.chunk_exception = ex + self.wait_for_chunk = -1 + self.wait_lock.notify_all() + + def decoded_length(self): + return self.__decoded_length + + class ChunkException(IOError): + @staticmethod + def from_stream_error(stream_error: int): + return AbsChunkedInputStream \ + .ChunkException("Failed due to stream error, code: {}".format(stream_error)) + + +class AudioKeyManager(PacketsReceiver, Closeable): + audio_key_request_timeout = 20 + logger = logging.getLogger("Librespot:AudioKeyManager") + __callbacks: typing.Dict[int, Callback] = {} + __seq_holder = 0 + __seq_holder_lock = threading.Condition() + __session: Session + __zero_short = b"\x00\x00" + + def __init__(self, session: Session): + self.__session = session + + def dispatch(self, packet: Packet) -> None: + payload = io.BytesIO(packet.payload) + seq = struct.unpack(">i", payload.read(4))[0] + callback = self.__callbacks.get(seq) + if callback is None: + self.logger.warning( + "Couldn't find callback for seq: {}".format(seq)) + return + if packet.is_cmd(Packet.Type.aes_key): + key = payload.read(16) + callback.key(key) + elif packet.is_cmd(Packet.Type.aes_key_error): + code = struct.unpack(">H", payload.read(2))[0] + callback.error(code) + else: + self.logger.warning( + "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: + seq: int + with self.__seq_holder_lock: + seq = self.__seq_holder + self.__seq_holder += 1 + out = io.BytesIO() + out.write(file_id) + out.write(gid) + out.write(struct.pack(">i", seq)) + out.write(self.__zero_short) + out.seek(0) + self.__session.send(Packet.Type.request_key, out.read()) + callback = AudioKeyManager.SyncCallback(self) + self.__callbacks[seq] = callback + key = callback.wait_response() + if key is None: + if retry: + return self.get_audio_key(gid, file_id, False) + raise RuntimeError( + "Failed fetching audio key! gid: {}, fileId: {}".format( + util.bytes_to_hex(gid), util.bytes_to_hex(file_id))) + return key + + class Callback: + def key(self, key: bytes) -> None: + raise NotImplementedError + + def error(self, code: int) -> None: + raise NotImplementedError + + class SyncCallback(Callback): + __audio_key_manager: AudioKeyManager + __reference = queue.Queue() + __reference_lock = threading.Condition() + + def __init__(self, audio_key_manager: AudioKeyManager): + self.__audio_key_manager = audio_key_manager + + def key(self, key: bytes) -> None: + with self.__reference_lock: + self.__reference.put(key) + self.__reference_lock.notify_all() + + def error(self, code: int) -> None: + 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) + return self.__reference.get(block=False) + + +class CdnFeedHelper: + _LOGGER: logging = logging.getLogger(__name__) + + @staticmethod + def get_url(resp: StorageResolve.StorageResolveResponse) -> str: + 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: + if type(resp_or_url) is str: + url = resp_or_url + else: + url = CdnFeedHelper.get_url(resp_or_url) + start = int(time.time() * 1000) + key = session.audio_key().get_audio_key(track.gid, file.file_id) + audio_key_time = int(time.time() * 1000) - start + + streamer = session.cdn().stream_file(file, key, url, halt_listener) + input_stream = streamer.stream() + normalization_data = NormalizationData.read(input_stream) + if input_stream.skip(0xA7) != 0xA7: + raise IOError("Couldn't skip 0xa7 bytes!") + return PlayableContentFeeder.LoadedStream( + track, + streamer, + normalization_data, + 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: + resp = session.client().head(episode.external_url) + + if resp.status_code != 200: + CdnFeedHelper._LOGGER.warning("Couldn't resolve redirect!") + + url = resp.url + CdnFeedHelper._LOGGER.debug("Fetched external url for {}: {}".format( + util.bytes_to_hex(episode.gid), url)) + + streamer = session.cdn().stream_external_episode( + episode, url, halt_listener) + return PlayableContentFeeder.LoadedStream( + episode, + streamer, + None, + PlayableContentFeeder.Metrics(None, False, -1), + ) + + @staticmethod + def load_episode( + session: Session, + episode: Metadata.Episode, + file: Metadata.AudioFile, + resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str], + halt_listener: HaltListener, + ) -> PlayableContentFeeder.LoadedStream: + if type(resp_or_url) is str: + url = resp_or_url + else: + url = CdnFeedHelper.get_url(resp_or_url) + start = int(time.time() * 1000) + key = session.audio_key().get_audio_key(episode.gid, file.file_id) + audio_key_time = int(time.time() * 1000) - start + + streamer = session.cdn().stream_file(file, key, url, halt_listener) + input_stream = streamer.stream() + normalization_data = NormalizationData.read(input_stream) + if input_stream.skip(0xA7) != 0xA7: + raise IOError("Couldn't skip 0xa7 bytes!") + return PlayableContentFeeder.LoadedStream( + episode, + streamer, + normalization_data, + PlayableContentFeeder.Metrics( + file.file_id, False, audio_key_time), + ) + + +class CdnManager: + logger: logging = logging.getLogger("Librespot:CdnManager") + __session: Session + + def __init__(self, session: Session): + self.__session = session + + def get_head(self, file_id: bytes): + response = self.__session.client() \ + .get(self.__session.get_user_attribute("head-files-url", "https://heads-fa.spotify.com/head/{file_id}") + .replace("{file_id}", util.bytes_to_hex(file_id))) + if response.status_code != 200: + raise IOError("{}".format(response.status_code)) + body = response.content + if body is None: + raise IOError("Response body is empty!") + return body + + def stream_external_episode(self, episode: Metadata.Episode, external_url: str, halt_listener: HaltListener): + return CdnManager.Streamer( + self.__session, + StreamId(episode), + SuperAudioFormat.MP3, + CdnManager.CdnUrl(self, None, external_url), + self.__session.cache(), + NoopAudioDecrypt(), + halt_listener, + ) + + def stream_file(self, file: Metadata.AudioFile, key: bytes, url: str, halt_listener: HaltListener): + return CdnManager.Streamer( + self.__session, + StreamId(file), + SuperAudioFormat.get(file.format), + CdnManager.CdnUrl(self, file.file_id, url), + self.__session.cache(), + AesAudioDecrypt(key), + halt_listener, + ) + + def get_audio_url(self, file_id: bytes): + response = self.__session.api()\ + .send("GET", "/storage-resolve/files/audio/interactive/{}".format(util.bytes_to_hex(file_id)), None, None) + if response.status_code != 200: + raise IOError(response.status_code) + body = response.content + if body is None: + raise IOError("Response body is empty!") + proto = StorageResolve.StorageResolveResponse() + 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)) + return url + raise CdnManager.CdnException("Could not retrieve CDN url! result: {}".format(proto.result)) + + class CdnException(Exception): + pass + + class InternalResponse: + buffer: bytes + headers: typing.Dict[str, str] + + def __init__(self, buffer: bytes, headers: typing.Dict[str, str]): + self.buffer = buffer + self.headers = headers + + class CdnUrl: + __cdn_manager = None + __file_id: bytes + __expiration: int + 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) + + def url(self): + if self.__expiration == -1: + return self.url + if self.__expiration <= int(time.time() * 1000) + 5 * 60 * 1000: + self.url = self.__cdn_manager.get_audio_url(self.__file_id) + return self.url + + def set_url(self, url: str): + self.url = url + if self.__file_id is not None: + token_url = urllib.parse.urlparse(url) + token_query = urllib.parse.parse_qs(token_url.query) + token_list = token_query.get("__token__") + try: + token_str = str(token_list[0]) + except TypeError: + token_str = "" + if token_str != "None" and len(token_str) != 0: + expire_at = None + split = token_str.split("~") + for s in split: + try: + i = s.index("=") + except ValueError: + continue + if s[:i] == "exp": + expire_at = int(s[i + 1:]) + break + if expire_at is None: + self.__expiration = -1 + self.__cdn_manager.logger.warning("Invalid __token__ in CDN url: {}".format(url)) + return + self.__expiration = expire_at * 1000 + else: + try: + i = token_url.query.index("_") + except ValueError: + self.__expiration = -1 + self.__cdn_manager.logger \ + .warning("Couldn't extract expiration, invalid parameter in CDN url: {}".format(url)) + return + self.__expiration = int(token_url.query[:i]) * 1000 + + else: + self.__expiration = -1 + + class Streamer(GeneralAudioStream, GeneralWritableStream): + available: typing.List[bool] + buffer: typing.List[bytes] + chunks: int + executor_service = concurrent.futures.ThreadPoolExecutor() + halt_listener: HaltListener + requested: typing.List[bool] + size: int + __audio_format: SuperAudioFormat + __audio_decrypt: AudioDecrypt + __cdn_url: CdnManager.CdnUrl + __internal_stream: InternalStream + __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): + 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) + content_range = response.headers.get("Content-Range") + if content_range is None: + raise IOError("Missing Content-Range header!") + split = content_range.split("/") + self.size = int(split[1]) + self.chunks = int(math.ceil(self.size / ChannelManager.chunk_size)) + first_chunk = response.buffer + 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.requested[0] = True + self.write_chunk(first_chunk, 0, False) + + def write_chunk(self, chunk: bytes, chunk_index: int, + 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.__internal_stream.notify_chunk_available(chunk_index) + + def stream(self) -> AbsChunkedInputStream: + return self.__internal_stream + + def codec(self) -> SuperAudioFormat: + return self.__audio_format + + def describe(self) -> str: + if self.__stream_id.is_episode(): + return "episode_gid: {}".format( + self.__stream_id.get_episode_gid()) + return "file_id: {}".format(self.__stream_id.get_file_id()) + + def decrypt_time_ms(self) -> int: + return self.__audio_decrypt.decrypt_time_ms() + + def request_chunk(self, index: int) -> None: + response = self.request(index) + self.write_chunk(response.buffer, index, False) + + def request(self, chunk: int = None, range_start: int = None, range_end: int = None)\ + -> CdnManager.InternalResponse: + if chunk is None and range_start is None and range_end is None: + raise TypeError() + if chunk is not None: + range_start = ChannelManager.chunk_size * chunk + 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)}, + ) + if response.status_code != 206: + raise IOError(response.status_code) + body = response.content + if body is None: + raise IOError("Response body is empty!") + return CdnManager.InternalResponse(body, dict(response.headers)) + + class InternalStream(AbsChunkedInputStream): + streamer: CdnManager.Streamer + + def __init__(self, streamer, retry_on_chunk_error: bool): + self.streamer: CdnManager.Streamer = streamer + super().__init__(retry_on_chunk_error) + + def buffer(self) -> typing.List[bytes]: + return self.streamer.buffer + + def size(self) -> int: + return self.streamer.size + + def requested_chunks(self) -> typing.List[bool]: + return self.streamer.requested + + def available_chunks(self) -> typing.List[bool]: + return self.streamer.available + + def chunks(self) -> int: + return self.streamer.chunks + + def request_chunk_from_stream(self, index: int) -> None: + self.streamer.executor_service \ + .submit(lambda: self.streamer.request_chunk(index)) + + def stream_read_halted(self, chunk: int, _time: int) -> None: + if self.streamer.halt_listener is not None: + self.streamer.executor_service\ + .submit(lambda: self.streamer.halt_listener.stream_read_halted(chunk, _time)) + + def stream_read_resumed(self, chunk: int, _time: int) -> None: + if self.streamer.halt_listener is not None: + self.streamer.executor_service \ + .submit(lambda: self.streamer.halt_listener.stream_read_resumed(chunk, _time)) + + +class NormalizationData: + _LOGGER: logging = logging.getLogger(__name__) + track_gain_db: float + track_peak: float + album_gain_db: float + album_peak: float + + def __init__(self, track_gain_db: float, track_peak: float, + album_gain_db: float, album_peak: float): + self.track_gain_db = track_gain_db + self.track_peak = track_peak + 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)) + + @staticmethod + def read(input_stream: io.BytesIO) -> NormalizationData: + input_stream.seek(144) + data = input_stream.read(4 * 4) + input_stream.seek(16) + buffer = io.BytesIO(data) + return NormalizationData(struct.unpack(" float: + 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.") + normalisation_factor = 1 / self.track_peak + return normalisation_factor + + +class PlayableContentFeeder: + logger = logging.getLogger("Librespot:PlayableContentFeeder") + storage_resolve_interactive = "/storage-resolve/files/audio/interactive/{}" + storage_resolve_interactive_prefetch = "/storage-resolve/files/audio/interactive_prefetch/{}" + __session: Session + + 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]): + if type(playable_id) is TrackId: + 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): + 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_episode(self.__session, episode, file, + response, preload, halt_lister) + elif response.result == StorageResolve.StorageResolveResponse.Result.STORAGE: + if track is None: + pass + elif response.result == StorageResolve.StorageResolveResponse.Result.RESTRICTED: + raise RuntimeError("Content is restricted!") + elif response.result == StorageResolve.StorageResolveResponse.Response.UNRECOGNIZED: + raise RuntimeError("Content is unrecognized!") + else: + raise RuntimeError("Unknown result: {}".format(response.result)) + + 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) + track = self.pick_alternative_if_necessary(original) + if track is None: + raise RuntimeError("Cannot get alternative track") + else: + 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") + return self.load_stream(file, track, None, preload, halt_listener) + + 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: + if len(alt.file) > 0: + return Metadata.Track( + gid=track.gid, + name=track.name, + album=track.album, + artist=track.artist, + number=track.number, + disc_number=track.disc_number, + duration=track.duration, + popularity=track.popularity, + explicit=track.explicit, + external_id=track.external_id, + restriction=track.restriction, + file=alt.file, + sale_period=track.sale_period, + preview=track.preview, + tags=track.tags, + earliest_live_timestamp=track.earliest_live_timestamp, + has_lyrics=track.has_lyrics, + availability=track.availability, + licensor=track.licensor) + return None + + def resolve_storage_interactive( + self, file_id: bytes, + 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, + ) + if resp.status_code != 200: + raise RuntimeError(resp.status_code) + body = resp.content + if body is None: + raise RuntimeError("Response body is empty!") + storage_resolve_response = StorageResolve.StorageResolveResponse() + storage_resolve_response.ParseFromString(body) + return storage_resolve_response + + class LoadedStream: + episode: Metadata.Episode + track: Metadata.Track + input_stream: GeneralAudioStream + 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], + metrics: PlayableContentFeeder.Metrics): + if type(track_or_episode) is Metadata.Track: + self.track = track_or_episode + self.episode = None + elif type(track_or_episode) is Metadata.Episode: + self.track = None + self.episode = track_or_episode + else: + raise TypeError() + self.input_stream = input_stream + self.normalization_data = normalization_data + self.metrics = metrics + + class Metrics: + file_id: str + 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) + self.preloaded_audio_key = preloaded_audio_key + self.audio_key_time = audio_key_time + if preloaded_audio_key and audio_key_time != -1: + raise RuntimeError() + + +class StreamId: + file_id: bytes + episode_gid: bytes + + def __init__(self, + file: Metadata.AudioFile = None, + episode: Metadata.Episode = None): + if file is None and episode is None: + return + self.file_id = None if file is None else file.file_id + self.episode_gid = None if episode is None else episode.gid + + def get_file_id(self): + if self.file_id is None: + raise RuntimeError("Not a file!") + return util.bytes_to_hex(self.file_id) + + def is_episode(self): + return self.episode_gid is not None + + def get_episode_gid(self): + if self.episode_gid is None: + raise RuntimeError("Not an episode!") + return util.bytes_to_hex(self.episode_gid) diff --git a/librespot/audio/cdn/CdnFeedHelper.py b/librespot/audio/cdn/CdnFeedHelper.py deleted file mode 100644 index 6b69113..0000000 --- a/librespot/audio/cdn/CdnFeedHelper.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -import logging -import random -import time -import typing - -from librespot.audio import HaltListener -from librespot.audio import NormalizationData -from librespot.audio import PlayableContentFeeder -from librespot.common import Utils -from librespot.core import Session -from librespot.proto import Metadata_pb2 as Metadata -from librespot.proto import StorageResolve_pb2 as StorageResolve - - -class CdnFeedHelper: - _LOGGER: logging = logging.getLogger(__name__) - - @staticmethod - def get_url(resp: StorageResolve.StorageResolveResponse) -> str: - 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.HaltListener, - ) -> PlayableContentFeeder.PlayableContentFeeder.LoadedStream: - if type(resp_or_url) is str: - url = resp_or_url - else: - url = CdnFeedHelper.get_url(resp_or_url) - start = int(time.time() * 1000) - key = session.audio_key().get_audio_key(track.gid, file.file_id) - audio_key_time = int(time.time() * 1000) - start - - streamer = session.cdn().stream_file(file, key, url, halt_listener) - input_stream = streamer.stream() - normalization_data = NormalizationData.NormalizationData.read( - input_stream) - if input_stream.skip(0xA7) != 0xA7: - raise IOError("Couldn't skip 0xa7 bytes!") - return PlayableContentFeeder.PlayableContentFeeder.LoadedStream( - track, - streamer, - normalization_data, - PlayableContentFeeder.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.HaltListener - ) -> PlayableContentFeeder.PlayableContentFeeder.LoadedStream: - resp = session.client().head(episode.external_url) - - if resp.status_code != 200: - CdnFeedHelper._LOGGER.warning("Couldn't resolve redirect!") - - url = resp.url - CdnFeedHelper._LOGGER.debug("Fetched external url for {}: {}".format( - Utils.bytes_to_hex(episode.gid), url)) - - streamer = session.cdn().stream_external_episode( - episode, url, halt_listener) - return PlayableContentFeeder.PlayableContentFeeder.LoadedStream( - episode, - streamer, - None, - PlayableContentFeeder.PlayableContentFeeder.Metrics( - None, False, -1), - ) - - @staticmethod - def load_episode( - session: Session, - episode: Metadata.Episode, - file: Metadata.AudioFile, - resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str], - halt_listener: HaltListener.HaltListener, - ) -> PlayableContentFeeder.PlayableContentFeeder.LoadedStream: - if type(resp_or_url) is str: - url = resp_or_url - else: - url = CdnFeedHelper.get_url(resp_or_url) - start = int(time.time() * 1000) - key = session.audio_key().get_audio_key(episode.gid, file.file_id) - audio_key_time = int(time.time() * 1000) - start - - streamer = session.cdn().stream_file(file, key, url, halt_listener) - input_stream = streamer.stream() - normalization_data = NormalizationData.NormalizationData.read( - input_stream) - if input_stream.skip(0xA7) != 0xA7: - raise IOError("Couldn't skip 0xa7 bytes!") - return PlayableContentFeeder.PlayableContentFeeder.LoadedStream( - episode, - streamer, - normalization_data, - PlayableContentFeeder.PlayableContentFeeder.Metrics( - file.file_id, False, audio_key_time), - ) diff --git a/librespot/audio/cdn/CdnManager.py b/librespot/audio/cdn/CdnManager.py deleted file mode 100644 index 6bd2416..0000000 --- a/librespot/audio/cdn/CdnManager.py +++ /dev/null @@ -1,338 +0,0 @@ -from __future__ import annotations - -import concurrent.futures -import logging -import math -import random -import time -import typing -import urllib.parse - -from librespot.audio import GeneralAudioStream -from librespot.audio import GeneralWritableStream -from librespot.audio import StreamId -from librespot.audio.AbsChunkedInputStream import AbsChunkedInputStream -from librespot.audio.decrypt import AesAudioDecrypt -from librespot.audio.decrypt import NoopAudioDecrypt -from librespot.audio.format import SuperAudioFormat -from librespot.audio.storage import ChannelManager -from librespot.common import Utils -from librespot.proto import StorageResolve_pb2 as StorageResolve - -if typing.TYPE_CHECKING: - from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt - from librespot.audio.HaltListener import HaltListener - from librespot.cache.CacheManager import CacheManager - from librespot.core.Session import Session - from librespot.proto import Metadata_pb2 as Metadata - - -class CdnManager: - _LOGGER: logging = logging.getLogger(__name__) - _session: Session - - def __init__(self, session: Session): - self._session = session - - def get_head(self, file_id: bytes): - resp = self._session.client().get( - self._session.get_user_attribute( - "head-files-url", - "https://heads-fa.spotify.com/head/{file_id}").replace( - "{file_id}", Utils.bytes_to_hex(file_id))) - - if resp.status_code != 200: - raise IOError("{}".format(resp.status_code)) - - body = resp.content - if body is None: - raise IOError("Response body is empty!") - - return body - - def stream_external_episode(self, episode: Metadata.Episode, - external_url: str, - halt_listener: HaltListener): - return CdnManager.Streamer( - self._session, - StreamId.StreamId(episode), - SuperAudioFormat.MP3, - CdnManager.CdnUrl(self, None, external_url), - self._session.cache(), - NoopAudioDecrypt(), - halt_listener, - ) - - def stream_file( - self, - file: Metadata.AudioFile, - key: bytes, - url: str, - halt_listener: HaltListener, - ): - return CdnManager.Streamer( - self._session, - StreamId.StreamId(file), - SuperAudioFormat.get(file.format), - CdnManager.CdnUrl(self, file.file_id, url), - self._session.cache(), - AesAudioDecrypt(key), - halt_listener, - ) - - def get_audio_url(self, file_id: bytes): - resp = self._session.api().send( - "GET", - "/storage-resolve/files/audio/interactive/{}".format( - Utils.bytes_to_hex(file_id)), - None, - None, - ) - - if resp.status_code != 200: - raise IOError(resp.status_code) - - body = resp.content - if body is None: - raise IOError("Response body is empty!") - - proto = StorageResolve.StorageResolveResponse() - proto.ParseFromString(body) - if proto.result == StorageResolve.StorageResolveResponse.Result.CDN: - url = random.choice(proto.cdnurl) - self._LOGGER.debug("Fetched CDN url for {}: {}".format( - Utils.bytes_to_hex(file_id), url)) - return url - raise CdnManager.CdnException( - "Could not retrieve CDN url! result: {}".format(proto.result)) - - class CdnException(Exception): - pass - - class InternalResponse: - _buffer: bytearray - _headers: typing.Dict[str, str] - - def __init__(self, buffer: bytearray, headers: typing.Dict[str, str]): - self._buffer = buffer - self._headers = headers - - class CdnUrl: - __cdnManager = None - __fileId: bytes - _expiration: int - _url: str - - def __init__(self, cdn_manager, file_id: bytes, url: str): - self.__cdnManager: CdnManager = cdn_manager - self.__fileId = file_id - self.set_url(url) - - def url(self): - if self._expiration == -1: - return self._url - - if self._expiration <= int(time.time() * 1000) + 5 * 60 * 1000: - self._url = self.__cdnManager.get_audio_url(self.__fileId) - - return self.url - - def set_url(self, url: str): - self._url = url - - if self.__fileId is not None: - token_url = urllib.parse.urlparse(url) - token_query = urllib.parse.parse_qs(token_url.query) - token_list = token_query.get("__token__") - try: - token_str = str(token_list[0]) - except TypeError: - token_str = "" - if token_str != "None" and len(token_str) != 0: - expire_at = None - split = token_str.split("~") - for s in split: - try: - i = s.index("=") - except ValueError: - continue - - if s[:i] == "exp": - expire_at = int(s[i + 1:]) - break - - if expire_at is None: - self._expiration = -1 - self.__cdnManager._LOGGER.warning( - "Invalid __token__ in CDN url: {}".format(url)) - return - - self._expiration = expire_at * 1000 - else: - try: - i = token_url.query.index("_") - except ValueError: - self._expiration = -1 - self.__cdnManager._LOGGER.warning( - "Couldn't extract expiration, invalid parameter in CDN url: {}" - .format(url)) - return - - self._expiration = int(token_url.query[:i]) * 1000 - - else: - self._expiration = -1 - - class Streamer( - GeneralAudioStream.GeneralAudioStream, - GeneralWritableStream.GeneralWritableStream, - ): - _session: Session - _streamId: StreamId.StreamId - _executorService = concurrent.futures.ThreadPoolExecutor() - _audioFormat: SuperAudioFormat - _audioDecrypt: AudioDecrypt - _cdnUrl = None - _size: int - _buffer: typing.List[bytearray] - _available: typing.List[bool] - _requested: typing.List[bool] - _chunks: int - _internalStream: CdnManager.Streamer.InternalStream - _haltListener: HaltListener - - def __init__( - self, - session: Session, - stream_id: StreamId.StreamId, - audio_format: SuperAudioFormat, - cdn_url, - cache: CacheManager, - audio_decrypt: AudioDecrypt, - halt_listener: HaltListener, - ): - self._session = session - self._streamId = stream_id - self._audioFormat = audio_format - self._audioDecrypt = audio_decrypt - self._cdnUrl = cdn_url - self._haltListener = halt_listener - - resp = self.request(range_start=0, - range_end=ChannelManager.CHUNK_SIZE - 1) - content_range = resp._headers.get("Content-Range") - if content_range is None: - raise IOError("Missing Content-Range header!") - - split = Utils.split(content_range, "/") - self._size = int(split[1]) - self._chunks = int( - math.ceil(self._size / ChannelManager.CHUNK_SIZE)) - - first_chunk = resp._buffer - - self._available = [False for _ in range(self._chunks)] - self._requested = [False for _ in range(self._chunks)] - self._buffer = [bytearray() for _ in range(self._chunks)] - self._internalStream = CdnManager.Streamer.InternalStream( - self, False) - - self._requested[0] = True - self.write_chunk(first_chunk, 0, False) - - def write_chunk(self, chunk: bytes, chunk_index: int, - cached: bool) -> None: - if self._internalStream.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._audioDecrypt.decrypt_chunk( - chunk_index, chunk) - self._internalStream.notify_chunk_available(chunk_index) - - def stream(self) -> AbsChunkedInputStream: - return self._internalStream - - def codec(self) -> SuperAudioFormat: - return self._audioFormat - - def describe(self) -> str: - if self._streamId.is_episode(): - return "episode_gid: {}".format( - self._streamId.get_episode_gid()) - return "file_id: {}".format(self._streamId.get_file_id()) - - def decrypt_time_ms(self) -> int: - return self._audioDecrypt.decrypt_time_ms() - - def request_chunk(self, index: int) -> None: - resp = self.request(index) - self.write_chunk(resp._buffer, index, False) - - def request(self, - chunk: int = None, - range_start: int = None, - range_end: int = None) -> CdnManager.InternalResponse: - if chunk is None and range_start is None and range_end is None: - raise TypeError() - - if chunk is not None: - range_start = ChannelManager.CHUNK_SIZE * chunk - range_end = (chunk + 1) * ChannelManager.CHUNK_SIZE - 1 - - resp = self._session.client().get( - self._cdnUrl._url, - headers={ - "Range": "bytes={}-{}".format(range_start, range_end) - }, - ) - - if resp.status_code != 206: - raise IOError(resp.status_code) - - body = resp.content - if body is None: - raise IOError("Response body is empty!") - - return CdnManager.InternalResponse(bytearray(body), resp.headers) - - class InternalStream(AbsChunkedInputStream): - streamer = None - - def __init__(self, streamer, retry_on_chunk_error: bool): - self.streamer: CdnManager.Streamer = streamer - super().__init__(retry_on_chunk_error) - - def buffer(self) -> typing.List[bytearray]: - return self.streamer._buffer - - def size(self) -> int: - return self.streamer._size - - def requested_chunks(self) -> typing.List[bool]: - return self.streamer._requested - - def available_chunks(self) -> typing.List[bool]: - return self.streamer._available - - def chunks(self) -> int: - return self.streamer._chunks - - def request_chunk_from_stream(self, index: int) -> None: - self.streamer._executorService.submit( - lambda: self.streamer.request_chunk(index)) - - def stream_read_halted(self, chunk: int, _time: int) -> None: - if self.streamer._haltListener is not None: - self.streamer._executorService.submit( - lambda: self.streamer._haltListener.stream_read_halted( - chunk, _time)) - - def stream_read_resumed(self, chunk: int, _time: int) -> None: - if self.streamer._haltListener is not None: - self.streamer._executorService.submit( - lambda: self.streamer._haltListener. - stream_read_resumed(chunk, _time)) diff --git a/librespot/audio/cdn/__init__.py b/librespot/audio/cdn/__init__.py deleted file mode 100644 index da8fa1d..0000000 --- a/librespot/audio/cdn/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from librespot.audio.cdn.CdnFeedHelper import CdnFeedHelper -from librespot.audio.cdn.CdnManager import CdnManager diff --git a/librespot/audio/decoders/AudioQuality.py b/librespot/audio/decoders.py similarity index 84% rename from librespot/audio/decoders/AudioQuality.py rename to librespot/audio/decoders.py index 6e4f7df..66e0e91 100644 --- a/librespot/audio/decoders/AudioQuality.py +++ b/librespot/audio/decoders.py @@ -1,10 +1,8 @@ from __future__ import annotations - +from librespot.proto.Metadata_pb2 import AudioFile import enum import typing -from librespot.proto.Metadata_pb2 import AudioFile - class AudioQuality(enum.Enum): NORMAL = 0x00 @@ -29,12 +27,9 @@ 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 diff --git a/librespot/audio/decoders/__init__.py b/librespot/audio/decoders/__init__.py deleted file mode 100644 index 5e194d8..0000000 --- a/librespot/audio/decoders/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.audio.decoders.AudioQuality import AudioQuality diff --git a/librespot/audio/decrypt.py b/librespot/audio/decrypt.py new file mode 100644 index 0000000..23af0e2 --- /dev/null +++ b/librespot/audio/decrypt.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from Cryptodome.Cipher import AES +from Cryptodome.Util import Counter +from librespot.audio.storage import ChannelManager +from librespot.structure import AudioDecrypt +import io +import time + + +class AesAudioDecrypt(AudioDecrypt): + audio_aes_iv = b'r\xe0g\xfb\xdd\xcb\xcfw\xeb\xe8\xbcd?c\r\x93' + cipher = None + decrypt_count = 0 + decrypt_total_time = 0 + iv_int = int.from_bytes(audio_aes_iv, "big") + iv_diff = 0x100 + key: bytes + + def __init__(self, key: bytes): + self.key = key + + def decrypt_chunk(self, chunk_index: int, buffer: bytes): + new_buffer = io.BytesIO() + 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, + 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)) + iv += self.iv_diff + self.decrypt_total_time += time.time_ns() - start + self.decrypt_count += 1 + new_buffer.seek(0) + 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) diff --git a/librespot/audio/decrypt/AesAudioDecrypt.py b/librespot/audio/decrypt/AesAudioDecrypt.py deleted file mode 100644 index bdc4fa2..0000000 --- a/librespot/audio/decrypt/AesAudioDecrypt.py +++ /dev/null @@ -1,67 +0,0 @@ -import time - -from Cryptodome.Cipher import AES -from Cryptodome.Util import Counter - -from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt -from librespot.audio.storage import ChannelManager - - -class AesAudioDecrypt(AudioDecrypt): - audio_aes_iv = bytes([ - 0x72, - 0xE0, - 0x67, - 0xFB, - 0xDD, - 0xCB, - 0xCF, - 0x77, - 0xEB, - 0xE8, - 0xBC, - 0x64, - 0x3F, - 0x63, - 0x0D, - 0x93, - ]) - iv_int = int.from_bytes(audio_aes_iv, "big") - iv_diff = 0x100 - cipher = None - decrypt_count = 0 - decrypt_total_time = 0 - key: bytes - - def __init__(self, key: bytes): - self.key = key - - def decrypt_chunk(self, chunk_index: int, buffer: bytes): - new_buffer = b"" - 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, - counter=Counter.new(128, initial_value=iv), - ) - - count = min(4096, len(buffer) - i) - decrypted_buffer = cipher.decrypt(buffer[i:i + count]) - new_buffer += decrypted_buffer - if count != len(decrypted_buffer): - 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 - - return new_buffer - - def decrypt_time_ms(self): - return (0 if self.decrypt_count == 0 else int( - (self.decrypt_total_time / self.decrypt_count) / 1000000)) diff --git a/librespot/audio/decrypt/AudioDecrypt.py b/librespot/audio/decrypt/AudioDecrypt.py deleted file mode 100644 index c7be936..0000000 --- a/librespot/audio/decrypt/AudioDecrypt.py +++ /dev/null @@ -1,6 +0,0 @@ -class AudioDecrypt: - def decrypt_chunk(self, chunk_index: int, buffer: bytes): - pass - - def decrypt_time_ms(self): - pass diff --git a/librespot/audio/decrypt/NoopAudioDecrypt.py b/librespot/audio/decrypt/NoopAudioDecrypt.py deleted file mode 100644 index 1c756a4..0000000 --- a/librespot/audio/decrypt/NoopAudioDecrypt.py +++ /dev/null @@ -1,9 +0,0 @@ -from librespot.audio.decrypt import AudioDecrypt - - -class NoopAudioDecrypt(AudioDecrypt): - def decrypt_chunk(self, chunk_index: int, buffer: bytes): - pass - - def decrypt_time_ms(self): - return 0 diff --git a/librespot/audio/decrypt/__init__.py b/librespot/audio/decrypt/__init__.py deleted file mode 100644 index 0af9c05..0000000 --- a/librespot/audio/decrypt/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from librespot.audio.decrypt.AesAudioDecrypt import AesAudioDecrypt -from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt -from librespot.audio.decrypt.NoopAudioDecrypt import NoopAudioDecrypt diff --git a/librespot/audio/format.py b/librespot/audio/format.py new file mode 100644 index 0000000..5ea945d --- /dev/null +++ b/librespot/audio/format.py @@ -0,0 +1,26 @@ +from librespot.proto import Metadata_pb2 as Metadata +import enum + + +class SuperAudioFormat(enum.Enum): + MP3 = 0x00 + VORBIS = 0x01 + AAC = 0x02 + + @staticmethod + def get(audio_format: Metadata.AudioFile.Format): + if audio_format == Metadata.AudioFile.Format.OGG_VORBIS_96 or \ + audio_format == Metadata.AudioFile.Format.OGG_VORBIS_160 or \ + audio_format == Metadata.AudioFile.Format.OGG_VORBIS_320: + return SuperAudioFormat.VORBIS + if audio_format == Metadata.AudioFile.Format.MP3_256 or \ + audio_format == Metadata.AudioFile.Format.MP3_320 or \ + audio_format == Metadata.AudioFile.Format.MP3_160 or \ + audio_format == Metadata.AudioFile.Format.MP3_96 or \ + audio_format == Metadata.AudioFile.Format.MP3_160_ENC: + return SuperAudioFormat.MP3 + if audio_format == Metadata.AudioFile.Format.AAC_24 or \ + audio_format == Metadata.AudioFile.Format.AAC_48 or \ + audio_format == Metadata.AudioFile.Format.AAC_24_NORM: + return SuperAudioFormat.AAC + raise RuntimeError("Unknown audio format: {}".format(audio_format)) diff --git a/librespot/audio/format/AudioQualityPicker.py b/librespot/audio/format/AudioQualityPicker.py deleted file mode 100644 index c395565..0000000 --- a/librespot/audio/format/AudioQualityPicker.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import typing - -if typing.TYPE_CHECKING: - from librespot.proto import Metadata_pb2 as Metadata - - -class AudioQualityPicker: - def get_file(self, - files: typing.List[Metadata.AudioFile]) -> Metadata.AudioFile: - pass diff --git a/librespot/audio/format/SuperAudioFormat.py b/librespot/audio/format/SuperAudioFormat.py deleted file mode 100644 index 0b7e589..0000000 --- a/librespot/audio/format/SuperAudioFormat.py +++ /dev/null @@ -1,26 +0,0 @@ -from librespot.proto import Metadata_pb2 as Metadata -import enum - - -class SuperAudioFormat(enum.Enum): - MP3 = 0x00 - VORBIS = 0x01 - AAC = 0x02 - - @staticmethod - def get(audio_format: Metadata.AudioFile.Format): - if audio_format == Metadata.AudioFile.Format.OGG_VORBIS_96 or \ - audio_format == Metadata.AudioFile.Format.OGG_VORBIS_160 or \ - audio_format == Metadata.AudioFile.Format.OGG_VORBIS_320: - return SuperAudioFormat.VORBIS - if audio_format == Metadata.AudioFile.Format.MP3_256 or \ - audio_format == Metadata.AudioFile.Format.MP3_320 or \ - audio_format == Metadata.AudioFile.Format.MP3_160 or \ - audio_format == Metadata.AudioFile.Format.MP3_96 or \ - audio_format == Metadata.AudioFile.Format.MP3_160_ENC: - return SuperAudioFormat.MP3 - if audio_format == Metadata.AudioFile.Format.AAC_24 or \ - audio_format == Metadata.AudioFile.Format.AAC_48 or \ - audio_format == Metadata.AudioFile.Format.AAC_24_NORM: - return SuperAudioFormat.AAC - raise RuntimeError("Unknown audio format: {}".format(audio_format)) diff --git a/librespot/audio/format/__init__.py b/librespot/audio/format/__init__.py deleted file mode 100644 index f27b14c..0000000 --- a/librespot/audio/format/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from librespot.audio.format.AudioQualityPicker import AudioQualityPicker -from librespot.audio.format.SuperAudioFormat import SuperAudioFormat diff --git a/librespot/audio/storage.py b/librespot/audio/storage.py new file mode 100644 index 0000000..32fcadf --- /dev/null +++ b/librespot/audio/storage.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from librespot import util +from librespot.crypto import Packet +from librespot.proto.Metadata_pb2 import AudioFile +from librespot.structure import Closeable, PacketsReceiver +import concurrent.futures +import io +import logging +import queue +import struct +import threading +import typing + +if typing.TYPE_CHECKING: + from librespot.core import Session + + +class ChannelManager(Closeable, PacketsReceiver): + channels: typing.Dict[int, Channel] = {} + chunk_size = 128 * 1024 + executor_service = concurrent.futures.ThreadPoolExecutor() + logger = logging.getLogger("Librespot:ChannelManager") + seq_holder = 0 + seq_holder_lock = threading.Condition() + __session: Session = None + + def __init__(self, session: Session): + self.__session = session + + def request_chunk(self, file_id: bytes, index: int, file: AudioFile): + start = int(index * self.chunk_size / 4) + end = int((index + 1) * self.chunk_size / 4) + channel = ChannelManager.Channel(self, file, index) + self.channels[channel.chunk_id] = channel + out = io.BytesIO() + out.write(struct.pack(">H", channel.chunk_id)) + out.write(struct.pack(">i", 0x00000000)) + out.write(struct.pack(">i", 0x00000000)) + out.write(struct.pack(">i", 0x00004E20)) + out.write(struct.pack(">i", 0x00030D40)) + out.write(file_id) + out.write(struct.pack(">i", start)) + out.write(struct.pack(">i", end)) + out.seek(0) + self.__session.send(Packet.Type.stream_chunk, out.read()) + + def dispatch(self, packet: Packet) -> None: + payload = io.BytesIO(packet.payload) + if packet.is_cmd(Packet.Type.stream_chunk_res): + 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))) + 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])) + 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))) + + def close(self) -> None: + self.executor_service.shutdown() + + class Channel: + channel_manager: ChannelManager + chunk_id: int + q = queue.Queue() + __buffer = io.BytesIO() + __chunk_index: int + __file: AudioFile + __header: bool = True + + def __init__(self, channel_manager: ChannelManager, file: AudioFile, + chunk_index: int): + self.channel_manager = channel_manager + self.__file = file + self.__chunk_index = chunk_index + 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)) + + 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.") + return False + if self.__header: + length: int + while len(payload.buffer) > 0: + length = payload.read_short() + if not length > 0: + break + header_id = payload.read_byte() + header_data = payload.read(length - 1) + self.__file.write_header(int.from_bytes(header_id, "big"), + bytearray(header_data), False) + self.__header = False + else: + self.__buffer.write(payload.read(len(payload.buffer))) + return False + + def add_to_queue(self, payload): + self.q.put(payload) + + def stream_error(self, code: int) -> None: + self.__file.stream_error(self.__chunk_index, code) + + class Handler: + __channel: ChannelManager.Channel = None + + def __init__(self, channel: ChannelManager.Channel): + self.__channel = channel + + def run(self) -> None: + 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") diff --git a/librespot/audio/storage/AudioFile.py b/librespot/audio/storage/AudioFile.py deleted file mode 100644 index b3cd41c..0000000 --- a/librespot/audio/storage/AudioFile.py +++ /dev/null @@ -1,12 +0,0 @@ -from librespot.audio.GeneralWritableStream import GeneralWritableStream - - -class AudioFile(GeneralWritableStream): - def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool): - pass - - def write_header(self, chunk_id: int, b: bytearray, cached: bool): - pass - - def stream_error(self, chunk_index: int, code: int): - pass diff --git a/librespot/audio/storage/AudioFileStreaming.py b/librespot/audio/storage/AudioFileStreaming.py deleted file mode 100644 index 61382fd..0000000 --- a/librespot/audio/storage/AudioFileStreaming.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations -import typing - -if typing.TYPE_CHECKING: - from librespot.core.Session import Session - - -class AudioFileStreaming: - cache_handler = None - - def __init__(self, session: Session): - pass diff --git a/librespot/audio/storage/ChannelManager.py b/librespot/audio/storage/ChannelManager.py deleted file mode 100644 index 03e18fc..0000000 --- a/librespot/audio/storage/ChannelManager.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import concurrent.futures -import logging -import queue -import threading -import typing - -from librespot.audio.storage import AudioFile -from librespot.common import Utils -from librespot.core import PacketsReceiver -from librespot.core import Session -from librespot.crypto import Packet -from librespot.standard import BytesInputStream -from librespot.standard import BytesOutputStream -from librespot.standard import Closeable -from librespot.standard import Runnable - - -class ChannelManager(Closeable, PacketsReceiver.PacketsReceiver): - CHUNK_SIZE: int = 128 * 1024 - _LOGGER: logging = logging.getLogger(__name__) - _channels: typing.Dict[int, Channel] = {} - _seqHolder: int = 0 - _seqHolderLock: threading.Condition = threading.Condition() - _executorService: concurrent.futures.ThreadPoolExecutor = ( - concurrent.futures.ThreadPoolExecutor()) - _session: Session = None - - def __init__(self, session: Session): - self._session = session - - def request_chunk(self, file_id: bytes, index: int, file: AudioFile): - start = int(index * self.CHUNK_SIZE / 4) - end = int((index + 1) * self.CHUNK_SIZE / 4) - - channel = ChannelManager.Channel(self, file, index) - self._channels[channel.chunkId] = channel - - out = BytesOutputStream() - out.write_short(channel.chunkId) - out.write_int(0x00000000) - out.write_int(0x00000000) - out.write_int(0x00004E20) - out.write_int(0x00030D40) - out.write(file_id) - out.write_int(start) - out.write_int(end) - - self._session.send(Packet.Type.stream_chunk, out.buffer) - - def dispatch(self, packet: Packet) -> None: - payload = BytesInputStream(packet.payload) - if packet.is_cmd(Packet.Type.stream_chunk_res): - chunk_id = payload.read_short() - 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))) - return - - channel._add_to_queue(payload) - elif packet.is_cmd(Packet.Type.channel_error): - chunk_id = payload.read_short() - channel = self._channels.get(chunk_id) - if channel is None: - self._LOGGER.warning( - "Dropping channel error, id: {}, code: {}".format( - chunk_id, payload.read_short())) - return - - channel.stream_error(payload.read_short()) - else: - self._LOGGER.warning( - "Couldn't handle packet, cmd: {}, payload: {}".format( - packet.cmd, Utils.bytes_to_hex(packet.payload))) - - def close(self) -> None: - self._executorService.shutdown() - - class Channel: - _channelManager: ChannelManager - chunkId: int - _q: queue.Queue = queue.Queue() - _file: AudioFile - _chunkIndex: int - _buffer: BytesOutputStream = BytesOutputStream() - _header: bool = True - - def __init__(self, channel_manager: ChannelManager, file: AudioFile, - chunk_index: int): - self._channelManager = channel_manager - self._file = file - self._chunkIndex = chunk_index - with self._channelManager._seqHolderLock: - self.chunkId = self._channelManager._seqHolder - self._channelManager._seqHolder += 1 - - self._channelManager._executorService.submit( - lambda: ChannelManager.Channel.Handler(self)) - - def _handle(self, payload: BytesInputStream) -> bool: - if len(payload.buffer) == 0: - if not self._header: - self._file.write_chunk(bytearray(payload.buffer), - self._chunkIndex, False) - return True - - self._channelManager._LOGGER.debug( - "Received empty chunk, skipping.") - return False - - if self._header: - length: int - while len(payload.buffer) > 0: - length = payload.read_short() - if not length > 0: - break - header_id = payload.read_byte() - header_data = payload.read(length - 1) - self._file.write_header(int.from_bytes(header_id, "big"), - bytearray(header_data), False) - self._header = False - else: - self._buffer.write(payload.read(len(payload.buffer))) - - return False - - def _add_to_queue(self, payload): - self._q.put(payload) - - def stream_error(self, code: int) -> None: - self._file.stream_error(self._chunkIndex, code) - - class Handler(Runnable): - _channel: ChannelManager.Channel = None - - def __init__(self, channel: ChannelManager.Channel): - self._channel = channel - - def run(self) -> None: - self._channel._channelManager._LOGGER.debug( - "ChannelManager.Handler is starting") - - with self._channel._q.all_tasks_done: - self._channel._channelManager._channels.pop( - self._channel.chunkId) - - self._channel._channelManager._LOGGER.debug( - "ChannelManager.Handler is shutting down") diff --git a/librespot/audio/storage/__init__.py b/librespot/audio/storage/__init__.py deleted file mode 100644 index 71135b8..0000000 --- a/librespot/audio/storage/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from librespot.audio.storage.AudioFile import AudioFile -from librespot.audio.storage.AudioFileStreaming import AudioFileStreaming -from librespot.audio.storage.ChannelManager import ChannelManager diff --git a/librespot/cache.py b/librespot/cache.py new file mode 100644 index 0000000..7937c46 --- /dev/null +++ b/librespot/cache.py @@ -0,0 +1,18 @@ +from __future__ import annotations +import typing + +if typing.TYPE_CHECKING: + from librespot.core import Session + + +class CacheManager: + clean_up_threshold = 604800000 + header_hash = 253 + header_timestamp = 254 + parent: str + + def __init__(self, session: Session): + """ + @Todo Implement function + :param session: + """ diff --git a/librespot/cache/CacheManager.py b/librespot/cache/CacheManager.py deleted file mode 100644 index 713a294..0000000 --- a/librespot/cache/CacheManager.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations -from librespot.core import Session - - -class CacheManager: - CLEAN_UP_THRESHOLD = 604800000 - HEADER_TIMESTAMP = 254 - HEADER_HASH = 253 - - parent: str - - def __init__(self, conf: Session.Configuration): - pass diff --git a/librespot/cache/__init__.py b/librespot/cache/__init__.py deleted file mode 100644 index e41fdbc..0000000 --- a/librespot/cache/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.cache.CacheManager import CacheManager diff --git a/librespot/common/Utils.py b/librespot/common/Utils.py deleted file mode 100644 index 9602689..0000000 --- a/librespot/common/Utils.py +++ /dev/null @@ -1,41 +0,0 @@ -import binascii -import os - - -class Utils: - @staticmethod - def random_hex_string(length: int): - buffer = os.urandom(int(length / 2)) - return Utils.bytes_to_hex(buffer) - - @staticmethod - def truncate_middle(s: str, length: int) -> str: - if length <= 1: - raise TypeError() - - first = length / 2 - result = s[:first] - result += "..." - result += s[len(s) - (length - first):] - return result - - @staticmethod - def split(s: str, c: str): - return s.split(c) - - @staticmethod - def to_byte_array(i: int) -> bytes: - width = i.bit_length() - width += 8 - ((width % 8) or 8) - fmt = '%%0%dx' % (width // 4) - if i == 0: - return bytes([0]) - return binascii.unhexlify(fmt % i) - - @staticmethod - def bytes_to_hex(buffer: bytes) -> str: - return binascii.hexlify(buffer).decode() - - @staticmethod - def hex_to_bytes(s: str) -> bytes: - return binascii.unhexlify(s) diff --git a/librespot/common/__init__.py b/librespot/common/__init__.py deleted file mode 100644 index 0f8b899..0000000 --- a/librespot/common/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from librespot.common.Base62 import Base62 -from librespot.common.Utils import Utils diff --git a/librespot/core.py b/librespot/core.py new file mode 100644 index 0000000..e376e62 --- /dev/null +++ b/librespot/core.py @@ -0,0 +1,1275 @@ +from __future__ import annotations +from Cryptodome import Random +from Cryptodome.Hash import HMAC, SHA1 +from Cryptodome.PublicKey import RSA +from Cryptodome.Signature import PKCS1_v1_5 +from librespot import util +from librespot.audio import AudioKeyManager, CdnManager, PlayableContentFeeder +from librespot.audio.storage import ChannelManager +from librespot.cache import CacheManager +from librespot.crypto import CipherPair, DiffieHellman, Packet +from librespot.mercury import MercuryClient, MercuryRequests, RawMercuryRequest +from librespot.metadata import AlbumId, ArtistId, EpisodeId, ShowId, TrackId +from librespot.proto import Authentication_pb2 as Authentication, Connect_pb2 as Connect, Keyexchange_pb2 as Keyexchange, Metadata_pb2 as Metadata +from librespot.structure import Closeable, SubListener +from librespot.version import Version +import base64 +import concurrent.futures +import defusedxml.ElementTree +import enum +import io +import json +import logging +import os +import random +import requests +import sched +import signal +import socket +import struct +import threading +import time +import typing + + +class ApiClient(Closeable): + logger = logging.getLogger("Librespot:ApiClient") + __base_url: str + __session: Session + + def __init__(self, session: Session): + 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: + 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.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)) + return response + + 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}, + proto.SerializeToString(), + ) + if response.status_code == 413: + 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)) + + def get_metadata_4_track(self, track: TrackId) -> Metadata.Track: + 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: + raise RuntimeError() + proto = Metadata.Track() + proto.ParseFromString(body) + 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) + ApiClient.StatusCodeException.check_status(response) + body = response.content + if body is None: + raise IOError() + proto = Metadata.Episode() + proto.ParseFromString(body) + 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) + ApiClient.StatusCodeException.check_status(response) + + body = response.content + if body is None: + raise IOError() + proto = Metadata.Album() + proto.ParseFromString(body) + 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) + ApiClient.StatusCodeException.check_status(response) + body = response.content + if body is None: + raise IOError() + proto = Metadata.Artist() + proto.ParseFromString(body) + 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) + ApiClient.StatusCodeException.check_status(response) + body = response.content + if body is None: + raise IOError() + proto = Metadata.Show() + proto.ParseFromString(body) + return proto + + class StatusCodeException(IOError): + code: int + + def __init__(self, response: requests.Response): + super().__init__(response.status_code) + self.code = response.status_code + + @staticmethod + def check_status(response: requests.Response) -> None: + if response.status_code != 200: + raise ApiClient.StatusCodeException(response) + + +class ApResolver: + base_url = "https://apresolve.spotify.com/" + + @staticmethod + def request(service_type: str) -> typing.Any: + """ + Gets the specified ApResolve + Args: + service_type: Unique ID for service name + Returns: + The resulting object will be returned + """ + response = requests.get("{}?type={}".format(ApResolver.base_url, service_type)) + return response.json() + + @staticmethod + def get_random_of(service_type: str) -> str: + """ + Gets the specified random ApResolve url + Args: + service_type: Unique ID for service name + Returns: + A random ApResolve url will be returned + """ + pool = ApResolver.request(service_type) + urls = pool.get(service_type) + if urls is None or len(urls) == 0: + raise RuntimeError("No ApResolve url available") + return random.choice(urls) + + @staticmethod + def get_random_dealer() -> str: + """ + Get dealer endpoint url + Returns: + dealer endpoint url + """ + return ApResolver.get_random_of("dealer") + + @staticmethod + def get_random_spclient() -> str: + """ + Get spclient endpoint url + Returns: + spclient endpoint url + """ + return ApResolver.get_random_of("spclient") + + @staticmethod + def get_random_accesspoint() -> str: + """ + Get accesspoint endpoint url + Returns: + accesspoint endpoint url + """ + return ApResolver.get_random_of("accesspoint") + + +class EventService(Closeable): + logger = logging.getLogger("Librespot:EventService") + __session: Session + __worker = concurrent.futures.ThreadPoolExecutor() + + def __init__(self, session: Session): + self.__session = session + + def __worker_callback(self, event_builder: EventBuilder): + 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)) + except IOError as ex: + self.logger.error("Failed sending event: {} {}".format(event_builder, ex)) + + 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: + builder = event_or_builder + else: + raise TypeError() + self.__worker.submit(lambda: self.__worker_callback(builder)) + + def language(self, lang: str): + event = EventService.EventBuilder(EventService.Type.LANGUAGE) + event.append(s=lang) + + def close(self): + self.__worker.shutdown() + + class Type(enum.Enum): + LANGUAGE = ("812", 1) + FETCHED_FILE_ID = ("274", 3) + NEW_SESSION_ID = ("557", 3) + NEW_PLAYBACK_ID = ("558", 1) + TRACK_PLAYED = ("372", 1) + TRACK_TRANSITION = ("12", 37) + CDN_REQUEST = ("10", 20) + + eventId: str + unknown: str + + def __init__(self, event_id: str, unknown: str): + self.eventId = event_id + self.unknown = unknown + + class GenericEvent: + def build(self) -> EventService.EventBuilder: + raise NotImplementedError + + class EventBuilder: + body = io.BytesIO() + + def __init__(self, event_type: EventService.Type): + self.append_no_delimiter(event_type.value[0]) + self.append(event_type.value[1]) + + def append_no_delimiter(self, s: str = None) -> None: + if s is None: + s = "" + self.body.write(s.encode()) + + 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: + self.body.write(b"\x09") + self.body.write(bytes([c])) + return self + if s is not None: + self.body.write(b"\x09") + self.append_no_delimiter(s) + return self + + def to_array(self) -> bytes: + pos = self.body.tell() + self.body.seek(0) + data = self.body.read() + self.body.seek(pos) + return data + + +class Session(Closeable, SubListener): + cipher_pair: typing.Union[CipherPair, None] + connection: typing.Union[ConnectionHolder, None] + country_code: str + logger = logging.getLogger("Librespot:Session") + scheduled_reconnect: typing.Union[sched.Event, None] = None + scheduler = sched.scheduler(time.time) + __api: ApiClient + __ap_welcome: Authentication.APWelcome + __audio_key_manager: typing.Union[AudioKeyManager, None] + __auth_lock = threading.Condition() + __auth_lock_bool = False + __cache_manager: typing.Union[CacheManager, None] + __cdn_manager: typing.Union[CdnManager, None] + __channel_manager: typing.Union[ChannelManager, None] + __client: typing.Union[requests.Session, None] + __closed = False + __closing = False + __content_feeder: typing.Union[PlayableContentFeeder, None] + __event_service: typing.Union[EventService, None] + __keys: DiffieHellman + __mercury_client: MercuryClient + __receiver: typing.Union[Receiver, None] + __server_key = b"\xac\xe0F\x0b\xff\xc20\xaf\xf4k\xfe\xc3\xbf\xbf\x86=" \ + b"\xa1\x91\xc6\xcc3l\x93\xa1O\xb3\xb0\x16\x12\xac\xacj" \ + b"\xf1\x80\xe7\xf6\x14\xd9B\x9d\xbe.4fC\xe3b\xd22z\x1a" \ + b"\r\x92;\xae\xdd\x14\x02\xb1\x81U\x05a\x04\xd5,\x96\xa4" \ + b"L\x1e\xcc\x02J\xd4\xb2\x0c\x00\x1f\x17\xed\xc2/\xc45" \ + b"!\xc8\xf0\xcb\xae\xd2\xad\xd7+\x0f\x9d\xb3\xc52\x1a*" \ + b"\xfeY\xf3Z\r\xach\xf1\xfab\x1e\xfb,\x8d\x0c\xb79-\x92" \ + b"G\xe3\xd75\x1am\xbd$\xc2\xae%[\x88\xff\xabs)\x8a\x0b" \ + b"\xcc\xcd\x0cXg1\x89\xe8\xbd4\x80xJ_\xc9k\x89\x9d\x95k" \ + b"\xfc\x86\xd7O3\xa6x\x17\x96\xc9\xc3-\r2\xa5\xab\xcd\x05'" \ + b"\xe2\xf7\x10\xa3\x96\x13\xc4/\x99\xc0'\xbf\xed\x04\x9c" \ + b"<'X\x04\xb6\xb2\x19\xf9\xc1/\x02\xe9Hc\xec\xa1\xb6B\xa0" \ + b"\x9dH%\xf8\xb3\x9d\xd0\xe8j\xf9HM\xa1\xc2\xba\x860B\xea" \ + b"\x9d\xb3\x08l\x19\x0eH\xb3\x9df\xeb\x00\x06\xa2Z\xee\xa1" \ + b"\x1b\x13\x87<\xd7\x19\xe6U\xbd" + __token_provider: typing.Union[TokenProvider, None] + __user_attributes = {} + + def __init__(self, inner: Inner, address: str) -> None: + signal.signal(signal.SIGINT, lambda _1, _2: self.close()) + signal.signal(signal.SIGTERM, lambda _1, _2: self.close()) + self.__client = Session.create_client(inner.conf) + 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)) + + def api(self) -> ApiClient: + self.__wait_auth_lock() + if self.__api is None: + raise RuntimeError("Session isn't authenticated!") + return self.__api + + def ap_welcome(self): + self.__wait_auth_lock() + if self.__ap_welcome is None: + raise RuntimeError("Session isn't authenticated!") + return self.__ap_welcome + + def audio_key(self) -> AudioKeyManager: + self.__wait_auth_lock() + if self.__audio_key_manager is None: + raise RuntimeError("Session isn't authenticated!") + return self.__audio_key_manager + + def authenticate(self, credential: Authentication.LoginCredentials) -> None: + """ + Log in to Spotify + Args: + credential: Spotify account login information + """ + self.__authenticate_partial(credential, False) + with self.__auth_lock: + self.__mercury_client = MercuryClient(self) + self.__token_provider = TokenProvider(self) + self.__audio_key_manager = AudioKeyManager(self) + self.__channel_manager = ChannelManager(self) + self.__api = ApiClient(self) + self.__cdn_manager = CdnManager(self) + self.__content_feeder = PlayableContentFeeder(self) + self.__cache_manager = CacheManager(self) + 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.mercury().interested_in("spotify:user:attributes:update", self) + + def cache(self) -> CacheManager: + self.__wait_auth_lock() + if self.__cache_manager is None: + raise RuntimeError("Session isn't authenticated!") + return self.__cache_manager + + def cdn(self) -> CdnManager: + self.__wait_auth_lock() + if self.__cdn_manager is None: + raise RuntimeError("Session isn't authenticated!") + return self.__cdn_manager + + def channel(self) -> ChannelManager: + self.__wait_auth_lock() + if self.__channel_manager is None: + raise RuntimeError("Session isn't authenticated!") + return self.__channel_manager + + def client(self) -> requests.Session: + return self.__client + + def close(self) -> None: + """ + Close instance + """ + 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 + if self.__channel_manager is not None: + self.__channel_manager.close() + self.__channel_manager = None + if self.__event_service is not None: + self.__event_service.close() + self.__event_service = None + if self.__receiver is not None: + self.__receiver.stop() + self.__receiver = None + if self.__client is not None: + self.__client.close() + self.__client = None + if self.connection is not None: + self.connection.close() + self.connection = None + with self.__auth_lock: + self.__ap_welcome = None + self.cipher_pair = None + self.__closed = True + self.logger.info("Closed session. device_id: {}".format(self.__inner.device_id)) + + def connect(self) -> None: + """ + Connect to the Spotify Server + """ + acc = Session.Accumulator() + # Send ClientHello + nonce = Random.get_random_bytes(0x10) + client_hello_proto = Keyexchange.ClientHello( + build_info=Version.standard_build_info(), + client_nonce=nonce, + cryptosuites_supported=[ + Keyexchange.Cryptosuite.CRYPTO_SUITE_SHANNON + ], + login_crypto_hello=Keyexchange.LoginCryptoHelloUnion( + diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello( + gc=self.__keys.public_key_bytes(), + server_keys_known=1 + ), + ), + padding=b"\x1e", + ) + client_hello_bytes = client_hello_proto.SerializeToString() + self.connection.write(b"\x00\x04") + self.connection.write_int(2 + 4 + len(client_hello_bytes)) + self.connection.write(client_hello_bytes) + self.connection.flush() + acc.write(b"\x00\x04") + acc.write_int(2 + 4 + len(client_hello_bytes)) + acc.write(client_hello_bytes) + # 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) + 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 + ) + ) + # 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) + if not pkcs1_v1_5.verify( + sha1, ap_response_message_proto.challenge.login_crypto_challenge.diffie_hellman.gs_signature): + raise RuntimeError("Failed signature check!") + # Solve challenge + buffer = io.BytesIO() + for i in range(1, 6): + mac = HMAC.new(shared_key, digestmod=SHA1) + mac.update(acc.read()) + mac.update(bytes([i])) + buffer.write(mac.digest()) + buffer.seek(0) + mac = HMAC.new(buffer.read(20), digestmod=SHA1) + mac.update(acc.read()) + challenge = mac.digest() + client_response_plaintext_proto = Keyexchange.ClientResponsePlaintext( + crypto_response=Keyexchange.CryptoResponseUnion(), + login_crypto_response=Keyexchange.LoginCryptoResponseUnion( + diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(hmac=challenge) + ), + pow_response=Keyexchange.PoWResponseUnion(), + ) + 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() + try: + self.connection.set_timeout(1) + scrap = self.connection.read(4) + if len(scrap) == 4: + payload = self.connection.read(struct.unpack(">i", scrap)[0] - 4) + failed = Keyexchange.APResponseMessage() + failed.ParseFromString(payload) + raise RuntimeError(failed) + except socket.timeout: + pass + finally: + self.connection.set_timeout(0) + buffer.seek(20) + with self.__auth_lock: + self.cipher_pair = CipherPair(buffer.read(32), buffer.read(32)) + self.__auth_lock_bool = True + self.logger.info("Connection successfully!") + + def content_feeder(self) -> PlayableContentFeeder: + self.__wait_auth_lock() + if self.__content_feeder is None: + raise RuntimeError("Session isn't authenticated!") + return self.__content_feeder + + @staticmethod + def create_client(conf: Configuration) -> requests.Session: + client = requests.Session() + return client + + def device_id(self) -> str: + 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 + + def is_valid(self) -> bool: + if self.__closed: + return False + self.__wait_auth_lock() + return self.__ap_welcome is not None and self.connection is not None + + def mercury(self) -> MercuryClient: + self.__wait_auth_lock() + if self.__mercury_client is None: + raise RuntimeError("Session isn't authenticated!") + return self.__mercury_client + + def parse_product_info(self, data) -> None: + """ + Parse product information + Args: + data: Raw product information + """ + products = defusedxml.ElementTree.fromstring(data) + if products is None: + return + product = products[0] + if product is None: + 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)) + + def reconnect(self) -> None: + """ + Reconnect to the Spotify Server + """ + 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.connect() + self.__authenticate_partial( + Authentication.LoginCredentials( + typ=self.__ap_welcome.reusable_auth_credentials_type, + username=self.__ap_welcome.canonical_username, + auth_data=self.__ap_welcome.reusable_auth_credentials, + ), + True, + ) + 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 + + def send(self, cmd: bytes, payload: bytes): + """ + Send data to socket using send_unchecked + Args: + cmd: Command + payload: Payload + """ + if self.__closing and self.connection is None: + self.logger.debug("Connection was broken while closing.") + return + if self.__closed: + raise RuntimeError("Session is closed!") + with self.__auth_lock: + if self.cipher_pair is None or self.__auth_lock_bool: + self.__auth_lock.wait() + self.__send_unchecked(cmd, payload) + + def tokens(self) -> TokenProvider: + self.__wait_auth_lock() + if self.__token_provider is None: + raise RuntimeError("Session isn't authenticated!") + return self.__token_provider + + def __authenticate_partial(self, credential: Authentication.LoginCredentials, remove_lock: bool) -> None: + """ + Login to Spotify + Args: + credential: Spotify account login information + """ + if self.cipher_pair is None: + raise RuntimeError("Connection not established!") + client_response_encrypted_proto = Authentication.ClientResponseEncrypted( + login_credentials=credential, + system_info=Authentication.SystemInfo( + os=Authentication.Os.OS_UNKNOWN, + cpu_family=Authentication.CpuFamily.CPU_UNKNOWN, + system_information_string=Version.system_info_string(), + device_id=self.__inner.device_id, + ), + version_string=Version.version_string(), + ) + 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() + self.__ap_welcome.ParseFromString(packet.payload) + self.__receiver = Session.Receiver(self) + 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.seek(0) + self.__send_unchecked(Packet.Type.preferred_locale, preferred_locale.read()) + if remove_lock: + with self.__auth_lock: + self.__auth_lock_bool = False + self.__auth_lock.notify_all() + if self.__inner.conf.store_credentials: + reusable = self.__ap_welcome.reusable_auth_credentials + reusable_type = Authentication.AuthenticationType.Name( + self.__ap_welcome.reusable_auth_credentials_type) + if self.__inner.conf.stored_credentials_file is None: + 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) + + elif packet.is_cmd(Packet.Type.auth_failure): + ap_login_failed = Keyexchange.APLoginFailed() + ap_login_failed.ParseFromString(packet.payload) + raise Session.SpotifyAuthenticationException(ap_login_failed) + else: + raise RuntimeError("Unknown CMD 0x" + packet.cmd.hex()) + + def __send_unchecked(self, cmd: bytes, payload: bytes) -> None: + self.cipher_pair.send_encoded(self.connection, cmd, payload) + + def __wait_auth_lock(self) -> None: + if self.__closing and self.connection is None: + self.logger.debug("Connection was broken while closing.") + return + if self.__closed: + raise RuntimeError("Session is closed!") + with self.__auth_lock: + if self.cipher_pair is None or self.__auth_lock_bool: + self.__auth_lock.wait() + + class AbsBuilder: + conf = None + device_id = None + device_name = "libretto-python" + device_type = Connect.DeviceType.COMPUTER + preferred_locale = "en" + + def __init__(self, conf: Session.Configuration = None): + if conf is None: + self.conf = Session.Configuration.Builder().build() + else: + self.conf = conf + + def set_preferred_locale(self, locale: str) -> Session.AbsBuilder: + if len(locale) != 2: + raise TypeError("Invalid locale: {}".format(locale)) + self.preferred_locale = locale + return self + + def set_device_name(self, device_name: str) -> Session.AbsBuilder: + self.device_name = device_name + return self + + def set_device_id(self, device_id: str) -> Session.AbsBuilder: + if self.device_id is not None and len(device_id) != 40: + raise TypeError("Device ID must be 40 chars long.") + self.device_id = device_id + return self + + def set_device_type( + self, device_type: Connect.DeviceType) -> Session.AbsBuilder: + self.device_type = device_type + return self + + class Accumulator: + __buffer = io.BytesIO() + + def read(self) -> bytes: + """ + Read all buffer + Returns: + All buffer + """ + pos = self.__buffer.tell() + self.__buffer.seek(0) + data = self.__buffer.read() + self.__buffer.seek(pos) + return data + + def write(self, data: bytes) -> None: + """ + Write data to buffer + Args: + data: Bytes to be written + """ + self.__buffer.write(data) + + def write_int(self, data: int) -> None: + """ + Write data to buffer + Args: + data: Integer to be written + """ + self.write(struct.pack(">i", data)) + + def write_short(self, data: int) -> None: + """ + Write data to buffer + Args: + data: Short integer to be written + """ + self.write(struct.pack(">h", data)) + + class Builder(AbsBuilder): + login_credentials: Authentication.LoginCredentials = None + + def stored(self): + """ + TODO: implement function + """ + pass + + def stored_file(self, stored_credentials: str = None) -> Session.Builder: + """ + Create credential from stored file + Args: + stored_credentials: credential file path + Returns: + Builder + """ + if stored_credentials is None: + stored_credentials = self.conf.stored_credentials_file + if os.path.isfile(stored_credentials): + try: + with open(stored_credentials) as f: + obj = json.load(f) + except json.JSONDecodeError: + pass + else: + try: + self.login_credentials = Authentication.LoginCredentials( + typ=Authentication.AuthenticationType.Value(obj["type"]), + username=obj["username"], + auth_data=base64.b64decode(obj["credentials"]), + ) + except KeyError: + pass + return self + + def user_pass(self, username: str, password: str) -> Session.Builder: + """ + Create credential from username and password + Args: + username: Spotify's account username + password: Spotify's account password + Returns: + Builder + """ + self.login_credentials = Authentication.LoginCredentials( + username=username, + typ=Authentication.AuthenticationType.AUTHENTICATION_USER_PASS, + auth_data=password.encode(), + ) + return self + + def create(self) -> Session: + """ + Create the Session instance + Returns: + Session instance + """ + if self.login_credentials is None: + raise RuntimeError("You must select an authentication method.") + session = Session( + Session.Inner( + self.device_type, + self.device_name, + self.preferred_locale, + self.conf, + self.device_id, + ), + ApResolver.get_random_accesspoint(), + ) + session.connect() + session.authenticate(self.login_credentials) + return session + + class Configuration: + # Proxy + # proxyEnabled: bool + # proxyType: Proxy.Type + # proxyAddress: str + # proxyPort: int + # proxyAuth: bool + # proxyUsername: str + # proxyPassword: str + + # Cache + cache_enabled: bool + cache_dir: str + do_cache_clean_up: bool + + # Stored credentials + store_credentials: bool + stored_credentials_file: str + + # Fetching + 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.proxyEnabled = proxy_enabled + # self.proxyType = proxy_type + # self.proxyAddress = proxy_address + # self.proxyPort = proxy_port + # self.proxyAuth = proxy_auth + # self.proxyUsername = proxy_username + # self.proxyPassword = proxy_password + self.cache_enabled = cache_enabled + self.cache_dir = cache_dir + self.do_cache_clean_up = do_cache_clean_up + self.store_credentials = store_credentials + self.stored_credentials_file = stored_credentials_file + self.retry_on_chunk_error = retry_on_chunk_error + + class Builder: + # Proxy + # proxyEnabled: bool = False + # proxyType: Proxy.Type = Proxy.Type.DIRECT + # proxyAddress: str = None + # proxyPort: int = None + # proxyAuth: bool = None + # proxyUsername: str = None + # proxyPassword: str = None + + # Cache + cache_enabled: bool = True + cache_dir: str = os.path.join(os.getcwd(), "cache") + do_cache_clean_up: bool = True + + # Stored credentials + store_credentials: bool = True + stored_credentials_file: str = os.path.join(os.getcwd(), "credentials.json") + + # Fetching + retry_on_chunk_error: bool = True + + # def set_proxy_enabled( + # self, + # proxy_enabled: bool) -> Session.Configuration.Builder: + # self.proxyEnabled = proxy_enabled + # return self + + # def set_proxy_type( + # self, + # proxy_type: Proxy.Type) -> Session.Configuration.Builder: + # self.proxyType = proxy_type + # return self + + # def set_proxy_address( + # self, proxy_address: str) -> Session.Configuration.Builder: + # self.proxyAddress = proxy_address + # return self + + # def set_proxy_auth( + # self, proxy_auth: bool) -> Session.Configuration.Builder: + # self.proxyAuth = proxy_auth + # return self + + # def set_proxy_username( + # self, + # proxy_username: str) -> Session.Configuration.Builder: + # self.proxyUsername = proxy_username + # return self + + # def set_proxy_password( + # self, + # proxy_password: str) -> Session.Configuration.Builder: + # self.proxyPassword = proxy_password + # return self + + def set_cache_enabled(self, cache_enabled: bool) -> Session.Configuration.Builder: + """ + Set cache_enabled + Args: + cache_enabled: Cache enabled + Returns: + Builder + """ + self.cache_enabled = cache_enabled + return self + + def set_cache_dir(self, cache_dir: str) -> Session.Configuration.Builder: + """ + Set cache_dir + Args: + cache_dir: Cache directory + Returns: + Builder + """ + self.cache_dir = cache_dir + return self + + def set_do_cache_clean_up(self, do_cache_clean_up: bool) -> Session.Configuration.Builder: + """ + Set do_cache_clean_up + Args: + do_cache_clean_up: Do cache clean up + Returns: + Builder + """ + self.do_cache_clean_up = do_cache_clean_up + return self + + def set_store_credentials(self, store_credentials: bool) -> Session.Configuration.Builder: + """ + Set store_credentials + Args: + store_credentials: Store credentials + Returns: + Builder + """ + self.store_credentials = store_credentials + return self + + def set_stored_credential_file(self, stored_credential_file: str) -> Session.Configuration.Builder: + """ + Set stored_credential_file + Args: + stored_credential_file: Stored credential file + Returns: + Builder + """ + self.stored_credentials_file = stored_credential_file + return self + + def set_retry_on_chunk_error(self, retry_on_chunk_error: bool) -> Session.Configuration.Builder: + """ + Set retry_on_chunk_error + Args: + retry_on_chunk_error: Retry on chunk error + Returns: + Builder + """ + self.retry_on_chunk_error = retry_on_chunk_error + return self + + def build(self) -> Session.Configuration: + """ + Build Configuration instance + Returns: + Session.Configuration + """ + return Session.Configuration( + # self.proxyEnabled, + # self.proxyType, + # self.proxyAddress, + # self.proxyPort, + # self.proxyAuth, + # self.proxyUsername, + # self.proxyPassword, + self.cache_enabled, + self.cache_dir, + self.do_cache_clean_up, + self.store_credentials, + self.stored_credentials_file, + self.retry_on_chunk_error, + ) + + class ConnectionHolder: + __buffer = io.BytesIO() + __socket: socket.socket + + def __init__(self, sock: socket.socket): + self.__socket = sock + + @staticmethod + def create(address: str, conf) \ + -> Session.ConnectionHolder: + """ + Create the ConnectionHolder instance + Args: + address: Address to connect + conf: Configuration + Returns: + ConnectionHolder instance + """ + ap_address = address.split(":")[0] + ap_port = int(address.split(":")[1]) + sock = socket.socket() + sock.connect((ap_address, ap_port)) + return Session.ConnectionHolder(sock) + + def close(self) -> None: + """ + Close the connection + """ + self.__socket.close() + + def flush(self) -> None: + """ + Flush data to socket + """ + self.__buffer.seek(0) + self.__socket.send(self.__buffer.read()) + self.__buffer = io.BytesIO() + + def read(self, length: int) -> bytes: + """ + Read data from socket + Args: + length: Reading length + Returns: + Bytes data from socket + """ + return self.__socket.recv(length) + + def read_int(self) -> int: + """ + Read integer from socket + Returns: + integer from socket + """ + return struct.unpack(">i", self.read(4))[0] + + def read_short(self) -> int: + """ + Read short integer from socket + Returns: + short integer from socket + """ + return struct.unpack(">h", self.read(2))[0] + + def set_timeout(self, seconds: float) -> None: + """ + Set socket's timeout + Args: + seconds: Number of seconds until timeout + """ + self.__socket.settimeout(None if seconds == 0 else seconds) + + def write(self, data: bytes) -> None: + """ + Write data to buffer + Args: + data: Bytes to be written + """ + self.__buffer.write(data) + + def write_int(self, data: int) -> None: + """ + Write data to buffer + Args: + data: Integer to be written + """ + self.write(struct.pack(">i", data)) + + def write_short(self, data: int) -> None: + """ + Write data to buffer + Args: + data: Short integer to be written + """ + self.write(struct.pack(">h", data)) + + class Inner: + device_type: Connect.DeviceType = None + device_name: str + device_id: str + conf = None + preferred_locale: str + + def __init__( + 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)) + + class Receiver: + __session: Session + __thread: threading.Thread + __running: bool = True + + def __init__(self, session): + self.__session = session + self.__thread = threading.Thread(target=self.run) + self.__thread.setDaemon(True) + self.__thread.setName("session-packet-receiver") + self.__thread.start() + + def stop(self) -> None: + self.__running = False + + def run(self) -> None: + """ + Receive Packet thread function + """ + self.__session.logger.info("Session.Receiver started") + while self.__running: + packet: Packet + cmd: bytes + try: + 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)) + continue + except RuntimeError as ex: + if self.__running: + 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) + + def anonymous(): + 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.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)) + elif cmd == Packet.Type.license_version: + license_version = io.BytesIO(packet.payload) + 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())) + else: + 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))) + elif cmd in [ + 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 + ]: + 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))) + + class SpotifyAuthenticationException(Exception): + def __init__(self, login_failed: Keyexchange.APLoginFailed): + super().__init__(Keyexchange.ErrorCode.Name(login_failed.error_code)) + + +class TokenProvider: + logger = logging.getLogger("Librespot:TokenProvider") + token_expire_threshold = 10 + __session: Session + __tokens: typing.List[StoredToken] = [] + + def __init__(self, session: Session): + self._session = session + + 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 + return None + + def get(self, scope: str) -> str: + return self.get_token(scope).access_token + + def get_token(self, *scopes) -> StoredToken: + scopes = list(scopes) + if len(scopes) == 0: + raise RuntimeError("The token doesn't have any scope") + token = self.find_token_with_all_scopes(scopes) + if token is not None: + if token.expired(): + self.__tokens.remove(token) + else: + return token + self.logger.debug( + "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))) + token = TokenProvider.StoredToken(response) + self.logger.debug("Updated token successfully! scopes: {}, new_token: {}".format(scopes, token)) + self.__tokens.append(token) + return token + + class StoredToken: + expires_in: int + access_token: str + scopes: typing.List[str] + timestamp: int + + def __init__(self, obj): + self.timestamp = int(time.time_ns() / 1000) + self.expires_in = obj["expiresIn"] + self.access_token = obj["accessToken"] + self.scopes = obj["scope"] + + def expired(self) -> bool: + 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: + if s == scope: + return True + return False + + def has_scopes(self, sc: typing.List[str]) -> bool: + for s in sc: + if not self.has_scope(s): + return False + return True diff --git a/librespot/core/ApResolver.py b/librespot/core/ApResolver.py deleted file mode 100644 index 6652514..0000000 --- a/librespot/core/ApResolver.py +++ /dev/null @@ -1,33 +0,0 @@ -import random - -import requests - - -class ApResolver: - base_url = "http://apresolve.spotify.com/" - - @staticmethod - def request(service_type: str): - response = requests.get("{}?type={}".format(ApResolver.base_url, - service_type)) - return response.json() - - @staticmethod - def get_random_of(service_type: str): - pool = ApResolver.request(service_type) - urls = pool.get(service_type) - if urls is None or len(urls) == 0: - raise RuntimeError() - return random.choice(urls) - - @staticmethod - def get_random_dealer() -> str: - return ApResolver.get_random_of("dealer") - - @staticmethod - def get_random_spclient() -> str: - return ApResolver.get_random_of("spclient") - - @staticmethod - def get_random_accesspoint() -> str: - return ApResolver.get_random_of("accesspoint") diff --git a/librespot/core/EventService.py b/librespot/core/EventService.py deleted file mode 100644 index 427b736..0000000 --- a/librespot/core/EventService.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations -import concurrent.futures -import enum -import time -import typing -import logging - -from librespot.core import Session -from librespot.mercury import RawMercuryRequest -from librespot.standard import ByteArrayOutputStream - - -class EventService: - _session: Session - _LOGGER: logging = logging.getLogger(__name__) - _worker: concurrent.futures.ThreadPoolExecutor = concurrent.futures.ThreadPoolExecutor( - ) - - def __init__(self, session: Session): - self._session = session - - def _worker_callback(self, event_builder: EventService.EventBuilder): - 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)) - except IOError as ex: - self._LOGGER.error("Failed sending event: {} {}".format( - event_builder, ex)) - - def send_event(self, - event_or_builder: typing.Union[EventService.GenericEvent, - EventService.EventBuilder]): - if type(event_or_builder) is EventService.GenericEvent: - builder = event_or_builder.build() - elif type(event_or_builder) is EventService.EventBuilder: - builder = event_or_builder - else: - raise TypeError() - self._worker.submit(lambda: self._worker_callback(builder)) - - def language(self, lang: str): - event = EventService.EventBuilder(EventService.Type.LANGUAGE) - event.append(s=lang) - - def close(self): - pass - - class Type(enum.Enum): - LANGUAGE = ("812", 1) - FETCHED_FILE_ID = ("274", 3) - NEW_SESSION_ID = ("557", 3) - NEW_PLAYBACK_ID = ("558", 1) - TRACK_PLAYED = ("372", 1) - TRACK_TRANSITION = ("12", 37) - CDN_REQUEST = ("10", 20) - - _eventId: str - _unknown: str - - def __init__(self, event_id: str, unknown: str): - self._eventId = event_id - self._unknown = unknown - - class GenericEvent: - def build(self) -> EventService.EventBuilder: - pass - - class EventBuilder: - body: ByteArrayOutputStream = ByteArrayOutputStream(256) - - def __init__(self, type: EventService.Type): - self.append_no_delimiter(type.value[0]) - self.append(type.value[1]) - - def append_no_delimiter(self, s: str = None) -> None: - if s is None: - s = "" - - self.body.write(buffer=bytearray(s.encode())) - - 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: - self.body.write(byte=0x09) - self.body.write(byte=c) - return self - if s is not None: - self.body.write(byte=0x09) - self.append_no_delimiter(s) - return self - - def to_array(self) -> bytearray: - return self.body.to_byte_array() diff --git a/librespot/core/PacketsReceiver.py b/librespot/core/PacketsReceiver.py deleted file mode 100644 index a4c59c1..0000000 --- a/librespot/core/PacketsReceiver.py +++ /dev/null @@ -1,6 +0,0 @@ -from librespot.crypto.Packet import Packet - - -class PacketsReceiver: - def dispatch(self, packet: Packet): - pass diff --git a/librespot/core/SearchManager.py b/librespot/core/SearchManager.py deleted file mode 100644 index 706be4d..0000000 --- a/librespot/core/SearchManager.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations -from librespot.core import Session - - -class SearchManager: - _BASE_URL: str = "hm://searchview/km/v4/search/" - _session: Session - - def __init__(self, session: Session): - self._session = session diff --git a/librespot/core/Session.py b/librespot/core/Session.py deleted file mode 100644 index bb7e99c..0000000 --- a/librespot/core/Session.py +++ /dev/null @@ -1,1328 +0,0 @@ -from __future__ import annotations - -import base64 -import json -import logging -import os -import sched -import socket -import struct -import threading -import time -import typing - -import defusedxml.ElementTree -import requests -from Cryptodome.Hash import HMAC -from Cryptodome.Hash import SHA1 -from Cryptodome.PublicKey import RSA -from Cryptodome.Signature import PKCS1_v1_5 - -from librespot.audio import AudioKeyManager -from librespot.audio import PlayableContentFeeder -from librespot.audio.cdn import CdnManager -from librespot.audio.storage import ChannelManager -from librespot.cache import CacheManager -from librespot.common.Utils import Utils -from librespot.core import ApResolver -from librespot.core import EventService -from librespot.core import SearchManager -from librespot.core import TokenProvider -from librespot.crypto import CipherPair -from librespot.crypto import DiffieHellman -from librespot.crypto import Packet -from librespot.dealer import ApiClient -from librespot.dealer import DealerClient -from librespot.mercury import MercuryClient -from librespot.mercury import SubListener -from librespot.proto import Authentication_pb2 as Authentication -from librespot.proto import Connect_pb2 as Connect -from librespot.proto import Keyexchange_pb2 as Keyexchange -from librespot.proto.ExplicitContentPubsub_pb2 import UserAttributesUpdate -from librespot.standard import BytesInputStream -from librespot.standard import Closeable -from librespot.standard import Proxy -from librespot.Version import Version - - -class Session(Closeable, SubListener, DealerClient.MessageListener): - _LOGGER: logging = logging.getLogger(__name__) - _serverKey: bytes = bytes([ - 0xAC, - 0xE0, - 0x46, - 0x0B, - 0xFF, - 0xC2, - 0x30, - 0xAF, - 0xF4, - 0x6B, - 0xFE, - 0xC3, - 0xBF, - 0xBF, - 0x86, - 0x3D, - 0xA1, - 0x91, - 0xC6, - 0xCC, - 0x33, - 0x6C, - 0x93, - 0xA1, - 0x4F, - 0xB3, - 0xB0, - 0x16, - 0x12, - 0xAC, - 0xAC, - 0x6A, - 0xF1, - 0x80, - 0xE7, - 0xF6, - 0x14, - 0xD9, - 0x42, - 0x9D, - 0xBE, - 0x2E, - 0x34, - 0x66, - 0x43, - 0xE3, - 0x62, - 0xD2, - 0x32, - 0x7A, - 0x1A, - 0x0D, - 0x92, - 0x3B, - 0xAE, - 0xDD, - 0x14, - 0x02, - 0xB1, - 0x81, - 0x55, - 0x05, - 0x61, - 0x04, - 0xD5, - 0x2C, - 0x96, - 0xA4, - 0x4C, - 0x1E, - 0xCC, - 0x02, - 0x4A, - 0xD4, - 0xB2, - 0x0C, - 0x00, - 0x1F, - 0x17, - 0xED, - 0xC2, - 0x2F, - 0xC4, - 0x35, - 0x21, - 0xC8, - 0xF0, - 0xCB, - 0xAE, - 0xD2, - 0xAD, - 0xD7, - 0x2B, - 0x0F, - 0x9D, - 0xB3, - 0xC5, - 0x32, - 0x1A, - 0x2A, - 0xFE, - 0x59, - 0xF3, - 0x5A, - 0x0D, - 0xAC, - 0x68, - 0xF1, - 0xFA, - 0x62, - 0x1E, - 0xFB, - 0x2C, - 0x8D, - 0x0C, - 0xB7, - 0x39, - 0x2D, - 0x92, - 0x47, - 0xE3, - 0xD7, - 0x35, - 0x1A, - 0x6D, - 0xBD, - 0x24, - 0xC2, - 0xAE, - 0x25, - 0x5B, - 0x88, - 0xFF, - 0xAB, - 0x73, - 0x29, - 0x8A, - 0x0B, - 0xCC, - 0xCD, - 0x0C, - 0x58, - 0x67, - 0x31, - 0x89, - 0xE8, - 0xBD, - 0x34, - 0x80, - 0x78, - 0x4A, - 0x5F, - 0xC9, - 0x6B, - 0x89, - 0x9D, - 0x95, - 0x6B, - 0xFC, - 0x86, - 0xD7, - 0x4F, - 0x33, - 0xA6, - 0x78, - 0x17, - 0x96, - 0xC9, - 0xC3, - 0x2D, - 0x0D, - 0x32, - 0xA5, - 0xAB, - 0xCD, - 0x05, - 0x27, - 0xE2, - 0xF7, - 0x10, - 0xA3, - 0x96, - 0x13, - 0xC4, - 0x2F, - 0x99, - 0xC0, - 0x27, - 0xBF, - 0xED, - 0x04, - 0x9C, - 0x3C, - 0x27, - 0x58, - 0x04, - 0xB6, - 0xB2, - 0x19, - 0xF9, - 0xC1, - 0x2F, - 0x02, - 0xE9, - 0x48, - 0x63, - 0xEC, - 0xA1, - 0xB6, - 0x42, - 0xA0, - 0x9D, - 0x48, - 0x25, - 0xF8, - 0xB3, - 0x9D, - 0xD0, - 0xE8, - 0x6A, - 0xF9, - 0x48, - 0x4D, - 0xA1, - 0xC2, - 0xBA, - 0x86, - 0x30, - 0x42, - 0xEA, - 0x9D, - 0xB3, - 0x08, - 0x6C, - 0x19, - 0x0E, - 0x48, - 0xB3, - 0x9D, - 0x66, - 0xEB, - 0x00, - 0x06, - 0xA2, - 0x5A, - 0xEE, - 0xA1, - 0x1B, - 0x13, - 0x87, - 0x3C, - 0xD7, - 0x19, - 0xE6, - 0x55, - 0xBD, - ]) - _keys: DiffieHellman = None - _inner: Session.Inner = None - _scheduler: sched.scheduler = sched.scheduler(time.time) - _authLock: threading.Condition = threading.Condition() - _authLockBool: bool = False - _client: requests.Session = None - _closeListeners: typing.List[Session.CloseListener] = [] - _closeListenersLock: threading.Condition = threading.Condition() - _reconnectionListeners: typing.List[Session.ReconnectionListener] = [] - _reconnectionListenersLock: threading.Condition = threading.Condition() - _userAttributes: typing.Dict[str, str] = {} - _conn: Session.ConnectionHolder = None - _cipherPair: CipherPair = None - _receiver: Session.Receiver = None - _apWelcome: Authentication.APWelcome = None - _mercuryClient: MercuryClient = None - _audioKeyManager: AudioKeyManager.AudioKeyManager = None - _channelManager: ChannelManager = None - _tokenProvider: TokenProvider = None - _cdnManager: CdnManager = None - _cacheManager = None - _dealer: DealerClient = None - _api: ApiClient = None - _search: SearchManager = None - _contentFeeder: PlayableContentFeeder.PlayableContentFeeder = None - _eventService: EventService = None - _countryCode: str - _closed: bool = False - _closing: bool = False - _scheduledReconnect: sched.Event = None - - def __init__(self, inner: Session.Inner, addr: str): - self._inner = inner - self._keys = DiffieHellman() - self._conn = Session.ConnectionHolder.create(addr, inner.conf) - self._client = Session._create_client(self._inner.conf) - - self._LOGGER.info("Created new session! device_id: {}, ap: {}".format( - inner.device_id, addr)) - - @staticmethod - def _create_client(conf: Session.Configuration) -> requests.Session: - client = requests.Session() - if conf.proxyAddress and conf.proxyPort and conf.proxyType is not Proxy.Type.DIRECT: - proxy_setting = [ - conf.proxyUsername, - conf.proxyPassword, - conf.proxyAddress, - conf.proxyPort, - ] - client.proxies = { - "http": "{}:{}@{}:{}".format(*proxy_setting), - "https": "{}:{}@{}:{}".format(*proxy_setting), - } - - return client - - @staticmethod - def _read_blob_int(buffer: bytearray) -> int: - lo = buffer[0] - if (lo & 0x80) == 0: - return lo - hi = buffer[1] - return lo & 0x7F | hi << 7 - - def client(self) -> requests.Session: - return self._client - - def _connect(self) -> None: - acc = Session.Accumulator() - - # Send ClientHello - - nonce = os.urandom(0x10) - - client_hello = Keyexchange.ClientHello( - build_info=Version.standard_build_info(), - cryptosuites_supported=[ - Keyexchange.Cryptosuite.CRYPTO_SUITE_SHANNON - ], - login_crypto_hello=Keyexchange.LoginCryptoHelloUnion( - diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello( - gc=self._keys.public_key_array(), server_keys_known=1), ), - client_nonce=nonce, - padding=bytes([0x1E]), - ) - - client_hello_bytes = client_hello.SerializeToString() - length = 2 + 4 + len(client_hello_bytes) - self._conn.write_byte(0) - self._conn.write_byte(4) - self._conn.write_int(length) - self._conn.write(client_hello_bytes) - self._conn.flush() - - acc.write_byte(0) - acc.write_byte(4) - acc.write_int(length) - acc.write(client_hello_bytes) - - # Read APResponseMessage - - length = self._conn.read_int() - acc.write_int(length) - buffer = self._conn.read(length - 4) - acc.write(buffer) - - ap_response_message = Keyexchange.APResponseMessage() - ap_response_message.ParseFromString(buffer) - shared_key = Utils.to_byte_array( - self._keys.compute_shared_key( - ap_response_message.challenge.login_crypto_challenge. - diffie_hellman.gs)) - - # Check gs_signature - - rsa = RSA.construct((int.from_bytes(self._serverKey, "big"), 65537)) - pkcs1_v1_5 = PKCS1_v1_5.new(rsa) - sha1 = SHA1.new() - sha1.update(ap_response_message.challenge.login_crypto_challenge. - diffie_hellman.gs) - # noinspection PyTypeChecker - if not pkcs1_v1_5.verify( - sha1, - ap_response_message.challenge.login_crypto_challenge. - diffie_hellman.gs_signature, - ): - raise RuntimeError("Failed signature check!") - - # Solve challenge - - data = b"" - - for i in range(1, 6): - # noinspection PyTypeChecker - mac = HMAC.new(shared_key, digestmod=SHA1) - mac.update(acc.array()) - mac.update(bytes([i])) - data += mac.digest() - - # noinspection PyTypeChecker - mac = HMAC.new(data[:20], digestmod=SHA1) - mac.update(acc.array()) - - challenge = mac.digest() - client_response_plaintext = Keyexchange.ClientResponsePlaintext( - login_crypto_response=Keyexchange.LoginCryptoResponseUnion( - diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse( - hmac=challenge)), - pow_response=Keyexchange.PoWResponseUnion(), - crypto_response=Keyexchange.CryptoResponseUnion(), - ) - - client_response_plaintext_bytes = client_response_plaintext.SerializeToString( - ) - length = 4 + len(client_response_plaintext_bytes) - self._conn.write_int(length) - self._conn.write(client_response_plaintext_bytes) - self._conn.flush() - - try: - self._conn.set_timeout(1) - scrap = self._conn.read(4) - if 4 == len(scrap): - length = ((scrap[0] << 24) - | (scrap[1] << 16) - | (scrap[2] << 8) - | (scrap[3] & 0xFF)) - payload = self._conn.read(length - 4) - failed = Keyexchange.APResponseMessage() - failed.ParseFromString(payload) - raise RuntimeError(failed) - except socket.timeout: - pass - finally: - self._conn.set_timeout(0) - - with self._authLock: - self._cipherPair = CipherPair(data[20:52], data[52:84]) - - self._authLockBool = True - - self._LOGGER.info("Connection successfully!") - - def _authenticate(self, - credentials: Authentication.LoginCredentials) -> None: - self._authenticate_partial(credentials, False) - - with self._authLock: - self._mercuryClient = MercuryClient(self) - self._tokenProvider = TokenProvider.TokenProvider(self) - self._audioKeyManager = AudioKeyManager.AudioKeyManager(self) - self._channelManager = ChannelManager(self) - self._api = ApiClient.ApiClient(self) - self._cdnManager = CdnManager(self) - self._contentFeeder = PlayableContentFeeder.PlayableContentFeeder( - self) - self._cacheManager = CacheManager(self) - self._dealer = DealerClient(self) - self._search = SearchManager.SearchManager(self) - self._eventService = EventService.EventService(self) - - self._authLockBool = False - self._authLock.notify_all() - - self._eventService.language(self._inner.preferred_locale) - # TimeProvider.init(self) - self._dealer.connect() - - self._LOGGER.info("Authenticated as {}!".format( - self._apWelcome.canonical_username)) - self.mercury().interested_in("spotify:user:attributes:update", self) - self.dealer().add_message_listener( - self, "hm://connect-state/v1/connect/logout") - - def _authenticate_partial(self, - credentials: Authentication.LoginCredentials, - remove_lock: bool) -> None: - if self._cipherPair is None: - raise RuntimeError("Connection not established!") - - client_response_encrypted = Authentication.ClientResponseEncrypted( - login_credentials=credentials, - system_info=Authentication.SystemInfo( - os=Authentication.Os.OS_UNKNOWN, - cpu_family=Authentication.CpuFamily.CPU_UNKNOWN, - system_information_string=Version.system_info_string(), - device_id=self._inner.device_id, - ), - version_string=Version.version_string(), - ) - - self._send_unchecked(Packet.Type.login, - client_response_encrypted.SerializeToString()) - - packet = self._cipherPair.receive_encoded(self._conn) - if packet.is_cmd(Packet.Type.ap_welcome): - self._apWelcome = Authentication.APWelcome() - self._apWelcome.ParseFromString(packet.payload) - - self._receiver = Session.Receiver(self) - - bytes0x0f = os.urandom(20) - self._send_unchecked(Packet.Type.unknown_0x0f, bytes0x0f) - - preferred_locale = bytes() - preferred_locale += bytes([0x00, 0x00, 0x10, 0x00, 0x02]) - preferred_locale += "preferred-locale".encode() - preferred_locale += self._inner.preferred_locale.encode() - self._send_unchecked(Packet.Type.preferred_locale, - preferred_locale) - - if remove_lock: - with self._authLock: - self._authLockBool = False - self._authLock.notify_all() - - if self._inner.conf.store_credentials: - reusable = self._apWelcome.reusable_auth_credentials - reusable_type = Authentication.AuthenticationType.Name( - self._apWelcome.reusable_auth_credentials_type) - - if self._inner.conf.stored_credentials_file is None: - raise TypeError() - - with open(self._inner.conf.stored_credentials_file, "w") as f: - json.dump( - { - "username": self._apWelcome.canonical_username, - "credentials": base64.b64encode(reusable).decode(), - "type": reusable_type, - }, - f, - ) - - elif packet.is_cmd(Packet.Type.auth_failure): - ap_login_failed = Keyexchange.APLoginFailed() - ap_login_failed.ParseFromString(packet.payload) - raise Session.SpotifyAuthenticationException(ap_login_failed) - else: - raise RuntimeError("Unknown CMD 0x" + packet.cmd.hex()) - - def close(self) -> None: - self._LOGGER.info("Closing session. device_id: {}".format( - self._inner.device_id)) - - self._closing = True - - if self._dealer is not None: - self._dealer.close() - # noinspection PyTypeChecker - self._dealer = None - - if self._audioKeyManager is not None: - # noinspection PyTypeChecker - self._audioKeyManager = None - - if self._channelManager is not None: - self._channelManager.close() - # noinspection PyTypeChecker - self._channelManager = None - - if self._eventService is not None: - self._eventService.close() - # noinspection PyTypeChecker - self._eventService = None - - if self._mercuryClient is not None: - self._mercuryClient.close() - # noinspection PyTypeChecker - self._mercuryClient = None - - if self._receiver is not None: - self._receiver.stop() - # noinspection PyTypeChecker - self._receiver = None - - if self._client is not None: - # noinspection PyTypeChecker - self._client = None - - if self._conn is not None: - self._conn.sock.close() - # noinspection PyTypeChecker - self._conn = None - - with self._authLock: - self._apWelcome = None - # noinspection PyTypeChecker - self._cipherPair = None - self._closed = True - - with self._closeListenersLock: - for listener in self._closeListeners: - listener.on_closed() - self._closeListeners: typing.List[Session.CloseListener] = [] - - self._reconnectionListeners: typing.List[ - Session.ReconnectionListener] = [] - - self._LOGGER.info("Closed session. device_id: {}".format( - self._inner.device_id)) - - def _send_unchecked(self, cmd: bytes, payload: bytes) -> None: - self._cipherPair.send_encoded(self._conn, cmd, payload) - - def _wait_auth_lock(self) -> None: - if self._closing and self._conn is None: - self._LOGGER.debug("Connection was broken while closing.") - return - - if self._closed: - raise RuntimeError("Session is closed!") - - with self._authLock: - if self._cipherPair is None or self._authLockBool: - self._authLock.wait() - - def send(self, cmd: bytes, payload: bytes): - if self._closing and self._conn is None: - self._LOGGER.debug("Connection was broken while closing.") - return - - if self._closed: - raise RuntimeError("Session is closed!") - - with self._authLock: - if self._cipherPair is None or self._authLockBool: - self._authLock.wait() - - self._send_unchecked(cmd, payload) - - def mercury(self) -> MercuryClient: - self._wait_auth_lock() - if self._mercuryClient is None: - raise RuntimeError("Session isn't authenticated!") - return self._mercuryClient - - def audio_key(self) -> AudioKeyManager.AudioKeyManager: - self._wait_auth_lock() - if self._audioKeyManager is None: - raise RuntimeError("Session isn't authenticated!") - return self._audioKeyManager - - def cache(self) -> CacheManager: - self._wait_auth_lock() - if self._cacheManager is None: - raise RuntimeError("Session isn't authenticated!") - return self._cacheManager - - def cdn(self) -> CdnManager: - self._wait_auth_lock() - if self._cdnManager is None: - raise RuntimeError("Session isn't authenticated!") - return self._cdnManager - - def channel(self) -> ChannelManager: - self._wait_auth_lock() - if self._channelManager is None: - raise RuntimeError("Session isn't authenticated!") - return self._channelManager - - def tokens(self) -> TokenProvider: - self._wait_auth_lock() - if self._tokenProvider is None: - raise RuntimeError("Session isn't authenticated!") - return self._tokenProvider - - def dealer(self) -> DealerClient: - self._wait_auth_lock() - if self._dealer is None: - raise RuntimeError("Session isn't authenticated!") - return self._dealer - - def api(self) -> ApiClient: - self._wait_auth_lock() - if self._api is None: - raise RuntimeError("Session isn't authenticated!") - return self._api - - def content_feeder(self) -> PlayableContentFeeder.PlayableContentFeeder: - if self._contentFeeder is None: - raise RuntimeError("Session isn't authenticated!") - return self._contentFeeder - - def search(self) -> SearchManager: - self._wait_auth_lock() - if self._search is None: - raise RuntimeError("Session isn't authenticated!") - return self._search - - def event_service(self) -> EventService: - self._wait_auth_lock() - if self._eventService is None: - raise RuntimeError("Session isn't authenticated!") - return self._eventService - - def username(self) -> str: - return self.ap_welcome().canonical_username - - def ap_welcome(self) -> Authentication.APWelcome: - self._wait_auth_lock() - if self._apWelcome is None: - raise RuntimeError("Session isn't authenticated!") - return self._apWelcome - - def is_valid(self) -> bool: - if self._closed: - return False - - self._wait_auth_lock() - return self._apWelcome is not None and self._conn is not None - - def reconnecting(self) -> bool: - return not self._closing and not self._closed and self._conn is None - - def country_code(self) -> str: - return self._countryCode - - def device_id(self) -> str: - return self._inner.device_id - - def preferred_locale(self) -> str: - return self._inner.preferred_locale - - def device_type(self) -> Connect.DeviceType: - return self._inner.device_type - - def device_name(self) -> str: - return self._inner.device_name - - def configuration(self) -> Session.Configuration: - return self._inner.conf - - def _reconnect(self) -> None: - with self._reconnectionListenersLock: - for listener in self._reconnectionListeners: - listener.on_connection_dropped() - - if self._conn is not None: - self._conn.sock.close() - self._receiver.stop() - - self._conn = Session.ConnectionHolder.create( - ApResolver.get_random_accesspoint(), self._inner.conf) - self._connect() - self._authenticate_partial( - Authentication.LoginCredentials( - typ=self._apWelcome.reusable_auth_credentials_type, - username=self._apWelcome.canonical_username, - auth_data=self._apWelcome.reusable_auth_credentials, - ), - True, - ) - - self._LOGGER.info("Re-authenticated as {}!".format( - self._apWelcome.canonical_username)) - - with self._reconnectionListenersLock: - for listener in self._reconnectionListeners: - listener.on_connection_established() - - def add_close_listener(self, listener: CloseListener) -> None: - if listener not in self._closeListeners: - self._closeListeners.append(listener) - - def add_reconnection_listener(self, - listener: ReconnectionListener) -> None: - if listener not in self._reconnectionListeners: - self._reconnectionListeners.append(listener) - - def remove_reconnection_listener(self, - listener: ReconnectionListener) -> None: - self._reconnectionListeners.remove(listener) - - def _parse_product_info(self, data) -> None: - doc = defusedxml.ElementTree.fromstring(data) - - products = doc - if products is None: - return - - product = products[0] - if product is None: - return - - for i in range(len(product)): - self._userAttributes[product[i].tag] = product[i].text - - self._LOGGER.debug("Parsed product info: {}".format( - self._userAttributes)) - - def get_user_attribute(self, key: str, fallback: str = None) -> str: - return (self._userAttributes.get(key) - if self._userAttributes.get(key) is not None else fallback) - - def event(self, resp: MercuryClient.Response) -> None: - if resp.uri == "spotify:user:attributes:update": - attributes_update = UserAttributesUpdate() - attributes_update.ParseFromString(resp.payload) - - for pair in attributes_update.pairs_list: - self._userAttributes[pair.key] = pair.value - self._LOGGER.info("Updated user attribute: {} -> {}".format( - pair.key, pair.value)) - - def on_message(self, uri: str, headers: typing.Dict[str, str], - payload: bytes) -> None: - if uri == "hm://connect-state/v1/connect/logout": - self.close() - - class ReconnectionListener: - def on_connection_dropped(self) -> None: - pass - - def on_connection_established(self) -> None: - pass - - class CloseListener: - def on_closed(self) -> None: - pass - - class Inner: - device_type: Connect.DeviceType = None - device_name: str - device_id: str - conf = None - preferred_locale: str - - def __init__( - 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 - Utils.random_hex_string(40)) - - class AbsBuilder: - conf = None - device_id = None - device_name = "librespot-python" - device_type = Connect.DeviceType.COMPUTER - preferred_locale = "en" - - def __init__(self, conf: Session.Configuration = None): - if conf is None: - self.conf = Session.Configuration.Builder().build() - else: - self.conf = conf - - def set_preferred_locale(self, locale: str) -> Session.AbsBuilder: - if len(locale) != 2: - raise TypeError("Invalid locale: {}".format(locale)) - - self.preferred_locale = locale - return self - - def set_device_name(self, device_name: str) -> Session.AbsBuilder: - self.device_name = device_name - return self - - def set_device_id(self, device_id: str) -> Session.AbsBuilder: - if self.device_id is not None and len(device_id) != 40: - raise TypeError("Device ID must be 40 chars long.") - - self.device_id = device_id - return self - - def set_device_type( - self, device_type: Connect.DeviceType) -> Session.AbsBuilder: - self.device_type = device_type - return self - - class Builder(AbsBuilder): - login_credentials: Authentication.LoginCredentials = None - - def stored(self): - pass - - def stored_file(self, - stored_credentials: str = None) -> Session.Builder: - if stored_credentials is None: - stored_credentials = self.conf.stored_credentials_file - if os.path.isfile(stored_credentials): - try: - with open(stored_credentials) as f: - obj = json.load(f) - except json.JSONDecodeError: - pass - else: - try: - self.login_credentials = Authentication.LoginCredentials( - typ=Authentication.AuthenticationType.Value( - obj["type"]), - username=obj["username"], - auth_data=base64.b64decode(obj["credentials"]), - ) - except KeyError: - pass - - return self - - def user_pass(self, username: str, password: str) -> Session.Builder: - self.login_credentials = Authentication.LoginCredentials( - username=username, - typ=Authentication.AuthenticationType.AUTHENTICATION_USER_PASS, - auth_data=password.encode(), - ) - return self - - def create(self) -> Session: - if self.login_credentials is None: - raise RuntimeError("You must select an authentication method.") - - session = Session( - Session.Inner( - self.device_type, - self.device_name, - self.preferred_locale, - self.conf, - self.device_id, - ), - ApResolver.get_random_accesspoint(), - ) - session._connect() - session._authenticate(self.login_credentials) - return session - - class Configuration: - # Proxy - proxyEnabled: bool - proxyType: Proxy.Type - proxyAddress: str - proxyPort: int - proxyAuth: bool - proxyUsername: str - proxyPassword: str - - # Cache - cache_enabled: bool - cache_dir: str - do_cache_clean_up: bool - - # Stored credentials - store_credentials: bool - stored_credentials_file: str - - # Fetching - 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.proxyEnabled = proxy_enabled - self.proxyType = proxy_type - self.proxyAddress = proxy_address - self.proxyPort = proxy_port - self.proxyAuth = proxy_auth - self.proxyUsername = proxy_username - self.proxyPassword = proxy_password - self.cache_enabled = cache_enabled - self.cache_dir = cache_dir - self.do_cache_clean_up = do_cache_clean_up - self.store_credentials = store_credentials - self.stored_credentials_file = stored_credentials_file - self.retry_on_chunk_error = retry_on_chunk_error - - class Builder: - # Proxy - proxyEnabled: bool = False - proxyType: Proxy.Type = Proxy.Type.DIRECT - proxyAddress: str = None - proxyPort: int = None - proxyAuth: bool = None - proxyUsername: str = None - proxyPassword: str = None - - # Cache - cache_enabled: bool = True - cache_dir: str = os.path.join(os.getcwd(), "cache") - do_cache_clean_up: bool = True - - # Stored credentials - store_credentials: bool = True - stored_credentials_file: str = os.path.join( - os.getcwd(), "credentials.json") - - # Fetching - retry_on_chunk_error: bool = True - - def set_proxy_enabled( - self, - proxy_enabled: bool) -> Session.Configuration.Builder: - self.proxyEnabled = proxy_enabled - return self - - def set_proxy_type( - self, - proxy_type: Proxy.Type) -> Session.Configuration.Builder: - self.proxyType = proxy_type - return self - - def set_proxy_address( - self, proxy_address: str) -> Session.Configuration.Builder: - self.proxyAddress = proxy_address - return self - - def set_proxy_auth( - self, proxy_auth: bool) -> Session.Configuration.Builder: - self.proxyAuth = proxy_auth - return self - - def set_proxy_username( - self, - proxy_username: str) -> Session.Configuration.Builder: - self.proxyUsername = proxy_username - return self - - def set_proxy_password( - self, - proxy_password: str) -> Session.Configuration.Builder: - self.proxyPassword = proxy_password - return self - - def set_cache_enabled( - self, - cache_enabled: bool) -> Session.Configuration.Builder: - self.cache_enabled = cache_enabled - return self - - def set_cache_dir(self, - cache_dir: str) -> Session.Configuration.Builder: - self.cache_dir = cache_dir - return self - - def set_do_cache_clean_up( - self, - do_cache_clean_up: bool) -> Session.Configuration.Builder: - self.do_cache_clean_up = do_cache_clean_up - return self - - def set_store_credentials( - self, - store_credentials: bool) -> Session.Configuration.Builder: - self.store_credentials = store_credentials - return self - - def set_stored_credential_file( - self, stored_credential_file: str - ) -> Session.Configuration.Builder: - self.stored_credentials_file = stored_credential_file - return self - - def set_retry_on_chunk_error( - self, retry_on_chunk_error: bool - ) -> Session.Configuration.Builder: - self.retry_on_chunk_error = retry_on_chunk_error - return self - - def build(self) -> Session.Configuration: - return Session.Configuration( - self.proxyEnabled, - self.proxyType, - self.proxyAddress, - self.proxyPort, - self.proxyAuth, - self.proxyUsername, - self.proxyPassword, - self.cache_enabled, - self.cache_dir, - self.do_cache_clean_up, - self.store_credentials, - self.stored_credentials_file, - self.retry_on_chunk_error, - ) - - class SpotifyAuthenticationException(Exception): - def __init__(self, login_failed: Keyexchange.APLoginFailed): - super().__init__( - Keyexchange.ErrorCode.Name(login_failed.error_code)) - - class Accumulator: - buffer: bytes = bytes() - - def array(self) -> bytes: - return self.buffer - - def write(self, data: bytes) -> int: - self.buffer += data - return len(data) - - def write_byte(self, data: int) -> int: - self.buffer += bytes([data]) - return 1 - - def write_int(self, data: int) -> int: - self.buffer += struct.pack(">i", data) - return 4 - - class ConnectionHolder: - buffer: bytes = bytes() - - def __init__(self, sock: socket.socket): - self.sock = sock - - @staticmethod - def create(addr: str, - conf: Session.Configuration) -> Session.ConnectionHolder: - ap_addr = addr.split(":")[0] - ap_port = int(addr.split(":")[1]) - if not conf.proxyEnabled or conf.proxyType is Proxy.Type.DIRECT: - sock = socket.socket() - sock.connect((ap_addr, ap_port)) - return Session.ConnectionHolder(sock) - - if conf.proxyType is Proxy.Type.HTTP: - sock = socket.socket() - sock.connect((conf.proxyAddress, conf.proxyPort)) - - sock.send("CONNECT {}:{} HTTP/1.0\n".format(ap_addr, - ap_port).encode()) - if conf.proxyAuth: - sock.send( - "Proxy-Authorization: {}\n".format(None).encode()) - - sock.send(b"\n") - - elif conf.proxyType is Proxy.Type.SOCKS: - pass - else: - raise RuntimeError() - - def flush(self) -> None: - self.sock.send(self.buffer) - self.buffer = b"" - - def read(self, length: int) -> bytes: - return self.sock.recv(length) - - def read_int(self) -> int: - return struct.unpack(">i", self.sock.recv(4))[0] - - def set_timeout(self, timeout: int) -> None: - if timeout == 0: - self.sock.settimeout(None) - else: - self.sock.settimeout(timeout) - - def write(self, data: bytes) -> int: - self.buffer += data - return len(data) - - def write_byte(self, data: int) -> int: - self.buffer += bytes([data]) - return 1 - - def write_int(self, data: int) -> int: - self.buffer += struct.pack(">i", data) - return 4 - - def write_short(self, data: int) -> int: - self.buffer += struct.pack(">h", data) - return 2 - - class Receiver: - session: Session - thread: threading.Thread - running: bool = True - - def __init__(self, session): - self.session = session - self.thread = threading.Thread(target=self.run) - self.thread.setDaemon(True) - self.thread.setName("session-packet-receiver") - self.thread.start() - - def stop(self) -> None: - self.running = False - - def run(self) -> None: - self.session._LOGGER.debug("Session.Receiver started") - - while self.running: - packet: Packet - cmd: bytes - try: - # noinspection PyProtectedMember - packet = self.session._cipherPair.receive_encoded( - self.session._conn) - cmd = Packet.Type.parse(packet.cmd) - if cmd is None: - self.session._LOGGER.info( - "Skipping unknown command cmd: 0x{}, payload: {}". - format(Utils.bytes_to_hex(packet.cmd), - packet.payload)) - continue - except RuntimeError as ex: - if self.running: - self.session._LOGGER.fatal( - "Failed reading packet! {}".format(ex)) - # noinspection PyProtectedMember - self.session._reconnect() - break - - if not self.running: - break - if cmd == Packet.Type.ping: - # noinspection PyProtectedMember - if self.session._scheduledReconnect is not None: - # noinspection PyProtectedMember - self.session._scheduler.cancel( - self.session._scheduledReconnect) - - def anonymous(): - self.session._LOGGER.warning( - "Socket timed out. Reconnecting...") - self.session._reconnect() - - # noinspection PyProtectedMember - 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)) - elif cmd == Packet.Type.license_version: - license_version = BytesInputStream(packet.payload) - license_id = license_version.read_short() - if license_id != 0: - buffer = license_version.read() - self.session._LOGGER.info( - "Received license_version: {}, {}".format( - license_id, buffer.decode())) - else: - self.session._LOGGER.info( - "Received license_version: {}".format(license_id)) - elif cmd == Packet.Type.unknown_0x10: - self.session._LOGGER.debug("Received 0x10: {}".format( - Utils.bytes_to_hex(packet.payload))) - elif cmd in [ - 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]: - self.session.channel().dispatch(packet) - elif cmd == Packet.Type.product_info: - # noinspection PyProtectedMember - self.session._parse_product_info(packet.payload) - else: - self.session._LOGGER.info("Skipping {}".format( - Utils.bytes_to_hex(cmd))) - - self.session._LOGGER.debug("Session.Receiver stopped") diff --git a/librespot/core/TimeProvider.py b/librespot/core/TimeProvider.py deleted file mode 100644 index 34ea289..0000000 --- a/librespot/core/TimeProvider.py +++ /dev/null @@ -1,36 +0,0 @@ -import math -import time - - -class TimeProvider: - offset = 0 - method = 0x00 - - def init(self, conf=None, session=None): - if conf is None and session is None: - return - if conf is not None: - self.method = conf.time_synchronization_method - if conf.time_synchronization_method == TimeProvider.Method.ntp: - self.update_with_ntp() - if conf.time_synchronization_method == TimeProvider.Method.manual: - self.offset = conf.time_manual_correction - if session is not None: - if self.method != TimeProvider.Method.melody: - return - self.update_melody(session) - - def current_time_millis(self): - return math.floor(time.time() * 1000) + self.offset - - def update_melody(self, session): - pass - - def update_with_ntp(self): - pass - - class Method: - ntp = 0x00 - ping = 0x01 - melody = 0x02 - manual = 0x03 diff --git a/librespot/core/TokenProvider.py b/librespot/core/TokenProvider.py deleted file mode 100644 index 3b6f43b..0000000 --- a/librespot/core/TokenProvider.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import logging -import typing - -from librespot.core import Session -from librespot.core import TimeProvider -from librespot.mercury import MercuryRequests - - -class TokenProvider: - _LOGGER: logging = logging.getLogger(__name__) - _TOKEN_EXPIRE_THRESHOLD = 10 - _session: Session = None - _tokens: typing.List[TokenProvider.StoredToken] = [] - - def __init__(self, session: Session): - self._session = session - - def find_token_with_all_scopes( - self, scopes: typing.List[str]) -> TokenProvider.StoredToken: - for token in self._tokens: - if token.has_scopes(scopes): - return token - - # noinspection PyTypeChecker - return None - - def get_token(self, *scopes) -> TokenProvider.StoredToken: - scopes = list(scopes) - if len(scopes) == 0: - raise RuntimeError() - - token = self.find_token_with_all_scopes(scopes) - if token is not None: - if token.expired(): - self._tokens.remove(token) - else: - return token - - self._LOGGER.debug( - "Token expired or not suitable, requesting again. scopes: {}, old_token: {}" - .format(scopes, token)) - resp = self._session.mercury().send_sync_json( - MercuryRequests.request_token(self._session.device_id(), - ",".join(scopes))) - token = TokenProvider.StoredToken(resp) - - self._LOGGER.debug( - "Updated token successfully! scopes: {}, new_token: {}".format( - scopes, token)) - self._tokens.append(token) - - return token - - def get(self, scope: str) -> str: - return self.get_token(scope).access_token - - class StoredToken: - expires_in: int - access_token: str - scopes: typing.List[str] - timestamp: int - - def __init__(self, obj): - self.timestamp = TimeProvider.TimeProvider().current_time_millis() - self.expires_in = obj["expiresIn"] - self.access_token = obj["accessToken"] - self.scopes = obj["scope"] - - def expired(self) -> bool: - return (self.timestamp + - (self.expires_in - TokenProvider._TOKEN_EXPIRE_THRESHOLD) * - 1000 < TimeProvider.TimeProvider().current_time_millis()) - - def has_scope(self, scope: str) -> bool: - for s in self.scopes: - if s == scope: - return True - - return False - - def has_scopes(self, sc: typing.List[str]) -> bool: - for s in sc: - if not self.has_scope(s): - return False - - return True diff --git a/librespot/core/__init__.py b/librespot/core/__init__.py deleted file mode 100644 index 81e08d5..0000000 --- a/librespot/core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from librespot.core.ApResolver import ApResolver -from librespot.core.EventService import EventService -from librespot.core.PacketsReceiver import PacketsReceiver -from librespot.core.SearchManager import SearchManager -from librespot.core.Session import Session -from librespot.core.TimeProvider import TimeProvider -from librespot.core.TokenProvider import TokenProvider diff --git a/librespot/crypto.py b/librespot/crypto.py new file mode 100644 index 0000000..d4f112c --- /dev/null +++ b/librespot/crypto.py @@ -0,0 +1,410 @@ +from __future__ import annotations +from Cryptodome import Random +from librespot import util +import io +import re +import struct +import typing + +if typing.TYPE_CHECKING: + from librespot.core import Session + + +class CipherPair: + __receive_cipher: Shannon + __receive_nonce = 0 + __send_cipher: Shannon + __send_nonce = 0 + + def __init__(self, send_key: bytes, receive_key: bytes): + self.__send_cipher = Shannon() + self.__send_cipher.key(send_key) + self.__receive_cipher = Shannon() + self.__receive_cipher.key(receive_key) + + def send_encoded(self, connection: Session.ConnectionHolder, cmd: bytes, payload: bytes) -> None: + """ + Send decrypted data to the socket + :param connection: + :param cmd: + :param payload: + :return: + """ + self.__send_cipher.nonce(self.__send_nonce) + self.__send_nonce += 1 + buffer = io.BytesIO() + buffer.write(cmd) + buffer.write(struct.pack(">H", len(payload))) + buffer.write(payload) + buffer.seek(0) + contents = self.__send_cipher.encrypt(buffer.read()) + mac = self.__send_cipher.finish(4) + connection.write(contents) + connection.write(mac) + connection.flush() + + def receive_encoded(self, connection: Session.ConnectionHolder) -> Packet: + """ + Receive and parse decrypted data from the socket + Args: + connection: ConnectionHolder + Return: + The parsed packet will be returned + """ + try: + self.__receive_cipher.nonce(self.__receive_nonce) + self.__receive_nonce += 1 + 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)) + mac = connection.read(4) + expected_mac = self.__receive_cipher.finish(4) + if mac != expected_mac: + raise RuntimeError() + return Packet(cmd, payload_bytes) + except IndexError: + raise RuntimeError("Failed to receive packet") + + +class DiffieHellman: + """ + DiffieHellman Keyexchange + """ + __prime = int.from_bytes( + b'\xff\xff\xff\xff\xff\xff\xff\xff\xc9\x0f' + b'\xda\xa2!h\xc24\xc4\xc6b\x8b\x80\xdc\x1c' + b'\xd1)\x02N\x08\x8ag\xcct\x02\x0b\xbe\xa6;' + 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") + __private_key: int + __public_key: int + + def __init__(self): + key_data = Random.get_random_bytes(0x5f) + self.__private_key = int.from_bytes(key_data, byteorder="big") + self.__public_key = pow(2, self.__private_key, self.__prime) + + def compute_shared_key(self, remote_key_bytes: bytes): + """ + Compute shared_key + """ + remote_key = int.from_bytes(remote_key_bytes, "big") + return pow(remote_key, self.__private_key, self.__prime) + + def private_key(self) -> int: + """ + Return DiffieHellman's private key + Returns: + DiffieHellman's private key + """ + return self.__private_key + + def public_key(self) -> int: + """ + Return DiffieHellman's public key + Returns: + DiffieHellman's public key + """ + return self.__public_key + + def public_key_bytes(self) -> bytes: + """ + Return DiffieHellman's packed public key + Returns: + DiffieHellman's packed public key + """ + return util.int_to_bytes(self.__public_key) + + +class Packet: + cmd: bytes + payload: bytes + + def __init__(self, cmd: bytes, payload: bytes): + self.cmd = cmd + self.payload = payload + + def is_cmd(self, cmd: bytes) -> bool: + return cmd == self.cmd + + class Type: + secret_block = b"\x02" + ping = b"\x04" + stream_chunk = b"\x08" + stream_chunk_res = b"\x09" + channel_error = b"\x0a" + channel_abort = b"\x0b" + request_key = b"\x0c" + aes_key = b"\x0d" + aes_key_error = b"\x0e" + image = b"\x19" + country_code = b"\x1b" + pong = b"\x49" + pong_ack = b"\x4a" + pause = b"\x4b" + product_info = b"\x50" + legacy_welcome = b"\x69" + license_version = b"\x76" + login = b"\xab" + ap_welcome = b"\xac" + auth_failure = b"\xad" + mercury_req = b"\xb2" + mercury_sub = b"\xb3" + mercury_unsub = b"\xb4" + mercury_event = b"\xb5" + track_ended_time = b"\x82" + unknown_data_all_zeros = b"\x1f" + preferred_locale = b"\x74" + unknown_0x4f = b"\x4f" + unknown_0x0f = b"\x0f" + unknown_0x10 = b"\x10" + + @staticmethod + def parse(val: typing.Union[bytes, None]) -> typing.Union[bytes, None]: + for cmd in [ + Packet.Type.__dict__[attr] + for attr in Packet.Type.__dict__.keys() + if re.search("__.+?__", attr) is None + and type(Packet.Type.__dict__[attr]) is bytes + ]: + if cmd == val: + return cmd + return None + + @staticmethod + def for_method(method: str) -> bytes: + if method == "SUB": + return Packet.Type.mercury_sub + if method == "UNSUB": + return Packet.Type.mercury_unsub + return Packet.Type.mercury_req + + +class Shannon: + n = 16 + fold = n + initkonst = 0x6996c53a + keyp = 13 + r: list + crc: list + init_r: list + konst: int + sbuf: int + mbuf: int + nbuf: int + + def __init__(self): + self.r = [0 for _ in range(self.n)] + self.crc = [0 for _ in range(self.n)] + self.init_r = [0 for _ in range(self.n)] + + def rotl(self, i: int, distance: int) -> int: + return ((i << distance) | (i >> (32 - distance))) & 0xffffffff + + def sbox(self, i: int) -> int: + i ^= self.rotl(i, 5) | self.rotl(i, 7) + i ^= self.rotl(i, 19) | self.rotl(i, 22) + return i + + def sbox2(self, i: int) -> int: + i ^= self.rotl(i, 7) | self.rotl(i, 22) + i ^= self.rotl(i, 5) | self.rotl(i, 19) + return i + + def cycle(self) -> None: + t: int + t = self.r[12] ^ self.r[13] ^ self.konst + t = self.sbox(t) ^ self.rotl(self.r[0], 1) + for i in range(1, self.n): + self.r[i - 1] = self.r[i] + self.r[self.n - 1] = t + t = self.sbox2(self.r[2] ^ self.r[15]) + self.r[0] ^= t + self.sbuf = t ^ self.r[8] ^ self.r[12] + + def crc_func(self, i: int) -> None: + t: int + t = self.crc[0] ^ self.crc[2] ^ self.crc[15] ^ i + for j in range(1, self.n): + self.crc[j - 1] = self.crc[j] + self.crc[self.n - 1] = t + + def mac_func(self, i: int) -> None: + self.crc_func(i) + self.r[self.keyp] ^= i + + def init_state(self) -> None: + self.r[0] = 1 + self.r[1] = 1 + for i in range(2, self.n): + self.r[i] = self.r[i - 1] + self.r[i - 2] + self.konst = self.initkonst + + def save_state(self) -> None: + for i in range(self.n): + self.init_r[i] = self.r[i] + + def reload_state(self) -> None: + for i in range(self.n): + self.r[i] = self.init_r[i] + + def gen_konst(self) -> None: + self.konst = self.r[0] + + def add_key(self, k: int) -> None: + self.r[self.keyp] ^= k + + def diffuse(self) -> None: + for _ in range(self.fold): + self.cycle() + + def load_key(self, key: bytes) -> None: + i: int + j: int + t: int + padding_size = int((len(key) + 3) / 4) * 4 - len(key) + key = key + (b"\x00" * padding_size) + struct.pack(" None: + self.init_state() + self.load_key(key) + self.gen_konst() + self.save_state() + self.nbuf = 0 + + def nonce(self, nonce: typing.Union[bytes, int]) -> None: + if type(nonce) is int: + nonce = bytes(struct.pack(">I", nonce)) + self.reload_state() + self.konst = self.initkonst + self.load_key(nonce) + self.gen_konst() + self.nbuf = 0 + + def encrypt(self, buffer: bytes, n: int = None) -> bytes: + if n is None: + return self.encrypt(buffer, len(buffer)) + buffer = bytearray(buffer) + i = 0 + j: int + t: int + if self.nbuf != 0: + while self.nbuf != 0 and n != 0: + self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) + buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff + i += 1 + self.nbuf -= 8 + n -= 1 + if self.nbuf != 0: + return b"" + self.mac_func(self.mbuf) + j = n & ~0x03 + while i < j: + self.cycle() + t = ((buffer[i + 3] & 0xFF) << 24) | \ + ((buffer[i + 2] & 0xFF) << 16) | \ + ((buffer[i + 1] & 0xFF) << 8) | \ + (buffer[i] & 0xFF) + self.mac_func(t) + t ^= self.sbuf + buffer[i + 3] = (t >> 24) & 0xFF + buffer[i + 2] = (t >> 16) & 0xFF + buffer[i + 1] = (t >> 8) & 0xFF + buffer[i] = t & 0xFF + i += 4 + n &= 0x03 + if n != 0: + self.cycle() + self.mbuf = 0 + self.nbuf = 32 + while self.nbuf != 0 and n != 0: + self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) + buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff + i += 1 + self.nbuf -= 8 + n -= 1 + return bytes(buffer) + + def decrypt(self, buffer: bytes, n: int = None) -> bytes: + if n is None: + return self.decrypt(buffer, len(buffer)) + buffer = bytearray(buffer) + i = 0 + j: int + t: int + if self.nbuf != 0: + while self.nbuf != 0 and n != 0: + buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff + self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) + i += 1 + self.nbuf -= 8 + n -= 1 + if self.nbuf != 0: + return b"" + self.mac_func(self.mbuf) + j = n & ~0x03 + while i < j: + self.cycle() + t = ((buffer[i + 3] & 0xFF) << 24) | \ + ((buffer[i + 2] & 0xFF) << 16) | \ + ((buffer[i + 1] & 0xFF) << 8) | \ + (buffer[i] & 0xFF) + t ^= self.sbuf + self.mac_func(t) + buffer[i + 3] = (t >> 24) & 0xFF + buffer[i + 2] = (t >> 16) & 0xFF + buffer[i + 1] = (t >> 8) & 0xFF + buffer[i] = t & 0xFF + i += 4 + n &= 0x03 + if n != 0: + self.cycle() + self.mbuf = 0 + self.nbuf = 32 + while self.nbuf != 0 and n != 0: + buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff + self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) + i += 1 + self.nbuf -= 8 + n -= 1 + return bytes(buffer) + + def finish(self, n: int) -> bytes: + buffer = bytearray(4) + i = 0 + j: int + if self.nbuf != 0: + self.mac_func(self.mbuf) + self.cycle() + self.add_key(self.initkonst ^ (self.nbuf << 3)) + self.nbuf = 0 + for j in range(self.n): + self.r[j] ^= self.crc[j] + self.diffuse() + while n > 0: + self.cycle() + if n >= 4: + buffer[i + 3] = (self.sbuf >> 24) & 0xff + buffer[i + 2] = (self.sbuf >> 16) & 0xff + buffer[i + 1] = (self.sbuf >> 8) & 0xff + buffer[i] = self.sbuf & 0xff + n -= 4 + i += 4 + else: + for j in range(n): + buffer[i + j] = (self.sbuf >> (i * 8)) & 0xff + break + return bytes(buffer) diff --git a/librespot/crypto/CipherPair.py b/librespot/crypto/CipherPair.py deleted file mode 100644 index 878f273..0000000 --- a/librespot/crypto/CipherPair.py +++ /dev/null @@ -1,59 +0,0 @@ -from librespot.crypto.Packet import Packet -from librespot.crypto.Shannon import Shannon -import struct - - -class CipherPair: - send_cipher: Shannon - receive_cipher: Shannon - send_nonce = 0 - receive_nonce = 0 - - def __init__(self, send_key: bytes, receive_key: bytes): - self.send_cipher = Shannon() - self.send_cipher.key(send_key) - self.send_nonce = 0 - - self.receive_cipher = Shannon() - self.receive_cipher.key(receive_key) - self.receive_nonce = 0 - - def send_encoded(self, conn, cmd: bytes, payload: bytes): - self.send_cipher.nonce(self.send_nonce) - self.send_nonce += 1 - - buffer = b"" - buffer += cmd - buffer += struct.pack(">H", len(payload)) - buffer += payload - - buffer = self.send_cipher.encrypt(buffer) - - mac = self.send_cipher.finish(4) - - conn.write(buffer) - conn.write(mac) - conn.flush() - - def receive_encoded(self, conn) -> Packet: - try: - self.receive_cipher.nonce(self.receive_nonce) - self.receive_nonce += 1 - - header_bytes = self.receive_cipher.decrypt(conn.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( - conn.read(payload_length)) - - mac = conn.read(4) - - expected_mac = self.receive_cipher.finish(4) - if mac != expected_mac: - raise RuntimeError() - - return Packet(cmd, payload_bytes) - except IndexError: - raise RuntimeError() diff --git a/librespot/crypto/DiffieHellman.py b/librespot/crypto/DiffieHellman.py deleted file mode 100644 index e61c1ce..0000000 --- a/librespot/crypto/DiffieHellman.py +++ /dev/null @@ -1,125 +0,0 @@ -import os - -from librespot.common.Utils import Utils - - -class DiffieHellman: - prime_bytes: bytearray = bytes([ - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xC9, - 0x0F, - 0xDA, - 0xA2, - 0x21, - 0x68, - 0xC2, - 0x34, - 0xC4, - 0xC6, - 0x62, - 0x8B, - 0x80, - 0xDC, - 0x1C, - 0xD1, - 0x29, - 0x02, - 0x4E, - 0x08, - 0x8A, - 0x67, - 0xCC, - 0x74, - 0x02, - 0x0B, - 0xBE, - 0xA6, - 0x3B, - 0x13, - 0x9B, - 0x22, - 0x51, - 0x4A, - 0x08, - 0x79, - 0x8E, - 0x34, - 0x04, - 0xDD, - 0xEF, - 0x95, - 0x19, - 0xB3, - 0xCD, - 0x3A, - 0x43, - 0x1B, - 0x30, - 0x2B, - 0x0A, - 0x6D, - 0xF2, - 0x5F, - 0x14, - 0x37, - 0x4F, - 0xE1, - 0x35, - 0x6D, - 0x6D, - 0x51, - 0xC2, - 0x45, - 0xE4, - 0x85, - 0xB5, - 0x76, - 0x62, - 0x5E, - 0x7E, - 0xC6, - 0xF4, - 0x4C, - 0x42, - 0xE9, - 0xA6, - 0x3A, - 0x36, - 0x20, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - ]) - prime: int = int.from_bytes(prime_bytes, "big") - private_key: int - public_key: int - - def __init__(self): - key_data = os.urandom(95) - self.private_key = int.from_bytes(key_data, "big") - self.public_key = pow(2, self.private_key, self.prime) - - def compute_shared_key(self, remote_key_bytes: bytes): - remote_key = int.from_bytes(remote_key_bytes, "big") - return pow(remote_key, self.private_key, self.prime) - - def private_key(self): - return self.private_key - - def public_key(self): - return self.public_key - - def public_key_array(self): - return Utils.to_byte_array(self.public_key) diff --git a/librespot/crypto/Packet.py b/librespot/crypto/Packet.py deleted file mode 100644 index 97d7216..0000000 --- a/librespot/crypto/Packet.py +++ /dev/null @@ -1,66 +0,0 @@ -import re - - -class Packet: - cmd: bytes - payload: bytes - - def __init__(self, cmd: bytes, payload: bytes): - self.cmd = cmd - self.payload = payload - - def is_cmd(self, cmd: bytes): - return cmd == self.cmd - - class Type: - secret_block = b"\x02" - ping = b"\x04" - stream_chunk = b"\x08" - stream_chunk_res = b"\x09" - channel_error = b"\x0a" - channel_abort = b"\x0b" - request_key = b"\x0c" - aes_key = b"\x0d" - aes_key_error = b"\x0e" - image = b"\x19" - country_code = b"\x1b" - pong = b"\x49" - pong_ack = b"\x4a" - pause = b"\x4b" - product_info = b"\x50" - legacy_welcome = b"\x69" - license_version = b"\x76" - login = b"\xab" - ap_welcome = b"\xac" - auth_failure = b"\xad" - mercury_req = b"\xb2" - mercury_sub = b"\xb3" - mercury_unsub = b"\xb4" - mercury_event = b"\xb5" - track_ended_time = b"\x82" - unknown_data_all_zeros = b"\x1f" - preferred_locale = b"\x74" - unknown_0x4f = b"\x4f" - unknown_0x0f = b"\x0f" - unknown_0x10 = b"\x10" - - @staticmethod - def parse(val: bytes): - for cmd in [ - Packet.Type.__dict__[attr] - for attr in Packet.Type.__dict__.keys() - if re.search("__.+?__", attr) is None - and type(Packet.Type.__dict__[attr]) is bytes - ]: - if cmd == val: - return cmd - - return None - - @staticmethod - def for_method(method: str): - if method == "SUB": - return Packet.Type.mercury_sub - if method == "UNSUB": - return Packet.Type.mercury_unsub - return Packet.Type.mercury_req diff --git a/librespot/crypto/Shannon.py b/librespot/crypto/Shannon.py deleted file mode 100644 index 1e8ad5b..0000000 --- a/librespot/crypto/Shannon.py +++ /dev/null @@ -1,322 +0,0 @@ -import struct -import typing - - -class Shannon: - N = 16 - FOLD = N - INITKONST = 0x6996c53a - KEYP = 13 - - R: list - CRC: list - initR: list - konst: int - sbuf: int - mbuf: int - nbuf: int - - def __init__(self): - self.R = [0 for _ in range(self.N)] - self.CRC = [0 for _ in range(self.N)] - self.initR = [0 for _ in range(self.N)] - - def rotl(self, i: int, distance: int): - return ((i << distance) | (i >> (32 - distance))) & 0xffffffff - - def sbox(self, i: int): - i ^= self.rotl(i, 5) | self.rotl(i, 7) - i ^= self.rotl(i, 19) | self.rotl(i, 22) - - return i - - def sbox2(self, i: int): - i ^= self.rotl(i, 7) | self.rotl(i, 22) - i ^= self.rotl(i, 5) | self.rotl(i, 19) - - return i - - def cycle(self): - t: int - - t = self.R[12] ^ self.R[13] ^ self.konst - t = self.sbox(t) ^ self.rotl(self.R[0], 1) - - for i in range(1, self.N): - self.R[i - 1] = self.R[i] - - self.R[self.N - 1] = t - - t = self.sbox2(self.R[2] ^ self.R[15]) - self.R[0] ^= t - self.sbuf = t ^ self.R[8] ^ self.R[12] - - def crc_func(self, i: int): - t: int - - t = self.CRC[0] ^ self.CRC[2] ^ self.CRC[15] ^ i - - for j in range(1, self.N): - self.CRC[j - 1] = self.CRC[j] - - self.CRC[self.N - 1] = t - - def mac_func(self, i: int): - self.crc_func(i) - - self.R[self.KEYP] ^= i - - def init_state(self): - self.R[0] = 1 - self.R[1] = 1 - - for i in range(2, self.N): - self.R[i] = self.R[i - 1] + self.R[i - 2] - - self.konst = self.INITKONST - - def save_state(self): - for i in range(self.N): - self.initR[i] = self.R[i] - - def reload_state(self): - for i in range(self.N): - self.R[i] = self.initR[i] - - def gen_konst(self): - self.konst = self.R[0] - - def add_key(self, k: int): - self.R[self.KEYP] ^= k - - def diffuse(self): - for i in range(self.FOLD): - self.cycle() - - def load_key(self, key: bytes): - extra = bytearray(4) - i: int - j: int - t: int - - padding_size = int((len(key) + 3) / 4) * 4 - len(key) - key = key + (b"\x00" * padding_size) + struct.pack("I", nonce)) - - self.reload_state() - - self.konst = self.INITKONST - - self.load_key(nonce) - - self.gen_konst() - - self.nbuf = 0 - - def encrypt(self, buffer: bytes, n: int = None): - if n is None: - return self.encrypt(buffer, len(buffer)) - - buffer = bytearray(buffer) - - i = 0 - j: int - t: int - - if self.nbuf != 0: - while self.nbuf != 0 and n != 0: - self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) - buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff - - i += 1 - - self.nbuf -= 8 - - n -= 1 - - if self.nbuf != 0: - return - - self.mac_func(self.mbuf) - - j = n & ~0x03 - - while i < j: - self.cycle() - - t = ((buffer[i + 3] & 0xFF) << 24) | \ - ((buffer[i + 2] & 0xFF) << 16) | \ - ((buffer[i + 1] & 0xFF) << 8) | \ - (buffer[i] & 0xFF) - - self.mac_func(t) - - t ^= self.sbuf - - buffer[i + 3] = (t >> 24) & 0xFF - buffer[i + 2] = (t >> 16) & 0xFF - buffer[i + 1] = (t >> 8) & 0xFF - buffer[i] = t & 0xFF - - i += 4 - - n &= 0x03 - - if n != 0: - self.cycle() - - self.mbuf = 0 - self.nbuf = 32 - - while self.nbuf != 0 and n != 0: - self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) - buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff - - i += 1 - - self.nbuf -= 8 - - n -= 1 - return bytes(buffer) - - def decrypt(self, buffer: bytes, n: int = None): - if n is None: - return self.decrypt(buffer, len(buffer)) - - buffer = bytearray(buffer) - - i = 0 - j: int - t: int - - if self.nbuf != 0: - while self.nbuf != 0 and n != 0: - buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff - self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) - - i += 1 - - self.nbuf -= 8 - - n -= 1 - - if self.nbuf != 0: - return - - self.mac_func(self.mbuf) - - j = n & ~0x03 - - while i < j: - self.cycle() - - t = ((buffer[i + 3] & 0xFF) << 24) | \ - ((buffer[i + 2] & 0xFF) << 16) | \ - ((buffer[i + 1] & 0xFF) << 8) | \ - (buffer[i] & 0xFF) - - t ^= self.sbuf - - self.mac_func(t) - - buffer[i + 3] = (t >> 24) & 0xFF - buffer[i + 2] = (t >> 16) & 0xFF - buffer[i + 1] = (t >> 8) & 0xFF - buffer[i] = t & 0xFF - - i += 4 - - n &= 0x03 - - if n != 0: - self.cycle() - - self.mbuf = 0 - self.nbuf = 32 - - while self.nbuf != 0 and n != 0: - buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff - self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf) - - i += 1 - - self.nbuf -= 8 - - n -= 1 - - return bytes(buffer) - - def finish(self, n: int): - buffer = bytearray(4) - - i = 0 - j: int - - if self.nbuf != 0: - self.mac_func(self.mbuf) - - self.cycle() - self.add_key(self.INITKONST ^ (self.nbuf << 3)) - - self.nbuf = 0 - - for j in range(self.N): - self.R[j] ^= self.CRC[j] - - self.diffuse() - - while n > 0: - self.cycle() - - if n >= 4: - buffer[i + 3] = (self.sbuf >> 24) & 0xff - buffer[i + 2] = (self.sbuf >> 16) & 0xff - buffer[i + 1] = (self.sbuf >> 8) & 0xff - buffer[i] = self.sbuf & 0xff - - n -= 4 - i += 4 - else: - for j in range(n): - buffer[i + j] = (self.sbuf >> (i * 8)) & 0xff - break - return bytes(buffer) - - -if __name__ == "__main__": - TEST_KEY = b"test key 128bits" - TEST_PHRASE = b'\x00' * 20 - sh = Shannon() - sh.key(TEST_KEY) - sh.nonce(0) - encr = sh.encrypt(TEST_PHRASE) - print(encr) diff --git a/librespot/crypto/__init__.py b/librespot/crypto/__init__.py deleted file mode 100644 index 3fbf182..0000000 --- a/librespot/crypto/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from librespot.crypto.CipherPair import CipherPair -from librespot.crypto.DiffieHellman import DiffieHellman -from librespot.crypto.Packet import Packet -from librespot.crypto.Shannon import Shannon diff --git a/librespot/dealer.py b/librespot/dealer.py new file mode 100644 index 0000000..29bc85c --- /dev/null +++ b/librespot/dealer.py @@ -0,0 +1,13 @@ +from __future__ import annotations +from librespot.core import ApResolver +from librespot.metadata import AlbumId, ArtistId, EpisodeId, ShowId, TrackId +from librespot.proto import Connect_pb2 as Connect, Metadata_pb2 as Metadata +from librespot.structure import Closeable +import logging +import requests +import typing + +if typing.TYPE_CHECKING: + from librespot.core import Session + + diff --git a/librespot/dealer/ApiClient.py b/librespot/dealer/ApiClient.py deleted file mode 100644 index 5e38beb..0000000 --- a/librespot/dealer/ApiClient.py +++ /dev/null @@ -1,147 +0,0 @@ -import logging -import typing - -import requests - -from librespot.core.ApResolver import ApResolver -from librespot.metadata import AlbumId -from librespot.metadata import ArtistId -from librespot.metadata import EpisodeId -from librespot.metadata import ShowId -from librespot.metadata import TrackId -from librespot.proto import Connect_pb2 as Connect -from librespot.proto import Metadata_pb2 as Metadata -from librespot.standard import Closeable - - -class ApiClient(Closeable): - _LOGGER: logging = logging.getLogger(__name__) - _session = None - _baseUrl: str - - def __init__(self, session): - self._session = session - self._baseUrl = "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: - 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.url = self._baseUrl + 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: - resp = self._session.client().send( - self.build_request(method, suffix, headers, body)) - return resp - - def put_connect_state(self, connection_id: str, - proto: Connect.PutStateRequest) -> None: - resp = self.send( - "PUT", - "/connect-state/v1/devices/{}".format(self._session.device_id()), - { - "Content-Type": "application/protobuf", - "X-Spotify-Connection-Id": connection_id, - }, - proto.SerializeToString(), - ) - - if resp.status_code == 413: - self._LOGGER.warning( - "PUT state payload is too large: {} bytes uncompressed.". - format(len(proto.SerializeToString()))) - elif resp.status_code != 200: - self._LOGGER.warning("PUT state returned {}. headers: {}".format( - resp.status_code, resp.headers)) - - def get_metadata_4_track(self, track: TrackId) -> Metadata.Track: - resp = self.send("GET", "/metadata/4/track/{}".format(track.hex_id()), - None, None) - ApiClient.StatusCodeException.check_status(resp) - - body = resp.content - if body is None: - raise RuntimeError() - proto = Metadata.Track() - proto.ParseFromString(body) - return proto - - def get_metadata_4_episode(self, episode: EpisodeId) -> Metadata.Episode: - resp = self.send("GET", - "/metadata/4/episode/{}".format(episode.hex_id()), - None, None) - ApiClient.StatusCodeException.check_status(resp) - - body = resp.content - if body is None: - raise IOError() - proto = Metadata.Episode() - proto.ParseFromString(body) - return proto - - def get_metadata_4_album(self, album: AlbumId) -> Metadata.Album: - resp = self.send("GET", "/metadata/4/album/{}".format(album.hex_id()), - None, None) - ApiClient.StatusCodeException.check_status(resp) - - body = resp.content - if body is None: - raise IOError() - proto = Metadata.Album() - proto.ParseFromString(body) - return proto - - def get_metadata_4_artist(self, artist: ArtistId) -> Metadata.Artist: - resp = self.send("GET", - "/metadata/4/artist/{}".format(artist.hex_id()), None, - None) - ApiClient.StatusCodeException.check_status(resp) - - body = resp.content - if body is None: - raise IOError() - proto = Metadata.Artist() - proto.ParseFromString(body) - return proto - - def get_metadata_4_show(self, show: ShowId) -> Metadata.Show: - resp = self.send("GET", "/metadata/4/show/{}".format(show.hex_id()), - None, None) - ApiClient.StatusCodeException.check_status(resp) - - body = resp.content - if body is None: - raise IOError() - proto = Metadata.Show() - proto.ParseFromString(body) - return proto - - class StatusCodeException(IOError): - code: int - - def __init__(self, resp: requests.Response): - super().__init__(resp.status_code) - self.code = resp.status_code - - @staticmethod - def check_status(resp: requests.Response) -> None: - if resp.status_code != 200: - raise ApiClient.StatusCodeException(resp) diff --git a/librespot/dealer/DealerClient.py b/librespot/dealer/DealerClient.py deleted file mode 100644 index 7b679d2..0000000 --- a/librespot/dealer/DealerClient.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -import typing - -from librespot.standard.Closeable import Closeable - - -class DealerClient(Closeable): - def __init__(self, session): - pass - - def connect(self): - pass - - def add_message_listener(self, listener: DealerClient.MessageListener, - *uris: str): - pass - - class MessageListener: - def on_message(self, uri: str, headers: typing.Dict[str, str], - payload: bytes): - pass diff --git a/librespot/dealer/__init__.py b/librespot/dealer/__init__.py deleted file mode 100644 index 93f259c..0000000 --- a/librespot/dealer/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from librespot.dealer.ApiClient import ApResolver -from librespot.dealer.DealerClient import DealerClient diff --git a/librespot/mercury.py b/librespot/mercury.py new file mode 100644 index 0000000..6592567 --- /dev/null +++ b/librespot/mercury.py @@ -0,0 +1,384 @@ +from __future__ import annotations +from librespot import util +from librespot.crypto import Packet +from librespot.proto import Mercury_pb2 as Mercury, Pubsub_pb2 as Pubsub +from librespot.structure import Closeable, PacketsReceiver, SubListener +import io +import json +import logging +import queue +import struct +import threading +import typing + +if typing.TYPE_CHECKING: + from librespot.core import Session + + +class JsonMercuryRequest: + request: RawMercuryRequest + + def __init__(self, request: RawMercuryRequest): + self.request = request + + +class MercuryClient(Closeable, PacketsReceiver): + logger = logging.getLogger("Librespot:MercuryClient") + mercury_request_timeout = 3 + __callbacks: typing.Dict[int, Callback] = {} + __remove_callback_lock = threading.Condition() + __partials: typing.Dict[int, typing.List[bytes]] = {} + __seq_holder = 0 + __seq_holder_lock = threading.Condition() + __session: Session + __subscriptions: typing.List[InternalSubListener] = [] + __subscriptions_lock = threading.Condition() + + def __init__(self, session: Session): + self.__session = session + + def close(self) -> None: + """ + Close the MercuryClient instance + """ + if len(self.__subscriptions) != 0: + for listener in self.__subscriptions: + if listener.is_sub: + self.unsubscribe(listener.uri) + else: + self.not_interested_in(listener.listener) + if len(self.__callbacks) != 0: + with self.__remove_callback_lock: + self.__remove_callback_lock.wait(self.mercury_request_timeout) + self.__callbacks.clear() + + def dispatch(self, packet: Packet) -> None: + payload = io.BytesIO(packet.payload) + seq_length = struct.unpack(">H", payload.read(2))[0] + if seq_length == 2: + seq = struct.unpack(">H", payload.read(2))[0] + elif seq_length == 4: + seq = struct.unpack(">i", payload.read(4))[0] + elif seq_length == 8: + seq = struct.unpack(">q", payload.read(8))[0] + else: + raise RuntimeError("Unknown seq length: {}".format(seq_length)) + flags = payload.read(1) + parts = struct.unpack(">H", payload.read(2))[0] + partial = self.__partials.get(seq) + if partial is None or flags == 0: + partial = [] + self.__partials[seq] = partial + self.logger.debug( + "Handling packet, cmd: 0x{}, seq: {}, flags: {}, parts: {}".format( + util.bytes_to_hex(packet.cmd), seq, flags, parts)) + for _ in range(parts): + size = struct.unpack(">H", payload.read(2))[0] + buffer = payload.read(size) + partial.append(buffer) + self.__partials[seq] = partial + if flags != b"\x01": + return + self.__partials.pop(seq) + header = Mercury.Header() + header.ParseFromString(partial[0]) + response = MercuryClient.Response(header, partial) + if packet.is_cmd(Packet.Type.mercury_event): + dispatched = False + with self.__subscriptions_lock: + for sub in self.__subscriptions: + if sub.matches(header.uri): + sub.dispatch(response) + dispatched = True + if not dispatched: + self.logger.debug( + "Couldn't dispatch Mercury event seq: {}, uri: {}, code: {}, 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)): + callback = self.__callbacks.get(seq) + self.__callbacks.pop(seq) + if callback is not None: + callback.response(response) + else: + self.logger.warning( + "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: + self.logger.warning( + "Couldn't handle packet, seq: {}, uri: {}, code: {}".format( + seq, header.uri, header.status_code)) + + def interested_in(self, uri: str, listener: SubListener) -> None: + self.__subscriptions.append(MercuryClient.InternalSubListener(uri, listener, False)) + + def not_interested_in(self, listener: SubListener) -> None: + try: + for subscription in self.__subscriptions: + if subscription.listener is listener: + self.__subscriptions.remove(subscription) + break + except ValueError: + pass + + def send(self, request: RawMercuryRequest, callback) -> int: + """ + Send the Mercury request + Args: + request: RawMercuryRequest + callback: Callback function + Returns: + MercuryClient.Response + """ + buffer = io.BytesIO() + seq: int + with self.__seq_holder_lock: + 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)) + buffer.write(struct.pack(">H", 4)) + buffer.write(struct.pack(">i", seq)) + buffer.write(b"\x01") + buffer.write(struct.pack(">H", 1 + len(request.payload))) + header_bytes = request.header.SerializeToString() + buffer.write(struct.pack(">H", len(header_bytes))) + buffer.write(header_bytes) + for part in request.payload: + buffer.write(struct.pack(">H", len(part))) + buffer.write(part) + buffer.seek(0) + cmd = Packet.Type.for_method(request.header.method) + self.__session.send(cmd, buffer.read()) + self.__callbacks[seq] = callback + return seq + + def send_sync(self, request: RawMercuryRequest) -> Response: + """ + Send the Mercury request + Args: + request: RawMercuryRequest + Returns: + MercuryClient.Response + """ + callback = MercuryClient.SyncCallback() + seq = self.send(request, callback) + 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)) + return response + except queue.Empty as e: + raise IOError(e) + + def send_sync_json(self, request: JsonMercuryRequest) -> typing.Any: + response = self.send_sync(request.request) + if 200 <= response.status_code < 300: + return json.loads(response.payload[0]) + raise MercuryClient.MercuryException(response) + + def subscribe(self, uri: str, listener: SubListener) -> None: + """ + Subscribe URI + Args: + uri: + listener: + """ + response = self.send_sync(RawMercuryRequest.sub(uri)) + if response.status_code != 200: + raise RuntimeError(response) + if len(response.payload) > 0: + for payload in response.payload: + sub = Pubsub.Subscription() + sub.ParseFromString(payload) + self.__subscriptions.append(MercuryClient.InternalSubListener(sub.uri, listener, True)) + else: + self.__subscriptions.append(MercuryClient.InternalSubListener(uri, listener, True)) + self.logger.debug("Subscribed successfully to {}!".format(uri)) + + def unsubscribe(self, uri) -> None: + """ + Unsubscribe URI + Args: + uri: + """ + response = self.send_sync(RawMercuryRequest.unsub(uri)) + if response.status_code != 200: + raise RuntimeError(response) + for subscription in self.__subscriptions: + if subscription.matches(uri): + self.__subscriptions.remove(subscription) + break + self.logger.debug("Unsubscribed successfully from {}!".format(uri)) + + class Callback: + def response(self, response: MercuryClient.Response) -> None: + raise NotImplementedError + + class InternalSubListener: + uri: str + listener: SubListener + is_sub: bool + + def __init__(self, uri: str, listener: SubListener, is_sub: bool): + self.uri = uri + self.listener = listener + self.is_sub = is_sub + + def matches(self, uri: str) -> bool: + """ + Compare with the URI given + Args: + uri: URI to be compared + Returns: + bool + """ + return uri.startswith(self.uri) + + def dispatch(self, response: MercuryClient.Response) -> None: + """ + Dispatch the event response + Args: + response: Response generated by the event + """ + self.listener.event(response) + + class MercuryException(Exception): + code: int + + def __init__(self, response: MercuryClient.Response): + super().__init__("status: {}".format(response.status_code)) + self.code = response.status_code + + class PubSubException(MercuryException): + def __init__(self, response: MercuryClient.Response): + super().__init__(response) + + class Response: + uri: str + payload: typing.List[bytes] + status_code: int + + def __init__(self, header: Mercury.Header, payload: typing.List[bytes]): + self.uri = header.uri + self.status_code = header.status_code + self.payload = payload[1:] + + class SyncCallback(Callback): + __reference = queue.Queue() + + def response(self, response: MercuryClient.Response) -> None: + """ + Set the response + :param response: + :return: + """ + self.__reference.put(response) + self.__reference.task_done() + + def wait_response(self) -> typing.Any: + return self.__reference.get(timeout=MercuryClient.mercury_request_timeout) + + +class MercuryRequests: + keymaster_client_id = "65b708073fc0480ea92a077233ca87bd" + + @staticmethod + def get_root_playlists(username: str): + """ + @TODO implement function + """ + + @staticmethod + def request_token(device_id, scope): + return JsonMercuryRequest( + RawMercuryRequest.get( + "hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}" + .format(scope, MercuryRequests.keymaster_client_id, device_id))) + + +class RawMercuryRequest: + header: Mercury.Header + payload: typing.List[bytes] + + def __init__(self, header: Mercury.Header, payload: typing.List[bytes]): + self.header = header + self.payload = payload + + @staticmethod + def sub(uri: str): + 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() + + @staticmethod + def get(uri: str): + return RawMercuryRequest.new_builder().set_uri(uri).set_method("GET").build() + + @staticmethod + def send(uri: str, part: bytes): + return RawMercuryRequest.new_builder().set_uri(uri) \ + .add_payload_part(part).set_method("SEND").build() + + @staticmethod + def post(uri: str, part: bytes): + return RawMercuryRequest.new_builder().set_uri(uri) \ + .set_method("POST").add_payload_part(part).build() + + @staticmethod + def new_builder(): + return RawMercuryRequest.Builder() + + class Builder: + header_dict: dict + payload: typing.List[bytes] + + def __init__(self): + self.header_dict = {} + self.payload = [] + + def set_uri(self, uri: str): + self.header_dict["uri"] = uri + return self + + def set_content_type(self, content_type: str): + self.header_dict["content_type"] = content_type + return self + + def set_method(self, method: str): + self.header_dict["method"] = method + return self + + def add_user_field(self, + field: Mercury.UserField = None, + key: str = None, + value: str = None): + if field is None and (key is None or value is None): + return self + try: + self.header_dict["user_fields"] + except KeyError: + self.header_dict["user_fields"] = [] + if field is not None: + self.header_dict["user_fields"].append(field) + if key is not None and value is not None: + self.header_dict["user_fields"].append( + Mercury.UserField(key=key, value=value.encode())) + return self + + def add_payload_part(self, part: bytes): + self.payload.append(part) + return self + + def add_protobuf_payload(self, msg): + return self.add_payload_part(msg) + + def build(self): + return RawMercuryRequest(Mercury.Header(**self.header_dict), + self.payload) diff --git a/librespot/mercury/JsonMercuryRequest.py b/librespot/mercury/JsonMercuryRequest.py deleted file mode 100644 index 6eaeb51..0000000 --- a/librespot/mercury/JsonMercuryRequest.py +++ /dev/null @@ -1,5 +0,0 @@ -class JsonMercuryRequest: - request = None - - def __init__(self, request): - self.request = request diff --git a/librespot/mercury/MercuryClient.py b/librespot/mercury/MercuryClient.py deleted file mode 100644 index bbd85fb..0000000 --- a/librespot/mercury/MercuryClient.py +++ /dev/null @@ -1,264 +0,0 @@ -from __future__ import annotations - -import json -import logging -import queue -import threading -import typing - -from librespot.common import Utils -from librespot.core import PacketsReceiver -from librespot.core import Session -from librespot.crypto import Packet -from librespot.mercury import JsonMercuryRequest -from librespot.mercury import RawMercuryRequest -from librespot.mercury import SubListener -from librespot.proto import Mercury_pb2 as Mercury -from librespot.proto import Pubsub_pb2 as Pubsub -from librespot.standard import BytesInputStream -from librespot.standard import BytesOutputStream -from librespot.standard import Closeable - - -class MercuryClient(PacketsReceiver.PacketsReceiver, Closeable): - _LOGGER: logging = logging.getLogger(__name__) - _MERCURY_REQUEST_TIMEOUT: int = 3 - _seqHolder: int = 1 - _seqHolderLock: threading.Condition = threading.Condition() - _callbacks: typing.Dict[int, Callback] = {} - _removeCallbackLock: threading.Condition = threading.Condition() - _subscriptions: typing.List[MercuryClient.InternalSubListener] = [] - _subscriptionsLock: threading.Condition = threading.Condition() - _partials: typing.Dict[int, bytes] = {} - _session: Session = None - - def __init__(self, session: Session): - self._session = session - - def subscribe(self, uri: str, listener: SubListener) -> None: - response = self.send_sync(RawMercuryRequest.sub(uri)) - if response.status_code != 200: - raise RuntimeError(response) - - if len(response.payload) > 0: - for payload in response.payload: - sub = Pubsub.Subscription() - sub.ParseFromString(payload) - self._subscriptions.append( - MercuryClient.InternalSubListener(sub.uri, listener, True)) - else: - self._subscriptions.append( - MercuryClient.InternalSubListener(uri, listener, True)) - - self._LOGGER.debug("Subscribed successfully to {}!".format(uri)) - - def unsubscribe(self, uri) -> None: - response = self.send_sync(RawMercuryRequest.unsub(uri)) - if response.status_code != 200: - raise RuntimeError(response) - - for subscription in self._subscriptions: - if subscription.matches(uri): - self._subscriptions.remove(subscription) - break - self._LOGGER.debug("Unsubscribed successfully from {}!".format(uri)) - - def send_sync(self, request: RawMercuryRequest) -> MercuryClient.Response: - callback = MercuryClient.SyncCallback() - seq = self.send(request, callback) - - try: - resp = callback.wait_response() - if resp is None: - raise IOError( - "Request timeout out, {} passed, yet no response. seq: {}". - format(self._MERCURY_REQUEST_TIMEOUT, seq)) - return resp - except queue.Empty as e: - raise IOError(e) - - def send_sync_json(self, request: JsonMercuryRequest) -> typing.Any: - resp = self.send_sync(request.request) - if 200 <= resp.status_code < 300: - return json.loads(resp.payload[0]) - raise MercuryClient.MercuryException(resp) - - def send(self, request: RawMercuryRequest, callback) -> int: - buffer = BytesOutputStream() - - seq: int - with self._seqHolderLock: - seq = self._seqHolder - self._seqHolder += 1 - - self._LOGGER.debug( - "Send Mercury request, seq: {}, uri: {}, method: {}".format( - seq, request.header.uri, request.header.method)) - - buffer.write_short(4) - buffer.write_int(seq) - - buffer.write_byte(1) - buffer.write_short(1 + len(request.payload)) - - header_bytes = request.header.SerializeToString() - buffer.write_short(len(header_bytes)) - buffer.write(header_bytes) - - for part in request.payload: - buffer.write_short(len(part)) - buffer.write(part) - - cmd = Packet.Type.for_method(request.header.method) - self._session.send(cmd, buffer.buffer) - - self._callbacks[seq] = callback - return seq - - def dispatch(self, packet: Packet) -> None: - payload = BytesInputStream(packet.payload) - seq_length = payload.read_short() - if seq_length == 2: - seq = payload.read_short() - elif seq_length == 4: - seq = payload.read_int() - elif seq_length == 8: - seq = payload.read_long() - else: - raise RuntimeError("Unknown seq length: {}".format(seq_length)) - - flags = payload.read_byte() - parts = payload.read_short() - - partial = self._partials.get(seq) - if partial is None or flags == 0: - partial = [] - self._partials[seq] = partial - - self._LOGGER.debug( - "Handling packet, cmd: 0x{}, seq: {}, flags: {}, parts: {}".format( - Utils.bytes_to_hex(packet.cmd), seq, flags, parts)) - - for i in range(parts): - size = payload.read_short() - buffer = payload.read(size) - partial.append(buffer) - self._partials[seq] = partial - - if flags != b"\x01": - return - - self._partials.pop(seq) - - header = Mercury.Header() - header.ParseFromString(partial[0]) - - resp = MercuryClient.Response(header, partial) - - if packet.is_cmd(Packet.Type.mercury_event): - dispatched = False - with self._subscriptionsLock: - for sub in self._subscriptions: - if sub.matches(header.uri): - sub.dispatch(resp) - dispatched = True - - if not dispatched: - self._LOGGER.debug( - "Couldn't dispatch Mercury event seq: {}, uri: {}, code: {}, payload: {}" - .format(seq, header.uri, header.status_code, resp.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)): - callback = self._callbacks.get(seq) - self._callbacks.pop(seq) - if callback is not None: - callback.response(resp) - else: - self._LOGGER.warning( - "Skipped Mercury response, seq: {}, uri: {}, code: {}". - format(seq, resp.uri, resp.status_code)) - - with self._removeCallbackLock: - self._removeCallbackLock.notify_all() - else: - self._LOGGER.warning( - "Couldn't handle packet, seq: {}, uri: {}, code: {}".format( - seq, header.uri, header.status_code)) - - def interested_in(self, uri: str, listener: SubListener) -> None: - self._subscriptions.append( - MercuryClient.InternalSubListener(uri, listener, False)) - - def not_interested_in(self, listener: SubListener) -> None: - try: - # noinspection PyTypeChecker - self._subscriptions.remove(listener) - except ValueError: - pass - - def close(self) -> None: - if len(self._subscriptions) != 0: - for listener in self._subscriptions: - if listener.isSub: - self.unsubscribe(listener.uri) - else: - self.not_interested_in(listener.listener) - - if len(self._callbacks) != 0: - with self._removeCallbackLock: - self._removeCallbackLock.wait(self._MERCURY_REQUEST_TIMEOUT) - - self._callbacks.clear() - - class Callback: - def response(self, response) -> None: - pass - - class SyncCallback(Callback): - _reference = queue.Queue() - - def response(self, response) -> None: - self._reference.put(response) - self._reference.task_done() - - def wait_response(self) -> typing.Any: - return self._reference.get( - timeout=MercuryClient._MERCURY_REQUEST_TIMEOUT) - - # class PubSubException(MercuryClient.MercuryException): - # pass - - class InternalSubListener: - uri: str - listener: SubListener - isSub: bool - - def __init__(self, uri: str, listener: SubListener, is_sub: bool): - self.uri = uri - self.listener = listener - self.isSub = is_sub - - def matches(self, uri: str) -> bool: - return uri.startswith(self.uri) - - def dispatch(self, resp: MercuryClient.Response) -> None: - self.listener.event(resp) - - class MercuryException(Exception): - code: int - - def __init__(self, response): - super().__init__("status: {}".format(response.status_code)) - self.code = response.status_code - - class Response: - uri: str - payload: typing.List[bytes] - status_code: int - - def __init__(self, header: Mercury.Header, - payload: typing.List[bytes]): - self.uri = header.uri - self.status_code = header.status_code - self.payload = payload[1:] diff --git a/librespot/mercury/MercuryRequests.py b/librespot/mercury/MercuryRequests.py deleted file mode 100644 index 0694a6e..0000000 --- a/librespot/mercury/MercuryRequests.py +++ /dev/null @@ -1,18 +0,0 @@ -from librespot.mercury.JsonMercuryRequest import JsonMercuryRequest -from librespot.mercury.RawMercuryRequest import RawMercuryRequest - - -class MercuryRequests: - keymaster_client_id = "65b708073fc0480ea92a077233ca87bd" - - @staticmethod - def get_root_playlists(username: str): - pass - - @staticmethod - def request_token(device_id, scope): - return JsonMercuryRequest( - RawMercuryRequest.get( - "hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}" - .format(scope, MercuryRequests.keymaster_client_id, - device_id))) diff --git a/librespot/mercury/ProtobufMercuryRequest.py b/librespot/mercury/ProtobufMercuryRequest.py deleted file mode 100644 index c94f49f..0000000 --- a/librespot/mercury/ProtobufMercuryRequest.py +++ /dev/null @@ -1,7 +0,0 @@ -class ProtobufMercuryRequest: - request = None - parser = None - - def __init__(self, request, parser): - self.request = request - self.parser = parser diff --git a/librespot/mercury/RawMercuryRequest.py b/librespot/mercury/RawMercuryRequest.py deleted file mode 100644 index 0ba37ed..0000000 --- a/librespot/mercury/RawMercuryRequest.py +++ /dev/null @@ -1,89 +0,0 @@ -import typing - -from librespot.proto import Mercury_pb2 as Mercury - - -class RawMercuryRequest: - header: Mercury.Header - payload: typing.List[bytes] - - def __init__(self, header: Mercury.Header, payload: typing.List[bytes]): - self.header = header - self.payload = payload - - @staticmethod - def sub(uri: str): - 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() - - @staticmethod - def get(uri: str): - return RawMercuryRequest.new_builder().set_uri(uri).set_method( - "GET").build() - - @staticmethod - def send(uri: str, part: bytes): - return (RawMercuryRequest.new_builder().set_uri(uri).add_payload_part( - part).set_method("SEND").build()) - - @staticmethod - def post(uri: str, part: bytes): - return (RawMercuryRequest.new_builder().set_uri(uri).set_method( - "POST").add_payload_part(part).build()) - - @staticmethod - def new_builder(): - return RawMercuryRequest.Builder() - - class Builder: - header_dict: dict - payload: typing.List[bytes] - - def __init__(self): - self.header_dict = {} - self.payload = [] - - def set_uri(self, uri: str): - self.header_dict["uri"] = uri - return self - - def set_content_type(self, content_type: str): - self.header_dict["content_type"] = content_type - return self - - def set_method(self, method: str): - self.header_dict["method"] = method - return self - - def add_user_field(self, - field: Mercury.UserField = None, - key: str = None, - value: str = None): - if field is None and (key is None or value is None): - return self - try: - self.header_dict["user_fields"] - except KeyError: - self.header_dict["user_fields"] = [] - if field is not None: - self.header_dict["user_fields"].append(field) - if key is not None and value is not None: - self.header_dict["user_fields"].append( - Mercury.UserField(key=key, value=value.encode())) - return self - - def add_payload_part(self, part: bytes): - self.payload.append(part) - return self - - def add_protobuf_payload(self, msg): - return self.add_payload_part(msg) - - def build(self): - return RawMercuryRequest(Mercury.Header(**self.header_dict), - self.payload) diff --git a/librespot/mercury/SubListener.py b/librespot/mercury/SubListener.py deleted file mode 100644 index 0aad578..0000000 --- a/librespot/mercury/SubListener.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations -from librespot.mercury import MercuryClient - - -class SubListener: - def event(self, resp: MercuryClient.Response) -> None: - pass diff --git a/librespot/mercury/__init__.py b/librespot/mercury/__init__.py deleted file mode 100644 index f58e59b..0000000 --- a/librespot/mercury/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from librespot.mercury.JsonMercuryRequest import JsonMercuryRequest -from librespot.mercury.MercuryClient import MercuryClient -from librespot.mercury.MercuryRequests import MercuryRequests -from librespot.mercury.ProtobufMercuryRequest import ProtobufMercuryRequest -from librespot.mercury.RawMercuryRequest import RawMercuryRequest -from librespot.mercury.SubListener import SubListener diff --git a/librespot/metadata/__init__.py b/librespot/metadata.py similarity index 60% rename from librespot/metadata/__init__.py rename to librespot/metadata.py index 1b43855..290c9d1 100644 --- a/librespot/metadata/__init__.py +++ b/librespot/metadata.py @@ -1,10 +1,8 @@ from __future__ import annotations - -import re - -from librespot.common import Base62 -from librespot.common import Utils +from librespot import util from librespot.proto.ContextTrack_pb2 import ContextTrack +from librespot.util import Base62 +import re class SpotifyId: @@ -14,34 +12,33 @@ class SpotifyId: @staticmethod def from_base62(base62: str): - pass + raise NotImplementedError @staticmethod def from_hex(hex_str: str): - pass + raise NotImplementedError @staticmethod def from_uri(uri: str): - pass + raise NotImplementedError def to_spotify_uri(self) -> str: - pass + raise NotImplementedError class SpotifyIdParsingException(Exception): pass class PlayableId: - BASE62 = Base62.create_instance_with_inverted_character_set() + base62 = Base62.create_instance_with_inverted_character_set() @staticmethod def from_uri(uri: str) -> PlayableId: if not PlayableId.is_supported(uri): return UnsupportedId(uri) - - if TrackId.PATTERN.search(uri) is not None: + if TrackId.pattern.search(uri) is not None: return TrackId.from_uri(uri) - elif EpisodeId.PATTERN.search(uri) is not None: + elif EpisodeId.pattern.search(uri) is not None: return EpisodeId.from_uri(uri) else: raise TypeError("Unknown uri: {}".format(uri)) @@ -57,13 +54,13 @@ class PlayableId: return track.metadata_or_default def get_gid(self) -> bytes: - pass + raise NotImplementedError def hex_id(self) -> str: - pass + raise NotImplementedError def to_spotify_uri(self) -> str: - pass + raise NotImplementedError class UnsupportedId(PlayableId): @@ -83,25 +80,25 @@ class UnsupportedId(PlayableId): class AlbumId(SpotifyId): - _PATTERN = re.compile(r"spotify:album:(.{22})") - _BASE62 = Base62.create_instance_with_inverted_character_set() - _hexId: str + base62 = Base62.create_instance_with_inverted_character_set() + pattern = re.compile(r"spotify:album:(.{22})") + __hex_id: str def __init__(self, hex_id: str): - self._hexId = hex_id.lower() + self.__hex_id = hex_id.lower() @staticmethod def from_uri(uri: str) -> AlbumId: - matcher = AlbumId._PATTERN.search(uri) + matcher = AlbumId.pattern.search(uri) if matcher is not None: album_id = matcher.group(1) return AlbumId( - Utils.bytes_to_hex(AlbumId._BASE62.decode(album_id, 16))) + util.bytes_to_hex(AlbumId.base62.decode(album_id, 16))) raise TypeError("Not a Spotify album ID: {}.f".format(uri)) @staticmethod def from_base62(base62: str) -> AlbumId: - return AlbumId(Utils.bytes_to_hex(AlbumId._BASE62.decode(base62, 16))) + return AlbumId(util.bytes_to_hex(AlbumId.base62.decode(base62, 16))) @staticmethod def from_hex(hex_str: str) -> AlbumId: @@ -109,154 +106,157 @@ class AlbumId(SpotifyId): def to_mercury_uri(self) -> str: return "spotify:album:{}".format( - AlbumId._BASE62.encode(Utils.hex_to_bytes(self._hexId))) + AlbumId.base62.encode(util.hex_to_bytes(self.__hex_id))) def hex_id(self) -> str: - return self._hexId + return self.__hex_id + + def to_spotify_uri(self) -> str: + return "spotify:album:{}".format( + ArtistId.base62.encode(util.hex_to_bytes(self.__hex_id))) class ArtistId(SpotifyId): - _PATTERN = re.compile("spotify:artist:(.{22})") - _BASE62 = Base62.create_instance_with_inverted_character_set() - _hexId: str + base62 = Base62.create_instance_with_inverted_character_set() + pattern = re.compile("spotify:artist:(.{22})") + __hex_id: str def __init__(self, hex_id: str): - self._hexId = hex_id + self.__hex_id = hex_id @staticmethod def from_uri(uri: str) -> ArtistId: - matcher = ArtistId._PATTERN.search(uri) + matcher = ArtistId.pattern.search(uri) if matcher is not None: artist_id = matcher.group(1) return ArtistId( - Utils.bytes_to_hex(ArtistId._BASE62.decode(artist_id, 16))) + util.bytes_to_hex(ArtistId.base62.decode(artist_id, 16))) raise TypeError("Not a Spotify artist ID: {}".format(uri)) @staticmethod def from_base62(base62: str) -> ArtistId: - return ArtistId(Utils.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: return ArtistId(hex_str) def to_mercury_uri(self) -> str: - return "hm://metadata/4/artist/{}".format(self._hexId) + return "hm://metadata/4/artist/{}".format(self.__hex_id) def to_spotify_uri(self) -> str: return "spotify:artist:{}".format( - ArtistId._BASE62.encode(Utils.hex_to_bytes(self._hexId))) + ArtistId.base62.encode(util.hex_to_bytes(self.__hex_id))) def hex_id(self) -> str: - return self._hexId + return self.__hex_id class EpisodeId(SpotifyId, PlayableId): - PATTERN = re.compile(r"spotify:episode:(.{22})") - _hexId: str + pattern = re.compile(r"spotify:episode:(.{22})") + __hex_id: str def __init__(self, hex_id: str): - self._hexId = hex_id.lower() + self.__hex_id = hex_id.lower() @staticmethod def from_uri(uri: str) -> EpisodeId: - matcher = EpisodeId.PATTERN.search(uri) + matcher = EpisodeId.pattern.search(uri) if matcher is not None: episode_id = matcher.group(1) return EpisodeId( - Utils.bytes_to_hex(PlayableId.BASE62.decode(episode_id, 16))) + util.bytes_to_hex(PlayableId.base62.decode(episode_id, 16))) raise TypeError("Not a Spotify episode ID: {}".format(uri)) @staticmethod def from_base62(base62: str) -> EpisodeId: return EpisodeId( - Utils.bytes_to_hex(PlayableId.BASE62.decode(base62, 16))) + util.bytes_to_hex(PlayableId.base62.decode(base62, 16))) @staticmethod def from_hex(hex_str: str) -> EpisodeId: return EpisodeId(hex_str) def to_mercury_uri(self) -> str: - return "hm://metadata/4/episode/{}".format(self._hexId) + return "hm://metadata/4/episode/{}".format(self.__hex_id) def to_spotify_uri(self) -> str: return "Spotify:episode:{}".format( - PlayableId.BASE62.encode(Utils.hex_to_bytes(self._hexId))) + PlayableId.base62.encode(util.hex_to_bytes(self.__hex_id))) def hex_id(self) -> str: - return self._hexId + return self.__hex_id def get_gid(self) -> bytes: - return Utils.hex_to_bytes(self._hexId) + return util.hex_to_bytes(self.__hex_id) class ShowId(SpotifyId): - _PATTERN = re.compile("spotify:show:(.{22})") - _BASE62 = Base62.create_instance_with_inverted_character_set() - _hexId: str + base62 = Base62.create_instance_with_inverted_character_set() + pattern = re.compile("spotify:show:(.{22})") + __hex_id: str def __init__(self, hex_id: str): - self._hexId = hex_id + self.__hex_id = hex_id @staticmethod def from_uri(uri: str) -> ShowId: - matcher = ShowId._PATTERN.search(uri) + matcher = ShowId.pattern.search(uri) if matcher is not None: show_id = matcher.group(1) return ShowId( - Utils.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)) @staticmethod def from_base62(base62: str) -> ShowId: - return ShowId(Utils.bytes_to_hex(ShowId._BASE62.decode(base62, 16))) + return ShowId(util.bytes_to_hex(ShowId.base62.decode(base62, 16))) @staticmethod def from_hex(hex_str: str) -> ShowId: return ShowId(hex_str) def to_mercury_uri(self) -> str: - return "hm://metadata/4/show/{}".format(self._hexId) + return "hm://metadata/4/show/{}".format(self.__hex_id) def to_spotify_uri(self) -> str: return "spotify:show:{}".format( - ShowId._BASE62.encode(Utils.hex_to_bytes(self._hexId))) + ShowId.base62.encode(util.hex_to_bytes(self.__hex_id))) def hex_id(self) -> str: - return self._hexId + return self.__hex_id class TrackId(PlayableId, SpotifyId): - PATTERN = re.compile("spotify:track:(.{22})") - _hexId: str + pattern = re.compile("spotify:track:(.{22})") + __hex_id: str def __init__(self, hex_id: str): - self._hexId = hex_id.lower() + self.__hex_id = hex_id.lower() @staticmethod def from_uri(uri: str) -> TrackId: - search = TrackId.PATTERN.search(uri) + search = TrackId.pattern.search(uri) if search is not None: track_id = search.group(1) return TrackId( - Utils.bytes_to_hex(PlayableId.BASE62.decode(track_id, 16))) + util.bytes_to_hex(PlayableId.base62.decode(track_id, 16))) raise RuntimeError("Not a Spotify track ID: {}".format(uri)) @staticmethod def from_base62(base62: str) -> TrackId: - return TrackId(Utils.bytes_to_hex(PlayableId.BASE62.decode(base62, - 16))) + return TrackId(util.bytes_to_hex(PlayableId.base62.decode(base62, 16))) @staticmethod def from_hex(hex_str: str) -> TrackId: return TrackId(hex_str) def to_spotify_uri(self) -> str: - return "spotify:track:{}".format(self._hexId) + return "spotify:track:{}".format(self.__hex_id) def hex_id(self) -> str: - return self._hexId + return self.__hex_id def get_gid(self) -> bytes: - return Utils.hex_to_bytes(self._hexId) + return util.hex_to_bytes(self.__hex_id) diff --git a/librespot/player/Player.py b/librespot/player/Player.py deleted file mode 100644 index d4238b7..0000000 --- a/librespot/player/Player.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import logging -import sched -import time -import typing - -from librespot.core.Session import Session -from librespot.player import PlayerConfiguration -from librespot.player import StateWrapper -from librespot.player.metrics import PlaybackMetrics -from librespot.player.mixing import AudioSink -from librespot.player.playback.PlayerSession import PlayerSession -from librespot.player.state.DeviceStateHandler import DeviceStateHandler -from librespot.standard.Closeable import Closeable - - -class Player(Closeable, PlayerSession.Listener, AudioSink.Listener): - VOLUME_MAX: int = 65536 - _LOGGER: logging = logging.getLogger(__name__) - _scheduler: sched.scheduler = sched.scheduler(time.time) - _session: Session - _conf: PlayerConfiguration - _events: Player.EventsDispatcher - _sink: AudioSink - _metrics: typing.Dict[str, PlaybackMetrics] = {} - _state: StateWrapper - _playerSession: PlayerSession - _releaseLineFuture = None - _deviceStateListener: DeviceStateHandler.Listener - - def __init__(self, conf: PlayerConfiguration, session: Session): - self._conf = conf - self._session = session - self._events = Player.EventsDispatcher(conf) - self._sink = AudioSink(conf, self) - - self.__init_state() - - def __init_state(self): - self._state = StateWrapper.StateWrapper(self._session, self, - self._conf) - - class Anonymous(DeviceStateHandler.Listener): - _player: Player = None - - def __init__(self, player: Player): - self._player = player - - def ready(self) -> None: - pass - - def command( - self, - endpoint: DeviceStateHandler.Endpoint, - data: DeviceStateHandler.CommandBody, - ) -> None: - self._player._LOGGER.debug( - "Received command: {}".format(endpoint)) - - self._deviceStateListener = Anonymous(self) - self._state.add_listener(self._deviceStateListener) - - def volume_up(self, steps: int = 1): - if self._state is None: - return - - class EventsDispatcher: - def __init__(self, conf: PlayerConfiguration): - pass diff --git a/librespot/player/PlayerConfiguration.py b/librespot/player/PlayerConfiguration.py deleted file mode 100644 index 8096cd3..0000000 --- a/librespot/player/PlayerConfiguration.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from librespot.audio.decoders import AudioQuality - - -class PlayerConfiguration: - # Audio - preferred_quality: AudioQuality - enable_normalisation: bool - normalisation_pregain: float - autoplay_enabled: bool - crossfade_duration: int - preload_enabled: bool - - # Volume - initial_volume: int - volume_steps: int - - def __init__( - self, - preferred_quality: AudioQuality, - enable_normalisation: bool, - normalisation_pregain: float, - autoplay_enabled: bool, - crossfade_duration: int, - preload_enabled: bool, - initial_volume: int, - volume_steps: int, - ): - self.preferred_quality = preferred_quality - self.enable_normalisation = enable_normalisation - self.normalisation_pregain = normalisation_pregain - self.autoplay_enabled = autoplay_enabled - self.crossfade_duration = crossfade_duration - self.preload_enabled = preload_enabled - self.initial_volume = initial_volume - self.volume_steps = volume_steps - - class Builder: - preferred_quality: AudioQuality = AudioQuality.NORMAL - enable_normalisation: bool = True - normalisation_pregain: float = 3.0 - autoplay_enabled: bool = True - crossfade_duration: int = 0 - preload_enabled: bool = True - - # Volume - initial_volume: int = 65536 - volume_steps: int = 64 - - def __init__(self): - pass - - def set_preferred_quality( - self, preferred_quality: AudioQuality) -> __class__: - self.preferred_quality = preferred_quality - return self - - def set_enable_normalisation(self, - enable_normalisation: bool) -> __class__: - self.enable_normalisation = enable_normalisation - return self - - def set_normalisation_pregain( - self, normalisation_pregain: float) -> __class__: - self.normalisation_pregain = normalisation_pregain - return self - - def set_autoplay_enabled(self, autoplay_enabled: bool) -> __class__: - self.autoplay_enabled = autoplay_enabled - return self - - def set_crossfade_duration(self, crossfade_duration: int) -> __class__: - self.crossfade_duration = crossfade_duration - return self - - def set_preload_enabled(self, preload_enabled: bool) -> __class__: - self.preload_enabled = preload_enabled - return self - - def build(self) -> PlayerConfiguration: - return PlayerConfiguration( - self.preferred_quality, - self.enable_normalisation, - self.normalisation_pregain, - self.autoplay_enabled, - self.crossfade_duration, - self.preload_enabled, - self.initial_volume, - self.volume_steps, - ) diff --git a/librespot/player/StateWrapper.py b/librespot/player/StateWrapper.py deleted file mode 100644 index 6652ab5..0000000 --- a/librespot/player/StateWrapper.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import typing - -from librespot.core import Session -from librespot.dealer import DealerClient -from librespot.player import Player -from librespot.player import PlayerConfiguration -from librespot.player.state import DeviceStateHandler -from librespot.proto import Connect_pb2 as Connect -from librespot.proto.Player_pb2 import ContextPlayerOptions -from librespot.proto.Player_pb2 import PlayerState -from librespot.proto.Player_pb2 import Restrictions -from librespot.proto.Player_pb2 import Suppressions - - -class StateWrapper(DeviceStateHandler.Listener, DealerClient.MessageListener): - _state: PlayerState = None - _session: Session = None - _player: Player = None - _device: DeviceStateHandler = None - - def __init__(self, session: Session, player: Player, - conf: PlayerConfiguration): - self._session = session - self._player = player - self._device = DeviceStateHandler(session, self, conf) - self._state = self._init_state() - - self._device.add_listener(self) - self._session.dealer().add_message_listener( - self, - "spotify:user:attributes:update", - "hm://playlist/", - "hm://collection/collection/" + self._session.username() + "/json", - ) - - def _init_state(self) -> PlayerState: - return PlayerState( - playback_speed=1.0, - suppressions=Suppressions(), - context_restrictions=Restrictions(), - options=ContextPlayerOptions(repeating_context=False, - shuffling_context=False, - repeating_track=False), - position_as_of_timestamp=0, - position=0, - is_playing=False, - ) - - def add_listener(self, listener: DeviceStateHandler.Listener): - self._device.add_listener(listener) - - def ready(self) -> None: - self._device.update_state(Connect.PutStateReason.NEW_DEVICE, 0, - self._state) - - def on_message(self, uri: str, headers: typing.Dict[str, str], - payload: bytes): - pass diff --git a/librespot/player/__init__.py b/librespot/player/__init__.py index 024150b..60282ce 100644 --- a/librespot/player/__init__.py +++ b/librespot/player/__init__.py @@ -1,3 +1,88 @@ -from librespot.player.Player import Player -from librespot.player.PlayerConfiguration import PlayerConfiguration -from librespot.player.StateWrapper import StateWrapper +from __future__ import annotations +from librespot.audio.decoders import AudioQuality + + +class PlayerConfiguration: + # Audio + preferred_quality: AudioQuality + enable_normalisation: bool + normalisation_pregain: float + autoplay_enabled: bool + crossfade_duration: int + preload_enabled: bool + + # Volume + initial_volume: int + volume_steps: int + + def __init__( + self, + preferred_quality: AudioQuality, + enable_normalisation: bool, + normalisation_pregain: float, + autoplay_enabled: bool, + crossfade_duration: int, + preload_enabled: bool, + initial_volume: int, + volume_steps: int, + ): + self.preferred_quality = preferred_quality + self.enable_normalisation = enable_normalisation + self.normalisation_pregain = normalisation_pregain + self.autoplay_enabled = autoplay_enabled + self.crossfade_duration = crossfade_duration + self.preload_enabled = preload_enabled + self.initial_volume = initial_volume + self.volume_steps = volume_steps + + class Builder: + preferred_quality: AudioQuality = AudioQuality.NORMAL + enable_normalisation: bool = True + normalisation_pregain: float = 3.0 + autoplay_enabled: bool = True + crossfade_duration: int = 0 + preload_enabled: bool = True + + # Volume + initial_volume: int = 65536 + volume_steps: int = 64 + + def set_preferred_quality( + self, preferred_quality: AudioQuality) -> __class__: + self.preferred_quality = preferred_quality + return self + + def set_enable_normalisation(self, + enable_normalisation: bool) -> __class__: + self.enable_normalisation = enable_normalisation + return self + + def set_normalisation_pregain( + self, normalisation_pregain: float) -> __class__: + self.normalisation_pregain = normalisation_pregain + return self + + def set_autoplay_enabled(self, autoplay_enabled: bool) -> __class__: + self.autoplay_enabled = autoplay_enabled + return self + + def set_crossfade_duration(self, crossfade_duration: int) -> __class__: + self.crossfade_duration = crossfade_duration + return self + + def set_preload_enabled(self, preload_enabled: bool) -> __class__: + self.preload_enabled = preload_enabled + return self + + def build(self) -> PlayerConfiguration: + return PlayerConfiguration( + self.preferred_quality, + self.enable_normalisation, + self.normalisation_pregain, + self.autoplay_enabled, + self.crossfade_duration, + self.preload_enabled, + self.initial_volume, + self.volume_steps, + ) + diff --git a/librespot/player/codecs/VorbisOnlyAudioQuality.py b/librespot/player/codecs.py similarity index 50% rename from librespot/player/codecs/VorbisOnlyAudioQuality.py rename to librespot/player/codecs.py index 48755f6..2405751 100644 --- a/librespot/player/codecs/VorbisOnlyAudioQuality.py +++ b/librespot/player/codecs.py @@ -1,16 +1,14 @@ from __future__ import annotations - +from librespot.audio import SuperAudioFormat +from librespot.audio.decoders import AudioQuality +from librespot.proto import Metadata_pb2 as Metadata +from librespot.structure import AudioQualityPicker import logging import typing -from librespot.audio.decoders.AudioQuality import AudioQuality -from librespot.audio.format.AudioQualityPicker import AudioQualityPicker -from librespot.audio.format.SuperAudioFormat import SuperAudioFormat -from librespot.proto import Metadata_pb2 as Metadata - class VorbisOnlyAudioQuality(AudioQualityPicker): - _LOGGER: logging = logging.getLogger(__name__) + logger = logging.getLogger("Librespot:Player:VorbisOnlyAudioQuality") preferred: AudioQuality def __init__(self, preferred: AudioQuality): @@ -19,26 +17,18 @@ 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 diff --git a/librespot/player/codecs/__init__.py b/librespot/player/codecs/__init__.py deleted file mode 100644 index c56b7eb..0000000 --- a/librespot/player/codecs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.player.codecs.VorbisOnlyAudioQuality import VorbisOnlyAudioQuality diff --git a/librespot/player/metrics/PlaybackMetrics.py b/librespot/player/metrics/PlaybackMetrics.py deleted file mode 100644 index a4c3a97..0000000 --- a/librespot/player/metrics/PlaybackMetrics.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import annotations -import logging - - -class PlaybackMetrics: - _LOGGER = logging.getLogger(__name__) diff --git a/librespot/player/metrics/__init__.py b/librespot/player/metrics/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/librespot/player/mixing/AudioSink.py b/librespot/player/mixing/AudioSink.py deleted file mode 100644 index 716c68e..0000000 --- a/librespot/player/mixing/AudioSink.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from librespot.player import PlayerConfiguration - - -class AudioSink: - def __init__(self, conf: PlayerConfiguration, - listener: AudioSink.Listener): - pass - - class Listener: - def sink_error(self, ex: Exception): - pass diff --git a/librespot/player/mixing/__init__.py b/librespot/player/mixing/__init__.py deleted file mode 100644 index bc7cf51..0000000 --- a/librespot/player/mixing/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.player.mixing.AudioSink import AudioSink diff --git a/librespot/player/playback/PlayerSession.py b/librespot/player/playback/PlayerSession.py deleted file mode 100644 index facd163..0000000 --- a/librespot/player/playback/PlayerSession.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations -from librespot.standard import Closeable - - -class PlayerSession(Closeable): - class Listener: - pass diff --git a/librespot/player/playback/__init__.py b/librespot/player/playback/__init__.py deleted file mode 100644 index 4b87385..0000000 --- a/librespot/player/playback/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.player.playback.PlayerSession import PlayerSession diff --git a/librespot/player/state/DeviceStateHandler.py b/librespot/player/state/DeviceStateHandler.py deleted file mode 100644 index c4988f6..0000000 --- a/librespot/player/state/DeviceStateHandler.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import base64 -import concurrent.futures -import enum -import logging -import time -import typing -import urllib.parse - -from librespot.common import Utils -from librespot.core import Session -from librespot.player import PlayerConfiguration -from librespot.proto import Connect_pb2 as Connect -from librespot.proto import Player_pb2 as Player - - -class DeviceStateHandler: - _LOGGER: logging = logging.getLogger(__name__) - _session: Session - _deviceInfo: Connect.DeviceInfo - _listeners: typing.List[DeviceStateHandler.Listener] = [] - _putState: Connect.PutStateRequest - _putStateWorker: concurrent.futures.ThreadPoolExecutor = ( - concurrent.futures.ThreadPoolExecutor()) - _connectionId: str - - def __init__(self, session: Session, player, conf: PlayerConfiguration): - self._session = session - self._deviceInfo = None - self._putState = Connect.PutStateRequest() - - def _update_connection_id(self, newer: str) -> None: - newer = urllib.parse.unquote(newer, "UTF-8") - - if self._connectionId is None or self._connectionId != newer: - self._connectionId = newer - self._LOGGER.debug("Updated Spotify-Connection-Id: {}".format( - self._connectionId)) - self._notify_ready() - - def add_listener(self, listener: DeviceStateHandler.Listener): - self._listeners.append(listener) - - def _notify_ready(self) -> None: - for listener in self._listeners: - listener.ready() - - def update_state( - self, - reason: Connect.PutStateReason, - player_time: int, - state: Player.PlayerState, - ): - if self._connectionId is None: - raise TypeError() - - if player_time == -1: - pass - else: - pass - - self._putState.put_state_reason = reason - self._putState.client_side_timestamp = int(time.time() * 1000) - self._putState.device.device_info = self._deviceInfo - self._putState.device.player_state = state - - self._putStateWorker.submit(self._put_connect_state, self._putState) - - def _put_connect_state(self, req: Connect.PutStateRequest): - self._session.api().put_connect_state(self._connectionId, req) - self._LOGGER.info("Put state. ts: {}, connId: {}, reason: {}".format( - req.client_side_timestamp, - Utils.truncate_middle(self._connectionId, 10), - req.put_state_reason, - )) - - class Endpoint(enum.Enum): - Play: str = "play" - Pause: str = "pause" - Resume: str = "resume" - SeekTo: str = "seek_to" - SkipNext: str = "skip_next" - SkipPrev: str = "skip_prev" - - class Listener: - def ready(self) -> None: - pass - - def command( - self, - endpoint: DeviceStateHandler.Endpoint, - data: DeviceStateHandler.CommandBody, - ) -> None: - pass - - def volume_changed(self) -> None: - pass - - def not_active(self) -> None: - pass - - class CommandBody: - _obj: typing.Any - _data: bytes - _value: str - - def __init__(self, obj: typing.Any): - self._obj = obj - - if obj.get("data") is not None: - self._data = base64.b64decode(obj.get("data")) - - if obj.get("value") is not None: - self._value = obj.get("value") diff --git a/librespot/player/state/__init__.py b/librespot/player/state/__init__.py deleted file mode 100644 index 7f33797..0000000 --- a/librespot/player/state/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.player.state.DeviceStateHandler import DeviceStateHandler diff --git a/librespot/standard/AutoCloseable.py b/librespot/standard/AutoCloseable.py deleted file mode 100644 index 7707ac1..0000000 --- a/librespot/standard/AutoCloseable.py +++ /dev/null @@ -1,3 +0,0 @@ -class AutoCloseable: - def close(self) -> None: - pass diff --git a/librespot/standard/ByteArrayOutputStream.py b/librespot/standard/ByteArrayOutputStream.py deleted file mode 100644 index 25ed5c7..0000000 --- a/librespot/standard/ByteArrayOutputStream.py +++ /dev/null @@ -1,68 +0,0 @@ -from librespot.standard.OutputStream import OutputStream - - -class ByteArrayOutputStream(OutputStream): - buf: bytearray - count: int = 0 - - def __init__(self, size: int = 32): - if size < 0: - raise RuntimeError("Negative initial size: {}".format(self)) - self.buf = bytearray(size) - - def ensure_capacity(self, min_capacity: int) -> None: - old_capacity = len(self.buf) - min_growth = min_capacity - old_capacity - if min_growth > 0: - new_buf = bytearray(min_capacity) - new_buf[0:len(self.buf)] = self.buf - self.buf = new_buf - - def internal_write(self, byte: int) -> None: - self.ensure_capacity(self.count + 1) - self.buf[self.count] = byte - self.count += 1 - - def write(self, - byte: int = None, - buffer: bytearray = None, - offset: int = None, - length: int = None) -> None: - if byte is not None and buffer is None and offset is None and length is None: - self.internal_write(byte) - return - if byte is None and buffer is not None and offset is None and length is None: - offset = 0 - length = len(buffer) - elif not (byte is None and buffer is not None and offset is not None - and length is not None): - raise TypeError() - - if len(buffer) < (offset + length): - raise IndexError() - - self.ensure_capacity(self.count + length) - self.buf[self.count:self.count + length] = buffer[offset:offset + - length] - self.count += length - - def write_bytes(self, b: bytearray): - self.write(buffer=b, offset=0, length=len(b)) - - def write_to(self, out: OutputStream) -> None: - out.write(buffer=self.buf, offset=0, length=self.count) - - def reset(self) -> None: - self.count = 0 - - def to_byte_array(self) -> bytearray: - return self.buf - - def to_bytes(self) -> bytes: - return bytes(self.buf) - - def size(self) -> int: - return self.count - - def close(self) -> None: - pass diff --git a/librespot/standard/BytesInputStream.py b/librespot/standard/BytesInputStream.py deleted file mode 100644 index 4e1d8d9..0000000 --- a/librespot/standard/BytesInputStream.py +++ /dev/null @@ -1,42 +0,0 @@ -import struct - - -class BytesInputStream: - buffer: bytes - endian: str - - def __init__(self, buffer: bytes, endian: str = ">"): - self.buffer = buffer - self.endian = endian - - def read(self, length: int = None) -> bytes: - if length is None: - length = len(self.buffer) - buffer = self.buffer[:length] - self.buffer = self.buffer[length:] - return buffer - - def read_byte(self) -> bytes: - buffer = struct.unpack("s", self.buffer[:1])[0] - self.buffer = self.buffer[1:] - return buffer - - def read_int(self) -> int: - buffer = struct.unpack("{}i".format(self.endian), self.buffer[:4])[0] - self.buffer = self.buffer[4:] - return buffer - - def read_short(self) -> int: - buffer = struct.unpack("{}h".format(self.endian), self.buffer[:2])[0] - self.buffer = self.buffer[2:] - return buffer - - def read_long(self) -> int: - buffer = struct.unpack("{}q".format(self.endian), self.buffer[:8])[0] - self.buffer = self.buffer[8:] - return buffer - - def read_float(self) -> float: - buffer = struct.unpack("{}f".format(self.endian), self.buffer[:4])[0] - self.buffer = self.buffer[4:] - return buffer diff --git a/librespot/standard/BytesOutputStream.py b/librespot/standard/BytesOutputStream.py deleted file mode 100644 index e6438d6..0000000 --- a/librespot/standard/BytesOutputStream.py +++ /dev/null @@ -1,24 +0,0 @@ -import struct - - -class BytesOutputStream: - buffer: bytes - - def __init__(self): - self.buffer = b"" - - def write(self, data: bytes): - self.buffer += data - return len(data) - - def write_byte(self, data: int): - self.buffer += bytes([data]) - return 1 - - def write_int(self, data: int): - self.buffer += struct.pack(">i", data) - return 4 - - def write_short(self, data: int): - self.buffer += struct.pack(">h", data) - return 2 diff --git a/librespot/standard/Closeable.py b/librespot/standard/Closeable.py deleted file mode 100644 index 8810afb..0000000 --- a/librespot/standard/Closeable.py +++ /dev/null @@ -1,6 +0,0 @@ -from librespot.standard.AutoCloseable import AutoCloseable - - -class Closeable(AutoCloseable): - def close(self) -> None: - pass diff --git a/librespot/standard/DataInput.py b/librespot/standard/DataInput.py deleted file mode 100644 index caef152..0000000 --- a/librespot/standard/DataInput.py +++ /dev/null @@ -1,48 +0,0 @@ -class DataInput: - def internal_read_fully(self, b: bytearray) -> None: - pass - - def read_fully(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> None: - pass - - def skip_bytes(self, n: int) -> int: - pass - - def read_boolean(self) -> bool: - pass - - def read_byte(self) -> bytes: - pass - - def read_unsigned_byte(self) -> int: - pass - - def read_short(self) -> int: - pass - - def read_unsigned_short(self) -> int: - pass - - def read_char(self) -> str: - pass - - def read_int(self) -> int: - pass - - def read_long(self) -> int: - pass - - def read_float(self) -> float: - pass - - def read_double(self) -> float: - pass - - def read_line(self) -> str: - pass - - def read_utf(self) -> str: - pass diff --git a/librespot/standard/DataInputStream.py b/librespot/standard/DataInputStream.py deleted file mode 100644 index 063d389..0000000 --- a/librespot/standard/DataInputStream.py +++ /dev/null @@ -1,113 +0,0 @@ -from librespot.standard.DataInput import DataInput -from librespot.standard.FilterInputStream import FilterInputStream - - -class DataInputStream(FilterInputStream, DataInput): - def read(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> int: - if b is not None and offset is None and length is None: - return self.input_stream.read(b, 0, len(b)) - if b is not None and offset is not None and length is not None: - return self.input_stream.read(b, offset, length) - raise TypeError() - - def read_fully(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> None: - if b is not None and offset is None and length is None: - offset = 0 - length = len(b) - elif not (b is not None and offset is not None and length is not None): - raise TypeError() - if length < 0: - raise IndexError() - n = 0 - while n < length: - count = self.input_stream.read(b, offset + n, length - n) - if count < 0: - raise EOFError() - n += count - - def skip_bytes(self, n: int) -> int: - total = 0 - cur = 0 - while True: - cur = self.input_stream.skip(n - total) - if not (total < n and cur > 0): - break - total += cur - - return total - - def read_boolean(self) -> bool: - ch = self.input_stream.read() - if ch < 0: - raise EOFError() - return ch != 0 - - def read_byte(self) -> bytes: - ch = self.input_stream.read() - if ch < 0: - raise EOFError() - return bytes([ch]) - - def read_unsigned_byte(self) -> int: - ch = self.input_stream.read() - if ch < 0: - raise EOFError() - return ch - - def read_short(self) -> int: - ch1 = self.input_stream.read() - ch2 = self.input_stream.read() - if (ch1 | ch2) < 0: - raise EOFError() - return (ch1 << 8) + (ch2 << 0) - - def read_unsigned_short(self) -> int: - ch1 = self.input_stream.read() - ch2 = self.input_stream.read() - if (ch1 | ch2) < 0: - raise EOFError() - return (ch1 << 8) + (ch2 << 0) - - def read_char(self) -> str: - ch1 = self.input_stream.read() - ch2 = self.input_stream.read() - if (ch1 | ch2) < 0: - raise EOFError() - return chr((ch1 << 8) + (ch2 << 0)) - - def read_int(self) -> int: - ch1 = self.input_stream.read() - ch2 = self.input_stream.read() - ch3 = self.input_stream.read() - ch4 = self.input_stream.read() - if (ch1 | ch2 | ch3 | ch4) < 0: - raise EOFError() - return (ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0) - - read_buffer = bytearray(8) - - def read_long(self) -> int: - self.read_fully(self.read_buffer, 0, 8) - return ((self.read_buffer[0] << 56) + - ((self.read_buffer[1] & 255) << 48) + - ((self.read_buffer[2] & 255) << 40) + - ((self.read_buffer[3] & 255) << 32) + - ((self.read_buffer[4] & 255) << 24) + - ((self.read_buffer[5] & 255) << 16) + - ((self.read_buffer[6] & 255) << 8) + - ((self.read_buffer[7] & 255) << 0)) - - def read_float(self) -> float: - pass - - def read_double(self) -> float: - pass - - def read_line(self) -> str: - pass diff --git a/librespot/standard/DataOutput.py b/librespot/standard/DataOutput.py deleted file mode 100644 index 0b295a9..0000000 --- a/librespot/standard/DataOutput.py +++ /dev/null @@ -1,43 +0,0 @@ -class DataOutput: - def internal_write(self, byte: int) -> None: - pass - - def write(self, - byte: int = None, - buffer: bytearray = None, - offset: int = None, - length: int = None) -> None: - pass - - def write_boolean(self, v: bytes) -> None: - pass - - def write_byte(self, v: int) -> None: - pass - - def write_short(self, v: int) -> None: - pass - - def write_char(self, v: int) -> None: - pass - - def write_int(self, v: int) -> None: - pass - - def write_long(self, v: int) -> None: - pass - - def write_float(self, v: float) -> None: - pass - - def write_double(self, v: float) -> None: - pass - - def write_bytes(self, s: str) -> None: - pass - - def write_chars(self, s: str) -> None: - pass - - def write_utf(self, s: str) -> None: - pass diff --git a/librespot/standard/FilterInputStream.py b/librespot/standard/FilterInputStream.py deleted file mode 100644 index 9409cd9..0000000 --- a/librespot/standard/FilterInputStream.py +++ /dev/null @@ -1,41 +0,0 @@ -from librespot.standard.InputStream import InputStream - - -class FilterInputStream(InputStream): - input_stream: InputStream - - def __init__(self, input_stream: InputStream): - self.input_stream = input_stream - - def internal_read(self): - return self.input_stream.read() - - def read(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> int: - if b is not None and offset is None and length is None: - offset = 0 - length = len(b) - elif not (b is not None and offset is not None and length is not None): - raise TypeError() - - return self.input_stream.read(b, offset, length) - - def skip(self, n: int) -> int: - return self.input_stream.skip(n) - - def available(self) -> int: - return self.input_stream.available() - - def close(self) -> None: - self.input_stream.close() - - def mark(self, read_limit: int) -> None: - self.input_stream.mark(read_limit) - - def reset(self) -> None: - self.input_stream.reset() - - def mark_supported(self) -> bool: - return self.input_stream.mark_supported() diff --git a/librespot/standard/Flushable.py b/librespot/standard/Flushable.py deleted file mode 100644 index 148a0d0..0000000 --- a/librespot/standard/Flushable.py +++ /dev/null @@ -1,3 +0,0 @@ -class Flushable: - def flush(self) -> None: - pass diff --git a/librespot/standard/InputStream.py b/librespot/standard/InputStream.py deleted file mode 100644 index 242ff51..0000000 --- a/librespot/standard/InputStream.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations -from librespot.standard.Closeable import Closeable -import sys -import typing - -if typing.TYPE_CHECKING: - from librespot.standard.OutputStream import OutputStream - - -class InputStream(Closeable): - max_skip_buffer_size: typing.Final[int] = 2048 - default_buffer_size: typing.Final[int] = 8192 - - @staticmethod - def null_input_stream(): - class Anonymous(InputStream): - closed: bool - - def ensure_open(self) -> None: - if self.closed: - raise IOError("Stream closed") - - def available(self) -> int: - self.ensure_open() - return 0 - - def read(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> int: - if b is not None and offset is not None and length is not None: - if len(b) < (offset + length): - raise IndexError() - if length == 0: - return 0 - self.ensure_open() - return -1 - if b is None and offset is None and length is None: - self.ensure_open() - return -1 - raise TypeError() - - def read_all_bytes(self): - self.ensure_open() - return bytearray(0) - - def read_n_bytes(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> bytearray: - if length < 0: - raise TypeError("length < 0") - self.ensure_open() - return bytearray(0) - - def skip(self, n) -> int: - self.ensure_open() - return 0 - - def skip_n_bytes(self, n: int) -> None: - self.ensure_open() - if n > 0: - raise EOFError() - - def transfer_to(self, out) -> int: - if out is None: - raise TypeError() - self.ensure_open() - return 0 - - def close(self): - self.closed = True - - return Anonymous() - - def internal_read(self): - raise NotImplementedError() - - def read(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> int: - if b is None and offset is None and length is None: - return self.internal_read() - if b is not None and offset is None and length is None: - offset = 0 - length = len(b) - elif not (b is not None and offset is not None and length is not None): - raise TypeError() - if len(b) < (offset + length): - raise IndexError() - if length == 0: - return 0 - - c = self.read() - if c == -1: - return -1 - - b[offset] = c - - i = 1 - for i in range(i, length): - c = self.read() - if c == -1: - break - b[offset + i] = c - return i - - max_buffer_size: typing.Final[int] = sys.maxsize - 8 - - def read_all_bytes(self) -> bytearray: - return self.read_n_bytes(length=sys.maxsize) - - def read_n_bytes(self, - b: bytearray = None, - offset: int = None, - length: int = None) -> typing.Union[bytearray, int]: - if b is None and offset is None and len is not None: - if length < 0: - raise TypeError("length < 0") - - bufs = None - result = None - total = 0 - remaining = length - n: int - while True: - buf = bytearray(min(remaining, self.default_buffer_size)) - nread = 0 - - while True: - n = self.read(buf, nread, min(len(buf) - nread, remaining)) - if not n > 0: - break - nread += n - remaining -= n - - if nread > 0: - if self.max_buffer_size - total < nread: - raise MemoryError("Required array size too large") - total += nread - if result is None: - result = buf - else: - if bufs is None: - bufs = [result] - bufs.append(buf) - if n >= 0 and remaining > 0: - break - - if bufs is None: - if result is None: - return bytearray(0) - return result if len(result) == total else result[:total] - - result = bytearray(total) - offset = 0 - remaining = total - for b in bufs: - count = min(len(b), remaining) - for i in range(offset, offset + count): - result.insert(i, b[i]) - offset += count - remaining -= count - - return result - if b is not None and offset is not None and length is not None: - if len(b) < (offset + length): - raise IndexError() - - n = 0 - while n < length: - count = self.read(b, offset + n, length - n) - if count < 0: - break - n += count - return n - raise TypeError() - - def skip(self, n: int) -> int: - remaining = n - nr: int - - if n <= 0: - return 0 - - size = min(self.max_skip_buffer_size, remaining) - skip_buffer = bytearray(size) - while remaining > 0: - nr = self.read(skip_buffer, 0, min(size, remaining)) - if nr < 0: - break - remaining -= nr - - return n - remaining - - def skip_n_bytes(self, n: int) -> None: - if n > 0: - ns = self.skip(n) - if ns >= 0 and ns < n: - n -= ns - - while n > 0 and self.read() != -1: - n -= 1 - - if n != 0: - raise EOFError() - elif ns != n: - raise IOError("Unable to skip exactly") - - def available(self) -> int: - return 0 - - def close(self) -> None: - pass - - def mark(self, read_limit: int) -> None: - pass - - def reset(self) -> None: - raise IOError("mark/reset not supported") - - def mark_supported(self) -> bool: - return False - - def transfer_to(self, out: OutputStream) -> int: - if out is None: - raise TypeError() - transferred = 0 - buffer = bytearray(self.default_buffer_size) - read: int - while True: - read = self.read(buffer, 0, self.default_buffer_size) - if not read: - break - out.write(buffer=buffer, offset=0, length=read) - transferred += read - return transferred diff --git a/librespot/standard/OutputStream.py b/librespot/standard/OutputStream.py deleted file mode 100644 index aa7cf68..0000000 --- a/librespot/standard/OutputStream.py +++ /dev/null @@ -1,61 +0,0 @@ -from librespot.standard.Closeable import Closeable -from librespot.standard.Flushable import Flushable - - -class OutputStream(Closeable, Flushable): - def null_output_stream(self): - class Annonymous(OutputStream): - closed: bool - - def ensure_open(self) -> None: - if self.closed: - raise IOError("Stream closed") - - def internal_write(self, byte: int): - self.ensure_open() - - def write(self, - byte: int = None, - buffer: bytearray = None, - offset: int = None, - length: int = None): - if byte is not None and buffer is None and offset is None and length is None: - self.internal_write(byte) - elif not (byte is None and buffer is not None - and offset is not None and length is not None): - raise TypeError() - if len(bytearray) < (offset + length): - raise IndexError() - self.ensure_open() - - def close(self) -> None: - self.closed = True - - def internal_write(self, byte: int): - raise NotImplementedError() - - def write(self, - byte: int = None, - buffer: bytearray = None, - offset: int = None, - length: int = None): - if byte is not None and buffer is None and offset is None and length is None: - self.internal_write(byte) - elif byte is None and buffer is not None and offset is None and length is None: - offset = 0 - length = len(buffer) - elif not (byte is None and buffer is not None and offset is not None - and length is not None): - raise TypeError() - - if len(bytearray) < (offset + length): - raise IndexError() - - for i in range(length): - self.write(buffer[offset + i]) - - def flush(self) -> None: - pass - - def close(self) -> None: - pass diff --git a/librespot/standard/Proxy.py b/librespot/standard/Proxy.py deleted file mode 100644 index 9baaeef..0000000 --- a/librespot/standard/Proxy.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations -import enum - - -class Proxy: - class Type(enum.Enum): - DIRECT = enum.auto() - HTTP = enum.auto() - SOCKS = enum.auto() diff --git a/librespot/standard/Runnable.py b/librespot/standard/Runnable.py deleted file mode 100644 index f12139f..0000000 --- a/librespot/standard/Runnable.py +++ /dev/null @@ -1,3 +0,0 @@ -class Runnable: - def run(self) -> None: - raise NotImplementedError() diff --git a/librespot/standard/__init__.py b/librespot/standard/__init__.py deleted file mode 100644 index 7af7efe..0000000 --- a/librespot/standard/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from librespot.standard.AutoCloseable import AutoCloseable -from librespot.standard.ByteArrayOutputStream import ByteArrayOutputStream -from librespot.standard.BytesInputStream import BytesInputStream -from librespot.standard.BytesOutputStream import BytesOutputStream -from librespot.standard.Closeable import Closeable -from librespot.standard.DataInput import DataInput -from librespot.standard.DataInputStream import DataInputStream -from librespot.standard.DataOutput import DataOutput -from librespot.standard.FilterInputStream import FilterInputStream -from librespot.standard.Flushable import Flushable -from librespot.standard.InputStream import InputStream -from librespot.standard.OutputStream import OutputStream -from librespot.standard.Proxy import Proxy -from librespot.standard.Runnable import Runnable diff --git a/librespot/structure.py b/librespot/structure.py new file mode 100644 index 0000000..f97f341 --- /dev/null +++ b/librespot/structure.py @@ -0,0 +1,72 @@ +from __future__ import annotations +import typing + +if typing.TYPE_CHECKING: + from librespot.audio import AbsChunkedInputStream + from librespot.audio.format import SuperAudioFormat + from librespot.crypto import Packet + from librespot.mercury import MercuryClient + from librespot.proto import Metadata_pb2 as Metadata + + +class AudioDecrypt: + def decrypt_chunk(self, chunk_index: int, buffer: bytes): + raise NotImplementedError + + def decrypt_time_ms(self): + raise NotImplementedError + + +class AudioQualityPicker: + def get_file(self, files: typing.List[Metadata.AudioFile]) -> Metadata.AudioFile: + raise NotImplementedError + + +class Closeable: + def close(self) -> None: + raise NotImplementedError + + +class GeneralAudioStream: + def stream(self) -> AbsChunkedInputStream: + raise NotImplementedError + + def codec(self) -> SuperAudioFormat: + raise NotImplementedError + + def describe(self) -> str: + raise NotImplementedError + + def decrypt_time_ms(self) -> int: + raise NotImplementedError + + +class GeneralWritableStream: + def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool): + raise NotImplementedError + + +class HaltListener: + def stream_read_halted(self, chunk: int, _time: int) -> None: + raise NotImplementedError + + def stream_read_resumed(self, chunk: int, _time: int) -> None: + raise NotImplementedError + + +class NoopAudioDecrypt(AudioDecrypt): + def decrypt_chunk(self, chunk_index: int, buffer: bytes): + raise NotImplementedError + + def decrypt_time_ms(self): + return 0 + + +class PacketsReceiver: + def dispatch(self, packet: Packet): + raise NotImplementedError + + +class SubListener: + def event(self, resp: MercuryClient.Response) -> None: + raise NotImplementedError diff --git a/librespot/common/Base62.py b/librespot/util.py similarity index 75% rename from librespot/common/Base62.py rename to librespot/util.py index 7bcce0b..bde34d7 100644 --- a/librespot/common/Base62.py +++ b/librespot/util.py @@ -1,6 +1,42 @@ +from Cryptodome import Random +import binascii import math +def bytes_to_hex(buffer: bytes) -> str: + """ + Convert bytes to hex + Args: + buffer: Bytes to convert + Returns: + hex + """ + return binascii.hexlify(buffer).decode() + + +def hex_to_bytes(s: str) -> bytes: + return binascii.unhexlify(s) + + +def int_to_bytes(i: int): + """ + Convert an integer to a byte(s) + Args: + i: Integer to convert + Returns: + bytes + """ + width = i.bit_length() + width += 8 - ((width % 8) or 8) + fmt = '%%0%dx' % (width // 4) + return b"\x00" if i == 0 else binascii.unhexlify(fmt % i) + + +def random_hex_string(length: int): + buffer = Random.get_random_bytes(int(length / 2)) + return bytes_to_hex(buffer) + + class Base62: standard_base = 256 target_base = 62 @@ -16,25 +52,20 @@ 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"" @@ -49,32 +80,26 @@ class Base62: remainder = int(accumulator % target_base) if len(quotient) > 0 or digit > 0: quotient += bytes([digit]) - out += bytes([remainder]) source = quotient - if len(out) < estimated_length: size = len(out) for _ in range(estimated_length - size): out += bytes([0]) - return self.reverse(out) if len(out) > estimated_length: 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) reversed_arr = bytearray(length) for i in range(length): reversed_arr[length - i - 1] = arr[i] - return bytes(reversed_arr) def create_lookup_table(self): diff --git a/librespot/zeroconf/Packet.py b/librespot/zeroconf/Packet.py deleted file mode 100644 index eafbf21..0000000 --- a/librespot/zeroconf/Packet.py +++ /dev/null @@ -1,59 +0,0 @@ -import struct - - -class Packet: - __FLAG_RESPONSE: int = 15 - __FLAG_AA: int = 10 - __questions: list - __answers: list - __authorities: list - __additionals: list - __id: int - __flags: int - __address: str - - def __init__(self, _id: int): - self.__id = _id - self.__questions = [] - self.__answers = [] - self.__authorities = [] - self.__additionals = [] - - def get_address(self) -> str: - return self.__address - - def set_address(self, address: str) -> None: - self.__address = address - - def get_id(self) -> int: - return self.__id - - def is_response(self) -> bool: - return self.__is_flag(self.__FLAG_RESPONSE) - - def set_response(self, on: bool) -> None: - self.__set_flag(self.__FLAG_RESPONSE, on) - - def is_authoritative(self) -> bool: - return self.__is_flag(self.__FLAG_AA) - - def set_authoritative(self, on: bool) -> None: - self.__set_flag(self.__FLAG_AA, on) - - def __is_flag(self, flag: int): - return (self.__flags & (1 << flag)) != 0 - - def __set_flag(self, flag: int, on: bool): - if on: - self.__flags |= 1 << flag - else: - self.__flags &= ~(1 << flag) - - def read(self, inp: bytes, address: str): - self.__address = address - self.__id = struct.unpack(" Service: - if protocol == "tcp" or protocol == "udp": - self.__protocol = protocol - else: - raise TypeError() - return self - - def get_domain(self) -> str: - return self.__domain - - def set_domain(self, domain: str) -> Service: - if domain is None or len(domain) < 2 or domain[0] != ".": - raise TypeError(domain) - self.__domain = domain - return self - - def get_host(self) -> str: - return self.__host - - def set_host(self, host: str) -> Service: - self.__host = host - return self - - def get_packet(self) -> Packet: - packet = Packet() - return packet diff --git a/librespot/zeroconf/Zeroconf.py b/librespot/zeroconf/Zeroconf.py deleted file mode 100644 index a0274ce..0000000 --- a/librespot/zeroconf/Zeroconf.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import base64 -import random -import socket - -from librespot.standard import Closeable - - -class Zeroconf(Closeable): - __DISCOVERY = "_services._dns-sd._udp.local" - __BROADCAST4: socket.socket - __BROADCAST6: socket.socket - __use_ipv4: bool = True - __use_ipv6: bool = True - __hostname: str - __domain: str - - def __init__(self): - try: - self.__BROADCAST4 = socket.socket(socket.AF_INET, - socket.SOCK_DGRAM) - self.__BROADCAST4.connect(("224.0.0.251", 5353)) - self.__BROADCAST6 = socket.socket(socket.AF_INET6, - socket.SOCK_DGRAM) - self.__BROADCAST6.connect(("FF02::FB", 5353)) - except Exception: - pass - self.set_domain(".local") - self.set_local_host_name(Zeroconf.get_or_create_local_host_name()) - - @staticmethod - def get_or_create_local_host_name() -> str: - host = socket.gethostname() - if host == "localhost": - host = (base64.b64encode( - random.randint(-9223372036854775808, - 9223372036854775807)).decode() + ".local") - return host - - def set_use_ipv4(self, ipv4: bool) -> Zeroconf: - self.__use_ipv4 = ipv4 - return self - - def set_use_ipv6(self, ipv6: bool) -> Zeroconf: - self.__use_ipv6 = ipv6 - return self - - def close(self) -> None: - super().close() - - def get_domain(self) -> str: - return self.__domain - - def set_domain(self, domain: str) -> Zeroconf: - self.__domain = domain - return self - - def get_local_host_name(self) -> str: - return self.__hostname - - def set_local_host_name(self, name: str) -> Zeroconf: - self.__hostname = name - return self - - def handle_packet(self, packet): - pass - - def announce(self, service): - pass diff --git a/librespot/zeroconf/__init__.py b/librespot/zeroconf/__init__.py deleted file mode 100644 index 7239a5d..0000000 --- a/librespot/zeroconf/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from librespot.zeroconf.Packet import Packet diff --git a/requirements.txt b/requirements.txt index 1f8fe3e..e4ed2da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ defusedxml==0.7.1 protobuf==3.17.3 pycryptodomex==3.10.1 -requests==2.26.0 +pyogg==0.6.14a.1 +requests==2.26.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 80c0a28..b78959e 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setuptools.setup( url="https://github.com/kokarare1212/librespot-python", license="Apache-2.0", packages=setuptools.find_packages("."), - install_requires=["defusedxml", "protobuf", "pycryptodomex", "requests"], + install_requires=["defusedxml", "protobuf", "pycryptodomex", "pyogg", "requests"], classifiers=[ "Development Status :: 1 - Planning", "License :: OSI Approved :: Apache Software License",