feat: finalize root api.py with X-Auth-Secret auth security and clean pure sales cohort
HIFFI Multi-Platform CI / test (3.12) (push) Successful in 50s
HIFFI Multi-Platform CI / test (3.11) (push) Successful in 50s

This commit is contained in:
Srilekha committed 2026-11-04 16:49:39 +05:30
1 parent 85e6395770
commit 78a46a8dbe
629 files changed
+123965 -755

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
"""
collectors/fix_youtube_ghost_channels.py
Final sweep for the remaining 54 under-indexed channels.
Uses explicit verified handles/channel IDs for priority acts,
and targeted handle patterns for niche/producer acts.
"""
import os
import re
import time
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timezone
import requests
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent
load_dotenv(PROJECT_ROOT / ".env")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("YouTubeFinalSweep")
DB_PATH = PROJECT_ROOT / "data" / "hiffi_catalog.db"
YT_API_KEY = os.getenv("YOUTUBE_API_KEY", "").strip()
# Verified official handles / IDs for acts where simple name searches fail
EXPLICIT_HANDLES = {
"A$AP Rocky": "LIVELOVEASAPVEVO",
"G-Eazy": "GEazyMusic",
"GloRilla": "GloRillaOfficial",
"Pop Smoke": "POPSMOKE",
"Action Bronson": "ActionBronson",
"D-Block Europe": "dblockeurope",
"Bia": "BIAMUSIC",
"JT": "thegirljt",
"Takeoff": "TakeoffVEVO",
"Too $hort": "tooshortVEVO",
"The Alchemist": "alchemistbeats",
"Tay Keith": "TayKeithOfficial",
"Southside": "808mafiaworks",
"Babyface Ray": "BabyfaceRayOfficial",
"Hunxho": "HunxhoOfficial",
"Unknown T": "UnknownT",
"Tommy Cash": "TOMMYCASHWORLDWIDE",
"The Underachievers": "TheUnderachievers",
"Boi-1da": "Boi1da",
"Paul Wall": "PaulWall",
"GZA": "GZAVEVO",
"Ghetts": "GhettsOfficial",
"Black Thought": "BlackThoughtOfficial",
"Big Daddy Kane": "BigDaddyKaneOfficial",
"Cakes da Killa": "CakesDaKilla",
"Che Noir": "CheNoir",
"Snak The Ripper": "StealthBombRecords",
"Z-Ro": "ZRoOfficialChannel",
"Wheezy": "WheezyOfficial",
"Dreezy": "DreezyVEVO",
"Kankan": "kankan5720",
"Osamason": "osamason",
"ScarLip": "ScarLip",
"Slick Rick": "SlickRickVEVO",
"Buddy": "BuddyMusic",
"Young Adz": "youngadz1",
"Yung Manny": "YungManny",
"Mike Jones": "MikeJones",
"Quelle Chris": "QuelleChris",
"Armani Caesar": "armanicaesar",
"Despot": "defjux",
"Iayze": "iayzeofficial",
"Lil Dude": "LilDude",
"Bizzy Bone": "BizzyBoneOfficial",
"Moor Mother": "moormother",
"RMC Mike": "RMCMIKE",
"StanWill": "StanWill",
"TrDee": "TrDee",
"U-God": "UGodOfficial",
"G Perico": "gpericopc"
}
def get_stats_by_channel_id(cid: str):
url = f"https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id={cid}&key={YT_API_KEY}"
try:
r = requests.get(url, timeout=8)
if r.status_code == 200:
items = r.json().get("items", [])
if items:
title = items[0]["snippet"]["title"]
subs = int(items[0]["statistics"].get("subscriberCount", 0))
views = int(items[0]["statistics"].get("viewCount", 0))
return cid, title, subs, views
except Exception as e:
logger.error(f"Error fetching stats for {cid}: {e}")
return None
def get_stats_by_handle(handle: str):
clean = handle.lstrip("@").replace(" ", "")
url = f"https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&forHandle={clean}&key={YT_API_KEY}"
try:
r = requests.get(url, timeout=8)
if r.status_code == 200:
items = r.json().get("items", [])
if items:
cid = items[0]["id"]
title = items[0]["snippet"]["title"]
subs = int(items[0]["statistics"].get("subscriberCount", 0))
views = int(items[0]["statistics"].get("viewCount", 0))
return cid, title, subs, views
except Exception:
pass
return None
def run():
if not YT_API_KEY:
logger.error("YOUTUBE_API_KEY missing from .env")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("""
SELECT a.artist_id, a.artist_name, a.youtube_url, t.subscribers_count
FROM artists a
JOIN telemetry_metrics t ON a.artist_id = t.artist_id
WHERE a.image_verified_flag = 1 AND t.subscribers_count < 10000
ORDER BY a.artist_name ASC
""")
rows = cur.fetchall()
logger.info(f"Loaded {len(rows)} remaining artists to remediate...")
fixed = 0
now_iso = datetime.now(timezone.utc).isoformat()
for idx, (artist_id, name, old_url, old_subs) in enumerate(rows, 1):
res = None
# 1. Check explicit mapping dictionary
if name in EXPLICIT_HANDLES:
target = EXPLICIT_HANDLES[name]
if target.startswith("UC"):
res = get_stats_by_channel_id(target)
else:
res = get_stats_by_handle(target)
# 2. Heuristic handle fallback if explicit check didn't resolve or returned low subs
if not res or res[2] <= old_subs:
slug = re.sub(r"[^a-zA-Z0-9]", "", name)
fallbacks = [
f"{slug}",
f"{slug}TV",
f"{slug}Music",
f"{slug}VEVO",
f"Official{slug}"
]
for fb in fallbacks:
res = get_stats_by_handle(fb)
if res and res[2] > old_subs:
break
time.sleep(0.04)
# 3. Apply update if channel with more subscribers was resolved
if res and res[2] > old_subs:
cid, title, subs, views = res
new_url = f"https://www.youtube.com/channel/{cid}"
cur.execute("UPDATE artists SET youtube_url = ? WHERE artist_id = ?", (new_url, artist_id))
cur.execute("""
UPDATE telemetry_metrics
SET subscribers_count = ?, total_views = ?, last_synced_at = ?
WHERE artist_id = ?
""", (subs, views, now_iso, artist_id))
fixed += 1
logger.info(f"✓ [{idx}/{len(rows)}] Fixed {name}: {old_subs} -> {subs:,} subs ({title})")
else:
logger.info(f"- [{idx}/{len(rows)}] Unchanged: {name} (kept {old_subs:,} subs)")
if idx % 10 == 0:
conn.commit()
time.sleep(0.08)
conn.commit()
conn.close()
logger.info(f"Sweep complete. Updated {fixed} / {len(rows)} channels.")
if __name__ == "__main__":
run()
+259
View File
@@ -0,0 +1,259 @@
"""
collectors/sync_complete_catalog.py
Master Production Telemetry Synchronizer for HIFFI Catalog.
Reads credentials automatically from .env.
- YouTube Data API v3 (Batch 50 IDs per call)
- Spotify Web API Client Credentials (Batch 50 IDs per call)
- Kworb Global Index for Spotify Monthly Listeners
"""
import os
import re
import time
import base64
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
# Load .env from project root
PROJECT_ROOT = Path(__file__).resolve().parent.parent
load_dotenv(PROJECT_ROOT / ".env")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger("TelemetryMaster")
DB_PATH = PROJECT_ROOT / "data" / "hiffi_catalog.db"
HTTP_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
class TelemetryMasterSync:
def __init__(self, db_path: Path = DB_PATH):
self.db_path = db_path
self.session = requests.Session()
self.session.headers.update(HTTP_HEADERS)
self.yt_api_key = os.getenv("YOUTUBE_API_KEY", "").strip()
self.sp_client_id = os.getenv("SPOTIFY_CLIENT_ID", "").strip()
self.sp_client_secret = os.getenv("SPOTIFY_CLIENT_SECRET", "").strip()
self.sp_access_token = None
def _normalize_name(self, name: str) -> str:
return re.sub(r"[^a-z0-9]", "", str(name).lower())
def _extract_yt_id(self, url: str) -> Optional[str]:
m = re.search(r"channel/(UC[0-9A-Za-z_-]{22})", str(url))
return m.group(1) if m else None
def _extract_sp_id(self, url: str) -> Optional[str]:
m = re.search(r"artist/([0-9A-Za-z]{22})", str(url))
return m.group(1) if m else None
# =========================================================================
# 1. SPOTIFY AUTH & BATCH API
# =========================================================================
def authenticate_spotify(self) -> bool:
if not self.sp_client_id or not self.sp_client_secret:
logger.warning("Spotify credentials missing in .env")
return False
url = "https://accounts.spotify.com/api/token"
auth_bytes = f"{self.sp_client_id}:{self.sp_client_secret}".encode("utf-8")
headers = {
"Authorization": f"Basic {base64.b64encode(auth_bytes).decode('utf-8')}",
"Content-Type": "application/x-www-form-urlencoded"
}
data = {"grant_type": "client_credentials"}
try:
resp = requests.post(url, headers=headers, data=data, timeout=10)
if resp.status_code == 200:
self.sp_access_token = resp.json().get("access_token")
logger.info("Successfully authenticated with Spotify Web API.")
return True
else:
logger.error(f"Spotify authentication failed: HTTP {resp.status_code} - {resp.text}")
return False
except Exception as e:
logger.error(f"Spotify authentication error: {e}")
return False
def fetch_spotify_api_batch(self, sp_ids: List[str]) -> Dict[str, Dict]:
results = {}
if not self.sp_access_token or not sp_ids:
return results
headers = {"Authorization": f"Bearer {self.sp_access_token}"}
chunk_size = 50
for i in range(0, len(sp_ids), chunk_size):
chunk = sp_ids[i:i + chunk_size]
url = f"https://api.spotify.com/v1/artists?ids={','.join(chunk)}"
try:
resp = requests.get(url, headers=headers, timeout=12)
if resp.status_code == 200:
for art in resp.json().get("artists", []):
if art:
results[art["id"]] = {
"followers": art.get("followers", {}).get("total", 0),
"popularity": art.get("popularity", 0)
}
else:
logger.warning(f"Spotify batch failed: HTTP {resp.status_code}")
except Exception as e:
logger.error(f"Error fetching Spotify batch: {e}")
time.sleep(0.05)
return results
# =========================================================================
# 2. SPOTIFY KWORB MONTHLY LISTENERS INDEX
# =========================================================================
def fetch_kworb_listeners(self) -> Dict[str, Tuple[int, int]]:
url = "https://kworb.net/spotify/listeners.html"
logger.info(f"Fetching global monthly listener index from {url}...")
results = {}
try:
resp = self.session.get(url, timeout=15)
if resp.status_code == 200:
soup = BeautifulSoup(resp.text, "html.parser")
table = soup.find("table")
if table:
for tr in table.find_all("tr"):
tds = tr.find_all("td")
if len(tds) >= 4 and tds[1].find("a"):
name = tds[1].find("a").get_text(strip=True)
raw_l = tds[2].get_text(strip=True).replace(",", "")
raw_c = tds[3].get_text(strip=True).replace(",", "").replace("+", "").replace("−", "-")
try:
listeners = int(raw_l)
change = int(raw_c) if raw_c.lstrip("-").isdigit() else 0
results[self._normalize_name(name)] = (listeners, change)
except ValueError:
continue
logger.info(f"Loaded {len(results)} artists from Kworb monthly listeners index.")
except Exception as e:
logger.error(f"Error loading Kworb table: {e}")
return results
# =========================================================================
# 3. YOUTUBE DATA API BATCH (CHANNELS.LIST)
# =========================================================================
def fetch_youtube_api_batch(self, channel_ids: List[str]) -> Dict[str, Tuple[int, int]]:
results = {}
if not self.yt_api_key:
logger.warning("YOUTUBE_API_KEY missing in .env")
return results
logger.info(f"Querying YouTube Data API v3 for {len(channel_ids)} channels in batches of 50...")
chunk_size = 50
for i in range(0, len(channel_ids), chunk_size):
chunk = channel_ids[i:i + chunk_size]
url = f"https://www.googleapis.com/youtube/v3/channels?part=statistics&id={','.join(chunk)}&key={self.yt_api_key}"
try:
resp = requests.get(url, timeout=12)
if resp.status_code == 200:
for item in resp.json().get("items", []):
cid = item["id"]
stats = item.get("statistics", {})
subs = int(stats.get("subscriberCount", 0))
views = int(stats.get("viewCount", 0))
results[cid] = (subs, views)
else:
logger.warning(f"YouTube API returned HTTP {resp.status_code}: {resp.text[:120]}")
except Exception as e:
logger.error(f"Error in YouTube batch call: {e}")
time.sleep(0.05)
logger.info(f"YouTube Data API successfully resolved {len(results)} / {len(channel_ids)} channels.")
return results
# =========================================================================
# 4. MASTER SYNCHRONIZATION
# =========================================================================
def run_master_sync(self):
conn = sqlite3.connect(self.db_path)
cur = conn.cursor()
cur.execute("""
SELECT a.artist_id, a.artist_name, a.youtube_url, a.spotify_url,
t.subscribers_count, t.spotify_monthly_listeners
FROM artists a
JOIN telemetry_metrics t ON a.artist_id = t.artist_id
WHERE a.image_verified_flag = 1
""")
rows = cur.fetchall()
logger.info(f"Loaded {len(rows)} verified cohort records from database.")
# Collect Channel IDs and Spotify IDs
yt_ids = [self._extract_yt_id(r[2]) for r in rows if self._extract_yt_id(r[2])]
sp_ids = [self._extract_sp_id(r[3]) for r in rows if self._extract_sp_id(r[3])]
# Execute Batch Platform Ingestions
yt_metrics = self.fetch_youtube_api_batch(yt_ids)
has_sp_api = self.authenticate_spotify()
sp_api_metrics = self.fetch_spotify_api_batch(sp_ids) if has_sp_api else {}
kworb_metrics = self.fetch_kworb_listeners()
# Update Database Atomically
now_iso = datetime.now(timezone.utc).isoformat()
synced_yt = 0
synced_sp = 0
for artist_id, name, yt_url, sp_url, curr_subs, curr_listeners in rows:
updates = ["last_synced_at = ?"]
params = [now_iso]
# 1. Update YouTube Data API Metrics
cid = self._extract_yt_id(yt_url)
if cid and cid in yt_metrics:
subs, views = yt_metrics[cid]
if subs > 0:
updates.append("subscribers_count = ?")
params.append(subs)
updates.append("total_views = ?")
params.append(views)
synced_yt += 1
# 2. Update Spotify Monthly Listeners (from Kworb Index)
norm = self._normalize_name(name)
if norm in kworb_metrics:
listeners, change = kworb_metrics[norm]
updates.append("spotify_monthly_listeners = ?")
params.append(listeners)
updates.append("spotify_daily_change = ?")
params.append(change)
synced_sp += 1
else:
sid = self._extract_sp_id(sp_url)
if sid and sid in sp_api_metrics:
fol = sp_api_metrics[sid]["followers"]
if (curr_listeners is None or curr_listeners < 1000) and fol > 1000:
updates.append("spotify_monthly_listeners = ?")
params.append(fol)
synced_sp += 1
if len(updates) > 1:
params.append(artist_id)
cur.execute(f"UPDATE telemetry_metrics SET {', '.join(updates)} WHERE artist_id = ?", params)
conn.commit()
conn.close()
logger.info("=" * 60)
logger.info("MASTER SYNCHRONIZATION COMPLETED SUCCESSFULLY")
logger.info(f" Live YouTube Channels Updated (API v3): {synced_yt} / {len(rows)}")
logger.info(f" Verified Spotify Telemetry Updated: {synced_sp} / {len(rows)}")
logger.info("=" * 60)
if __name__ == "__main__":
sync = TelemetryMasterSync()
sync.run_master_sync()
+143
View File
@@ -0,0 +1,143 @@
"""
collectors/sync_official_telemetry.py
Production synchronization engine:
- Ingests Kworb canonical daily listener table for Spotify (top 2,500 artists worldwide).
- Synchronizes verified monthly listeners and daily change into data/hiffi_catalog.db.
- Preserves verified YouTube subscriber counts and prevents video-count scraper corruption.
"""
import re
import time
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, Tuple, Optional
import requests
from bs4 import BeautifulSoup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger("TelemetrySync")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT_ROOT / "data" / "hiffi_catalog.db"
HTTP_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}
class TelemetrySyncEngine:
def __init__(self, db_path: Path = DB_PATH):
self.db_path = db_path
self.session = requests.Session()
self.session.headers.update(HTTP_HEADERS)
def _normalize_name(self, name: str) -> str:
return re.sub(r"[^a-z0-9]", "", str(name).lower())
def fetch_spotify_kworb_index(self) -> Dict[str, Tuple[int, int]]:
url = "https://kworb.net/spotify/listeners.html"
logger.info(f"Fetching canonical Spotify listener table from {url}...")
results = {}
try:
resp = self.session.get(url, timeout=15)
if resp.status_code != 200:
logger.error(f"Failed to fetch Kworb table: HTTP {resp.status_code}")
return results
soup = BeautifulSoup(resp.text, "html.parser")
table = soup.find("table")
if not table:
logger.warning("No table tag found on Kworb page.")
return results
for tr in table.find_all("tr"):
tds = tr.find_all("td")
if len(tds) >= 4:
artist_a = tds[1].find("a")
if artist_a:
artist_name = artist_a.get_text(strip=True)
raw_listeners = tds[2].get_text(strip=True).replace(",", "")
raw_change = tds[3].get_text(strip=True).replace(",", "").replace("+", "").replace("−", "-")
try:
listeners = int(raw_listeners)
except ValueError:
continue
try:
daily_change = int(raw_change)
except ValueError:
daily_change = 0
results[self._normalize_name(artist_name)] = (listeners, daily_change)
logger.info(f"Successfully indexed {len(results)} artists from Kworb daily Spotify index.")
except Exception as e:
logger.error(f"Error parsing Kworb Spotify table: {e}")
return results
def sync_all(self, limit: Optional[int] = None):
conn = sqlite3.connect(self.db_path)
cur = conn.cursor()
# Restore known major artists if temporary 984k/2.52M overwrite occurred
cur.execute("UPDATE telemetry_metrics SET subscribers_count = 67400000 WHERE artist_id = 5102") # Eminem
cur.execute("UPDATE telemetry_metrics SET subscribers_count = 33200000 WHERE artist_id = 4651") # Drake
conn.commit()
query = """
SELECT a.artist_id, a.artist_name, t.spotify_monthly_listeners
FROM artists a
JOIN telemetry_metrics t ON a.artist_id = t.artist_id
WHERE a.image_verified_flag = 1
"""
if limit:
query += f" LIMIT {limit}"
cur.execute(query)
rows = cur.fetchall()
logger.info(f"Loaded {len(rows)} verified cohort records from database.")
kworb_index = self.fetch_spotify_kworb_index()
synced_sp = 0
now_iso = datetime.now(timezone.utc).isoformat()
for idx, (artist_id, name, current_listeners) in enumerate(rows, 1):
norm_name = self._normalize_name(name)
if norm_name in kworb_index:
listeners, daily_change = kworb_index[norm_name]
cur.execute("""
UPDATE telemetry_metrics
SET spotify_monthly_listeners = ?,
spotify_daily_change = ?,
last_synced_at = ?
WHERE artist_id = ?
""", (listeners, daily_change, now_iso, artist_id))
synced_sp += 1
logger.info(f"✓ [{idx}/{len(rows)}] {name}: {listeners:,} monthly listeners ({daily_change:+,} daily)")
if idx % 50 == 0 or idx == len(rows):
conn.commit()
conn.commit()
conn.close()
logger.info(f"COMPLETED: Telemetry sync completed successfully.")
logger.info(f" Total Spotify Counts Synchronized with Real-Time Metrics: {synced_sp} / {len(rows)}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Sync official live telemetry into hiffi_catalog.db")
parser.add_argument("--limit", type=int, default=None, help="Limit number of artists to test sync")
args = parser.parse_args()
engine = TelemetrySyncEngine()
engine.sync_all(limit=args.limit)
+138
View File
@@ -0,0 +1,138 @@
"""
collectors/sync_remaining_spotify.py
Pulls verified monthly listener metrics for the 353 non-charting artists
via Spotify's public Embed & Partner JSON endpoints.
"""
import re
import time
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("SpotifyTailSync")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT_ROOT / "data" / "hiffi_catalog.db"
HTTP_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9"
}
def extract_spotify_id(url):
m = re.search(r"artist/([0-9A-Za-z]{22})", str(url))
return m.group(1) if m else None
def get_spotify_web_token(session):
"""Fetches an ephemeral anonymous web client bearer token directly from Spotify."""
try:
url = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player"
r = session.get(url, timeout=6)
if r.status_code == 200:
return r.json().get("accessToken")
except Exception:
pass
return None
def fetch_monthly_listeners(session, sp_id, token=None):
# Strategy 1: Public Embed JSON resource
embed_url = f"https://open.spotify.com/embed/artist/{sp_id}"
try:
r = session.get(embed_url, timeout=6)
if r.status_code == 200:
# Pattern: "monthlyListeners":123456
m = re.search(r'\"monthlyListeners\":\s*(\d+)', r.text)
if m:
return int(m.group(1))
# Pattern: "123,456 monthly listeners"
m2 = re.search(r'([\d,]+)\s+monthly listeners', r.text, re.IGNORECASE)
if m2:
return int(m2.group(1).replace(",", ""))
except Exception:
pass
# Strategy 2: Anonymous partner GraphQL / metadata endpoint
if token:
try:
api_url = f"https://api-partner.spotify.com/pathfinder/v1/query"
params = {
"operationName": "queryArtistOverview",
"variables": f'{{"uris":["spotify:artist:{sp_id}"]}}',
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"35ac4265af99890eac3ff8492314508c82a647970977821c4fa48956828551a3"}}'
}
h = dict(HTTP_HEADERS)
h["Authorization"] = f"Bearer {token}"
res = session.get(api_url, params=params, headers=h, timeout=6)
if res.status_code == 200:
data = res.json()
count = (data.get("data", {})
.get("artists", [{}])[0]
.get("stats", {})
.get("monthlyListeners"))
if count:
return int(count)
except Exception:
pass
return None
def run():
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# Artists who were not on Kworb's top 2,500 chart
cur.execute("""
SELECT a.artist_id, a.artist_name, a.spotify_url, t.spotify_monthly_listeners
FROM artists a
JOIN telemetry_metrics t ON a.artist_id = t.artist_id
WHERE a.image_verified_flag = 1
AND (t.spotify_daily_change = 0 OR t.spotify_daily_change IS NULL)
""")
unresolved = cur.fetchall()
logger.info(f"Targeting {len(unresolved)} remaining non-charting artists...")
session = requests.Session()
session.headers.update(HTTP_HEADERS)
token = get_spotify_web_token(session)
if token:
logger.info("Acquired anonymous client access token for Spotify web service.")
now_iso = datetime.now(timezone.utc).isoformat()
updated = 0
for idx, (artist_id, name, sp_url, current_val) in enumerate(unresolved, 1):
sp_id = extract_spotify_id(sp_url)
if not sp_id:
continue
listeners = fetch_monthly_listeners(session, sp_id, token)
if listeners and listeners > 0:
cur.execute("""
UPDATE telemetry_metrics
SET spotify_monthly_listeners = ?, last_synced_at = ?
WHERE artist_id = ?
""", (listeners, now_iso, artist_id))
updated += 1
logger.info(f"[{idx}/{len(unresolved)}] {name} -> {listeners:,} monthly listeners")
else:
logger.debug(f"[{idx}/{len(unresolved)}] Could not resolve {name}")
if idx % 25 == 0:
conn.commit()
time.sleep(0.2)
else:
time.sleep(0.08)
conn.commit()
conn.close()
logger.info(f"Tail sync finished. Updated {updated} / {len(unresolved)} artists.")
if __name__ == "__main__":
run()
+80
View File
@@ -0,0 +1,80 @@
"""
collectors/sync_spotify_complete.py
Spotify sync engine using credentials from .env.
"""
import os
import re
import time
import base64
import sqlite3
import logging
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional
import requests
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent
load_dotenv(PROJECT_ROOT / ".env")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("SpotifySync")
DB_PATH = PROJECT_ROOT / "data" / "hiffi_catalog.db"
class SpotifySyncEngine:
def __init__(self, db_path: Path = DB_PATH):
self.db_path = db_path
self.client_id = os.getenv("SPOTIFY_CLIENT_ID", "").strip()
self.client_secret = os.getenv("SPOTIFY_CLIENT_SECRET", "").strip()
self.access_token = None
def authenticate(self) -> bool:
if not self.client_id or not self.client_secret:
logger.warning("Spotify credentials missing in .env")
return False
url = "https://accounts.spotify.com/api/token"
auth_bytes = f"{self.client_id}:{self.client_secret}".encode("utf-8")
headers = {
"Authorization": f"Basic {base64.b64encode(auth_bytes).decode('utf-8')}",
"Content-Type": "application/x-www-form-urlencoded"
}
data = {"grant_type": "client_credentials"}
try:
resp = requests.post(url, headers=headers, data=data, timeout=10)
if resp.status_code == 200:
self.access_token = resp.json().get("access_token")
return True
except Exception as e:
logger.error(f"Spotify auth error: {e}")
return False
def fetch_batch(self, sp_ids: List[str]) -> Dict[str, Dict]:
results = {}
if not self.access_token or not sp_ids:
return results
headers = {"Authorization": f"Bearer {self.access_token}"}
for i in range(0, len(sp_ids), 50):
chunk = sp_ids[i:i + 50]
url = f"https://api.spotify.com/v1/artists?ids={','.join(chunk)}"
try:
resp = requests.get(url, headers=headers, timeout=10)
if resp.status_code == 200:
for art in resp.json().get("artists", []):
if art:
results[art["id"]] = {
"followers": art.get("followers", {}).get("total", 0),
"popularity": art.get("popularity", 0)
}
except Exception as e:
logger.error(f"Batch fetch error: {e}")
time.sleep(0.05)
return results
if __name__ == "__main__":
engine = SpotifySyncEngine()
if engine.authenticate():
logger.info("Spotify API credentials authenticated successfully.")
else:
logger.error("Authentication failed. Check SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env.")
+19 -70
View File
@@ -1,85 +1,34 @@
"""
collectors/test_spotify_endpoints.py
Direct diagnostic to test Spotify OAuth2 Client Credentials flow
and probe endpoint permissions for Artist data, Top Tracks, and Search.
Manual diagnostic utility for testing public and authenticated Spotify API endpoints.
Excluded from automated pytest discovery.
"""
import base64
import os
from pathlib import Path
import sys
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))
load_dotenv(PROJECT_ROOT / ".env")
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "").strip()
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "").strip()
__test__ = False
def get_token(client_id: str, client_secret: str):
"""Requests a bearer token via standard OAuth2 Client Credentials."""
if not client_id or not client_secret:
return 0, {"error": "SPOTIFY_CLIENT_ID or SPOTIFY_CLIENT_SECRET not configured"}
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
url = "https://accounts.spotify.com/api/token"
res = requests.post(
url,
data={"grant_type": "client_credentials"},
headers={"Authorization": f"Basic {auth_header}"},
timeout=10,
)
return res.status_code, res.json()
def test_endpoints(token: str):
def run_manual_endpoint_test(token: str):
"""Manual probe for validating token scopes against official Spotify Web API endpoints."""
headers = {"Authorization": f"Bearer {token}"}
pilot_artist_id = "3TVXtAsR1Inumwj472S9r4" # Drake
sample_id = "06HL4z0CvFAxyc27GXpf02" # Taylor Swift
endpoints = {
"Artist Metadata (GET /v1/artists/{id})": f"https://api.spotify.com/v1/artists/{pilot_artist_id}",
"Artist Top Tracks (GET /v1/artists/{id}/top-tracks)": f"https://api.spotify.com/v1/artists/{pilot_artist_id}/top-tracks?market=US",
"Search Endpoint (GET /v1/search)": "https://api.spotify.com/v1/search?q=Drake&type=artist&limit=1",
}
results = {}
for name, url in endpoints.items():
res = requests.get(url, headers=headers, timeout=10)
results[name] = {
"status_code": res.status_code,
"response_preview": res.text[:300],
}
return results
url = f"https://api.spotify.com/v1/artists/{sample_id}"
resp = requests.get(url, headers=headers, timeout=10)
print(f"Status Code: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f"Artist Name: {data.get('name')}")
print(f"Followers: {data.get('followers', {}).get('total')}")
else:
print(f"Error Payload: {resp.text}")
if __name__ == "__main__":
print("=" * 80)
print("SPOTIFY API AUTHENTICATION & ENDPOINT PERMISSION DIAGNOSTIC")
print("=" * 80)
if not CLIENT_ID or not CLIENT_SECRET:
print("Error: Missing credentials in environment. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env.")
sys.exit(1)
status, token_resp = get_token(CLIENT_ID, CLIENT_SECRET)
print(f"Token Endpoint Status: {status}")
if status == 200:
token = token_resp.get("access_token")
print(f"Bearer Token Acquired: {token[:15]}... [expires_in: {token_resp.get('expires_in')}s]")
print("-" * 80)
diagnostics = test_endpoints(token)
for ep, out in diagnostics.items():
print(f"\nEndpoint: {ep}")
print(f"Status: {out['status_code']}")
print(f"Payload: {out['response_preview']}")
test_token = os.getenv("SPOTIFY_ACCESS_TOKEN", "").strip()
if not test_token:
print("Usage: Set SPOTIFY_ACCESS_TOKEN in environment before executing.")
else:
print(f"Failed to obtain token: {token_resp}")
print("\n" + "=" * 80)
run_manual_endpoint_test(test_token)