Files
2026-06-08 00:01:55 +12:00

188 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 01 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