From f210850bee088e62f3515d526928b06142439397 Mon Sep 17 00:00:00 2001 From: werwolf2303 Date: Tue, 10 Jun 2025 21:43:03 +0200 Subject: [PATCH 1/3] Implement oauth support --- librespot/core.py | 9 +++- librespot/oauth.py | 118 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 librespot/oauth.py diff --git a/librespot/core.py b/librespot/core.py index 4da5c9c..c2c3c49 100644 --- a/librespot/core.py +++ b/librespot/core.py @@ -29,7 +29,7 @@ from Cryptodome.Protocol.KDF import PBKDF2 from Cryptodome.PublicKey import RSA from Cryptodome.Signature import PKCS1_v1_5 -from librespot import util +from librespot import util, oauth from librespot import Version from librespot.audio import AudioKeyManager from librespot.audio import CdnManager @@ -48,6 +48,7 @@ from librespot.metadata import EpisodeId from librespot.metadata import PlaylistId from librespot.metadata import ShowId from librespot.metadata import TrackId +from librespot.oauth import OAuth from librespot.proto import Authentication_pb2 as Authentication from librespot.proto import ClientToken_pb2 as ClientToken from librespot.proto import Connect_pb2 as Connect @@ -1595,6 +1596,12 @@ class Session(Closeable, MessageListener, SubListener): pass return self + def oauth(self) -> Session.Builder: + if os.path.isfile(self.conf.stored_credentials_file): + return self.stored_file(None) + self.login_credentials = OAuth(MercuryRequests.keymaster_client_id, "http://127.0.0.1:5588/login").flow() + return self + def user_pass(self, username: str, password: str) -> Session.Builder: """Create credential from username and password diff --git a/librespot/oauth.py b/librespot/oauth.py new file mode 100644 index 0000000..3e71238 --- /dev/null +++ b/librespot/oauth.py @@ -0,0 +1,118 @@ +import base64 +import logging +import random +import urllib +from hashlib import sha256 +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse +from librespot.proto import Authentication_pb2 as Authentication +import requests + + +class OAuth: + logger = logging.getLogger("Librespot:OAuth") + __spotify_auth = "https://accounts.spotify.com/authorize?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&scope=%s" + __scopes = ["app-remote-control", "playlist-modify", "playlist-modify-private", "playlist-modify-public", "playlist-read", "playlist-read-collaborative", "playlist-read-private", "streaming", "ugc-image-upload", "user-follow-modify", "user-follow-read", "user-library-modify", "user-library-read", "user-modify", "user-modify-playback-state", "user-modify-private", "user-personalized", "user-read-birthdate", "user-read-currently-playing", "user-read-email", "user-read-play-history", "user-read-playback-position", "user-read-playback-state", "user-read-private", "user-read-recently-played", "user-top-read"] + __spotify_token = "https://accounts.spotify.com/api/token" + __spotify_token_data = {"grant_type": "authorization_code", "client_id": "", "redirect_uri": "", "code": "", "code_verifier": ""} + __client_id = "" + __redirect_url = "" + __code_verifier = "" + __code = "" + __token = "" + __server = None + + def __init__(self, client_id, redirect_url): + self.__client_id = client_id + self.__redirect_url = redirect_url + + def __generate_generate_code_verifier(self): + possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + verifier = "" + for i in range(128): + verifier += possible[random.randint(0, len(possible) - 1)] + return verifier + + def __generate_code_challenge(self, code_verifier): + digest = sha256(code_verifier.encode('utf-8')).digest() + return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=') + + def get_auth_url(self): + self.__code_verifier = self.__generate_generate_code_verifier() + return self.__spotify_auth % (self.__client_id, self.__redirect_url, self.__generate_code_challenge(self.__code_verifier), "+".join(self.__scopes)) + + def set_code(self, code): + self.__code = code + + def request_token(self): + if not self.__code: + raise RuntimeError("You need to provide a code before!") + request_data = self.__spotify_token_data + request_data["client_id"] = self.__client_id + request_data["redirect_uri"] = self.__redirect_url + request_data["code"] = self.__code + request_data["code_verifier"] = self.__code_verifier + request = requests.post( + self.__spotify_token, + data=request_data, + ) + if request.status_code != 200: + raise RuntimeError("Received status code %d: %s" % (request.status_code, request.reason)) + self.__token = request.json()["access_token"] + + def get_credentials(self): + if not self.__token: + raise RuntimeError("You need to request a token bore!") + return Authentication.LoginCredentials( + typ=Authentication.AuthenticationType.AUTHENTICATION_SPOTIFY_TOKEN, + auth_data=self.__token.encode("utf-8") + ) + + class CallbackServer(HTTPServer): + callback_path = None + + def __init__(self, server_address, RequestHandlerClass, callback_path, set_code): + self.callback_path = callback_path + self.set_code = set_code + super().__init__(server_address, RequestHandlerClass) + + class CallbackRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if(self.path.startswith(self.server.callback_path)): + query = urllib.parse.parse_qs(urlparse(self.path).query) + if not query.__contains__("code"): + self.wfile.write(b"Request doesn't contain 'code'") + return + self.server.set_code(query.get("code")[0]) + self.wfile.write(b"librespot-python received callback") + pass + + def __start_server(self): + try: + self.__server.handle_request() + except KeyboardInterrupt: + return + if not self.__code: + self.__start_server() + + def run_callback_server(self): + url = urlparse(self.__redirect_url) + self.__server = self.CallbackServer( + (url.hostname, url.port), + self.CallbackRequestHandler, + url.path, + self.set_code + ) + logging.info("OAuth: Waiting for callback on %s", url.hostname + ":" + str(url.port)) + self.__start_server() + + def flow(self): + logging.info("OAuth: Visit in your browser and log in: %s ", self.get_auth_url()) + self.run_callback_server() + self.request_token() + return self.get_credentials() + + def __close(self): + if self.__server: + self.__server.shutdown() + From 22e6419bc95392424b62c3eb38a1b5ab35448aee Mon Sep 17 00:00:00 2001 From: werwolf2303 Date: Wed, 11 Jun 2025 11:30:52 +0200 Subject: [PATCH 2/3] Readme and implementation changes Added optional auth url callback Removed user pass from readme Added oauth to readme --- README.md | 33 +++++++++++++++++++++++++++++++-- librespot/core.py | 10 ++++++++-- librespot/oauth.py | 9 +++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 040e83a..cce95a3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,35 @@ from librespot.zeroconf import ZeroconfServer zeroconf = ZeroconfServer.Builder().create() ``` +### Use OAuth for Login + +#### Without auth url callback + +```python +from librespot.core import Session + +# This will log an url in the terminal that you have to open + +session = Session.Builder() \ + .oauth(None) \ + .create() +``` + +#### With auth url callback + +```python +from librespot.core import Session + +# This will pass the auth url to the method + +def auth_url_callback(url): + print(url) + +session = Session.Builder() \ + .oauth(auth_url_callback) \ + .create() +``` + ### Get Spotify's OAuth token ```python @@ -69,7 +98,7 @@ from librespot.core import Session session = Session.Builder() \ - .user_pass("Username", "Password") \ + .oauth(None) \ .create() access_token = session.tokens().get("playlist-read") @@ -85,7 +114,7 @@ from librespot.metadata import TrackId from librespot.audio.decoders import AudioQuality, VorbisOnlyAudioQuality session = Session.Builder() \ - .user_pass("Username", "Password") \ + .oauth(None) \ .create() track_id = TrackId.from_uri("spotify:track:xxxxxxxxxxxxxxxxxxxxxx") diff --git a/librespot/core.py b/librespot/core.py index c2c3c49..cec489e 100644 --- a/librespot/core.py +++ b/librespot/core.py @@ -1596,10 +1596,16 @@ class Session(Closeable, MessageListener, SubListener): pass return self - def oauth(self) -> Session.Builder: + def oauth(self, oauth_url_callback) -> Session.Builder: + """ + Login via OAuth + + You can supply an oauth_url_callback method that takes a string and returns the OAuth URL. + When oauth_url_callback is None, this will block until logged in. + """ if os.path.isfile(self.conf.stored_credentials_file): return self.stored_file(None) - self.login_credentials = OAuth(MercuryRequests.keymaster_client_id, "http://127.0.0.1:5588/login").flow() + self.login_credentials = OAuth(MercuryRequests.keymaster_client_id, "http://127.0.0.1:5588/login", oauth_url_callback).flow() return self def user_pass(self, username: str, password: str) -> Session.Builder: diff --git a/librespot/oauth.py b/librespot/oauth.py index 3e71238..fa90656 100644 --- a/librespot/oauth.py +++ b/librespot/oauth.py @@ -21,10 +21,12 @@ class OAuth: __code = "" __token = "" __server = None + __oauth_url_callback = None - def __init__(self, client_id, redirect_url): + def __init__(self, client_id, redirect_url, oauth_url_callback): self.__client_id = client_id self.__redirect_url = redirect_url + self.__oauth_url_callback = oauth_url_callback def __generate_generate_code_verifier(self): possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" @@ -39,7 +41,10 @@ class OAuth: def get_auth_url(self): self.__code_verifier = self.__generate_generate_code_verifier() - return self.__spotify_auth % (self.__client_id, self.__redirect_url, self.__generate_code_challenge(self.__code_verifier), "+".join(self.__scopes)) + auth_url = self.__spotify_auth % (self.__client_id, self.__redirect_url, self.__generate_code_challenge(self.__code_verifier), "+".join(self.__scopes)) + if self.__oauth_url_callback: + self.__oauth_url_callback(auth_url) + return auth_url def set_code(self, code): self.__code = code From 53d51156e9669383335438e2c5497b3ff169d327 Mon Sep 17 00:00:00 2001 From: werwolf2303 Date: Wed, 11 Jun 2025 11:42:07 +0200 Subject: [PATCH 3/3] Change method description --- librespot/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/librespot/core.py b/librespot/core.py index cec489e..41a546a 100644 --- a/librespot/core.py +++ b/librespot/core.py @@ -1601,7 +1601,7 @@ class Session(Closeable, MessageListener, SubListener): Login via OAuth You can supply an oauth_url_callback method that takes a string and returns the OAuth URL. - When oauth_url_callback is None, this will block until logged in. + When oauth_url_callback is None, this will only log the auth url to the console. """ if os.path.isfile(self.conf.stored_credentials_file): return self.stored_file(None)