This commit is contained in:
kokarare1212 2021-09-12 12:58:24 +09:00
parent 1a06d04028
commit 01914c44c9
No known key found for this signature in database
GPG Key ID: 9FB32C7C7D874F7A
105 changed files with 3420 additions and 6023 deletions

View File

@ -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<TrackID>[0-9a-zA-Z]{22})$", args[1])
track_url_search = re.search(
r"^(https?://)?open\.spotify\.com/track/(?P<TrackID>[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()

View File

@ -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<TrackID>[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<start>[0-9]+?)-(?P<end>[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()

View File

@ -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
)

View File

@ -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()

View File

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -1,3 +0,0 @@
class GeneralWritableStream:
def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool):
pass

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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("<f", buffer.read(4))[0], struct.unpack("<f", buffer.read(4))[0],
struct.unpack("<f", buffer.read(4))[0], struct.unpack("<f", buffer.read(4))[0])
def get_factor(self, normalisation_pregain) -> float:
normalisation_factor = float(math.pow(10, (self.track_gain_db + normalisation_pregain) / 20))
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)

View File

@ -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),
)

View File

@ -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))

View File

@ -1,2 +0,0 @@
from librespot.audio.cdn.CdnFeedHelper import CdnFeedHelper
from librespot.audio.cdn.CdnManager import CdnManager

View File

@ -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

View File

@ -1 +0,0 @@
from librespot.audio.decoders.AudioQuality import AudioQuality

View File

@ -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)

View File

@ -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))

View File

@ -1,6 +0,0 @@
class AudioDecrypt:
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
pass
def decrypt_time_ms(self):
pass

View File

@ -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

View File

@ -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

26
librespot/audio/format.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -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))

View File

@ -1,2 +0,0 @@
from librespot.audio.format.AudioQualityPicker import AudioQualityPicker
from librespot.audio.format.SuperAudioFormat import SuperAudioFormat

129
librespot/audio/storage.py Normal file
View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

18
librespot/cache.py Normal file
View File

@ -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:
"""

View File

@ -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

View File

@ -1 +0,0 @@
from librespot.cache.CacheManager import CacheManager

View File

@ -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)

View File

@ -1,2 +0,0 @@
from librespot.common.Base62 import Base62
from librespot.common.Utils import Utils

1275
librespot/core.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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")

View File

@ -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()

View File

@ -1,6 +0,0 @@
from librespot.crypto.Packet import Packet
class PacketsReceiver:
def dispatch(self, packet: Packet):
pass

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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

410
librespot/crypto.py Normal file
View File

@ -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("<I", len(key))
for i in range(0, len(key), 4):
self.r[self.keyp] = \
self.r[self.keyp] ^ \
struct.unpack("<I", key[i: i + 4])[0]
self.cycle()
for i in range(self.n):
self.crc[i] = self.r[i]
self.diffuse()
for i in range(self.n):
self.r[i] ^= self.crc[i]
def key(self, key: bytes) -> 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)

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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", len(key))
for i in range(0, len(key), 4):
self.R[self.KEYP] = \
self.R[self.KEYP] ^ \
struct.unpack("<I", key[i: i + 4])[0]
self.cycle()
for i in range(self.N):
self.CRC[i] = self.R[i]
self.diffuse()
for i in range(self.N):
self.R[i] ^= self.CRC[i]
def key(self, key: bytes):
self.init_state()
self.load_key(key)
self.gen_konst()
self.save_state()
self.nbuf = 0
def nonce(self, nonce: typing.Union[bytes, int]):
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):
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)

View File

@ -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

13
librespot/dealer.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -1,2 +0,0 @@
from librespot.dealer.ApiClient import ApResolver
from librespot.dealer.DealerClient import DealerClient

384
librespot/mercury.py Normal file
View File

@ -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)

View File

@ -1,5 +0,0 @@
class JsonMercuryRequest:
request = None
def __init__(self, request):
self.request = request

View File

@ -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:]

View File

@ -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)))

View File

@ -1,7 +0,0 @@
class ProtobufMercuryRequest:
request = None
parser = None
def __init__(self, request, parser):
self.request = request
self.parser = parser

View File

@ -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)

View File

@ -1,7 +0,0 @@
from __future__ import annotations
from librespot.mercury import MercuryClient
class SubListener:
def event(self, resp: MercuryClient.Response) -> None:
pass

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -1 +0,0 @@
from librespot.player.codecs.VorbisOnlyAudioQuality import VorbisOnlyAudioQuality

View File

@ -1,6 +0,0 @@
from __future__ import annotations
import logging
class PlaybackMetrics:
_LOGGER = logging.getLogger(__name__)

View File

@ -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

View File

@ -1 +0,0 @@
from librespot.player.mixing.AudioSink import AudioSink

View File

@ -1,7 +0,0 @@
from __future__ import annotations
from librespot.standard import Closeable
class PlayerSession(Closeable):
class Listener:
pass

View File

@ -1 +0,0 @@
from librespot.player.playback.PlayerSession import PlayerSession

View File

@ -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")

View File

@ -1 +0,0 @@
from librespot.player.state.DeviceStateHandler import DeviceStateHandler

View File

@ -1,3 +0,0 @@
class AutoCloseable:
def close(self) -> None:
pass

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +0,0 @@
from librespot.standard.AutoCloseable import AutoCloseable
class Closeable(AutoCloseable):
def close(self) -> None:
pass

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -1,3 +0,0 @@
class Flushable:
def flush(self) -> None:
pass

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -1,3 +0,0 @@
class Runnable:
def run(self) -> None:
raise NotImplementedError()

View File

@ -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

72
librespot/structure.py Normal file
View File

@ -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

View File

@ -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):

View File

@ -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("<h", inp[0:2])[0]
self.__flags = struct.unpack("<h", inp[2:4])[0]
num_questions = struct.unpack("<h", inp[4:6])[0]
num_answers = struct.unpack("<h", inp[6:8])[0]
num_authorities = struct.unpack("<h", inp[8:10])[0]
num_additionals = struct.unpack("<h", inp[10:12])[0]

View File

@ -1,36 +0,0 @@
from librespot.zeroconf import Packet
class Record:
TYPE_A: int = 0x01
TYPE_PTR: int = 0x0C
TYPE_CNAME: int = 0x05
TYPE_TXT: int = 0x10
TYPE_AAAA: int = 0x1C
TYPE_SRV: int = 0x21
TYPE_NSEC: int = 0x2F
TYPE_ANY: int = 0xFF
__type: int
_ttl: int
__name: str
__clazz: int
__data: bytes
def __init__(self, typ: int):
self.__type = typ
self.__clazz = 1
@staticmethod
def _write_name(self, name: str, packet: Packet):
length = len(name)
out = b""
start = 0
for i in range(length + 1):
c = "." if i == length else name[i]
if c == ".":
out += bytes([i - start])
for j in range(start, i):
out += name.encode()[j]
start = i + 1
out += bytes([0])
return out, len(name) + 2

Some files were not shown because too many files have changed in this diff Show More