Files
kino-app/backend/musicbrainz.py
T
Myron Blair 514cbad4ab Add filesystem Library Scan (movies/tv/music) and a full Music library
- New admin Library Scan page walks the NAS mounts directly for movies,
  TV, and music not already in Radarr/Sonarr/local storage, and imports
  them with storage_type=scan (fully integrated: streamable, transcodable,
  listed in Admin) — Radarr/Sonarr import flows are untouched.
- New Music section (nav tab, album-grid browse, per-album track list,
  persistent bottom player with queue/seek) backed by a Track model and
  range-request audio streaming endpoint.
- MusicBrainz + Cover Art Archive integration (free, no API key) to fill
  in album art for scanned tracks, paced to their 1 req/sec rate limit.
- Mounted the NAS music share into CT104 (new mp2 LXC mountpoint,
  CIFS read-only) alongside the existing video mount.
- Optional Bluetooth/output-device picker on the player bar via the Audio
  Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge
  only, since browsers can't pair new BT devices directly.
2026-07-26 20:25:41 -05:00

43 lines
1.5 KiB
Python

"""MusicBrainz + Cover Art Archive clients — both free, no API key.
MusicBrainz rate limit is 1 req/sec for unauthenticated clients; callers must pace requests.
"""
import requests
from typing import Optional, Dict
_HEADERS = {"User-Agent": "StreamHoard/1.0 (self-hosted media library)"}
MB_BASE = "https://musicbrainz.org/ws/2"
COVER_ART_BASE = "https://coverartarchive.org"
def lookup_release(artist: str, album: str) -> Optional[Dict]:
"""Best-effort match for an artist+album against MusicBrainz's release index."""
query = f'release:"{album}" AND artist:"{artist}"'
try:
r = requests.get(f"{MB_BASE}/release/", params={"query": query, "fmt": "json", "limit": 1},
headers=_HEADERS, timeout=15)
r.raise_for_status()
releases = (r.json() or {}).get("releases") or []
except Exception:
return None
if not releases:
return None
rel = releases[0]
return {
"mbid": rel.get("id"),
"title": rel.get("title") or album,
"artist": (rel.get("artist-credit") or [{}])[0].get("name") or artist,
"year": (rel.get("date") or "")[:4] or None,
}
def cover_art_url(mbid: str) -> Optional[str]:
"""Returns the front cover URL if the Cover Art Archive has one for this release, else None."""
url = f"{COVER_ART_BASE}/release/{mbid}/front"
try:
r = requests.head(url, headers=_HEADERS, timeout=10, allow_redirects=True)
if r.status_code == 200:
return r.url
except Exception:
pass
return None