Shaurya commited on
Commit
31fda9b
Β·
verified Β·
1 Parent(s): 763ed75

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +200 -38
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
- _spotify_token: str = ""
965
- _spotify_token_expires: float = 0.0
966
- _spotify_token_lock = threading.Lock() # sync lock for token state
 
 
 
 
 
 
 
 
 
 
 
 
 
967
 
 
 
 
 
 
968
 
969
- async def _refresh_spotify_token() -> str:
970
- """Fetch a fresh Spotify access token (client credentials flow)."""
971
- global _spotify_token, _spotify_token_expires
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  if not _SPOTIFY_CLIENT_ID or not _SPOTIFY_CLIENT_SECRET:
973
  return ""
974
- creds = base64.b64encode(f"{_SPOTIFY_CLIENT_ID}:{_SPOTIFY_CLIENT_SECRET}".encode()).decode()
 
 
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
- _spotify_token = data["access_token"]
989
- _spotify_token_expires = time.monotonic() + data.get("expires_in", 3_600)
990
- log.info("Spotify: token refreshed (expires in %ds)", data.get("expires_in", 3_600))
991
- return _spotify_token
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 _get_spotify_token() -> str:
 
 
 
 
 
 
 
 
 
999
  with _spotify_token_lock:
1000
- if _spotify_token and time.monotonic() < _spotify_token_expires - 60:
1001
- return _spotify_token
1002
- return await _refresh_spotify_token()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
 
1004
 
1005
  async def _fetch_spotify_trending(country: str, limit: int = 50) -> list:
1006
  """
1007
- Spotify featured playlists + top tracks β€” free with Client Credentials.
1008
- Returns [] silently when SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET are not set.
 
 
 
 
 
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
- token = await _get_spotify_token()
1017
- if not token:
 
1018
  return []
1019
- headers = {"Authorization": f"Bearer {token}"}
 
1020
  t0 = time.monotonic()
 
1021
  try:
1022
- # Fetch featured playlists for the country
1023
  async with _get_semaphore():
1024
- resp = await _get_http_client().get(
1025
  "https://api.spotify.com/v1/browse/featured-playlists",
1026
  headers=headers,
1027
- params={"country": c, "limit": 4},
1028
  )
1029
- resp.raise_for_status()
1030
- playlists = resp.json().get("playlists", {}).get("items") or []
 
1031
 
1032
  tracks: list = []
1033
- per_playlist = max(limit // max(len(playlists), 1), 10)
1034
 
1035
- for playlist in playlists[:3]: # cap to 3 playlists for speed
1036
  pid = playlist.get("id", "")
1037
  if not pid:
1038
  continue
1039
  try:
1040
  async with _get_semaphore():
1041
- r2 = await _get_http_client().get(
1042
  f"https://api.spotify.com/v1/playlists/{pid}/tracks",
1043
  headers=headers,
1044
  params={
1045
  "limit": per_playlist,
1046
- "fields": "items(track(id,name,artists,album,duration_ms,explicit,popularity))",
 
 
 
 
 
1047
  },
1048
  )
1049
- r2.raise_for_status()
1050
- items = r2.json().get("items") or []
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
- art = images[0]["url"] if images else ""
 
1058
  tracks.append({
1059
  "videoId": "",
1060
  "title": t.get("name", ""),
1061
- "artists": [{"name": a.get("name", ""), "id": ""}
1062
- for a in (t.get("artists") or [])],
 
 
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 fetch (%s) skipped: %s", pid, exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)