mirror of
https://github.com/kokarare1212/librespot-python.git
synced 2024-10-03 17:56:49 +02:00
9d8ecbbc1b
Remove methods with unnecessary super delegation.
1044 lines
39 KiB
Python
1044 lines
39 KiB
Python
from __future__ import annotations
|
|
from Crypto.Hash import HMAC, SHA1
|
|
from Crypto.PublicKey import RSA
|
|
from Crypto.Signature import PKCS1_v1_5
|
|
from librespot.audio import AudioKeyManager, PlayableContentFeeder
|
|
from librespot.audio.cdn import CdnManager
|
|
from librespot.audio.storage import ChannelManager
|
|
from librespot.cache import CacheManager
|
|
from librespot.common.Utils import Utils
|
|
from librespot.core import ApResolver, EventService, SearchManager, TokenProvider
|
|
from librespot.crypto import CipherPair, DiffieHellman, Packet
|
|
from librespot.dealer import ApiClient, DealerClient
|
|
from librespot.mercury import MercuryClient, SubListener
|
|
from librespot.proto import Authentication, Connect, Keyexchange
|
|
from librespot.proto.ExplicitContentPubsub import UserAttributesUpdate
|
|
from librespot.standard import BytesInputStream, Closeable, Proxy
|
|
from librespot.Version import Version
|
|
import base64
|
|
import defusedxml.ElementTree
|
|
import json
|
|
import logging
|
|
import os
|
|
import requests
|
|
import sched
|
|
import socket
|
|
import struct
|
|
import threading
|
|
import time
|
|
|
|
|
|
class Session(Closeable, SubListener, DealerClient.MessageListener):
|
|
_LOGGER: logging = logging.getLogger(__name__)
|
|
_serverKey: bytes = bytes([
|
|
0xac, 0xe0, 0x46, 0x0b, 0xff, 0xc2, 0x30, 0xaf, 0xf4, 0x6b, 0xfe, 0xc3,
|
|
0xbf, 0xbf, 0x86, 0x3d, 0xa1, 0x91, 0xc6, 0xcc, 0x33, 0x6c, 0x93, 0xa1,
|
|
0x4f, 0xb3, 0xb0, 0x16, 0x12, 0xac, 0xac, 0x6a, 0xf1, 0x80, 0xe7, 0xf6,
|
|
0x14, 0xd9, 0x42, 0x9d, 0xbe, 0x2e, 0x34, 0x66, 0x43, 0xe3, 0x62, 0xd2,
|
|
0x32, 0x7a, 0x1a, 0x0d, 0x92, 0x3b, 0xae, 0xdd, 0x14, 0x02, 0xb1, 0x81,
|
|
0x55, 0x05, 0x61, 0x04, 0xd5, 0x2c, 0x96, 0xa4, 0x4c, 0x1e, 0xcc, 0x02,
|
|
0x4a, 0xd4, 0xb2, 0x0c, 0x00, 0x1f, 0x17, 0xed, 0xc2, 0x2f, 0xc4, 0x35,
|
|
0x21, 0xc8, 0xf0, 0xcb, 0xae, 0xd2, 0xad, 0xd7, 0x2b, 0x0f, 0x9d, 0xb3,
|
|
0xc5, 0x32, 0x1a, 0x2a, 0xfe, 0x59, 0xf3, 0x5a, 0x0d, 0xac, 0x68, 0xf1,
|
|
0xfa, 0x62, 0x1e, 0xfb, 0x2c, 0x8d, 0x0c, 0xb7, 0x39, 0x2d, 0x92, 0x47,
|
|
0xe3, 0xd7, 0x35, 0x1a, 0x6d, 0xbd, 0x24, 0xc2, 0xae, 0x25, 0x5b, 0x88,
|
|
0xff, 0xab, 0x73, 0x29, 0x8a, 0x0b, 0xcc, 0xcd, 0x0c, 0x58, 0x67, 0x31,
|
|
0x89, 0xe8, 0xbd, 0x34, 0x80, 0x78, 0x4a, 0x5f, 0xc9, 0x6b, 0x89, 0x9d,
|
|
0x95, 0x6b, 0xfc, 0x86, 0xd7, 0x4f, 0x33, 0xa6, 0x78, 0x17, 0x96, 0xc9,
|
|
0xc3, 0x2d, 0x0d, 0x32, 0xa5, 0xab, 0xcd, 0x05, 0x27, 0xe2, 0xf7, 0x10,
|
|
0xa3, 0x96, 0x13, 0xc4, 0x2f, 0x99, 0xc0, 0x27, 0xbf, 0xed, 0x04, 0x9c,
|
|
0x3c, 0x27, 0x58, 0x04, 0xb6, 0xb2, 0x19, 0xf9, 0xc1, 0x2f, 0x02, 0xe9,
|
|
0x48, 0x63, 0xec, 0xa1, 0xb6, 0x42, 0xa0, 0x9d, 0x48, 0x25, 0xf8, 0xb3,
|
|
0x9d, 0xd0, 0xe8, 0x6a, 0xf9, 0x48, 0x4d, 0xa1, 0xc2, 0xba, 0x86, 0x30,
|
|
0x42, 0xea, 0x9d, 0xb3, 0x08, 0x6c, 0x19, 0x0e, 0x48, 0xb3, 0x9d, 0x66,
|
|
0xeb, 0x00, 0x06, 0xa2, 0x5a, 0xee, 0xa1, 0x1b, 0x13, 0x87, 0x3c, 0xd7,
|
|
0x19, 0xe6, 0x55, 0xbd
|
|
])
|
|
_keys: DiffieHellman = None
|
|
_inner: Session.Inner = None
|
|
_scheduler: sched.scheduler = sched.scheduler(time.time)
|
|
_authLock: threading.Condition = threading.Condition()
|
|
_authLockBool: bool = False
|
|
_client: requests.Session = None
|
|
_closeListeners: list[Session.CloseListener] = []
|
|
_closeListenersLock: threading.Condition = threading.Condition()
|
|
_reconnectionListeners: list[Session.ReconnectionListener] = []
|
|
_reconnectionListenersLock: threading.Condition = threading.Condition()
|
|
_userAttributes: dict[str, str] = {}
|
|
_conn: Session.ConnectionHolder = None
|
|
_cipherPair: CipherPair = None
|
|
_receiver: Session.Receiver = None
|
|
_apWelcome: Authentication.APWelcome = None
|
|
_mercuryClient: MercuryClient = None
|
|
_audioKeyManager: AudioKeyManager = None
|
|
_channelManager: ChannelManager = None
|
|
_tokenProvider: TokenProvider = None
|
|
_cdnManager: CdnManager = None
|
|
_cacheManager = None
|
|
_dealer: DealerClient = None
|
|
_api: ApiClient = None
|
|
_search: SearchManager = None
|
|
_contentFeeder: PlayableContentFeeder = None
|
|
_eventService: EventService = None
|
|
_countryCode: str = None
|
|
_closed: bool = False
|
|
_closing: bool = False
|
|
_scheduledReconnect: sched.Event = None
|
|
|
|
def __init__(self, inner: Session.Inner, addr: str):
|
|
self._inner = inner
|
|
self._keys = DiffieHellman()
|
|
self._conn = Session.ConnectionHolder.create(addr, inner.conf)
|
|
self._client = Session._create_client(self._inner.conf)
|
|
|
|
self._LOGGER.info("Created new session! device_id: {}, ap: {}".format(
|
|
inner.device_id, addr))
|
|
|
|
@staticmethod
|
|
def _create_client(conf: Session.Configuration) -> requests.Session:
|
|
client = requests.Session()
|
|
if conf.proxyAuth and conf.proxyType is not Proxy.Type.DIRECT:
|
|
if conf.proxyAuth:
|
|
proxy_setting = [
|
|
conf.proxyUsername, conf.proxyPassword, conf.proxyAddress,
|
|
conf.proxyPort
|
|
]
|
|
else:
|
|
proxy_setting = [conf.proxyAddress, conf.proxyPort]
|
|
client.proxies = {
|
|
"http": "{}:{}@{}:{}".format(*proxy_setting),
|
|
"https": "{}:{}@{}:{}".format(*proxy_setting)
|
|
}
|
|
|
|
return client
|
|
|
|
@staticmethod
|
|
def _read_blob_int(buffer: bytearray) -> int:
|
|
lo = buffer[0]
|
|
if (lo & 0x80) == 0:
|
|
return lo
|
|
hi = buffer[1]
|
|
return lo & 0x7f | hi << 7
|
|
|
|
def client(self) -> requests.Session:
|
|
return self._client
|
|
|
|
def _connect(self) -> None:
|
|
acc = Session.Accumulator()
|
|
|
|
# Send ClientHello
|
|
|
|
nonce = os.urandom(0x10)
|
|
|
|
client_hello = Keyexchange.ClientHello(
|
|
build_info=Version.standard_build_info(),
|
|
cryptosuites_supported=[
|
|
Keyexchange.Cryptosuite.CRYPTO_SUITE_SHANNON
|
|
],
|
|
login_crypto_hello=Keyexchange.LoginCryptoHelloUnion(
|
|
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello(
|
|
gc=self._keys.public_key_array(), server_keys_known=1), ),
|
|
client_nonce=nonce,
|
|
padding=bytes([0x1e]))
|
|
|
|
client_hello_bytes = client_hello.SerializeToString()
|
|
length = 2 + 4 + len(client_hello_bytes)
|
|
self._conn.write_byte(0)
|
|
self._conn.write_byte(4)
|
|
self._conn.write_int(length)
|
|
self._conn.write(client_hello_bytes)
|
|
self._conn.flush()
|
|
|
|
acc.write_byte(0)
|
|
acc.write_byte(4)
|
|
acc.write_int(length)
|
|
acc.write(client_hello_bytes)
|
|
|
|
# Read APResponseMessage
|
|
|
|
length = self._conn.read_int()
|
|
acc.write_int(length)
|
|
buffer = self._conn.read(length - 4)
|
|
acc.write(buffer)
|
|
|
|
ap_response_message = Keyexchange.APResponseMessage()
|
|
ap_response_message.ParseFromString(buffer)
|
|
shared_key = Utils.to_byte_array(
|
|
self._keys.compute_shared_key(
|
|
ap_response_message.challenge.login_crypto_challenge.
|
|
diffie_hellman.gs))
|
|
|
|
# Check gs_signature
|
|
|
|
rsa = RSA.construct((int.from_bytes(self._serverKey, "big"), 65537))
|
|
pkcs1_v1_5 = PKCS1_v1_5.new(rsa)
|
|
sha1 = SHA1.new()
|
|
sha1.update(ap_response_message.challenge.login_crypto_challenge.
|
|
diffie_hellman.gs)
|
|
# noinspection PyTypeChecker
|
|
if not pkcs1_v1_5.verify(
|
|
sha1, ap_response_message.challenge.login_crypto_challenge.
|
|
diffie_hellman.gs_signature):
|
|
raise RuntimeError("Failed signature check!")
|
|
|
|
# Solve challenge
|
|
|
|
data = b""
|
|
|
|
for i in range(1, 6):
|
|
# noinspection PyTypeChecker
|
|
mac = HMAC.new(shared_key, digestmod=SHA1)
|
|
mac.update(acc.array())
|
|
mac.update(bytes([i]))
|
|
data += mac.digest()
|
|
|
|
# noinspection PyTypeChecker
|
|
mac = HMAC.new(data[:20], digestmod=SHA1)
|
|
mac.update(acc.array())
|
|
|
|
challenge = mac.digest()
|
|
client_response_plaintext = Keyexchange.ClientResponsePlaintext(
|
|
login_crypto_response=Keyexchange.LoginCryptoResponseUnion(
|
|
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(
|
|
hmac=challenge)),
|
|
pow_response=Keyexchange.PoWResponseUnion(),
|
|
crypto_response=Keyexchange.CryptoResponseUnion())
|
|
|
|
client_response_plaintext_bytes = client_response_plaintext.SerializeToString(
|
|
)
|
|
length = 4 + len(client_response_plaintext_bytes)
|
|
self._conn.write_int(length)
|
|
self._conn.write(client_response_plaintext_bytes)
|
|
self._conn.flush()
|
|
|
|
try:
|
|
self._conn.set_timeout(1)
|
|
scrap = self._conn.read(4)
|
|
if 4 == len(scrap):
|
|
length = (scrap[0] << 24) | (scrap[1] << 16) | (
|
|
scrap[2] << 8) | (scrap[3] & 0xff)
|
|
payload = self._conn.read(length - 4)
|
|
failed = Keyexchange.APResponseMessage()
|
|
failed.ParseFromString(payload)
|
|
raise RuntimeError(failed)
|
|
except socket.timeout:
|
|
pass
|
|
finally:
|
|
self._conn.set_timeout(0)
|
|
|
|
with self._authLock:
|
|
self._cipherPair = CipherPair(data[20:52], data[52:84])
|
|
|
|
self._authLockBool = True
|
|
|
|
self._LOGGER.info("Connection successfully!")
|
|
|
|
def _authenticate(self,
|
|
credentials: Authentication.LoginCredentials) -> None:
|
|
self._authenticate_partial(credentials, False)
|
|
|
|
with self._authLock:
|
|
self._mercuryClient = MercuryClient(self)
|
|
self._tokenProvider = TokenProvider.TokenProvider(self)
|
|
self._audioKeyManager = AudioKeyManager(self)
|
|
self._channelManager = ChannelManager(self)
|
|
self._api = ApiClient.ApiClient(self)
|
|
self._cdnManager = CdnManager.CdnManager(self)
|
|
self._contentFeeder = PlayableContentFeeder(self)
|
|
self._cacheManager = CacheManager(self)
|
|
self._dealer = DealerClient(self)
|
|
self._search = SearchManager.SearchManager(self)
|
|
self._eventService = EventService.EventService(self)
|
|
|
|
self._authLockBool = False
|
|
self._authLock.notify_all()
|
|
|
|
self._eventService.language(self._inner.preferred_locale)
|
|
# TimeProvider.init(self)
|
|
self._dealer.connect()
|
|
|
|
self._LOGGER.info("Authenticated as {}!".format(
|
|
self._apWelcome.canonical_username))
|
|
self.mercury().interested_in("spotify:user:attributes:update", self)
|
|
self.dealer().add_message_listener(
|
|
self, "hm://connect-state/v1/connect/logout")
|
|
|
|
def _authenticate_partial(self,
|
|
credentials: Authentication.LoginCredentials,
|
|
remove_lock: bool) -> None:
|
|
if self._cipherPair is None:
|
|
raise RuntimeError("Connection not established!")
|
|
|
|
client_response_encrypted = Authentication.ClientResponseEncrypted(
|
|
login_credentials=credentials,
|
|
system_info=Authentication.SystemInfo(
|
|
os=Authentication.Os.OS_UNKNOWN,
|
|
cpu_family=Authentication.CpuFamily.CPU_UNKNOWN,
|
|
system_information_string=Version.system_info_string(),
|
|
device_id=self._inner.device_id),
|
|
version_string=Version.version_string())
|
|
|
|
self._send_unchecked(Packet.Type.login,
|
|
client_response_encrypted.SerializeToString())
|
|
|
|
packet = self._cipherPair.receive_encoded(self._conn)
|
|
if packet.is_cmd(Packet.Type.ap_welcome):
|
|
self._apWelcome = Authentication.APWelcome()
|
|
self._apWelcome.ParseFromString(packet.payload)
|
|
|
|
self._receiver = Session.Receiver(self)
|
|
|
|
bytes0x0f = os.urandom(20)
|
|
self._send_unchecked(Packet.Type.unknown_0x0f, bytes0x0f)
|
|
|
|
preferred_locale = bytes()
|
|
preferred_locale += bytes([0x00, 0x00, 0x10, 0x00, 0x02])
|
|
preferred_locale += "preferred-locale".encode()
|
|
preferred_locale += self._inner.preferred_locale.encode()
|
|
self._send_unchecked(Packet.Type.preferred_locale,
|
|
preferred_locale)
|
|
|
|
if remove_lock:
|
|
with self._authLock:
|
|
self._authLockBool = False
|
|
self._authLock.notify_all()
|
|
|
|
if self._inner.conf.store_credentials:
|
|
reusable = self._apWelcome.reusable_auth_credentials
|
|
reusable_type = Authentication.AuthenticationType.Name(
|
|
self._apWelcome.reusable_auth_credentials_type)
|
|
|
|
if self._inner.conf.stored_credentials_file is None:
|
|
raise TypeError()
|
|
|
|
with open(self._inner.conf.stored_credentials_file, "w") as f:
|
|
json.dump(
|
|
{
|
|
"username": self._apWelcome.canonical_username,
|
|
"credentials": base64.b64encode(reusable).decode(),
|
|
"type": reusable_type
|
|
}, f)
|
|
|
|
elif packet.is_cmd(Packet.Type.auth_failure):
|
|
ap_login_failed = Keyexchange.APLoginFailed()
|
|
ap_login_failed.ParseFromString(packet.payload)
|
|
raise Session.SpotifyAuthenticationException(ap_login_failed)
|
|
else:
|
|
raise RuntimeError("Unknown CMD 0x" + packet.cmd.hex())
|
|
|
|
def close(self) -> None:
|
|
self._LOGGER.info("Closing session. device_id: {}".format(
|
|
self._inner.device_id))
|
|
|
|
self._closing = True
|
|
|
|
if self._dealer is not None:
|
|
self._dealer.close()
|
|
# noinspection PyTypeChecker
|
|
self._dealer = None
|
|
|
|
if self._audioKeyManager is not None:
|
|
# noinspection PyTypeChecker
|
|
self._audioKeyManager = None
|
|
|
|
if self._channelManager is not None:
|
|
self._channelManager.close()
|
|
# noinspection PyTypeChecker
|
|
self._channelManager = None
|
|
|
|
if self._eventService is not None:
|
|
self._eventService.close()
|
|
# noinspection PyTypeChecker
|
|
self._eventService = None
|
|
|
|
if self._mercuryClient is not None:
|
|
self._mercuryClient.close()
|
|
# noinspection PyTypeChecker
|
|
self._mercuryClient = None
|
|
|
|
if self._receiver is not None:
|
|
self._receiver.stop()
|
|
# noinspection PyTypeChecker
|
|
self._receiver = None
|
|
|
|
if self._client is not None:
|
|
# noinspection PyTypeChecker
|
|
self._client = None
|
|
|
|
if self._conn is not None:
|
|
self._conn.sock.close()
|
|
# noinspection PyTypeChecker
|
|
self._conn = None
|
|
|
|
with self._authLock:
|
|
self._apWelcome = None
|
|
# noinspection PyTypeChecker
|
|
self._cipherPair = None
|
|
self._closed = True
|
|
|
|
with self._closeListenersLock:
|
|
for listener in self._closeListeners:
|
|
listener.on_closed()
|
|
self._closeListeners: list[Session.CloseListener] = []
|
|
|
|
self._reconnectionListeners: list[Session.ReconnectionListener] = []
|
|
|
|
self._LOGGER.info("Closed session. device_id: {}".format(
|
|
self._inner.device_id))
|
|
|
|
def _send_unchecked(self, cmd: bytes, payload: bytes) -> None:
|
|
self._cipherPair.send_encoded(self._conn, cmd, payload)
|
|
|
|
def _wait_auth_lock(self) -> None:
|
|
if self._closing and self._conn is None:
|
|
self._LOGGER.debug("Connection was broken while closing.")
|
|
return
|
|
|
|
if self._closed:
|
|
raise RuntimeError("Session is closed!")
|
|
|
|
with self._authLock:
|
|
if self._cipherPair is None or self._authLockBool:
|
|
self._authLock.wait()
|
|
|
|
def send(self, cmd: bytes, payload: bytes):
|
|
if self._closing and self._conn is None:
|
|
self._LOGGER.debug("Connection was broken while closing.")
|
|
return
|
|
|
|
if self._closed:
|
|
raise RuntimeError("Session is closed!")
|
|
|
|
with self._authLock:
|
|
if self._cipherPair is None or self._authLockBool:
|
|
self._authLock.wait()
|
|
|
|
self._send_unchecked(cmd, payload)
|
|
|
|
def mercury(self) -> MercuryClient:
|
|
self._wait_auth_lock()
|
|
if self._mercuryClient is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._mercuryClient
|
|
|
|
def audio_key(self) -> AudioKeyManager:
|
|
self._wait_auth_lock()
|
|
if self._audioKeyManager is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._audioKeyManager
|
|
|
|
def cache(self) -> CacheManager:
|
|
self._wait_auth_lock()
|
|
if self._cacheManager is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._cacheManager
|
|
|
|
def cdn(self) -> CdnManager:
|
|
self._wait_auth_lock()
|
|
if self._cdnManager is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._cdnManager
|
|
|
|
def channel(self) -> ChannelManager:
|
|
self._wait_auth_lock()
|
|
if self._channelManager is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._channelManager
|
|
|
|
def tokens(self) -> TokenProvider:
|
|
self._wait_auth_lock()
|
|
if self._tokenProvider is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._tokenProvider
|
|
|
|
def dealer(self) -> DealerClient:
|
|
self._wait_auth_lock()
|
|
if self._dealer is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._dealer
|
|
|
|
def api(self) -> ApiClient:
|
|
self._wait_auth_lock()
|
|
if self._api is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._api
|
|
|
|
def content_feeder(self) -> PlayableContentFeeder:
|
|
if self._contentFeeder is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._contentFeeder
|
|
|
|
def search(self) -> SearchManager:
|
|
self._wait_auth_lock()
|
|
if self._search is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._search
|
|
|
|
def event_service(self) -> EventService:
|
|
self._wait_auth_lock()
|
|
if self._eventService is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._eventService
|
|
|
|
def username(self) -> str:
|
|
return self.ap_welcome().canonical_username
|
|
|
|
def ap_welcome(self) -> Authentication.APWelcome:
|
|
self._wait_auth_lock()
|
|
if self._apWelcome is None:
|
|
raise RuntimeError("Session isn't authenticated!")
|
|
return self._apWelcome
|
|
|
|
def is_valid(self) -> bool:
|
|
if self._closed:
|
|
return False
|
|
|
|
self._wait_auth_lock()
|
|
return self._apWelcome is not None and self._conn is not None
|
|
|
|
def reconnecting(self) -> bool:
|
|
return not self._closing and not self._closed and self._conn is None
|
|
|
|
def country_code(self) -> str:
|
|
return self._countryCode
|
|
|
|
def device_id(self) -> str:
|
|
return self._inner.device_id
|
|
|
|
def preferred_locale(self) -> str:
|
|
return self._inner.preferred_locale
|
|
|
|
def device_type(self) -> Connect.DeviceType:
|
|
return self._inner.device_type
|
|
|
|
def device_name(self) -> str:
|
|
return self._inner.device_name
|
|
|
|
def configuration(self) -> Session.Configuration:
|
|
return self._inner.conf
|
|
|
|
def _reconnect(self) -> None:
|
|
with self._reconnectionListenersLock:
|
|
for listener in self._reconnectionListeners:
|
|
listener.on_connection_dropped()
|
|
|
|
if self._conn is not None:
|
|
self._conn.sock.close()
|
|
self._receiver.stop()
|
|
|
|
self._conn = Session.ConnectionHolder.create(
|
|
ApResolver.get_random_accesspoint(), self._inner.conf)
|
|
self._connect()
|
|
self._authenticate_partial(
|
|
Authentication.LoginCredentials(
|
|
typ=self._apWelcome.reusable_auth_credentials_type,
|
|
username=self._apWelcome.canonical_username,
|
|
auth_data=self._apWelcome.reusable_auth_credentials), True)
|
|
|
|
self._LOGGER.info("Re-authenticated as {}!".format(
|
|
self._apWelcome.canonical_username))
|
|
|
|
with self._reconnectionListenersLock:
|
|
for listener in self._reconnectionListeners:
|
|
listener.on_connection_established()
|
|
|
|
def add_close_listener(self, listener: CloseListener) -> None:
|
|
if listener not in self._closeListeners:
|
|
self._closeListeners.append(listener)
|
|
|
|
def add_reconnection_listener(self,
|
|
listener: ReconnectionListener) -> None:
|
|
if listener not in self._reconnectionListeners:
|
|
self._reconnectionListeners.append(listener)
|
|
|
|
def remove_reconnection_listener(self,
|
|
listener: ReconnectionListener) -> None:
|
|
self._reconnectionListeners.remove(listener)
|
|
|
|
def _parse_product_info(self, data) -> None:
|
|
doc = defusedxml.ElementTree.fromstring(data)
|
|
|
|
products = doc
|
|
if products is None:
|
|
return
|
|
|
|
product = products[0]
|
|
if product is None:
|
|
return
|
|
|
|
for i in range(len(product)):
|
|
self._userAttributes[product[i].tag] = product[i].text
|
|
|
|
self._LOGGER.debug("Parsed product info: {}".format(
|
|
self._userAttributes))
|
|
|
|
def get_user_attribute(self, key: str, fallback: str = None) -> str:
|
|
return self._userAttributes.get(key) if self._userAttributes.get(
|
|
key) is not None else fallback
|
|
|
|
def event(self, resp: MercuryClient.Response) -> None:
|
|
if resp.uri == "spotify:user:attributes:update":
|
|
attributes_update = UserAttributesUpdate()
|
|
attributes_update.ParseFromString(resp.payload)
|
|
|
|
for pair in attributes_update.pairs_list:
|
|
self._userAttributes[pair.key] = pair.value
|
|
self._LOGGER.info("Updated user attribute: {} -> {}".format(
|
|
pair.key, pair.value))
|
|
|
|
def on_message(self, uri: str, headers: dict[str, str],
|
|
payload: bytes) -> None:
|
|
if uri == "hm://connect-state/v1/connect/logout":
|
|
self.close()
|
|
|
|
class ReconnectionListener:
|
|
def on_connection_dropped(self) -> None:
|
|
pass
|
|
|
|
def on_connection_established(self) -> None:
|
|
pass
|
|
|
|
class CloseListener:
|
|
def on_closed(self) -> None:
|
|
pass
|
|
|
|
class Inner:
|
|
device_type: Connect.DeviceType = None
|
|
device_name: str = None
|
|
device_id: str = None
|
|
conf = None
|
|
preferred_locale: str = 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 AbsBuilder:
|
|
conf = None
|
|
device_id = None
|
|
device_name = "librespot-python"
|
|
device_type = Connect.DeviceType.COMPUTER
|
|
preferred_locale = "en"
|
|
|
|
def __init__(self, conf: Session.Configuration = None):
|
|
if conf is None:
|
|
self.conf = Session.Configuration.Builder().build()
|
|
else:
|
|
self.conf = conf
|
|
|
|
def set_preferred_locale(self, locale: str) -> Session.AbsBuilder:
|
|
if len(locale) != 2:
|
|
raise TypeError("Invalid locale: {}".format(locale))
|
|
|
|
self.preferred_locale = locale
|
|
return self
|
|
|
|
def set_device_name(self, device_name: str) -> Session.AbsBuilder:
|
|
self.device_name = device_name
|
|
return self
|
|
|
|
def set_device_id(self, device_id: str) -> Session.AbsBuilder:
|
|
if self.device_id is not None and len(device_id) != 40:
|
|
raise TypeError("Device ID must be 40 chars long.")
|
|
|
|
self.device_id = device_id
|
|
return self
|
|
|
|
def set_device_type(
|
|
self, device_type: Connect.DeviceType) -> Session.AbsBuilder:
|
|
self.device_type = device_type
|
|
return self
|
|
|
|
class Builder(AbsBuilder):
|
|
login_credentials: Authentication.LoginCredentials = None
|
|
|
|
def stored(self):
|
|
pass
|
|
|
|
def stored_file(self,
|
|
stored_credentials: str = None) -> Session.Builder:
|
|
if stored_credentials is None:
|
|
stored_credentials = self.conf.stored_credentials_file
|
|
if os.path.isfile(stored_credentials):
|
|
try:
|
|
with open(stored_credentials) as f:
|
|
obj = json.load(f)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
else:
|
|
try:
|
|
self.login_credentials = Authentication.LoginCredentials(
|
|
typ=Authentication.AuthenticationType.Value(
|
|
obj["type"]),
|
|
username=obj["username"],
|
|
auth_data=base64.b64decode(obj["credentials"]))
|
|
except KeyError:
|
|
pass
|
|
|
|
return self
|
|
|
|
def user_pass(self, username: str, password: str) -> Session.Builder:
|
|
self.login_credentials = Authentication.LoginCredentials(
|
|
username=username,
|
|
typ=Authentication.AuthenticationType.AUTHENTICATION_USER_PASS,
|
|
auth_data=password.encode())
|
|
return self
|
|
|
|
def create(self) -> Session:
|
|
if self.login_credentials is None:
|
|
raise RuntimeError("You must select an authentication method.")
|
|
|
|
session = Session(
|
|
Session.Inner(self.device_type, self.device_name,
|
|
self.preferred_locale, self.conf,
|
|
self.device_id),
|
|
ApResolver.get_random_accesspoint())
|
|
session._connect()
|
|
session._authenticate(self.login_credentials)
|
|
return session
|
|
|
|
class Configuration:
|
|
# Proxy
|
|
proxyEnabled: bool
|
|
proxyType: Proxy.Type
|
|
proxyAddress: str
|
|
proxyPort: int
|
|
proxyAuth: bool
|
|
proxyUsername: str
|
|
proxyPassword: str
|
|
|
|
# Cache
|
|
cache_enabled: bool
|
|
cache_dir: str
|
|
do_cache_clean_up: bool
|
|
|
|
# Stored credentials
|
|
store_credentials: bool
|
|
stored_credentials_file: str
|
|
|
|
# Fetching
|
|
retry_on_chunk_error: bool
|
|
|
|
def __init__(self, proxy_enabled: bool, proxy_type: Proxy.Type,
|
|
proxy_address: str, proxy_port: int, proxy_auth: bool,
|
|
proxy_username: str, proxy_password: str,
|
|
cache_enabled: bool, cache_dir: str,
|
|
do_cache_clean_up: bool, store_credentials: bool,
|
|
stored_credentials_file: str, retry_on_chunk_error: bool):
|
|
self.proxyEnabled = proxy_enabled
|
|
self.proxyType = proxy_type
|
|
self.proxyAddress = proxy_address
|
|
self.proxyPort = proxy_port
|
|
self.proxyAuth = proxy_auth
|
|
self.proxyUsername = proxy_username
|
|
self.proxyPassword = proxy_password
|
|
self.cache_enabled = cache_enabled
|
|
self.cache_dir = cache_dir
|
|
self.do_cache_clean_up = do_cache_clean_up
|
|
self.store_credentials = store_credentials
|
|
self.stored_credentials_file = stored_credentials_file
|
|
self.retry_on_chunk_error = retry_on_chunk_error
|
|
|
|
class Builder:
|
|
# Proxy
|
|
proxyEnabled: bool = False
|
|
proxyType: Proxy.Type = None
|
|
proxyAddress: str = None
|
|
proxyPort: int = None
|
|
proxyAuth: bool = None
|
|
proxyUsername: str = None
|
|
proxyPassword: str = None
|
|
|
|
# Cache
|
|
cache_enabled: bool = True
|
|
cache_dir: str = os.path.join(os.getcwd(), "cache")
|
|
do_cache_clean_up: bool = None
|
|
|
|
# Stored credentials
|
|
store_credentials: bool = True
|
|
stored_credentials_file: str = os.path.join(
|
|
os.getcwd(), "credentials.json")
|
|
|
|
# Fetching
|
|
retry_on_chunk_error: bool = None
|
|
|
|
def set_proxy_enabled(
|
|
self,
|
|
proxy_enabled: bool) -> Session.Configuration.Builder:
|
|
self.proxyEnabled = proxy_enabled
|
|
return self
|
|
|
|
def set_proxy_type(
|
|
self,
|
|
proxy_type: Proxy.Type) -> Session.Configuration.Builder:
|
|
self.proxyType = proxy_type
|
|
return self
|
|
|
|
def set_proxy_address(
|
|
self, proxy_address: str) -> Session.Configuration.Builder:
|
|
self.proxyAddress = proxy_address
|
|
return self
|
|
|
|
def set_proxy_auth(
|
|
self, proxy_auth: bool) -> Session.Configuration.Builder:
|
|
self.proxyAuth = proxy_auth
|
|
return self
|
|
|
|
def set_proxy_username(
|
|
self,
|
|
proxy_username: str) -> Session.Configuration.Builder:
|
|
self.proxyUsername = proxy_username
|
|
return self
|
|
|
|
def set_proxy_password(
|
|
self,
|
|
proxy_password: str) -> Session.Configuration.Builder:
|
|
self.proxyPassword = proxy_password
|
|
return self
|
|
|
|
def set_cache_enabled(
|
|
self,
|
|
cache_enabled: bool) -> Session.Configuration.Builder:
|
|
self.cache_enabled = cache_enabled
|
|
return self
|
|
|
|
def set_cache_dir(self,
|
|
cache_dir: str) -> Session.Configuration.Builder:
|
|
self.cache_dir = cache_dir
|
|
return self
|
|
|
|
def set_do_cache_clean_up(
|
|
self,
|
|
do_cache_clean_up: bool) -> Session.Configuration.Builder:
|
|
self.do_cache_clean_up = do_cache_clean_up
|
|
return self
|
|
|
|
def set_store_credentials(
|
|
self,
|
|
store_credentials: bool) -> Session.Configuration.Builder:
|
|
self.store_credentials = store_credentials
|
|
return self
|
|
|
|
def set_stored_credential_file(
|
|
self, stored_credential_file: str
|
|
) -> Session.Configuration.Builder:
|
|
self.stored_credentials_file = stored_credential_file
|
|
return self
|
|
|
|
def set_retry_on_chunk_error(
|
|
self, retry_on_chunk_error: bool
|
|
) -> Session.Configuration.Builder:
|
|
self.retry_on_chunk_error = retry_on_chunk_error
|
|
return self
|
|
|
|
def build(self) -> Session.Configuration:
|
|
return Session.Configuration(
|
|
self.proxyEnabled, self.proxyType, self.proxyAddress,
|
|
self.proxyPort, self.proxyAuth, self.proxyUsername,
|
|
self.proxyPassword, self.cache_enabled, self.cache_dir,
|
|
self.do_cache_clean_up, self.store_credentials,
|
|
self.stored_credentials_file, self.retry_on_chunk_error)
|
|
|
|
class SpotifyAuthenticationException(Exception):
|
|
def __init__(self, login_failed: Keyexchange.APLoginFailed):
|
|
super().__init__(login_failed.error_code.name)
|
|
|
|
class Accumulator:
|
|
buffer: bytes = bytes()
|
|
|
|
def array(self) -> bytes:
|
|
return self.buffer
|
|
|
|
def write(self, data: bytes) -> int:
|
|
self.buffer += data
|
|
return len(data)
|
|
|
|
def write_byte(self, data: int) -> int:
|
|
self.buffer += bytes([data])
|
|
return 1
|
|
|
|
def write_int(self, data: int) -> int:
|
|
self.buffer += struct.pack(">i", data)
|
|
return 4
|
|
|
|
class ConnectionHolder:
|
|
buffer: bytes = bytes()
|
|
|
|
def __init__(self, sock: socket.socket):
|
|
self.sock = sock
|
|
|
|
@staticmethod
|
|
def create(addr: str,
|
|
conf: Session.Configuration) -> Session.ConnectionHolder:
|
|
ap_addr = addr.split(":")[0]
|
|
ap_port = int(addr.split(":")[1])
|
|
if not conf.proxyEnabled or conf.proxyType is Proxy.Type.DIRECT:
|
|
sock = socket.socket()
|
|
sock.connect((ap_addr, ap_port))
|
|
return Session.ConnectionHolder(sock)
|
|
|
|
if conf.proxyType is Proxy.Type.HTTP:
|
|
sock = socket.socket()
|
|
sock.connect((conf.proxyAddress, conf.proxyPort))
|
|
|
|
sock.send("CONNECT {}:{} HTTP/1.0\n".format(ap_addr,
|
|
ap_port).encode())
|
|
if conf.proxyAuth:
|
|
sock.send(
|
|
"Proxy-Authorization: {}\n".format(None).encode())
|
|
|
|
sock.send(b"\n")
|
|
|
|
elif conf.proxyType is Proxy.Type.SOCKS:
|
|
pass
|
|
else:
|
|
raise RuntimeError()
|
|
|
|
def flush(self) -> None:
|
|
self.sock.send(self.buffer)
|
|
self.buffer = b""
|
|
|
|
def read(self, length: int) -> bytes:
|
|
return self.sock.recv(length)
|
|
|
|
def read_int(self) -> int:
|
|
return struct.unpack(">i", self.sock.recv(4))[0]
|
|
|
|
def set_timeout(self, timeout: int) -> None:
|
|
if timeout == 0:
|
|
self.sock.settimeout(None)
|
|
else:
|
|
self.sock.settimeout(timeout)
|
|
|
|
def write(self, data: bytes) -> int:
|
|
self.buffer += data
|
|
return len(data)
|
|
|
|
def write_byte(self, data: int) -> int:
|
|
self.buffer += bytes([data])
|
|
return 1
|
|
|
|
def write_int(self, data: int) -> int:
|
|
self.buffer += struct.pack(">i", data)
|
|
return 4
|
|
|
|
def write_short(self, data: int) -> int:
|
|
self.buffer += struct.pack(">h", data)
|
|
return 2
|
|
|
|
class Receiver:
|
|
session: Session = None
|
|
thread: threading.Thread
|
|
running: bool = True
|
|
|
|
def __init__(self, session):
|
|
self.session = session
|
|
self.thread = threading.Thread(target=self.run)
|
|
self.thread.setDaemon(True)
|
|
self.thread.setName("session-packet-receiver")
|
|
self.thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self.running = False
|
|
|
|
def run(self) -> None:
|
|
self.session._LOGGER.debug("Session.Receiver started")
|
|
|
|
while self.running:
|
|
packet: Packet
|
|
cmd: bytes
|
|
try:
|
|
# noinspection PyProtectedMember
|
|
packet = self.session._cipherPair.receive_encoded(
|
|
self.session._conn)
|
|
cmd = Packet.Type.parse(packet.cmd)
|
|
if cmd is None:
|
|
self.session._LOGGER.info(
|
|
"Skipping unknown command cmd: 0x{}, payload: {}".
|
|
format(Utils.bytes_to_hex(packet.cmd),
|
|
packet.payload))
|
|
continue
|
|
except RuntimeError as ex:
|
|
if self.running:
|
|
self.session._LOGGER.fatal(
|
|
"Failed reading packet! {}".format(ex))
|
|
# noinspection PyProtectedMember
|
|
self.session._reconnect()
|
|
break
|
|
|
|
if not self.running:
|
|
break
|
|
|
|
if cmd == Packet.Type.ping:
|
|
# noinspection PyProtectedMember
|
|
if self.session._scheduledReconnect is not None:
|
|
# noinspection PyProtectedMember
|
|
self.session._scheduler.cancel(
|
|
self.session._scheduledReconnect)
|
|
|
|
def anonymous():
|
|
self._LOGGER.warning(
|
|
"Socket timed out. Reconnecting...")
|
|
self.session._reconnect()
|
|
|
|
# noinspection PyProtectedMember
|
|
self.session.scheduled_reconnect = self.session._scheduler.enter(
|
|
2 * 60 + 5, 1, anonymous)
|
|
self.session.send(Packet.Type.pong, packet.payload)
|
|
continue
|
|
if cmd == Packet.Type.pong_ack:
|
|
continue
|
|
if cmd == Packet.Type.country_code:
|
|
self.session.country_code = packet.payload.decode()
|
|
self.session._LOGGER.info(
|
|
"Received country_code: {}".format(
|
|
self.session.country_code))
|
|
continue
|
|
if cmd == Packet.Type.license_version:
|
|
license_version = BytesInputStream(packet.payload)
|
|
license_id = license_version.read_short()
|
|
if license_id != 0:
|
|
buffer = license_version.read()
|
|
self.session._LOGGER.info(
|
|
"Received license_version: {}, {}".format(
|
|
license_id, buffer.decode()))
|
|
else:
|
|
self.session._LOGGER.info(
|
|
"Received license_version: {}".format(license_id))
|
|
continue
|
|
if cmd == Packet.Type.unknown_0x10:
|
|
self.session._LOGGER.debug("Received 0x10: {}".format(
|
|
Utils.bytes_to_hex(packet.payload)))
|
|
continue
|
|
if cmd == Packet.Type.mercury_sub or \
|
|
cmd == Packet.Type.mercury_unsub or \
|
|
cmd == Packet.Type.mercury_event or \
|
|
cmd == Packet.Type.mercury_req:
|
|
self.session.mercury().dispatch(packet)
|
|
continue
|
|
if cmd == Packet.Type.aes_key or \
|
|
cmd == Packet.Type.aes_key_error:
|
|
self.session.audio_key().dispatch(packet)
|
|
continue
|
|
if cmd == Packet.Type.channel_error or \
|
|
cmd == Packet.Type.stream_chunk_res:
|
|
self.session.channel().dispatch(packet)
|
|
continue
|
|
if cmd == Packet.Type.product_info:
|
|
# noinspection PyProtectedMember
|
|
self.session._parse_product_info(packet.payload)
|
|
continue
|
|
self.session._LOGGER.info("Skipping {}".format(
|
|
Utils.bytes_to_hex(cmd)))
|
|
|
|
self.session._LOGGER.debug("Session.Receiver stopped")
|