Spaces:
Running
Running
import base64 | |
import logging | |
from typing import Optional, Dict | |
from fastapi import FastAPI, HTTPException, Request | |
import requests | |
from bs4 import BeautifulSoup | |
import os | |
from datetime import datetime, timedelta | |
import time | |
import requests | |
from requests.adapters import HTTPAdapter | |
from urllib3.util.retry import Retry | |
import asyncio | |
from typing import Optional, Dict, Tuple | |
# Configure logging | |
logging.basicConfig( | |
level=logging.INFO, | |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', | |
handlers=[ | |
logging.StreamHandler(), | |
logging.FileHandler('spotify_api.log') | |
] | |
) | |
logger = logging.getLogger(__name__) | |
app = FastAPI(title="Spotify Track API", | |
description="API for retrieving Spotify track information and download URLs") | |
# Constants | |
SPOTIFY_API_URL = "https://api.spotify.com/v1" | |
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") | |
SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") | |
TOKEN_EXPIRY = 3500 # Slightly less than 1 hour to ensure token refresh before expiration | |
# Token cache | |
class TokenCache: | |
def __init__(self): | |
self.token: Optional[str] = None | |
self.expiry_time: Optional[datetime] = None | |
def set_token(self, token: str): | |
self.token = token | |
self.expiry_time = datetime.now() + timedelta(seconds=TOKEN_EXPIRY) | |
def get_token(self) -> Optional[str]: | |
if not self.token or not self.expiry_time or datetime.now() >= self.expiry_time: | |
return None | |
return self.token | |
def is_expired(self) -> bool: | |
return not self.token or not self.expiry_time or datetime.now() >= self.expiry_time | |
token_cache = TokenCache() | |
# Custom exception for Spotify API errors | |
class SpotifyAPIError(Exception): | |
pass | |
def get_spotify_token() -> str: | |
""" | |
Get Spotify access token with expiration handling. | |
Returns a valid token, either from cache or by requesting a new one. | |
""" | |
try: | |
# Check if we have a valid cached token | |
cached_token = token_cache.get_token() | |
if cached_token: | |
logger.info("Using cached Spotify token") | |
return cached_token | |
logger.info("Requesting new Spotify access token") | |
start_time = time.time() | |
if not SPOTIFY_CLIENT_ID or not SPOTIFY_CLIENT_SECRET: | |
raise SpotifyAPIError("Spotify credentials not configured") | |
auth_string = f"{SPOTIFY_CLIENT_ID}:{SPOTIFY_CLIENT_SECRET}" | |
auth_bytes = base64.b64encode(auth_string.encode()).decode() | |
auth_response = requests.post( | |
'https://accounts.spotify.com/api/token', | |
data={'grant_type': 'client_credentials'}, | |
headers={'Authorization': f'Basic {auth_bytes}'}, | |
timeout=10 | |
) | |
if auth_response.status_code != 200: | |
raise SpotifyAPIError(f"Failed to get token: {auth_response.text}") | |
new_token = auth_response.json()['access_token'] | |
token_cache.set_token(new_token) | |
logger.info(f"New token obtained successfully in {time.time() - start_time:.2f}s") | |
return new_token | |
except requests.exceptions.RequestException as e: | |
logger.error(f"Network error during token request: {str(e)}") | |
raise HTTPException(status_code=503, detail="Spotify authentication service unavailable") | |
except Exception as e: | |
logger.error(f"Unexpected error during token request: {str(e)}") | |
raise HTTPException(status_code=500, detail="Internal server error") | |
def extract_track_id(track_url: str) -> str: | |
"""Extract track ID from Spotify URL.""" | |
try: | |
return track_url.split("/")[-1].split("?")[0] | |
except Exception as e: | |
logger.error(f"Failed to extract track ID from URL {track_url}: {str(e)}") | |
raise HTTPException(status_code=400, detail="Invalid Spotify URL format") | |
# Constants | |
DOWNLOAD_TIMEOUT = 30 # Increased timeout for download URLs | |
MAX_RETRIES = 3 | |
BACKOFF_FACTOR = 0.5 # Will sleep for [0.5, 1.0, 2.0] seconds between retries | |
# Create a session with retry strategy | |
def create_request_session() -> requests.Session: | |
session = requests.Session() | |
retry_strategy = Retry( | |
total=MAX_RETRIES, | |
backoff_factor=BACKOFF_FACTOR, | |
status_forcelist=[408, 429, 500, 502, 503, 504], | |
) | |
adapter = HTTPAdapter(max_retries=retry_strategy) | |
session.mount("http://", adapter) | |
session.mount("https://", adapter) | |
return session | |
# Create a session to be reused | |
session = create_request_session() | |
async def get_track_download_url(track_id: str) -> Tuple[str, str]: | |
""" | |
Get download URL with fallback and retry logic. | |
Returns tuple of (url, api_version) where api_version is 'v1' or 'v2' | |
""" | |
apis = [ | |
("https://spotisongdownloader.vercel.app/", "v2"), | |
("https://spotisongdownloader.vercel.app/", "v1") | |
] | |
errors = [] | |
for api_base, version in apis: | |
api_url = f"{api_base}{track_id}" | |
try: | |
logger.info(f"Attempting to get download URL from: {api_url}") | |
# Make the request with increased timeout and session | |
response = session.get( | |
api_url, | |
timeout=DOWNLOAD_TIMEOUT, | |
headers={'User-Agent': 'Mozilla/5.0'} # Adding user agent to prevent some blocks | |
) | |
if response.status_code == 200: | |
download_url = response.json().get("url") | |
if download_url: | |
logger.info(f"Successfully obtained download URL from {api_url}") | |
return download_url, version | |
else: | |
logger.warning(f"No URL in response from {api_url}") | |
errors.append(f"{version}: Empty URL in response") | |
else: | |
logger.warning(f"Failed response from {api_url}: {response.status_code}") | |
errors.append(f"{version}: Status {response.status_code}") | |
except requests.exceptions.Timeout as e: | |
logger.warning(f"Timeout for {api_url}: {str(e)}") | |
errors.append(f"{version}: Timeout after {DOWNLOAD_TIMEOUT}s") | |
continue | |
except requests.exceptions.RequestException as e: | |
logger.warning(f"Failed to get download URL from {api_url}: {str(e)}") | |
errors.append(f"{version}: {str(e)}") | |
continue | |
except Exception as e: | |
logger.error(f"Unexpected error for {api_url}: {str(e)}") | |
errors.append(f"{version}: Unexpected error - {str(e)}") | |
continue | |
# If we get here, both APIs failed | |
error_msg = " | ".join(errors) | |
logger.error(f"All download URL attempts failed for track {track_id}: {error_msg}") | |
raise HTTPException( | |
status_code=404, | |
detail=f"Download URL not found. Errors: {error_msg}" | |
) | |
async def get_track( | |
request: Request, | |
track_id: Optional[str] = None, | |
track_url: Optional[str] = None | |
): | |
"""Get track information and download URL.""" | |
request_id = datetime.now().strftime('%Y%m%d%H%M%S%f') | |
logger.info(f"Request {request_id} started - Track ID: {track_id}, URL: {track_url}") | |
start_time = time.time() | |
try: | |
# Input validation | |
if not track_id and not track_url: | |
raise HTTPException(status_code=400, detail="Track ID or Track URL must be provided") | |
# Extract track ID from URL if provided | |
if track_url: | |
track_id = extract_track_id(track_url) | |
# Get Spotify API token | |
access_token = get_spotify_token() | |
# Get track metadata from Spotify | |
logger.info(f"Request {request_id} - Fetching track metadata for {track_id}") | |
response = requests.get( | |
f"{SPOTIFY_API_URL}/tracks/{track_id}", | |
headers={"Authorization": f"Bearer {access_token}"}, | |
timeout=10 | |
) | |
# Handle token expiration | |
if response.status_code == 401: | |
logger.info("Token expired, requesting new token") | |
token_cache.token = None # Clear expired token | |
access_token = get_spotify_token() | |
# Retry the request with new token | |
response = requests.get( | |
f"{SPOTIFY_API_URL}/tracks/{track_id}", | |
headers={"Authorization": f"Bearer {access_token}"}, | |
timeout=10 | |
) | |
if response.status_code != 200: | |
logger.error(f"Request {request_id} - Spotify API error: {response.text}") | |
raise HTTPException( | |
status_code=response.status_code, | |
detail="Failed to fetch track information from Spotify" | |
) | |
track_data = response.json() | |
# Get track metadata from Spotify | |
spotify_response = track_data | |
# Get download URL with version info | |
download_url, api_version = await get_track_download_url(track_id) | |
# Prepare response | |
result = { | |
"track_id": track_id, | |
"name": spotify_response["name"], | |
"album": spotify_response["album"]["name"], | |
"artist": spotify_response["artists"][0]["name"], | |
"release_date": spotify_response["album"]["release_date"], | |
"duration_ms": spotify_response["duration_ms"], | |
"url": download_url, | |
"download_api_version": api_version | |
} | |
duration = time.time() - start_time | |
logger.info(f"Request {request_id} completed successfully in {duration:.2f}s using API {api_version}") | |
return result | |
except HTTPException: | |
raise | |
except Exception as e: | |
logger.error(f"Request {request_id} failed with error: {str(e)}") | |
raise HTTPException(status_code=500, detail="Internal server error") | |
# ... (rest of the code remains the same) | |
async def health_check(): | |
"""Health check endpoint.""" | |
try: | |
# Test Spotify API token generation | |
token = get_spotify_token() | |
return { | |
"status": "healthy", | |
"spotify_auth": "ok", | |
"token_expires_in": token_cache.expiry_time.timestamp() - datetime.now().timestamp() if token_cache.expiry_time else None | |
} | |
except Exception as e: | |
logger.error(f"Health check failed: {str(e)}") | |
return {"status": "unhealthy", "error": str(e)} |