Files

179 lines
7.2 KiB
Python
Raw Permalink Normal View History

"""
collectors/spotify_api_collector.py
Official Spotify Web API Collector utilizing OAuth 2.0 Client Credentials Flow.
Provides authenticated telemetry fallback for artists unindexed on public aggregator tables.
Adheres strictly to zero-imputation policy.
"""
import base64
from datetime import datetime, timezone
import os
from pathlib import Path
import sys
import time
from typing import Dict, Optional
import requests
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from core.health_detector import ScraperCircuitBreaker
# Load .env file if present
load_dotenv(PROJECT_ROOT / ".env")
class SpotifyAPICollector:
"""
Handles token minting and artist queries via Spotify's Client Credentials flow.
Gracefully disables itself if credentials are not configured or access is restricted.
"""
TOKEN_URL = "https://accounts.spotify.com/api/token"
ARTIST_URL = "https://api.spotify.com/v1/artists"
def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
circuit_breaker: Optional[ScraperCircuitBreaker] = None
):
raw_id = os.getenv("SPOTIFY_CLIENT_ID", "") if client_id is None else client_id
raw_secret = os.getenv("SPOTIFY_CLIENT_SECRET", "") if client_secret is None else client_secret
self.client_id = str(raw_id).strip() if raw_id else ""
self.client_secret = str(raw_secret).strip() if raw_secret else ""
self.circuit_breaker = circuit_breaker or ScraperCircuitBreaker(failure_threshold=3, cooldown_seconds=300)
self.access_token: Optional[str] = None
self.token_expiry: float = 0.0
self.is_configured = bool(self.client_id and self.client_secret)
self.is_access_forbidden = False
def _authenticate(self) -> bool:
"""Exchanges client credentials for a bearer token."""
if not self.is_configured or self.is_access_forbidden:
return False
if self.access_token and time.time() < self.token_expiry - 60:
return True
if self.circuit_breaker.is_tripped():
return False
try:
creds = f"{self.client_id}:{self.client_secret}"
b64_creds = base64.b64encode(creds.encode("utf-8")).decode("utf-8")
headers = {
"Authorization": f"Basic {b64_creds}",
"Content-Type": "application/x-www-form-urlencoded"
}
data = {"grant_type": "client_credentials"}
resp = requests.post(self.TOKEN_URL, headers=headers, data=data, timeout=10)
if resp.status_code == 200:
payload = resp.json()
self.access_token = payload.get("access_token")
expires_in = payload.get("expires_in", 3600)
self.token_expiry = time.time() + expires_in
self.circuit_breaker.record_success()
return True
else:
self.circuit_breaker.record_failure(f"Auth HTTP {resp.status_code}")
return False
except Exception as e:
self.circuit_breaker.record_failure(f"Token exception: {e}")
return False
def fetch_artist(self, artist_id: str) -> Dict[str, object]:
"""
Queries official artist details by Spotify ID/URI.
Zero-imputation: missing values return None.
Fast-fails immediately if circuit breaker is open or 403 Forbidden occurred.
"""
now_utc = datetime.now(timezone.utc).isoformat()
clean_id = str(artist_id).replace("spotify:artist:", "").strip()
if not self.is_configured or not clean_id or clean_id.lower() in ("nan", "none", ""):
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": "API_NOT_CONFIGURED" if not self.is_configured else "INVALID_ID",
"collection_date": now_utc
}
if self.is_access_forbidden or self.circuit_breaker.is_tripped():
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": "API_CIRCUIT_OPEN",
"collection_date": now_utc
}
if not self._authenticate():
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": "API_AUTH_FAILED",
"collection_date": now_utc
}
url = f"{self.ARTIST_URL}/{clean_id}"
headers = {"Authorization": f"Bearer {self.access_token}"}
try:
resp = requests.get(url, headers=headers, timeout=5)
if resp.status_code == 200:
data = resp.json()
followers = data.get("followers", {}).get("total")
popularity = data.get("popularity")
self.circuit_breaker.record_success()
return {
"spotify_followers": followers,
"spotify_popularity": popularity,
"spotify_data_source": "official_spotify_web_api",
"spotify_status": "VERIFIED_SPOTIFY_API",
"collection_date": now_utc
}
elif resp.status_code == 403:
# Disable API tier gracefully so remaining cohort falls back to embed collector
self.is_access_forbidden = True
self.circuit_breaker.record_failure("API HTTP 403 - Restricted Access")
print("\n[SpotifyAPI] Notice: Spotify Web API returned HTTP 403. Disabling API tier and routing to Tier 3 embed fallback.")
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": "API_ERROR_403",
"collection_date": now_utc
}
elif resp.status_code == 404:
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": "ARTIST_NOT_FOUND_ON_API",
"collection_date": now_utc
}
else:
self.circuit_breaker.record_failure(f"API HTTP {resp.status_code}")
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": f"API_ERROR_{resp.status_code}",
"collection_date": now_utc
}
except Exception as e:
self.circuit_breaker.record_failure(f"API Request exception: {e}")
return {
"spotify_followers": None,
"spotify_popularity": None,
"spotify_data_source": None,
"spotify_status": "API_CONNECTION_ERROR",
"collection_date": now_utc
}