"""MusicBrainz client: the default free metadata source. Deliberately small and polite: * one global throttle so we never exceed ~1 request/second (MB's published limit); * a descriptive User-Agent (MB rejects anonymous clients); * retry/backoff on 503 and network errors; * responses cached in the ``mb_cache`` table so completeness never hits the API during a normal page render. """ from __future__ import annotations import json import logging import os import threading import time import requests from . import db logger = logging.getLogger("homelabtoolkit.musicbrainz") MB_BASE = "https://musicbrainz.org/ws/2" USER_AGENT = os.environ.get( "MUSICBRAINZ_USER_AGENT", "HomelabToolkit/1.0 ( https://github.com/homelabtoolkit )", ) MIN_INTERVAL_SECONDS = 1.1 CACHE_MAX_AGE_DAYS = 30 # Release-group filtering. Primary type must be Album; any of these secondary # types excludes it (live albums, compilations, soundtracks, remixes, etc.). # Kept as module constants so the filter can be relaxed later in one place. INCLUDED_PRIMARY_TYPES = {"album"} EXCLUDED_SECONDARY_TYPES = { "live", "compilation", "soundtrack", "remix", "dj-mix", "mixtape/street", "demo", "interview", "audiobook", "audio drama", "spokenword", } _throttle_lock = threading.Lock() _last_request_at = 0.0 def _throttle() -> None: global _last_request_at with _throttle_lock: wait = MIN_INTERVAL_SECONDS - (time.monotonic() - _last_request_at) if wait > 0: time.sleep(wait) _last_request_at = time.monotonic() def _cache_get(cache_key: str, max_age_days: int = CACHE_MAX_AGE_DAYS): cutoff = time.time() - max_age_days * 86400 with db.connect() as conn: row = conn.execute( "SELECT payload, fetched_at FROM mb_cache WHERE cache_key=?", (cache_key,) ).fetchone() if not row: return None # fetched_at is ISO; treat anything older than the cutoff as a miss. try: import datetime as _dt fetched = _dt.datetime.fromisoformat((row["fetched_at"] or "").replace("Z", "+00:00")) if fetched.timestamp() < cutoff: return None except ValueError: pass try: return json.loads(row["payload"]) except (TypeError, ValueError): return None def _cache_put(cache_key: str, payload) -> None: with db.connect() as conn: conn.execute( "INSERT INTO mb_cache(cache_key, payload, fetched_at) VALUES(?,?,?) " "ON CONFLICT(cache_key) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at", (cache_key, json.dumps(payload), db.now_iso()), ) def _request(path: str, params: dict, cache_key: str, *, use_cache: bool = True): if use_cache: cached = _cache_get(cache_key) if cached is not None: return cached headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} query = {**params, "fmt": "json"} for attempt in range(4): _throttle() try: resp = requests.get(f"{MB_BASE}/{path}", params=query, headers=headers, timeout=25) if resp.status_code == 503: time.sleep(2.0 * (attempt + 1)) continue resp.raise_for_status() data = resp.json() _cache_put(cache_key, data) return data except requests.RequestException as exc: logger.warning("MusicBrainz request failed (%s attempt %d): %s", path, attempt + 1, exc) time.sleep(1.5 * (attempt + 1)) return None def search_artist(name: str) -> dict | None: """Best artist match for a name, with a 0–1 confidence score.""" cache_key = f"artist_search::{name.lower()}" data = _request("artist", {"query": name, "limit": 5}, cache_key) if not data: return None artists = data.get("artists") or [] if not artists: return None best = artists[0] return { "mbid": best.get("id"), "name": best.get("name") or "", "confidence": round(int(best.get("score", 0)) / 100.0, 3), } def fetch_release_groups(artist_mbid: str) -> list[dict]: """All release groups for an artist (paged). Filtering happens later so the completeness filters can change without re-fetching.""" results: list[dict] = [] offset = 0 limit = 100 while True: cache_key = f"release_groups::{artist_mbid}::{offset}" data = _request( "release-group", {"artist": artist_mbid, "type": "album", "limit": limit, "offset": offset}, cache_key, ) if not data: break batch = data.get("release-groups") or [] for rg in batch: results.append( { "mbid": rg.get("id"), "title": rg.get("title") or "", "first_release_year": _year(rg.get("first-release-date")), "primary_type": (rg.get("primary-type") or "").strip(), "secondary_types": [s.strip() for s in (rg.get("secondary-types") or [])], } ) total = int(data.get("release-group-count", len(results))) offset += limit if offset >= total or not batch: break return results def is_official_album(primary_type: str | None, secondary_types: list[str] | None) -> bool: """Apply the default album filter (excludes EP/single/live/comp/etc.).""" if (primary_type or "").strip().lower() not in INCLUDED_PRIMARY_TYPES: return False for secondary in secondary_types or []: if secondary.strip().lower() in EXCLUDED_SECONDARY_TYPES: return False return True def _year(date_str: str | None) -> int: if not date_str: return 0 try: return int(str(date_str)[:4]) except (ValueError, TypeError): return 0