11from __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 } \n API 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