Skip to content

Commit ad47509

Browse files
committed
fully use librespot's native OAuth
fix #48 (once librespot-python accepts my pull request at kokarare1212/librespot-python#311) version bump v0.9.20
1 parent 5f86577 commit ad47509

3 files changed

Lines changed: 43 additions & 381 deletions

File tree

zotify/__init__.py

Lines changed: 1 addition & 354 deletions
Original file line numberDiff line numberDiff line change
@@ -1,355 +1,2 @@
11
from __future__ import annotations
2-
__version__ = "0.9.14"
3-
4-
from enum import IntEnum
5-
from http.server import BaseHTTPRequestHandler, HTTPServer
6-
from pathlib import Path
7-
from threading import Thread
8-
from typing import Any
9-
from time import time_ns
10-
from urllib.parse import urlencode, urlparse, parse_qs
11-
12-
from librespot.audio import AudioKeyManager, CdnManager
13-
from librespot.audio.storage import ChannelManager
14-
from librespot.cache import CacheManager
15-
from librespot.core import (
16-
ApResolver,
17-
DealerClient,
18-
EventService,
19-
PlayableContentFeeder,
20-
SearchManager,
21-
ApiClient as LibrespotApiClient,
22-
Session as LibrespotSession,
23-
TokenProvider as LibrespotTokenProvider,
24-
)
25-
from librespot.mercury import MercuryClient
26-
from librespot.proto import Authentication_pb2 as Authentication
27-
from pkce import generate_code_verifier, get_code_challenge
28-
from requests import HTTPError, get, post
29-
30-
31-
from zotify.const import AUTH_URL, BASE_URL, CLIENT_ID, SCOPES
32-
33-
34-
class Session(LibrespotSession):
35-
def __init__(
36-
self,
37-
session_builder: LibrespotSession.Builder,
38-
language: str = "en",
39-
oauth: OAuth | None = None,
40-
) -> None:
41-
"""
42-
Authenticates user, saves credentials to a file and generates api token.
43-
Args:
44-
session_builder: An instance of the Librespot Session builder
45-
langauge: ISO 639-1 language code
46-
"""
47-
super(Session, self).__init__(
48-
LibrespotSession.Inner(
49-
session_builder.device_type,
50-
session_builder.device_name,
51-
session_builder.preferred_locale,
52-
session_builder.conf,
53-
session_builder.device_id,
54-
),
55-
ApResolver.get_random_accesspoint(),
56-
)
57-
self.__oauth = oauth
58-
self.__language = language
59-
self.connect()
60-
self.authenticate(session_builder.login_credentials)
61-
62-
@staticmethod
63-
def from_file(cred_file: Path | str, language: str = "en") -> Session:
64-
"""
65-
Creates session using saved credentials file
66-
Args:
67-
cred_file: Path to credentials file
68-
language: ISO 639-1 language code for API responses
69-
Returns:
70-
Zotify session
71-
"""
72-
if not isinstance(cred_file, Path):
73-
cred_file = Path(cred_file).expanduser()
74-
config = (
75-
LibrespotSession.Configuration.Builder()
76-
.set_store_credentials(False)
77-
.build()
78-
)
79-
session = LibrespotSession.Builder(config).stored_file(str(cred_file))
80-
return Session(session, language)
81-
82-
@staticmethod
83-
def from_oauth(
84-
oauth: OAuth,
85-
save_file: Path | str | None = None,
86-
language: str = "en",
87-
) -> Session:
88-
"""
89-
Creates a session using OAuth2
90-
Args:
91-
save_file: Path to save login credentials to, optional.
92-
language: ISO 639-1 language code for API responses
93-
Returns:
94-
Zotify session
95-
"""
96-
config = LibrespotSession.Configuration.Builder()
97-
if save_file:
98-
if not isinstance(save_file, Path):
99-
save_file = Path(save_file).expanduser()
100-
save_file.parent.mkdir(parents=True, exist_ok=True)
101-
config.set_stored_credential_file(str(save_file))
102-
else:
103-
config.set_store_credentials(False)
104-
105-
token = oauth.await_token()
106-
107-
builder = LibrespotSession.Builder(config.build())
108-
builder.login_credentials = Authentication.LoginCredentials(
109-
username=oauth.username,
110-
typ=Authentication.AuthenticationType.values()[3],
111-
auth_data=token.access_token.encode(),
112-
)
113-
return Session(builder, language, oauth)
114-
115-
def oauth(self) -> OAuth | None:
116-
"""Returns OAuth service"""
117-
return self.__oauth
118-
119-
def language(self) -> str:
120-
"""Returns session language"""
121-
return self.__language
122-
123-
def is_premium(self) -> bool:
124-
"""Returns users premium account status"""
125-
return self.get_user_attribute("type") == "premium"
126-
127-
def authenticate(self, credential: Authentication.LoginCredentials) -> None: # type: ignore
128-
"""
129-
Log in to the thing
130-
Args:
131-
credential: Account login information
132-
"""
133-
self.__authenticate_partial(credential, False)
134-
with self.__auth_lock:
135-
self.__mercury_client = MercuryClient(self)
136-
self.__token_provider = TokenProvider(self)
137-
self.__audio_key_manager = AudioKeyManager(self)
138-
self.__channel_manager = ChannelManager(self)
139-
self.__api = ApiClient(self)
140-
self.__cdn_manager = CdnManager(self)
141-
self.__content_feeder = PlayableContentFeeder(self)
142-
self.__cache_manager = CacheManager(self)
143-
self.__dealer_client = DealerClient(self)
144-
self.__search = SearchManager(self)
145-
self.__event_service = EventService(self)
146-
self.__auth_lock_bool = False
147-
self.__auth_lock.notify_all()
148-
self.mercury().interested_in("sp" + "otify:user:attributes:update", self)
149-
150-
151-
class ApiClient(LibrespotApiClient):
152-
def __init__(self, session: Session):
153-
super(ApiClient, self).__init__(session)
154-
self.__session = session
155-
156-
def invoke_url(
157-
self,
158-
url: str,
159-
params: dict[str, Any] = {},
160-
limit: int = 20,
161-
offset: int = 0,
162-
) -> dict[str, Any]:
163-
"""
164-
Requests data from API
165-
Args:
166-
url: API URL and to get data from
167-
params: parameters to be sent in the request
168-
limit: The maximum number of items in the response
169-
offset: The offset of the items returned
170-
Returns:
171-
Dictionary representation of JSON response
172-
"""
173-
headers = {
174-
"Authorization": f"Bearer {self.__get_token()}",
175-
"Accept": "application/json",
176-
"Accept-Language": self.__session.language(),
177-
"app-platform": "WebPlayer",
178-
}
179-
params["limit"] = limit
180-
params["offset"] = offset
181-
182-
response = get(BASE_URL + url, headers=headers, params=params)
183-
data = response.json()
184-
185-
try:
186-
raise HTTPError(
187-
f"{url}\nAPI Error {data['error']['status']}: {data['error']['message']}"
188-
)
189-
except KeyError:
190-
return data
191-
192-
def __get_token(self) -> str:
193-
return (
194-
self.__session.tokens()
195-
.get_token(
196-
"playlist-read-private", # Private playlists
197-
"user-follow-read", # Followed artists
198-
"user-library-read", # Liked tracks/episodes/etc.
199-
"user-read-private", # Country
200-
)
201-
.access_token
202-
)
203-
204-
205-
class TokenProvider(LibrespotTokenProvider):
206-
def __init__(self, session: Session):
207-
super(TokenProvider, self).__init__(session)
208-
self._session = session
209-
210-
def get_token(self, *scopes) -> TokenProvider.StoredToken:
211-
oauth = self._session.oauth()
212-
if oauth is None:
213-
return super().get_token(*scopes)
214-
return oauth.get_token()
215-
216-
class StoredToken(LibrespotTokenProvider.StoredToken):
217-
def __init__(self, obj):
218-
self.timestamp = int(time_ns() / 1000)
219-
self.expires_in = int(obj["expires_in"])
220-
self.access_token = obj["access_token"]
221-
self.scopes = obj["scope"].split()
222-
self.refresh_token = obj["refresh_token"]
223-
224-
225-
class OAuth:
226-
__code_verifier: str
227-
__server_thread: Thread
228-
__token: TokenProvider.StoredToken
229-
username: str
230-
231-
def __init__(self, username: str, redirect_address: str | None, oauth_address: str | None) -> None:
232-
self.username = username
233-
self.port = 4381
234-
self.oauth_address = oauth_address if oauth_address else "0.0.0.0"
235-
self.redirect_uri = f"http://{redirect_address if redirect_address else '127.0.0.1'}:{self.port}/login"
236-
237-
def auth_interactive(self) -> str:
238-
"""
239-
Starts local server for token callback
240-
Returns:
241-
OAuth URL
242-
"""
243-
self.__server_thread = Thread(target=self.__run_server)
244-
self.__server_thread.start()
245-
self.__code_verifier = generate_code_verifier()
246-
code_challenge = get_code_challenge(self.__code_verifier)
247-
params = {
248-
"client_id": CLIENT_ID,
249-
"response_type": "code",
250-
"redirect_uri": self.redirect_uri,
251-
"scope": ",".join(SCOPES),
252-
"code_challenge_method": "S256",
253-
"code_challenge": code_challenge,
254-
}
255-
return f"{AUTH_URL}authorize?{urlencode(params)}"
256-
257-
def await_token(self) -> TokenProvider.StoredToken:
258-
"""
259-
Blocks until server thread gets token
260-
Returns:
261-
StoredToken
262-
"""
263-
self.__server_thread.join()
264-
return self.__token
265-
266-
def get_token(self) -> TokenProvider.StoredToken:
267-
"""
268-
Gets a valid token
269-
Returns:
270-
StoredToken
271-
"""
272-
if self.__token is None:
273-
raise RuntimeError("Session isn't authenticated!")
274-
elif self.__token.expired():
275-
self.set_token(self.__token.refresh_token, OAuth.RequestType.REFRESH)
276-
return self.__token
277-
278-
def set_token(self, code: str, request_type: RequestType) -> None:
279-
"""
280-
Fetches and sets stored token
281-
Returns:
282-
StoredToken
283-
"""
284-
token_url = f"{AUTH_URL}api/token"
285-
headers = {"Content-Type": "application/x-www-form-urlencoded"}
286-
if request_type == OAuth.RequestType.LOGIN:
287-
body = {
288-
"grant_type": "authorization_code",
289-
"code": code,
290-
"redirect_uri": self.redirect_uri,
291-
"client_id": CLIENT_ID,
292-
"code_verifier": self.__code_verifier,
293-
}
294-
elif request_type == OAuth.RequestType.REFRESH:
295-
body = {
296-
"grant_type": "refresh_token",
297-
"refresh_token": code,
298-
"client_id": CLIENT_ID,
299-
}
300-
response = post(token_url, headers=headers, data=body)
301-
if response.status_code != 200:
302-
raise IOError(
303-
f"Error fetching token: {response.status_code}, {response.text}"
304-
)
305-
self.__token = TokenProvider.StoredToken(response.json())
306-
307-
def __run_server(self) -> None:
308-
server_address = (self.oauth_address, self.port)
309-
httpd = self.OAuthHTTPServer(server_address, self.RequestHandler, self)
310-
httpd.authenticator = self
311-
httpd.serve_forever()
312-
313-
class RequestType(IntEnum):
314-
LOGIN = 0
315-
REFRESH = 1
316-
317-
class OAuthHTTPServer(HTTPServer):
318-
authenticator: OAuth
319-
320-
def __init__(
321-
self,
322-
server_address: tuple[str, int],
323-
RequestHandlerClass: type[BaseHTTPRequestHandler],
324-
authenticator: OAuth,
325-
):
326-
super().__init__(server_address, RequestHandlerClass)
327-
self.authenticator = authenticator
328-
329-
class RequestHandler(BaseHTTPRequestHandler):
330-
def log_message(self, format: str, *args):
331-
return
332-
333-
def do_GET(self) -> None:
334-
parsed_path = urlparse(self.path)
335-
query_params = parse_qs(parsed_path.query)
336-
code = query_params.get("code")
337-
338-
if code:
339-
if isinstance(self.server, OAuth.OAuthHTTPServer):
340-
self.server.authenticator.set_token(
341-
code[0], OAuth.RequestType.LOGIN
342-
)
343-
self.send_response(200)
344-
self.send_header("Content-type", "text/html")
345-
self.end_headers()
346-
self.wfile.write(
347-
b"Authorization successful. You can close this window."
348-
)
349-
Thread(target=self.server.shutdown).start()
350-
else:
351-
self.send_response(400)
352-
self.send_header("Content-type", "text/html")
353-
self.end_headers()
354-
self.wfile.write(b"Authorization code not found.")
355-
Thread(target=self.server.shutdown).start()
2+
__version__ = "0.9.20"

0 commit comments

Comments
 (0)