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)