Rewrite
This commit is contained in:
parent
1a06d04028
commit
01914c44c9
|
@ -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()
|
|
@ -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()
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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()
|
|
@ -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))
|
|
@ -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
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||
class GeneralWritableStream:
|
||||
def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool):
|
||||
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
)
|
|
@ -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))
|
|
@ -1,2 +0,0 @@
|
|||
from librespot.audio.cdn.CdnFeedHelper import CdnFeedHelper
|
||||
from librespot.audio.cdn.CdnManager import CdnManager
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
from librespot.audio.decoders.AudioQuality import AudioQuality
|
|
@ -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)
|
|
@ -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))
|
|
@ -1,6 +0,0 @@
|
|||
class AudioDecrypt:
|
||||
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
|
||||
pass
|
||||
|
||||
def decrypt_time_ms(self):
|
||||
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
|
@ -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
|
|
@ -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))
|
|
@ -1,2 +0,0 @@
|
|||
from librespot.audio.format.AudioQualityPicker import AudioQualityPicker
|
||||
from librespot.audio.format.SuperAudioFormat import SuperAudioFormat
|
|
@ -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")
|
|
@ -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
|
|
@ -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
|
|
@ -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")
|
|
@ -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
|
|
@ -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:
|
||||
"""
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
from librespot.cache.CacheManager import CacheManager
|
|
@ -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)
|
|
@ -1,2 +0,0 @@
|
|||
from librespot.common.Base62 import Base62
|
||||
from librespot.common.Utils import Utils
|
File diff suppressed because it is too large
Load Diff
|
@ -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")
|
|
@ -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()
|
|
@ -1,6 +0,0 @@
|
|||
from librespot.crypto.Packet import Packet
|
||||
|
||||
|
||||
class PacketsReceiver:
|
||||
def dispatch(self, packet: Packet):
|
||||
pass
|
|
@ -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
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
|||
from librespot.dealer.ApiClient import ApResolver
|
||||
from librespot.dealer.DealerClient import DealerClient
|
|
@ -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)
|
|
@ -1,5 +0,0 @@
|
|||
class JsonMercuryRequest:
|
||||
request = None
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
|
@ -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:]
|
|
@ -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)))
|
|
@ -1,7 +0,0 @@
|
|||
class ProtobufMercuryRequest:
|
||||
request = None
|
||||
parser = None
|
||||
|
||||
def __init__(self, request, parser):
|
||||
self.request = request
|
||||
self.parser = parser
|
|
@ -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)
|
|
@ -1,7 +0,0 @@
|
|||
from __future__ import annotations
|
||||
from librespot.mercury import MercuryClient
|
||||
|
||||
|
||||
class SubListener:
|
||||
def event(self, resp: MercuryClient.Response) -> None:
|
||||
pass
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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,
|
||||
)
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
from librespot.player.codecs.VorbisOnlyAudioQuality import VorbisOnlyAudioQuality
|
|
@ -1,6 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
|
||||
class PlaybackMetrics:
|
||||
_LOGGER = logging.getLogger(__name__)
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
from librespot.player.mixing.AudioSink import AudioSink
|
|
@ -1,7 +0,0 @@
|
|||
from __future__ import annotations
|
||||
from librespot.standard import Closeable
|
||||
|
||||
|
||||
class PlayerSession(Closeable):
|
||||
class Listener:
|
||||
pass
|
|
@ -1 +0,0 @@
|
|||
from librespot.player.playback.PlayerSession import PlayerSession
|
|
@ -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")
|
|
@ -1 +0,0 @@
|
|||
from librespot.player.state.DeviceStateHandler import DeviceStateHandler
|
|
@ -1,3 +0,0 @@
|
|||
class AutoCloseable:
|
||||
def close(self) -> None:
|
||||
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
from librespot.standard.AutoCloseable import AutoCloseable
|
||||
|
||||
|
||||
class Closeable(AutoCloseable):
|
||||
def close(self) -> None:
|
||||
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -1,3 +0,0 @@
|
|||
class Flushable:
|
||||
def flush(self) -> None:
|
||||
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -1,3 +0,0 @@
|
|||
class Runnable:
|
||||
def run(self) -> None:
|
||||
raise NotImplementedError()
|
|
@ -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
|
|
@ -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
|
|
@ -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):
|
|
@ -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]
|
|
@ -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
Loading…
Reference in New Issue