From 4de9fc97c2dc4e326b43c2856c7965a9ba5de260 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Wed, 19 Mar 2025 17:01:44 -0300 Subject: [PATCH] Rework/fix authorization Co-Authored-By: Arsenii es3n1n <40367813+es3n1n@users.noreply.github.com> --- votify/cli.py | 13 +++++------- votify/spotify_api.py | 47 +++++++++++++++++++++++-------------------- votify/totp.py | 29 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 votify/totp.py diff --git a/votify/cli.py b/votify/cli.py index 9087176..184c3d9 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -401,7 +401,7 @@ def main( return logger.info("Starting Votify") spotify_api = SpotifyApi.from_cookies_file(cookies_path) - if spotify_api.config_info["isAnonymous"]: + if spotify_api.session_info["isAnonymous"]: logger.critical( "Failed to get a valid session. Try logging in and exporting your cookies again" ) @@ -459,8 +459,8 @@ def main( downloader_music_video = DownloaderMusicVideo( downloader_video, ) - spotify_api.config_info["isPremium"] = ( - True if force_premium else spotify_api.config_info["isPremium"] + is_premium = ( + True if force_premium else spotify_api.user_profile["product"] == "premium" ) if not lrc_only: if audio_quality in AAC_AUDIO_QUALITIES: @@ -497,10 +497,7 @@ def main( if download_mode == DownloadMode.ARIA2C and not downloader.aria2c_path_full: logger.critical(X_NOT_FOUND_STRING.format("aria2c", aria2c_path)) return - if ( - not spotify_api.config_info["isPremium"] - and audio_quality in PREMIUM_AUDIO_QUALITIES - ): + if not is_premium and audio_quality in PREMIUM_AUDIO_QUALITIES: logger.critical("Cannot download at chosen quality with a free account") return can_download_music_videos = True @@ -532,7 +529,7 @@ def main( ) else: downloader.set_cdm() - if not spotify_api.config_info["isPremium"]: + if is_premium: music_video_warning_message.append( "Cannot download music videos with a non-premium account" ) diff --git a/votify/spotify_api.py b/votify/spotify_api.py index 02ce592..eae0cab 100644 --- a/votify/spotify_api.py +++ b/votify/spotify_api.py @@ -2,7 +2,6 @@ from __future__ import annotations import functools import json -import re import time import typing from http.cookiejar import CookieJar, MozillaCookieJar @@ -11,6 +10,7 @@ from pathlib import Path import base62 import requests +from .totp import TOTP from .utils import check_response @@ -50,6 +50,7 @@ class SpotifyApi: return cls(cookies) def _set_session(self): + self.totp = TOTP() self.session = requests.Session() if self.cookies is not None: self.session.cookies.update(self.cookies) @@ -72,25 +73,29 @@ class SpotifyApi: "app-platform": "WebPlayer", } ) - self._set_session_auth() + self._set_session_info() + self._set_user_profile() - def _set_session_auth(self): - home_page = self.get_home_page() - self.session_info = json.loads( - re.search( - r'', - home_page, - ).group(1) - ) - self.config_info = json.loads( - re.search( - r'', - home_page, - ).group(1) + def _set_session_info(self): + server_time_response = self.session.get("https://open.spotify.com/server-time") + check_response(server_time_response) + server_time = 1e3 * server_time_response.json()["serverTime"] + totp = self.totp.generate(timestamp=server_time) + session_info_response = self.session.get( + "https://open.spotify.com/get_access_token", + params={ + "reason": "init", + "productType": "web-player", + "totp": totp, + "totpVer": str(self.totp.version), + "ts": str(server_time), + }, ) + check_response(session_info_response) + self.session_info = session_info_response.json() self.session.headers.update( { - "Authorization": f"Bearer {self.session_info['accessToken']}", + "authorization": f"Bearer {self.session_info['accessToken']}", } ) @@ -101,14 +106,12 @@ class SpotifyApi: timestamp_now = time.time() * 1000 if timestamp_now < timestamp_session_expire: return - self._set_session_auth() + self._set_session_info() - def get_home_page(self) -> str: - response = self.session.get( - SpotifyApi.SPOTIFY_HOME_PAGE_URL, - ) + def _set_user_profile(self): + response = self.session.get(self.METADATA_API_URL.format(type="me", item_id="")) check_response(response) - return response.text + self.user_profile = response.json() @staticmethod def media_id_to_gid(media_id: str) -> str: diff --git a/votify/totp.py b/votify/totp.py new file mode 100644 index 0000000..6be6e22 --- /dev/null +++ b/votify/totp.py @@ -0,0 +1,29 @@ +import hashlib +import hmac +import math + +# thanks to https://github.com/glomatico/votify/pull/42#issuecomment-2727036757 +class TOTP: + def __init__(self) -> None: + # dumped directly from the object, after all decryptions + self.secret = b"5507145853487499592248630329347" + self.version = 5 + self.period = 30 + self.digits = 6 + + def generate(self, timestamp: int) -> str: + counter = math.floor(timestamp / 1000 / self.period) + counter_bytes = counter.to_bytes(8, byteorder="big") + + h = hmac.new(self.secret, counter_bytes, hashlib.sha1) + hmac_result = h.digest() + + offset = hmac_result[-1] & 0x0F + binary = ( + (hmac_result[offset] & 0x7F) << 24 + | (hmac_result[offset + 1] & 0xFF) << 16 + | (hmac_result[offset + 2] & 0xFF) << 8 + | (hmac_result[offset + 3] & 0xFF) + ) + + return str(binary % (10**self.digits)).zfill(self.digits)