3 Commits

Author SHA1 Message Date
kuelteabmas
1e18576a88 bugfix(spotify): Fixed reauthentication issue
* Reinserting self.authenticate() back into 401 reauthentication flow
2025-05-18 22:03:02 -04:00
kuelteabmas
ff99475fab bugfix(spotify): Updated functionality to fetch access_token and client_id required for making requests
* Removed deprecated session_data and its logic
* Added TOTP for new _get_access_token_and_client_id method
* Added some loggers
2025-05-18 20:05:21 -04:00
kuelteabmas
54d012009e bugfix(spotify): Updated script to be parsed for session_data from config to appServerConfig script
- config script is no longer returned from Fetch session data call from Spotify
- it's now called appServerConfig which contains the correlationId needed to make Spotify requests
-
2025-05-15 17:03:03 -04:00
2 changed files with 84 additions and 22 deletions

View File

@@ -203,6 +203,7 @@ else:
spotify_client = SpotifyClient() spotify_client = SpotifyClient()
spotify_client.authenticate() spotify_client.authenticate()
app.logger.info('spotify auth successful')
from .registry import MusicProviderRegistry from .registry import MusicProviderRegistry
MusicProviderRegistry.register_provider(spotify_client) MusicProviderRegistry.register_provider(spotify_client)

View File

@@ -1,7 +1,6 @@
from dataclasses import dataclass
import os import os
from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category from app.providers.base import AccountAttributes, Album, Artist, BrowseCard, BrowseSection, Image, MusicProviderClient, Owner, Playlist, PlaylistTrack, Profile, Track, ExternalUrl, Category
import requests import base64
import json import json
import requests import requests
@@ -11,8 +10,38 @@ from typing import List, Dict, Optional
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
import logging import logging
from typing import Callable, Tuple
import hmac
import time
import hashlib
l = logging.getLogger(__name__) l = logging.getLogger(__name__)
_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
def generate_totp(
secret: bytes = _TOTP_SECRET,
algorithm: Callable[[], object] = hashlib.sha1,
digits: int = 6,
counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
) -> Tuple[str, int]:
counter = counter_factory()
hmac_result = hmac.new(
secret, counter.to_bytes(8, byteorder="big"), algorithm # type: ignore
).digest()
offset = hmac_result[-1] & 15
truncated_value = (
(hmac_result[offset] & 127) << 24
| (hmac_result[offset + 1] & 255) << 16
| (hmac_result[offset + 2] & 255) << 8
| (hmac_result[offset + 3] & 255)
)
return (
str(truncated_value % (10**digits)).zfill(digits),
counter * 30_000,
) # (30 * 1000)
class SpotifyClient(MusicProviderClient): class SpotifyClient(MusicProviderClient):
""" """
Spotify implementation of the MusicProviderClient. Spotify implementation of the MusicProviderClient.
@@ -23,9 +52,10 @@ class SpotifyClient(MusicProviderClient):
def __init__(self, cookie_file: Optional[str] = None): def __init__(self, cookie_file: Optional[str] = None):
self.base_url = "https://api-partner.spotify.com" self.base_url = "https://api-partner.spotify.com"
self.session_data = None self.app_server_config_data = None
self.config_data = None
self.client_token = None self.client_token = None
self.access_token = None
self.client_id = None
self.cookies = None self.cookies = None
if cookie_file: if cookie_file:
self._load_cookies(cookie_file) self._load_cookies(cookie_file)
@@ -52,16 +82,19 @@ class SpotifyClient(MusicProviderClient):
""" """
if self.cookies: if self.cookies:
l.debug("Authenticating using cookies.") l.debug("Authenticating using cookies.")
self.session_data, self.config_data = self._fetch_session_data() self.app_server_config_data = self._fetch_app_server_config_data()
self.client_token = self._fetch_client_token()
else: else:
l.debug("Authenticating without cookies.") l.debug("Authenticating without cookies.")
self.session_data, self.config_data = self._fetch_session_data(fetch_with_cookies=False) self.app_server_config_data = self._fetch_app_server_config_data(fetch_with_cookies=False)
self.client_token = self._fetch_client_token()
def _fetch_session_data(self, fetch_with_cookies: bool = True): self._get_access_token_and_client_id()
self.client_token = self._fetch_client_token()
def _fetch_app_server_config_data(self, fetch_with_cookies: bool = True):
""" """
Fetch session data from Spotify. Fetch app_server_config data from Spotify.
We will use the correlationId from app_server_config_data for a successful _fetch_client_token call
:param fetch_with_cookies: Whether to include cookies in the request. :param fetch_with_cookies: Whether to include cookies in the request.
:return: Tuple containing session and config data. :return: Tuple containing session and config data.
@@ -75,13 +108,14 @@ class SpotifyClient(MusicProviderClient):
response = requests.get(url, headers=headers, cookies=cookies) response = requests.get(url, headers=headers, cookies=cookies)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
session_script = soup.find('script', {'id': 'session'}) app_server_config_script = soup.find('script', {'id': 'appServerConfig'}).text # decode JWT to obtain correlation required for obtain Bearer Token
config_script = soup.find('script', {'id': 'config'}) decoded_app_server_config_script = base64.b64decode(app_server_config_script) # base64 decode
if session_script and config_script: decoded_app_server_config_script = decoded_app_server_config_script.decode().strip("b'") # decode from byte object to string object
l.debug("fetched session and config scripts") if decoded_app_server_config_script:
return json.loads(session_script.string), json.loads(config_script.string) l.debug("fetched app_server_config_script scripts")
return json.loads(decoded_app_server_config_script)
else: else:
raise ValueError("Failed to fetch session or config data.") raise ValueError("Failed to fetch app_server_config data.")
def _fetch_client_token(self): def _fetch_client_token(self):
""" """
@@ -99,22 +133,49 @@ class SpotifyClient(MusicProviderClient):
payload = { payload = {
"client_data": { "client_data": {
"client_version": "1.2.52.404.gcb99a997", "client_version": "1.2.52.404.gcb99a997",
"client_id": self.session_data.get("clientId", ""), "client_id": self.client_id,
"js_sdk_data": { "js_sdk_data": {
"device_brand": "unknown", "device_brand": "unknown",
"device_model": "unknown", "device_model": "unknown",
"os": "windows", "os": "windows",
"os_version": "NT 10.0", "os_version": "NT 10.0",
"device_id": self.config_data.get("correlationId", ""), "device_id": self.app_server_config_data.get("correlationId", ""),
"device_type": "computer" "device_type": "computer"
} }
} }
} }
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies) response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
response.raise_for_status() response.raise_for_status()
l.debug("fetched granted_token") l.debug("fetched client_token (granted_token)")
return response.json().get("granted_token", "") return response.json().get("granted_token", "")
def _get_access_token_and_client_id(self, fetch_with_cookies: bool = True):
"""
Fetch the Access Token and Client ID by making GET call to open.spotify.com/get_access_token
"""
url = f'https://open.spotify.com/get_access_token'
headers = {
'accept': 'application/json',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
}
totp, timestamp = generate_totp()
query_params = {
"reason": "init",
"productType": "web-player",
"totp": totp,
"totpVer": 5,
"ts": timestamp,
}
cookies = self.cookies if fetch_with_cookies else None
response = requests.get(url, params=query_params, headers=headers, cookies=self.cookies)
response.raise_for_status()
self.client_id = response.json().get("clientId", "")
self.access_token = response.json().get("accessToken", "")
l.debug("fetched access_token and client_id")
def _make_request(self, endpoint: str, params: dict = None) -> dict: def _make_request(self, endpoint: str, params: dict = None) -> dict:
""" """
Helper method to make authenticated requests to Spotify APIs. Helper method to make authenticated requests to Spotify APIs.
@@ -122,8 +183,8 @@ class SpotifyClient(MusicProviderClient):
headers = { headers = {
'accept': 'application/json', 'accept': 'application/json',
'app-platform': 'WebPlayer', 'app-platform': 'WebPlayer',
'authorization': f'Bearer {self.session_data.get("accessToken", "")}', 'authorization': f'Bearer {self.access_token}',
'client-token': self.client_token.get('token',''), 'client-token': self.client_token.get('token','')
} }
l.debug(f"starting request: {self.base_url}/{endpoint}") l.debug(f"starting request: {self.base_url}/{endpoint}")
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies) response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
@@ -131,7 +192,7 @@ class SpotifyClient(MusicProviderClient):
if response.status_code == 401: if response.status_code == 401:
l.debug("reauthenticating") l.debug("reauthenticating")
self.authenticate() self.authenticate()
headers['authorization'] = f'Bearer {self.session_data.get("accessToken", "")}' headers['authorization'] = f'Bearer {self.access_token}'
headers['client-token'] = self.client_token.get('token','') headers['client-token'] = self.client_token.get('token','')
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies) response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)