Shaurya commited on
Upload app.py
Browse files
app.py
CHANGED
|
@@ -961,17 +961,102 @@ async def _fetch_deezer_editorial_playlists(limit: int = 20) -> list:
|
|
| 961 |
# External API helpers β Spotify (Client Credentials, 100% free)
|
| 962 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 963 |
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 968 |
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
if not _SPOTIFY_CLIENT_ID or not _SPOTIFY_CLIENT_SECRET:
|
| 973 |
return ""
|
| 974 |
-
creds = base64.b64encode(
|
|
|
|
|
|
|
| 975 |
try:
|
| 976 |
async with _get_semaphore():
|
| 977 |
resp = await _get_http_client().post(
|
|
@@ -985,81 +1070,123 @@ async def _refresh_spotify_token() -> str:
|
|
| 985 |
resp.raise_for_status()
|
| 986 |
data = resp.json()
|
| 987 |
with _spotify_token_lock:
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
log.info("Spotify: token refreshed (expires in %ds)", data.get("expires_in", 3_600))
|
| 991 |
-
return
|
| 992 |
except Exception as exc:
|
| 993 |
-
log.warning("Spotify token refresh FAILED: %s", exc)
|
| 994 |
log.debug("Spotify token traceback:\n%s", traceback.format_exc())
|
| 995 |
return ""
|
| 996 |
|
| 997 |
|
| 998 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 999 |
with _spotify_token_lock:
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1003 |
|
| 1004 |
|
| 1005 |
async def _fetch_spotify_trending(country: str, limit: int = 50) -> list:
|
| 1006 |
"""
|
| 1007 |
-
|
| 1008 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
"""
|
| 1010 |
if not _SPOTIFY_CLIENT_ID or not _SPOTIFY_CLIENT_SECRET:
|
| 1011 |
return []
|
| 1012 |
if not _cb_spotify.available:
|
| 1013 |
log.debug("Spotify circuit breaker open β skipping fetch")
|
| 1014 |
return []
|
|
|
|
| 1015 |
c = country.upper() if country and country not in ("ZZ", "zz") else "US"
|
| 1016 |
-
|
| 1017 |
-
if not
|
|
|
|
| 1018 |
return []
|
| 1019 |
-
|
|
|
|
| 1020 |
t0 = time.monotonic()
|
|
|
|
| 1021 |
try:
|
| 1022 |
-
#
|
| 1023 |
async with _get_semaphore():
|
| 1024 |
-
|
| 1025 |
"https://api.spotify.com/v1/browse/featured-playlists",
|
| 1026 |
headers=headers,
|
| 1027 |
-
params={"country": c, "limit":
|
| 1028 |
)
|
| 1029 |
-
|
| 1030 |
-
playlists =
|
|
|
|
| 1031 |
|
| 1032 |
tracks: list = []
|
| 1033 |
-
per_playlist
|
| 1034 |
|
| 1035 |
-
for playlist in playlists[:
|
| 1036 |
pid = playlist.get("id", "")
|
| 1037 |
if not pid:
|
| 1038 |
continue
|
| 1039 |
try:
|
| 1040 |
async with _get_semaphore():
|
| 1041 |
-
|
| 1042 |
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
|
| 1043 |
headers=headers,
|
| 1044 |
params={
|
| 1045 |
"limit": per_playlist,
|
| 1046 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
},
|
| 1048 |
)
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
for item in items:
|
| 1052 |
t = item.get("track")
|
| 1053 |
-
if not t or not isinstance(t, dict):
|
| 1054 |
continue
|
| 1055 |
album = t.get("album") or {}
|
| 1056 |
images = album.get("images") or []
|
| 1057 |
-
|
|
|
|
| 1058 |
tracks.append({
|
| 1059 |
"videoId": "",
|
| 1060 |
"title": t.get("name", ""),
|
| 1061 |
-
"artists": [
|
| 1062 |
-
|
|
|
|
|
|
|
| 1063 |
"album": album.get("name", ""),
|
| 1064 |
"duration": str(int(t.get("duration_ms", 0)) // 1_000),
|
| 1065 |
"thumbnails": [{"url": art, "width": 640, "height": 640}] if art else [],
|
|
@@ -1072,12 +1199,47 @@ async def _fetch_spotify_trending(country: str, limit: int = 50) -> list:
|
|
| 1072 |
"popularity": t.get("popularity", 0),
|
| 1073 |
})
|
| 1074 |
except Exception as exc:
|
| 1075 |
-
log.debug("Spotify playlist
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
|
| 1077 |
elapsed = (time.monotonic() - t0) * 1000
|
| 1078 |
_cb_spotify.success()
|
| 1079 |
log.info("Spotify: %d tracks for country=%s (%.0f ms)", len(tracks), c, elapsed)
|
| 1080 |
return tracks[:limit]
|
|
|
|
| 1081 |
except Exception as exc:
|
| 1082 |
_cb_spotify.failure()
|
| 1083 |
log.warning("Spotify trending FAILED country=%s: %s", c, exc)
|
|
|
|
| 961 |
# External API helpers β Spotify (Client Credentials, 100% free)
|
| 962 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 963 |
|
| 964 |
+
# ββ Spotify Android-client token state βββββββββββββββββββββββββββββββββββββββ
|
| 965 |
+
# We simulate the Spotify Android app. Two separate tokens are maintained:
|
| 966 |
+
# _spotify_client_token β short-lived token from clienttoken.spotify.com
|
| 967 |
+
# (proves we are a known Spotify client; no secret)
|
| 968 |
+
# _spotify_access_token β Bearer token from accounts.spotify.com
|
| 969 |
+
# (used in Authorization header; needs client_secret)
|
| 970 |
+
# Both are refreshed automatically; all state protected by a threading.Lock.
|
| 971 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 972 |
+
|
| 973 |
+
import secrets as _secrets
|
| 974 |
+
|
| 975 |
+
_SPOTIFY_DEVICE_ID = _secrets.token_hex(16) # stable per-process, random across restarts
|
| 976 |
+
|
| 977 |
+
# Android Spotify app version strings (update periodically if Spotify changes them)
|
| 978 |
+
_SPOTIFY_ANDROID_VERSION = "8.6.96.470"
|
| 979 |
+
_SPOTIFY_ANDROID_OS_VERSION = "30" # Android 11
|
| 980 |
|
| 981 |
+
_spotify_access_token: str = ""
|
| 982 |
+
_spotify_access_expires: float = 0.0
|
| 983 |
+
_spotify_client_token: str = ""
|
| 984 |
+
_spotify_client_token_expires: float = 0.0
|
| 985 |
+
_spotify_token_lock = threading.Lock()
|
| 986 |
|
| 987 |
+
|
| 988 |
+
async def _refresh_spotify_client_token() -> str:
|
| 989 |
+
"""
|
| 990 |
+
Fetch a Spotify client-token via clienttoken.spotify.com.
|
| 991 |
+
This endpoint accepts just the client_id (no secret) and returns a short-lived
|
| 992 |
+
client-token that proves the caller is a known Spotify client application.
|
| 993 |
+
Used identically by the real Spotify Android / iOS apps.
|
| 994 |
+
"""
|
| 995 |
+
global _spotify_client_token, _spotify_client_token_expires
|
| 996 |
+
if not _SPOTIFY_CLIENT_ID:
|
| 997 |
+
return ""
|
| 998 |
+
payload = {
|
| 999 |
+
"client_data": {
|
| 1000 |
+
"client_version": _SPOTIFY_ANDROID_VERSION,
|
| 1001 |
+
"client_id": _SPOTIFY_CLIENT_ID,
|
| 1002 |
+
"js_sdk_data": {
|
| 1003 |
+
"device_brand": "Google",
|
| 1004 |
+
"device_model": "sdk_gphone64_x86_64",
|
| 1005 |
+
"os": "android",
|
| 1006 |
+
"os_version": _SPOTIFY_ANDROID_OS_VERSION,
|
| 1007 |
+
"device_id": _SPOTIFY_DEVICE_ID,
|
| 1008 |
+
"device_type": "smartphone",
|
| 1009 |
+
},
|
| 1010 |
+
}
|
| 1011 |
+
}
|
| 1012 |
+
try:
|
| 1013 |
+
async with _get_semaphore():
|
| 1014 |
+
resp = await _get_http_client().post(
|
| 1015 |
+
"https://clienttoken.spotify.com/v1/clienttoken",
|
| 1016 |
+
json=payload,
|
| 1017 |
+
headers={"Accept": "application/json"},
|
| 1018 |
+
)
|
| 1019 |
+
resp.raise_for_status()
|
| 1020 |
+
data = resp.json()
|
| 1021 |
+
token = (
|
| 1022 |
+
data.get("granted_token", {}).get("token") or
|
| 1023 |
+
data.get("client_token") or ""
|
| 1024 |
+
)
|
| 1025 |
+
ttl = int(
|
| 1026 |
+
data.get("granted_token", {}).get("expires_after_seconds") or
|
| 1027 |
+
data.get("refresh_after_seconds") or
|
| 1028 |
+
1_800
|
| 1029 |
+
)
|
| 1030 |
+
with _spotify_token_lock:
|
| 1031 |
+
_spotify_client_token = token
|
| 1032 |
+
_spotify_client_token_expires = time.monotonic() + ttl - 30
|
| 1033 |
+
log.info("Spotify: client-token refreshed (ttl=%ds)", ttl)
|
| 1034 |
+
return token
|
| 1035 |
+
except Exception as exc:
|
| 1036 |
+
log.warning("Spotify client-token refresh FAILED: %s", exc)
|
| 1037 |
+
log.debug("Spotify client-token traceback:\n%s", traceback.format_exc())
|
| 1038 |
+
return ""
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
async def _get_spotify_client_token() -> str:
|
| 1042 |
+
with _spotify_token_lock:
|
| 1043 |
+
if _spotify_client_token and time.monotonic() < _spotify_client_token_expires:
|
| 1044 |
+
return _spotify_client_token
|
| 1045 |
+
return await _refresh_spotify_client_token()
|
| 1046 |
+
|
| 1047 |
+
|
| 1048 |
+
async def _refresh_spotify_access_token() -> str:
|
| 1049 |
+
"""
|
| 1050 |
+
Fetch a Spotify Bearer access token via client_credentials grant.
|
| 1051 |
+
Uses the provided SPOTIFY_CLIENT_ID + SPOTIFY_CLIENT_SECRET (Android/iOS app creds).
|
| 1052 |
+
Does NOT require Spotify Premium β gives access to all public catalog endpoints.
|
| 1053 |
+
"""
|
| 1054 |
+
global _spotify_access_token, _spotify_access_expires
|
| 1055 |
if not _SPOTIFY_CLIENT_ID or not _SPOTIFY_CLIENT_SECRET:
|
| 1056 |
return ""
|
| 1057 |
+
creds = base64.b64encode(
|
| 1058 |
+
f"{_SPOTIFY_CLIENT_ID}:{_SPOTIFY_CLIENT_SECRET}".encode()
|
| 1059 |
+
).decode()
|
| 1060 |
try:
|
| 1061 |
async with _get_semaphore():
|
| 1062 |
resp = await _get_http_client().post(
|
|
|
|
| 1070 |
resp.raise_for_status()
|
| 1071 |
data = resp.json()
|
| 1072 |
with _spotify_token_lock:
|
| 1073 |
+
_spotify_access_token = data["access_token"]
|
| 1074 |
+
_spotify_access_expires = time.monotonic() + data.get("expires_in", 3_600) - 30
|
| 1075 |
+
log.info("Spotify: access token refreshed (expires in %ds)", data.get("expires_in", 3_600))
|
| 1076 |
+
return _spotify_access_token
|
| 1077 |
except Exception as exc:
|
| 1078 |
+
log.warning("Spotify access token refresh FAILED: %s", exc)
|
| 1079 |
log.debug("Spotify token traceback:\n%s", traceback.format_exc())
|
| 1080 |
return ""
|
| 1081 |
|
| 1082 |
|
| 1083 |
+
async def _get_spotify_tokens() -> tuple[str, str]:
|
| 1084 |
+
"""Return (access_token, client_token). Either may be empty on failure."""
|
| 1085 |
+
with _spotify_token_lock:
|
| 1086 |
+
access_ok = _spotify_access_token and time.monotonic() < _spotify_access_expires
|
| 1087 |
+
client_ok = _spotify_client_token and time.monotonic() < _spotify_client_token_expires
|
| 1088 |
+
|
| 1089 |
+
access_task = (asyncio.sleep(0) if access_ok else _refresh_spotify_access_token())
|
| 1090 |
+
client_task = (asyncio.sleep(0) if client_ok else _refresh_spotify_client_token())
|
| 1091 |
+
await asyncio.gather(access_task, client_task, return_exceptions=True)
|
| 1092 |
+
|
| 1093 |
with _spotify_token_lock:
|
| 1094 |
+
return _spotify_access_token, _spotify_client_token
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
+
def _spotify_headers(access_token: str, client_token: str) -> dict:
|
| 1098 |
+
"""Build the full set of headers the Spotify Android app sends."""
|
| 1099 |
+
h = {
|
| 1100 |
+
"Authorization": f"Bearer {access_token}",
|
| 1101 |
+
"Accept": "application/json",
|
| 1102 |
+
"Accept-Language": "en",
|
| 1103 |
+
"app-platform": "Android",
|
| 1104 |
+
"spotify-app-version": _SPOTIFY_ANDROID_VERSION,
|
| 1105 |
+
"User-Agent": (
|
| 1106 |
+
f"Spotify/{_SPOTIFY_ANDROID_VERSION} Android/{_SPOTIFY_ANDROID_OS_VERSION} "
|
| 1107 |
+
"(Google sdk_gphone64_x86_64)"
|
| 1108 |
+
),
|
| 1109 |
+
}
|
| 1110 |
+
if client_token:
|
| 1111 |
+
h["client-token"] = client_token
|
| 1112 |
+
return h
|
| 1113 |
|
| 1114 |
|
| 1115 |
async def _fetch_spotify_trending(country: str, limit: int = 50) -> list:
|
| 1116 |
"""
|
| 1117 |
+
Fetch country trending tracks via the Spotify Android client approach:
|
| 1118 |
+
1. GET /v1/browse/featured-playlists β country-aware editorial playlists
|
| 1119 |
+
2. GET /v1/browse/new-releases β new albums/singles for the country
|
| 1120 |
+
3. Pull tracks from the top playlists using Android client headers.
|
| 1121 |
+
|
| 1122 |
+
Falls back gracefully to [] when credentials are absent or the circuit is open.
|
| 1123 |
+
No Spotify Premium required.
|
| 1124 |
"""
|
| 1125 |
if not _SPOTIFY_CLIENT_ID or not _SPOTIFY_CLIENT_SECRET:
|
| 1126 |
return []
|
| 1127 |
if not _cb_spotify.available:
|
| 1128 |
log.debug("Spotify circuit breaker open β skipping fetch")
|
| 1129 |
return []
|
| 1130 |
+
|
| 1131 |
c = country.upper() if country and country not in ("ZZ", "zz") else "US"
|
| 1132 |
+
access_token, client_token = await _get_spotify_tokens()
|
| 1133 |
+
if not access_token:
|
| 1134 |
+
log.warning("Spotify: no access token available, skipping trending for %s", c)
|
| 1135 |
return []
|
| 1136 |
+
|
| 1137 |
+
headers = _spotify_headers(access_token, client_token)
|
| 1138 |
t0 = time.monotonic()
|
| 1139 |
+
|
| 1140 |
try:
|
| 1141 |
+
# ββ 1. Featured playlists (country-localised) ββββββββββββββββββββββ
|
| 1142 |
async with _get_semaphore():
|
| 1143 |
+
fp_resp = await _get_http_client().get(
|
| 1144 |
"https://api.spotify.com/v1/browse/featured-playlists",
|
| 1145 |
headers=headers,
|
| 1146 |
+
params={"country": c, "limit": 5},
|
| 1147 |
)
|
| 1148 |
+
fp_resp.raise_for_status()
|
| 1149 |
+
playlists = fp_resp.json().get("playlists", {}).get("items") or []
|
| 1150 |
+
log.debug("Spotify: %d featured playlists for %s", len(playlists), c)
|
| 1151 |
|
| 1152 |
tracks: list = []
|
| 1153 |
+
per_playlist = max((limit // max(len(playlists), 1)) + 5, 15)
|
| 1154 |
|
| 1155 |
+
for playlist in playlists[:4]:
|
| 1156 |
pid = playlist.get("id", "")
|
| 1157 |
if not pid:
|
| 1158 |
continue
|
| 1159 |
try:
|
| 1160 |
async with _get_semaphore():
|
| 1161 |
+
tr_resp = await _get_http_client().get(
|
| 1162 |
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
|
| 1163 |
headers=headers,
|
| 1164 |
params={
|
| 1165 |
"limit": per_playlist,
|
| 1166 |
+
"market": c,
|
| 1167 |
+
"fields": (
|
| 1168 |
+
"items(track("
|
| 1169 |
+
"id,name,artists(name,id),album(name,images),"
|
| 1170 |
+
"duration_ms,explicit,popularity))"
|
| 1171 |
+
),
|
| 1172 |
},
|
| 1173 |
)
|
| 1174 |
+
tr_resp.raise_for_status()
|
| 1175 |
+
for item in (tr_resp.json().get("items") or []):
|
|
|
|
| 1176 |
t = item.get("track")
|
| 1177 |
+
if not t or not isinstance(t, dict) or not t.get("id"):
|
| 1178 |
continue
|
| 1179 |
album = t.get("album") or {}
|
| 1180 |
images = album.get("images") or []
|
| 1181 |
+
# pick largest image (Spotify orders by descending width)
|
| 1182 |
+
art = images[0]["url"] if images else ""
|
| 1183 |
tracks.append({
|
| 1184 |
"videoId": "",
|
| 1185 |
"title": t.get("name", ""),
|
| 1186 |
+
"artists": [
|
| 1187 |
+
{"name": a.get("name", ""), "id": a.get("id", "")}
|
| 1188 |
+
for a in (t.get("artists") or [])
|
| 1189 |
+
],
|
| 1190 |
"album": album.get("name", ""),
|
| 1191 |
"duration": str(int(t.get("duration_ms", 0)) // 1_000),
|
| 1192 |
"thumbnails": [{"url": art, "width": 640, "height": 640}] if art else [],
|
|
|
|
| 1199 |
"popularity": t.get("popularity", 0),
|
| 1200 |
})
|
| 1201 |
except Exception as exc:
|
| 1202 |
+
log.debug("Spotify playlist %s track fetch skipped: %s", pid, exc)
|
| 1203 |
+
|
| 1204 |
+
# ββ 2. New releases as supplemental source βββββββββββββββββββββββββ
|
| 1205 |
+
if len(tracks) < limit // 2:
|
| 1206 |
+
try:
|
| 1207 |
+
async with _get_semaphore():
|
| 1208 |
+
nr_resp = await _get_http_client().get(
|
| 1209 |
+
"https://api.spotify.com/v1/browse/new-releases",
|
| 1210 |
+
headers=headers,
|
| 1211 |
+
params={"country": c, "limit": 10},
|
| 1212 |
+
)
|
| 1213 |
+
nr_resp.raise_for_status()
|
| 1214 |
+
for album in (nr_resp.json().get("albums", {}).get("items") or []):
|
| 1215 |
+
images = album.get("images") or []
|
| 1216 |
+
art = images[0]["url"] if images else ""
|
| 1217 |
+
tracks.append({
|
| 1218 |
+
"videoId": "",
|
| 1219 |
+
"title": album.get("name", ""),
|
| 1220 |
+
"artists": [
|
| 1221 |
+
{"name": a.get("name", ""), "id": a.get("id", "")}
|
| 1222 |
+
for a in (album.get("artists") or [])
|
| 1223 |
+
],
|
| 1224 |
+
"album": album.get("name", ""),
|
| 1225 |
+
"duration": "",
|
| 1226 |
+
"thumbnails": [{"url": art, "width": 640, "height": 640}] if art else [],
|
| 1227 |
+
"thumbnail": art,
|
| 1228 |
+
"isExplicit": False,
|
| 1229 |
+
"year": (album.get("release_date") or "")[:4],
|
| 1230 |
+
"rank": len(tracks) + 1,
|
| 1231 |
+
"source": "spotify",
|
| 1232 |
+
"spotifyId": album.get("id", ""),
|
| 1233 |
+
"popularity": 0,
|
| 1234 |
+
})
|
| 1235 |
+
except Exception as exc:
|
| 1236 |
+
log.debug("Spotify new-releases skipped: %s", exc)
|
| 1237 |
|
| 1238 |
elapsed = (time.monotonic() - t0) * 1000
|
| 1239 |
_cb_spotify.success()
|
| 1240 |
log.info("Spotify: %d tracks for country=%s (%.0f ms)", len(tracks), c, elapsed)
|
| 1241 |
return tracks[:limit]
|
| 1242 |
+
|
| 1243 |
except Exception as exc:
|
| 1244 |
_cb_spotify.failure()
|
| 1245 |
log.warning("Spotify trending FAILED country=%s: %s", c, exc)
|