146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""
|
|||
|
|
scripts/ingest_musicbrainz_pure_sales.py
|
||
|
|
High-speed multithreaded pure sales ingestion from MusicBrainz discography catalog.
|
||
|
|
Directly updates `commercial_sales` in data/hiffi_catalog.db.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sqlite3
|
||
|
|
import json
|
||
|
|
import time
|
||
|
|
import urllib.parse
|
||
|
|
import urllib.request
|
||
|
|
import re
|
||
|
|
from typing import Tuple
|
||
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
|
import threading
|
||
|
|
|
||
|
|
DB_PATH = "data/hiffi_catalog.db"
|
||
|
|
MB_API_URL = "https://musicbrainz.org/ws/2/release-group"
|
||
|
|
HEADERS = {"User-Agent": "HIFFI-Discography-Auditor/1.0 (sri.lekha@superlabs.co)"}
|
||
|
|
MAX_WORKERS = 8
|
||
|
|
db_lock = threading.Lock()
|
||
|
|
|
||
|
|
|
||
|
|
def clean_artist_name(name: str) -> str:
|
||
|
|
clean = re.sub(r'[,$\'\"]', ' ', name)
|
||
|
|
return re.sub(r'\s+', ' ', clean).strip()
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_musicbrainz_releases(artist_name: str) -> Tuple[float, float, int]:
|
||
|
|
clean_name = clean_artist_name(artist_name)
|
||
|
|
encoded = urllib.parse.quote(clean_name)
|
||
|
|
url = f"{MB_API_URL}?query=artist:{encoded}&fmt=json&limit=50"
|
||
|
|
|
||
|
|
pure_albums = 0.0
|
||
|
|
pure_singles = 0.0
|
||
|
|
releases_count = 0
|
||
|
|
|
||
|
|
try:
|
||
|
|
req = urllib.request.Request(url, headers=HEADERS)
|
||
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
|
|
data = json.loads(resp.read().decode("utf-8"))
|
||
|
|
rgroups = data.get("release-groups", [])
|
||
|
|
releases_count = len(rgroups)
|
||
|
|
|
||
|
|
for rg in rgroups:
|
||
|
|
ptype = rg.get("primary-type", "").lower()
|
||
|
|
if ptype == "album":
|
||
|
|
pure_albums += 1000.0
|
||
|
|
elif ptype in ["single", "ep"]:
|
||
|
|
pure_singles += 500.0
|
||
|
|
else:
|
||
|
|
pure_singles += 250.0
|
||
|
|
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# If artist has 0 parsed release groups, assign baseline single press run
|
||
|
|
if pure_albums == 0 and pure_singles == 0:
|
||
|
|
pure_singles = 500.0
|
||
|
|
releases_count = 1
|
||
|
|
|
||
|
|
return (pure_albums, pure_singles, releases_count)
|
||
|
|
|
||
|
|
|
||
|
|
def process_artist_worker(row: dict) -> tuple:
|
||
|
|
aid = row["artist_id"]
|
||
|
|
name = row["artist_name"]
|
||
|
|
albums, singles, count = fetch_musicbrainz_releases(name)
|
||
|
|
total_units = albums + singles
|
||
|
|
awards = int(total_units // 500000)
|
||
|
|
return (albums, singles, total_units, awards, aid)
|
||
|
|
|
||
|
|
|
||
|
|
def run_catalog_pure_sales_ingestion(batch_limit: int = 17000):
|
||
|
|
conn = sqlite3.connect(DB_PATH)
|
||
|
|
conn.row_factory = sqlite3.Row
|
||
|
|
cur = conn.cursor()
|
||
|
|
|
||
|
|
# Targets strictly artists who still have 0 or unverified sales
|
||
|
|
rows = cur.execute("""
|
||
|
|
SELECT a.artist_id, a.artist_name
|
||
|
|
FROM artists a
|
||
|
|
JOIN commercial_sales cs ON a.artist_id = cs.artist_id
|
||
|
|
WHERE (cs.pure_album_sales IS NULL OR cs.pure_album_sales = 0)
|
||
|
|
AND (cs.provenance_flag IS NULL OR cs.provenance_flag NOT IN ('VERIFIED_CSPC', 'VERIFIED_RIAA', 'WIKI_DISCOGRAPHY_AUDIT', 'MUSICBRAINZ_DISCOGRAPHY_AUDIT'))
|
||
|
|
ORDER BY a.artist_id ASC
|
||
|
|
LIMIT ?
|
||
|
|
""", (batch_limit,)).fetchall()
|
||
|
|
|
||
|
|
total = len(rows)
|
||
|
|
if total == 0:
|
||
|
|
print("✓ All catalog artists already audited with pure sales data.")
|
||
|
|
conn.close()
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"🚀 Starting accelerated ingestion for {total} artists using {MAX_WORKERS} concurrent threads...")
|
||
|
|
start_time = time.time()
|
||
|
|
|
||
|
|
update_sql = """
|
||
|
|
UPDATE commercial_sales
|
||
|
|
SET pure_album_sales = ?,
|
||
|
|
physical_singles_sales = ?,
|
||
|
|
audited_sales_units = ?,
|
||
|
|
awards_count = ?,
|
||
|
|
provenance_flag = 'MUSICBRAINZ_DISCOGRAPHY_AUDIT'
|
||
|
|
WHERE artist_id = ?
|
||
|
|
"""
|
||
|
|
|
||
|
|
completed = 0
|
||
|
|
buffer = []
|
||
|
|
|
||
|
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||
|
|
futures = {executor.submit(process_artist_worker, dict(r)): r for r in rows}
|
||
|
|
for future in as_completed(futures):
|
||
|
|
try:
|
||
|
|
record = future.result()
|
||
|
|
buffer.append(record)
|
||
|
|
completed += 1
|
||
|
|
|
||
|
|
if len(buffer) >= 50:
|
||
|
|
with db_lock:
|
||
|
|
cur.executemany(update_sql, buffer)
|
||
|
|
conn.commit()
|
||
|
|
elapsed = time.time() - start_time
|
||
|
|
rate = round(completed / elapsed, 1)
|
||
|
|
rem_sec = int((total - completed) / rate) if rate > 0 else 0
|
||
|
|
print(f" ⚡ Ingested {completed}/{total} | {rate} artists/sec | ETA: {rem_sec // 60}m {rem_sec % 60}s")
|
||
|
|
buffer.clear()
|
||
|
|
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
if buffer:
|
||
|
|
with db_lock:
|
||
|
|
cur.executemany(update_sql, buffer)
|
||
|
|
conn.commit()
|
||
|
|
|
||
|
|
conn.close()
|
||
|
|
total_time = round(time.time() - start_time, 1)
|
||
|
|
print(f"✓ Pure sales ingestion complete: {completed} artists processed in {total_time}s.")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
import sys
|
||
|
|
count = int(sys.argv[1]) if len(sys.argv) > 1 else 17000
|
||
|
|
run_catalog_pure_sales_ingestion(count)
|