188 lines
5.8 KiB
Python
188 lines
5.8 KiB
Python
"""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
|