feat: improve Login5 authentication with token renewal
Signed-off-by: Lev Rusanov <30170278+JDM170@users.noreply.github.com>
This commit is contained in:
@@ -825,8 +825,6 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
__stored_str: str = ""
|
__stored_str: str = ""
|
||||||
__token_provider: typing.Union[TokenProvider, None]
|
__token_provider: typing.Union[TokenProvider, None]
|
||||||
__user_attributes = {}
|
__user_attributes = {}
|
||||||
__login5_access_token: typing.Union[str, None] = None
|
|
||||||
__login5_token_expiry: typing.Union[int, None] = None
|
|
||||||
|
|
||||||
def __init__(self, inner: Inner, address: str) -> None:
|
def __init__(self, inner: Inner, address: str) -> None:
|
||||||
self.__client = Session.create_client(inner.conf)
|
self.__client = Session.create_client(inner.conf)
|
||||||
@@ -867,12 +865,10 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
"""
|
"""
|
||||||
self.__authenticate_partial(credential, False)
|
self.__authenticate_partial(credential, False)
|
||||||
|
|
||||||
# Try Login5 authentication for access token
|
|
||||||
self.__authenticate_login5(credential)
|
|
||||||
|
|
||||||
with self.__auth_lock:
|
with self.__auth_lock:
|
||||||
self.__mercury_client = MercuryClient(self)
|
self.__mercury_client = MercuryClient(self)
|
||||||
self.__token_provider = TokenProvider(self)
|
self.__token_provider = TokenProvider(self)
|
||||||
|
|
||||||
self.__audio_key_manager = AudioKeyManager(self)
|
self.__audio_key_manager = AudioKeyManager(self)
|
||||||
self.__channel_manager = ChannelManager(self)
|
self.__channel_manager = ChannelManager(self)
|
||||||
self.__api = ApiClient(self)
|
self.__api = ApiClient(self)
|
||||||
@@ -882,6 +878,12 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
self.__dealer_client = DealerClient(self)
|
self.__dealer_client = DealerClient(self)
|
||||||
self.__search = SearchManager(self)
|
self.__search = SearchManager(self)
|
||||||
self.__event_service = EventService(self)
|
self.__event_service = EventService(self)
|
||||||
|
|
||||||
|
# Try Login5 authentication for access token (after session is fully initialized)
|
||||||
|
try:
|
||||||
|
self.__token_provider.authenticate_login5(credential)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Login5 authentication failed: {e}")
|
||||||
self.__auth_lock_bool = False
|
self.__auth_lock_bool = False
|
||||||
self.__auth_lock.notify_all()
|
self.__auth_lock.notify_all()
|
||||||
self.dealer().connect()
|
self.dealer().connect()
|
||||||
@@ -1287,65 +1289,6 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
def __send_unchecked(self, cmd: bytes, payload: bytes) -> None:
|
def __send_unchecked(self, cmd: bytes, payload: bytes) -> None:
|
||||||
self.cipher_pair.send_encoded(self.connection, cmd, payload)
|
self.cipher_pair.send_encoded(self.connection, cmd, payload)
|
||||||
|
|
||||||
def __authenticate_login5(self, credential: Authentication.LoginCredentials) -> None:
|
|
||||||
"""Authenticate using Login5 to get access token"""
|
|
||||||
try:
|
|
||||||
# Build Login5 request
|
|
||||||
login5_request = Login5.LoginRequest()
|
|
||||||
|
|
||||||
# Set client info
|
|
||||||
login5_request.client_info.client_id = Session.CLIENT_ID
|
|
||||||
login5_request.client_info.device_id = self.__inner.device_id
|
|
||||||
|
|
||||||
# Set stored credential from APWelcome
|
|
||||||
if hasattr(self, '_Session__ap_welcome') and self.__ap_welcome:
|
|
||||||
stored_cred = Login5Credentials.StoredCredential()
|
|
||||||
stored_cred.username = self.__ap_welcome.canonical_username
|
|
||||||
stored_cred.data = self.__ap_welcome.reusable_auth_credentials
|
|
||||||
login5_request.stored_credential.CopyFrom(stored_cred)
|
|
||||||
|
|
||||||
# Send Login5 request
|
|
||||||
login5_url = "https://login5.spotify.com/v3/login"
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/x-protobuf",
|
|
||||||
"Accept": "application/x-protobuf"
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
login5_url,
|
|
||||||
data=login5_request.SerializeToString(),
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
login5_response = Login5.LoginResponse()
|
|
||||||
login5_response.ParseFromString(response.content)
|
|
||||||
|
|
||||||
if login5_response.HasField('ok'):
|
|
||||||
self.__login5_access_token = login5_response.ok.access_token
|
|
||||||
self.__login5_token_expiry = int(time.time()) + login5_response.ok.access_token_expires_in
|
|
||||||
self.logger.info("Login5 authentication successful, got access token")
|
|
||||||
else:
|
|
||||||
error_msg = "Login5 authentication failed"
|
|
||||||
if login5_response.HasField('error'):
|
|
||||||
error_msg += f": {login5_response.error}"
|
|
||||||
self.logger.error(error_msg)
|
|
||||||
# Don't raise exception here, as the session can still work with some limitations
|
|
||||||
else:
|
|
||||||
self.logger.error("Login5 request failed with status: {}".format(response.status_code))
|
|
||||||
# Don't raise exception here, as the session can still work with some limitations
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("Failed to authenticate with Login5: {}".format(e))
|
|
||||||
# Don't raise exception here, as the session can still work with some limitations
|
|
||||||
|
|
||||||
def get_login5_token(self) -> typing.Union[str, None]:
|
|
||||||
"""Get the Login5 access token if available and not expired"""
|
|
||||||
if self.__login5_access_token and self.__login5_token_expiry:
|
|
||||||
if int(time.time()) < self.__login5_token_expiry - 60: # 60 second buffer
|
|
||||||
return self.__login5_access_token
|
|
||||||
else:
|
|
||||||
self.logger.debug("Login5 token expired, need to re-authenticate")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __wait_auth_lock(self) -> None:
|
def __wait_auth_lock(self) -> None:
|
||||||
if self.__closing and self.connection is None:
|
if self.__closing and self.connection is None:
|
||||||
@@ -2210,6 +2153,123 @@ class TokenProvider:
|
|||||||
def __init__(self, session: Session):
|
def __init__(self, session: Session):
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
|
def authenticate_login5(self, credential: Authentication.LoginCredentials) -> bool:
|
||||||
|
"""Authenticate using Login5 to get initial access token"""
|
||||||
|
self.logger.debug("Starting initial Login5 authentication process...")
|
||||||
|
|
||||||
|
# Use the login5 method to get initial token with common scopes
|
||||||
|
initial_scopes = ["playlist-read", "user-read-private", "user-read-playback-state"]
|
||||||
|
token = self.login5(initial_scopes)
|
||||||
|
|
||||||
|
if token is not None:
|
||||||
|
self.__tokens.append(token)
|
||||||
|
self.logger.info("Initial Login5 authentication successful, token cached")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.error("Initial Login5 authentication failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def login5(self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
|
||||||
|
"""Get a Login5 token for the specified scopes"""
|
||||||
|
self.logger.debug("Requesting Login5 token for scopes: {}".format(scopes))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build Login5 request
|
||||||
|
login5_request = Login5.LoginRequest()
|
||||||
|
|
||||||
|
# Set client info
|
||||||
|
login5_request.client_info.client_id = Session.CLIENT_ID
|
||||||
|
login5_request.client_info.device_id = self._session.device_id()
|
||||||
|
|
||||||
|
# Set stored credential from APWelcome
|
||||||
|
has_attr = hasattr(self._session, '_Session__ap_welcome')
|
||||||
|
|
||||||
|
if has_attr:
|
||||||
|
try:
|
||||||
|
# Use a different approach since signal doesn't work in threads
|
||||||
|
import threading
|
||||||
|
|
||||||
|
result = [None] # Use list for mutable reference
|
||||||
|
exception = [None]
|
||||||
|
|
||||||
|
def get_ap_welcome():
|
||||||
|
try:
|
||||||
|
result[0] = self._session.ap_welcome()
|
||||||
|
except Exception as e:
|
||||||
|
exception[0] = e
|
||||||
|
|
||||||
|
thread = threading.Thread(target=get_ap_welcome)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
thread.join(timeout=3) # 3-second timeout
|
||||||
|
|
||||||
|
if thread.is_alive():
|
||||||
|
self.logger.warning("ap_welcome() call timed out")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if exception[0]:
|
||||||
|
raise exception[0]
|
||||||
|
|
||||||
|
ap_welcome = result[0]
|
||||||
|
if ap_welcome:
|
||||||
|
stored_cred = Login5Credentials.StoredCredential()
|
||||||
|
stored_cred.username = ap_welcome.canonical_username
|
||||||
|
stored_cred.data = ap_welcome.reusable_auth_credentials
|
||||||
|
login5_request.stored_credential.CopyFrom(stored_cred)
|
||||||
|
else:
|
||||||
|
self.logger.warning("Login5 token request skipped: No APWelcome credentials available")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("Login5 token request skipped: No APWelcome credentials available")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
self.logger.warning("Login5 token request skipped: No APWelcome credentials available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Send Login5 request
|
||||||
|
login5_url = "https://login5.spotify.com/v3/login"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/x-protobuf",
|
||||||
|
"Accept": "application/x-protobuf"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.debug("Sending Login5 token request to: {}".format(login5_url))
|
||||||
|
response = requests.post(
|
||||||
|
login5_url,
|
||||||
|
data=login5_request.SerializeToString(),
|
||||||
|
headers=headers,
|
||||||
|
timeout=10 # Add timeout to prevent hanging
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
login5_response = Login5.LoginResponse()
|
||||||
|
login5_response.ParseFromString(response.content)
|
||||||
|
|
||||||
|
if login5_response.HasField('ok'):
|
||||||
|
# Create StoredToken object
|
||||||
|
token_data = {
|
||||||
|
"expiresIn": login5_response.ok.access_token_expires_in,
|
||||||
|
"accessToken": login5_response.ok.access_token,
|
||||||
|
"scope": scopes
|
||||||
|
}
|
||||||
|
stored_token = TokenProvider.StoredToken(token_data)
|
||||||
|
|
||||||
|
self.logger.info("Login5 token obtained successfully with {} seconds validity".format(
|
||||||
|
login5_response.ok.access_token_expires_in))
|
||||||
|
return stored_token
|
||||||
|
else:
|
||||||
|
error_msg = "Login5 token request failed"
|
||||||
|
if login5_response.HasField('error'):
|
||||||
|
error_msg += f": {login5_response.error}"
|
||||||
|
self.logger.error(error_msg)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
self.logger.error("Login5 token request failed with status: {}".format(response.status_code))
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("Failed to obtain Login5 token: {} - {}".format(type(e).__name__, str(e)))
|
||||||
|
return None
|
||||||
|
|
||||||
def find_token_with_all_scopes(
|
def find_token_with_all_scopes(
|
||||||
self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
|
self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
|
||||||
"""
|
"""
|
||||||
@@ -2240,45 +2300,28 @@ class TokenProvider:
|
|||||||
if len(scopes) == 0:
|
if len(scopes) == 0:
|
||||||
raise RuntimeError("The token doesn't have any scope")
|
raise RuntimeError("The token doesn't have any scope")
|
||||||
|
|
||||||
# Use Login5 token
|
# Check existing tokens first
|
||||||
login5_token = self._session.get_login5_token()
|
|
||||||
if login5_token:
|
|
||||||
# Create a StoredToken-compatible object using Login5 token
|
|
||||||
login5_stored_token = TokenProvider.Login5StoredToken(login5_token, scopes)
|
|
||||||
self.logger.debug("Using Login5 access token for scopes: {}".format(scopes))
|
|
||||||
return login5_stored_token
|
|
||||||
|
|
||||||
# Check existing tokens
|
|
||||||
token = self.find_token_with_all_scopes(scopes)
|
token = self.find_token_with_all_scopes(scopes)
|
||||||
if token is not None:
|
if token is not None:
|
||||||
if token.expired():
|
if token.expired():
|
||||||
|
self.logger.debug("Token expired, removing from cache")
|
||||||
self.__tokens.remove(token)
|
self.__tokens.remove(token)
|
||||||
else:
|
else:
|
||||||
|
self.logger.debug("Using cached token for scopes: {}".format(scopes))
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
# Try to get a new Login5 token
|
||||||
|
self.logger.debug("No valid cached token found, attempting Login5 renewal")
|
||||||
|
login5_token = self.login5(scopes)
|
||||||
|
if login5_token is not None:
|
||||||
|
self.__tokens.append(login5_token)
|
||||||
|
self.logger.debug("Login5 token obtained and cached for scopes: {}".format(scopes))
|
||||||
|
return login5_token
|
||||||
|
|
||||||
# No valid token available
|
# No valid token available
|
||||||
raise RuntimeError("Unable to obtain access token. Login5 authentication may have failed.")
|
self.logger.warning("Unable to obtain access token through Login5")
|
||||||
|
raise RuntimeError("Unable to obtain access token. Login5 authentication failed.")
|
||||||
|
|
||||||
class Login5StoredToken:
|
|
||||||
"""StoredToken-compatible wrapper for Login5 access tokens"""
|
|
||||||
access_token: str
|
|
||||||
scopes: typing.List[str]
|
|
||||||
|
|
||||||
def __init__(self, access_token: str, scopes: typing.List[str]):
|
|
||||||
self.access_token = access_token
|
|
||||||
self.scopes = scopes
|
|
||||||
|
|
||||||
def expired(self) -> bool:
|
|
||||||
"""Login5 tokens are managed by Session, so delegate expiry check"""
|
|
||||||
return False # Session handles expiry
|
|
||||||
|
|
||||||
def has_scope(self, scope: str) -> bool:
|
|
||||||
"""Login5 tokens are general-purpose, assume they have all scopes"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
def has_scopes(self, sc: typing.List[str]) -> bool:
|
|
||||||
"""Login5 tokens are general-purpose, assume they have all scopes"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
class StoredToken:
|
class StoredToken:
|
||||||
""" """
|
""" """
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from librespot.proto.spotify.login5.v3.credentials import \
|
|||||||
Credentials_pb2 as \
|
Credentials_pb2 as \
|
||||||
spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2
|
spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2
|
||||||
from librespot.proto.spotify.login5.v3.identifiers import \
|
from librespot.proto.spotify.login5.v3.identifiers import \
|
||||||
Identifiers as \
|
Identifiers_pb2 as \
|
||||||
spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2
|
spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2
|
||||||
|
|
||||||
# @@protoc_insertion_point(imports)
|
# @@protoc_insertion_point(imports)
|
||||||
|
|||||||
@@ -63,15 +63,16 @@ class ZeroconfServer(Closeable):
|
|||||||
def __init__(self, inner: Inner, listen_port):
|
def __init__(self, inner: Inner, listen_port):
|
||||||
self.__inner = inner
|
self.__inner = inner
|
||||||
self.__keys = DiffieHellman()
|
self.__keys = DiffieHellman()
|
||||||
|
|
||||||
if listen_port == -1:
|
if listen_port == -1:
|
||||||
listen_port = random.randint(self.__min_port + 1, self.__max_port)
|
listen_port = random.randint(self.__min_port + 1, self.__max_port)
|
||||||
|
|
||||||
self.__runner = ZeroconfServer.HttpRunner(self, listen_port)
|
self.__runner = ZeroconfServer.HttpRunner(self, listen_port)
|
||||||
threading.Thread(target=self.__runner.run,
|
threading.Thread(target=self.__runner.run,
|
||||||
name="zeroconf-http-server",
|
name="zeroconf-http-server",
|
||||||
daemon=True).start()
|
daemon=True).start()
|
||||||
|
|
||||||
self.__zeroconf = zeroconf.Zeroconf()
|
self.__zeroconf = zeroconf.Zeroconf()
|
||||||
|
|
||||||
advertised_ip_str = self._get_local_ip()
|
advertised_ip_str = self._get_local_ip()
|
||||||
|
|
||||||
server_hostname = socket.gethostname()
|
server_hostname = socket.gethostname()
|
||||||
@@ -105,7 +106,12 @@ class ZeroconfServer(Closeable):
|
|||||||
addresses=service_addresses # Pass resolved IP, or None if 0.0.0.0 or conversion failed
|
addresses=service_addresses # Pass resolved IP, or None if 0.0.0.0 or conversion failed
|
||||||
)
|
)
|
||||||
|
|
||||||
self.__zeroconf.register_service(self.__service_info)
|
try:
|
||||||
|
self.__zeroconf.register_service(self.__service_info)
|
||||||
|
self.logger.info("Zeroconf service registered successfully!")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("Failed to register zeroconf service: {}".format(e))
|
||||||
|
raise
|
||||||
|
|
||||||
def _get_local_ip(self) -> str:
|
def _get_local_ip(self) -> str:
|
||||||
"""Tries to determine a non-loopback local IP address for network advertisement."""
|
"""Tries to determine a non-loopback local IP address for network advertisement."""
|
||||||
@@ -184,6 +190,7 @@ class ZeroconfServer(Closeable):
|
|||||||
|
|
||||||
def handle_add_user(self, __socket: socket.socket, params: dict[str, str],
|
def handle_add_user(self, __socket: socket.socket, params: dict[str, str],
|
||||||
http_version: str) -> None:
|
http_version: str) -> None:
|
||||||
|
self.logger.info("Spotify Connect transfer detected")
|
||||||
username = params.get("userName")
|
username = params.get("userName")
|
||||||
if not username:
|
if not username:
|
||||||
self.logger.error("Missing userName!")
|
self.logger.error("Missing userName!")
|
||||||
@@ -236,31 +243,42 @@ class ZeroconfServer(Closeable):
|
|||||||
initial_value=int.from_bytes(
|
initial_value=int.from_bytes(
|
||||||
iv, "big")))
|
iv, "big")))
|
||||||
decrypted = aes.decrypt(encrypted)
|
decrypted = aes.decrypt(encrypted)
|
||||||
self.close_session()
|
try:
|
||||||
with self.__connection_lock:
|
self.close_session()
|
||||||
self.__connecting_username = username
|
with self.__connection_lock:
|
||||||
self.logger.info("Accepted new user from {}. [deviceId: {}]".format(
|
self.__connecting_username = username
|
||||||
params.get("deviceName"), self.__inner.device_id))
|
self.logger.info("Accepted new user from {}. [deviceId: {}]".format(
|
||||||
response = json.dumps(self.__default_successful_add_user)
|
params.get("deviceName"), self.__inner.device_id))
|
||||||
__socket.send(http_version.encode())
|
|
||||||
__socket.send(b" 200 OK")
|
response = json.dumps(self.__default_successful_add_user)
|
||||||
__socket.send(self.__eol)
|
__socket.send(http_version.encode())
|
||||||
__socket.send(b"Content-Length: ")
|
__socket.send(b" 200 OK")
|
||||||
__socket.send(str(len(response)).encode())
|
__socket.send(self.__eol)
|
||||||
__socket.send(self.__eol)
|
__socket.send(b"Content-Length: ")
|
||||||
__socket.send(self.__eol)
|
__socket.send(str(len(response)).encode())
|
||||||
__socket.send(response.encode())
|
__socket.send(self.__eol)
|
||||||
self.__session = Session.Builder(self.__inner.conf) \
|
__socket.send(self.__eol)
|
||||||
.set_device_id(self.__inner.device_id) \
|
__socket.send(response.encode())
|
||||||
.set_device_name(self.__inner.device_name) \
|
|
||||||
.set_device_type(self.__inner.device_type) \
|
self.__session = Session.Builder(self.__inner.conf) \
|
||||||
.set_preferred_locale(self.__inner.preferred_locale) \
|
.set_device_id(self.__inner.device_id) \
|
||||||
.blob(username, decrypted) \
|
.set_device_name(self.__inner.device_name) \
|
||||||
.create()
|
.set_device_type(self.__inner.device_type) \
|
||||||
with self.__connection_lock:
|
.set_preferred_locale(self.__inner.preferred_locale) \
|
||||||
self.__connecting_username = None
|
.blob(username, decrypted) \
|
||||||
for session_listener in self.__session_listeners:
|
.create()
|
||||||
session_listener.session_changed(self.__session)
|
|
||||||
|
with self.__connection_lock:
|
||||||
|
self.__connecting_username = None
|
||||||
|
|
||||||
|
self.logger.info("Session created for user: {}".format(self.__session.username()))
|
||||||
|
for session_listener in self.__session_listeners:
|
||||||
|
session_listener.session_changed(self.__session)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("Error in handle_add_user: {}".format(e))
|
||||||
|
with self.__connection_lock:
|
||||||
|
self.__connecting_username = None
|
||||||
|
|
||||||
def handle_get_info(self, __socket: socket.socket,
|
def handle_get_info(self, __socket: socket.socket,
|
||||||
http_version: str) -> None:
|
http_version: str) -> None:
|
||||||
@@ -289,6 +307,10 @@ class ZeroconfServer(Closeable):
|
|||||||
self.__session = None
|
self.__session = None
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
|
def get_session(self) -> typing.Union[Session, None]:
|
||||||
|
"""Get the current session if valid, None otherwise"""
|
||||||
|
return self.__session if self.has_valid_session() else None
|
||||||
|
|
||||||
def parse_path(self, path: str) -> dict[str, str]:
|
def parse_path(self, path: str) -> dict[str, str]:
|
||||||
url = "https://host" + path
|
url = "https://host" + path
|
||||||
parsed = {}
|
parsed = {}
|
||||||
@@ -309,10 +331,10 @@ class ZeroconfServer(Closeable):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def create(self) -> ZeroconfServer:
|
def create(self) -> ZeroconfServer:
|
||||||
return ZeroconfServer(
|
inner = ZeroconfServer.Inner(self.device_type, self.device_name,
|
||||||
ZeroconfServer.Inner(self.device_type, self.device_name,
|
self.device_id, self.preferred_locale,
|
||||||
self.device_id, self.preferred_locale,
|
self.conf)
|
||||||
self.conf), self.listen_port)
|
return ZeroconfServer(inner, self.listen_port)
|
||||||
|
|
||||||
class HttpRunner(Closeable, Runnable):
|
class HttpRunner(Closeable, Runnable):
|
||||||
__should_stop = False
|
__should_stop = False
|
||||||
@@ -392,6 +414,7 @@ class ZeroconfServer(Closeable):
|
|||||||
|
|
||||||
def handle_request(self, __socket: socket.socket, http_version: str,
|
def handle_request(self, __socket: socket.socket, http_version: str,
|
||||||
action: str, params: dict[str, str]) -> None:
|
action: str, params: dict[str, str]) -> None:
|
||||||
|
self.__zeroconf_server.logger.debug("HTTP request received - action: {}, params: {}".format(action, params.keys() if params else "None"))
|
||||||
if action == "addUser":
|
if action == "addUser":
|
||||||
if params is None:
|
if params is None:
|
||||||
raise RuntimeError
|
raise RuntimeError
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ def test_with_stored_credentials():
|
|||||||
token = token_provider.get("playlist-read")
|
token = token_provider.get("playlist-read")
|
||||||
print(f"✓ Successfully got playlist-read token: {token[:20]}...")
|
print(f"✓ Successfully got playlist-read token: {token[:20]}...")
|
||||||
|
|
||||||
# Check if Login5 token is available
|
# Test Login5 token by requesting a different scope
|
||||||
login5_token = session.get_login5_token()
|
try:
|
||||||
if login5_token:
|
login5_token = token_provider.get_token("user-read-email", "user-read-private")
|
||||||
print(f"✓ Login5 token available: {login5_token[:20]}...")
|
if login5_token and login5_token.access_token:
|
||||||
else:
|
print(f"✓ Login5 token available: {login5_token.access_token[:20]}...")
|
||||||
print("⚠ Login5 token not available")
|
else:
|
||||||
|
print("⚠ Login5 token not available")
|
||||||
|
except Exception as login5_error:
|
||||||
|
print(f"⚠ Login5 token test failed: {login5_error}")
|
||||||
|
|
||||||
session.close()
|
session.close()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(level
|
|||||||
def test_zeroconf_login5():
|
def test_zeroconf_login5():
|
||||||
"""Test Login5 using Zeroconf authentication"""
|
"""Test Login5 using Zeroconf authentication"""
|
||||||
print("=== Testing Login5 with Zeroconf ===")
|
print("=== Testing Login5 with Zeroconf ===")
|
||||||
|
print("IMPORTANT: This test requires a Spotify Connect transfer!")
|
||||||
print("1. Open Spotify on your phone/computer")
|
print("1. Open Spotify on your phone/computer")
|
||||||
print("2. Look for 'librespot-spotizerr' in Spotify Connect devices")
|
print("2. Start playing some music")
|
||||||
print("3. Transfer playback to it")
|
print("3. Look for 'librespot-spotizerr' in Spotify Connect devices")
|
||||||
print("4. Wait for credentials to be stored...")
|
print("4. Click on 'librespot-spotizerr' to transfer playback to it")
|
||||||
|
print("5. Wait for credentials to be stored...")
|
||||||
print("\nWaiting for Spotify Connect transfer...")
|
print("\nWaiting for Spotify Connect transfer...")
|
||||||
|
print("NOTE: You'll see 'Created new session!' logs - this is normal startup.")
|
||||||
|
print("The test will only succeed when you transfer playback from Spotify Connect.")
|
||||||
|
|
||||||
zs = ZeroconfServer.Builder().create()
|
zs = ZeroconfServer.Builder().create()
|
||||||
|
|
||||||
@@ -37,8 +41,9 @@ def test_zeroconf_login5():
|
|||||||
print("- Transfer playback to it")
|
print("- Transfer playback to it")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if zs._ZeroconfServer__session:
|
# Check for session
|
||||||
session = zs._ZeroconfServer__session
|
session = zs.get_session()
|
||||||
|
if session:
|
||||||
print(f"\n✓ Got session for user: {session.username()}")
|
print(f"\n✓ Got session for user: {session.username()}")
|
||||||
|
|
||||||
# Test token retrieval
|
# Test token retrieval
|
||||||
@@ -47,10 +52,10 @@ def test_zeroconf_login5():
|
|||||||
token = token_provider.get("playlist-read")
|
token = token_provider.get("playlist-read")
|
||||||
print(f"✓ Got playlist-read token: {token[:20]}...")
|
print(f"✓ Got playlist-read token: {token[:20]}...")
|
||||||
|
|
||||||
# Test Login5 token
|
# Test Login5 token by requesting a different scope using get_token
|
||||||
login5_token = session.get_login5_token()
|
login5_token = token_provider.get_token("user-read-email", "user-read-private")
|
||||||
if login5_token:
|
if login5_token:
|
||||||
print(f"✓ Login5 token available: {login5_token[:20]}...")
|
print(f"✓ Login5 token available: {login5_token.access_token[:20]}...")
|
||||||
else:
|
else:
|
||||||
print("⚠ Login5 token not available")
|
print("⚠ Login5 token not available")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user