179 lines
7.2 KiB
Python
179 lines
7.2 KiB
Python
"""
|
|||
|
|
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
|
||
|
|
}
|