Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
- run: uv sync
- run: uv run pytest tests/ -v
- run: uv run ruff check .
- run: uv run ruff format --check .
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ build/
*.swp
*.swo

# AI agents
.kiro/
.cursor/
.windsurf/
.aider*
.cline/
.roo/

# OS
.DS_Store

Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Thin wrapper around the [MusicBrainz Web Service v2](https://musicbrainz.org/doc

## Requirements

- Python 3.12+
- Python 3.10+
- [uv](https://docs.astral.sh/uv/)

## Installation
Expand Down Expand Up @@ -107,6 +107,52 @@ plain = annotation_to_text(artist.annotation)
md = annotation_to_markdown(artist.annotation)
```

### Cover Art Archive

```python
from musicbrainzpy import SyncCoverArtClient

with SyncCoverArtClient("myapp", "1.0", "me@example.com") as caa:
# List images for a release
image_list = caa.get_image_list(release_mbid)
for img in image_list.images:
print(f" {img.id}: {img.types} ({img.front=})")

# Download front cover (full-size or thumbnail)
data = caa.get_front(release_mbid)
data = caa.get_front(release_mbid, size=500) # 250, 500, or 1200

# Get image metadata without downloading
info = caa.image_info(release_mbid, "front")
print(f" {info['content_type']}, {info['content_length']} bytes")
```

Async version: `CoverArtClient` with the same methods (all `await`-able).

### Environment variables

Client defaults can be set via environment variables (explicit constructor args always win):

| Variable | Overrides |
|---|---|
| `MUSICBRAINZPY_APP` | `app_name` |
| `MUSICBRAINZPY_VERSION` | `app_version` |
| `MUSICBRAINZPY_CONTACT` | `app_contact` |
| `MUSICBRAINZPY_BASE_URL` | `base_url` |
| `MUSICBRAINZPY_USERNAME` | `username` |
| `MUSICBRAINZPY_PASSWORD` | `password` |

```bash
export MUSICBRAINZPY_APP=myapp
export MUSICBRAINZPY_VERSION=1.0
export MUSICBRAINZPY_CONTACT=me@example.com
```

```python
# No args needed when env vars are set
client = SyncMusicBrainzClient()
```

See [docs/oauth2.md](docs/oauth2.md) for the full OAuth2 guide with PKCE, token refresh, and examples.

Coming from **musicbrainzngs**? See the [migration guide](docs/migrating-from-ngs.md).
Expand Down
40 changes: 40 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ All requests set the `Accept: application/json` header.
- Max 1 request/second per client
- Must set a meaningful `User-Agent` header: `AppName/Version ( contact-url-or-email )`
- Violators get IP-blocked
- Server returns HTTP 503 (or 429) when rate limit is exceeded

## Retry Behavior

All clients automatically retry on transient failures:

- **Retried**: `httpx.TransportError` (connection errors, timeouts), HTTP 429, HTTP 503
- **Not retried**: HTTP 400, 401, 404, or any other client/server error
- **Default**: 3 retries with exponential backoff (1s, 2s, 4s)
- **Retry-After**: respected when the server includes the header
- **Configuration**: `max_retries=` and `retry_base_delay=` constructor params; set `max_retries=0` to disable

## Lookup

Expand Down Expand Up @@ -124,3 +135,32 @@ Annotations use MusicBrainz wiki markup. The `annotation` module provides conver
- `annotation_to_markdown(markup)` — convert to Markdown

See https://musicbrainz.org/doc/Annotation#Wiki_formatting for the markup spec.

## Cover Art Archive

Separate API at `https://coverartarchive.org/`. No authentication or rate limiting required.

| Endpoint | Returns |
|---|---|
| `GET /release/<mbid>/` | JSON image listing |
| `GET /release-group/<mbid>/` | JSON image listing |
| `GET /release/<mbid>/front` | Front cover image (binary), 307 redirect to archive.org |
| `GET /release/<mbid>/back` | Back cover image (binary) |
| `GET /release/<mbid>/<image_id>` | Specific image (binary) |

Thumbnail sizes: append `-250`, `-500`, or `-1200` to the image path (e.g. `/release/<mbid>/front-500`).

Use `HEAD` requests to get `Content-Type` and `Content-Length` without downloading the image.

## Environment Variables

Client defaults can be set via environment variables. Explicit constructor arguments always take precedence.

| Variable | Overrides |
|---|---|
| `MUSICBRAINZPY_APP` | `app_name` |
| `MUSICBRAINZPY_VERSION` | `app_version` |
| `MUSICBRAINZPY_CONTACT` | `app_contact` |
| `MUSICBRAINZPY_BASE_URL` | `base_url` |
| `MUSICBRAINZPY_USERNAME` | `username` |
| `MUSICBRAINZPY_PASSWORD` | `password` |
25 changes: 24 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,35 @@ The base URL is configurable via the client constructor to support mirrors and l
- Max 1 request per second (API requirement)
- Implemented as a simple timestamp check with `asyncio.sleep()`

## Retry

- Transient failures (`httpx.TransportError`, HTTP 429/503) are retried with exponential backoff
- Default: 3 retries with 1s base delay (1s, 2s, 4s)
- Respects `Retry-After` header when present
- Configurable via `max_retries` and `retry_base_delay` constructor params
- Permanent errors (400, 401, 404) are never retried

## Authentication

- Digest auth (via `httpx.DigestAuth`) — current standard
- OAuth2 — for user-scoped operations (tags, ratings, collections)

## Cover Art Archive

Separate client (`CoverArtClient` / `SyncCoverArtClient`) for the Cover Art Archive API at `coverartarchive.org`. Different host, no auth, no rate limiting. Supports image listings, binary downloads, and HEAD-based metadata queries.

## Environment Variables

Client defaults can be set via `MUSICBRAINZPY_`-prefixed environment variables. Explicit constructor arguments always take precedence. Supported: `APP`, `VERSION`, `CONTACT`, `BASE_URL`, `USERNAME`, `PASSWORD`.

## Module Layout

```
musicbrainzpy/
├── __init__.py # Public API re-exports
├── client.py # MusicBrainzClient (async)
├── sync_client.py # SyncMusicBrainzClient (sync wrapper)
├── coverart.py # CoverArtClient / SyncCoverArtClient (Cover Art Archive)
├── models/ # Pydantic models per entity type
│ ├── __init__.py # Re-exports all models
│ ├── common.py # Shared: ArtistCredit, LifeSpan, Tag, Genre, etc.
Expand All @@ -90,19 +107,25 @@ musicbrainzpy/
│ ├── instrument.py
│ ├── series.py
│ ├── genre.py
│ └── url.py
│ ├── url.py
│ ├── annotation.py # Annotation search result model
│ ├── collection.py # Collection model
│ └── coverart.py # CoverArtImage, CoverArtImageList, Thumbnails
├── enums.py # EntityType, ReleaseStatus, ReleaseGroupType, etc.
├── auth.py # OAuth2 flow helpers
├── exceptions.py # MusicBrainzError, NotFoundError, RateLimitedError, etc.
├── annotation.py # Wiki markup → plain text / Markdown converter
├── _xml.py # XML body builders for submissions
├── _ratelimit.py # Async/sync rate limiter
├── _retry.py # Retry with exponential backoff for transient failures
└── py.typed # PEP 561 marker
tests/
├── conftest.py # respx fixtures, sample JSON responses
├── test_client.py # Lookup/browse/search integration tests
├── test_sync_client.py # Sync wrapper tests
├── test_models.py # Deserialization round-trip tests
├── test_coverart.py # Cover Art Archive client tests
├── test_retry.py # Retry logic tests
├── test_xml.py # XML body builder tests
├── test_oauth.py # OAuth2 flow tests
└── test_annotation.py # Annotation converter tests
Expand Down
50 changes: 45 additions & 5 deletions docs/migrating-from-ngs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This guide helps you move from [python-musicbrainzngs](https://github.com/alasta
| Architecture | Module-level global state | Instance-based clients |
| Async support | No | Yes (+ sync client) |
| Return types | Plain dicts | Pydantic models or dicts |
| Python | 2.7+ / 3.x | 3.12+ |
| Python | 2.7+ / 3.x | 3.10+ |
| HTTP library | urllib | httpx |
| Auth | Digest only | Digest + OAuth2 |

Expand Down Expand Up @@ -255,6 +255,43 @@ await client.collection_add("myapp-1.0", collection_mbid, "releases", [release_m
await client.collection_remove("myapp-1.0", collection_mbid, "releases", [release_mbid])
```

## Cover Art

musicbrainzpy provides a separate client for the Cover Art Archive, imported from `musicbrainzpy.coverart` (or the top-level package).

```python
# musicbrainzngs
images = musicbrainzngs.get_image_list("76df3287-6cda-33eb-8e9a-044b5e15ffdd")
front = musicbrainzngs.get_image_front("76df3287-6cda-33eb-8e9a-044b5e15ffdd", size="500")
back = musicbrainzngs.get_image_back("76df3287-6cda-33eb-8e9a-044b5e15ffdd")
img = musicbrainzngs.get_image("76df3287-6cda-33eb-8e9a-044b5e15ffdd", "829521842", size="250")
rg_images = musicbrainzngs.get_release_group_image_list("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932")
rg_front = musicbrainzngs.get_release_group_image_front("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932")

# musicbrainzpy (async)
from musicbrainzpy import CoverArtClient

async with CoverArtClient("myapp", "1.0", "me@example.com") as caa:
images = await caa.get_image_list("76df3287-6cda-33eb-8e9a-044b5e15ffdd")
front = await caa.get_front("76df3287-6cda-33eb-8e9a-044b5e15ffdd", size=500)
back = await caa.get_back("76df3287-6cda-33eb-8e9a-044b5e15ffdd")
img = await caa.get_image("76df3287-6cda-33eb-8e9a-044b5e15ffdd", "829521842", size=250)
rg_images = await caa.get_release_group_image_list("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932")
rg_front = await caa.get_release_group_front("c31a5e2b-0bf8-32e0-8aeb-ef4ba9973932")

# musicbrainzpy (sync)
from musicbrainzpy import SyncCoverArtClient

with SyncCoverArtClient("myapp", "1.0", "me@example.com") as caa:
images = caa.get_image_list("76df3287-6cda-33eb-8e9a-044b5e15ffdd")
front = caa.get_front("76df3287-6cda-33eb-8e9a-044b5e15ffdd", size=500)
```

Key differences:
- Separate client (`CoverArtClient` / `SyncCoverArtClient`) instead of module-level functions
- `size` is an int (250, 500, 1200) instead of a string
- Image listings return typed `CoverArtImageList` with `CoverArtImage` models instead of plain dicts

## Exceptions

| musicbrainzngs | musicbrainzpy |
Expand Down Expand Up @@ -284,10 +321,7 @@ except MusicBrainzError as e:
print(e)
```

## Features not yet in musicbrainzpy

- **Cover Art Archive** — `get_image`, `get_image_front`, `get_image_back`, `get_image_list`, `get_release_group_image_list`, `get_release_group_image_front`
- **Custom response parsers** — `set_parser()`, `set_format()`
> **Note:** musicbrainzngs `set_parser()` and `set_format()` have no equivalent — musicbrainzpy always uses the JSON API natively and returns either Pydantic models (typed methods) or plain dicts (raw methods), so custom XML parsing is unnecessary.

## Quick reference

Expand Down Expand Up @@ -339,3 +373,9 @@ except MusicBrainzError as e:
| `remove_releases_from_collection(coll, ids)` | `await client.collection_remove(client_id, coll, "releases", ids)` |
| `get_collections()` | `client.get_collections()` |
| `get_releases_in_collection(coll, limit)` | `client.browse_typed("release", linked_type="collection", linked_id=coll, limit=limit)` |
| `get_image_list(release_id)` | `caa.get_image_list(release_id)` |
| `get_release_group_image_list(rg_id)` | `caa.get_release_group_image_list(rg_id)` |
| `get_image(mbid, cover_id, size)` | `caa.get_image(mbid, cover_id, size=size)` |
| `get_image_front(release_id, size)` | `caa.get_front(release_id, size=size)` |
| `get_image_back(release_id, size)` | `caa.get_back(release_id, size=size)` |
| `get_release_group_image_front(rg_id, size)` | `caa.get_release_group_front(rg_id, size=size)` |
48 changes: 48 additions & 0 deletions examples/cover_art.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Retrieve cover art for a release (Sufjan Stevens — Carrie & Lowell, 2xCD)."""

from __future__ import annotations

import asyncio
import tempfile
from pathlib import Path

from musicbrainzpy import CoverArtClient, MusicBrainzClient

RELEASE_ID = "de8231d3-b745-42fa-9e0c-7ffb6d3d9608"


async def main() -> None:
async with MusicBrainzClient("musicbrainzpy-examples", "0.1.0", "you@example.com") as mb:
release = await mb.lookup_typed("release", RELEASE_ID, includes=["artist-credits"])
print(f"Release: {release.title} (id: {release.id})") # type: ignore[attr-defined]

async with CoverArtClient("musicbrainzpy-examples", "0.1.0", "you@example.com") as caa:
# Download the 250px front cover
front = await caa.get_front(RELEASE_ID, size=250)
out = Path(tempfile.gettempdir()) / "front-250.jpg"
out.write_bytes(front)
print(f"\nFront cover saved to {out.resolve()} ({len(front)} bytes)")

# List all available cover art images with full-size metadata
listing = await caa.get_image_list(RELEASE_ID)
print(f"\nAvailable cover art ({len(listing.images)} images):\n")
for img in listing.images:
info = await caa.image_info(RELEASE_ID, str(img.id))
content_type = info["content_type"] or "?"
size_bytes = info["content_length"]
size_str = f"{int(size_bytes) / 1024:.0f} KB" if size_bytes else "?"
thumbs = []
if img.thumbnails.t250:
thumbs.append("250px")
if img.thumbnails.t500:
thumbs.append("500px")
if img.thumbnails.t1200:
thumbs.append("1200px")
types = ", ".join(img.types) or "untyped"
comment = f' "{img.comment}"' if img.comment else ""
print(f" [{img.id}] {types}")
print(f" {content_type}, {size_str}, thumbnails: {', '.join(thumbs)}{comment}")


if __name__ == "__main__":
asyncio.run(main())
2 changes: 1 addition & 1 deletion examples/digest_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async def main() -> None:
if not result.items:
print(" (empty)")
for item in result.items:
label = getattr(item, "title", None) or getattr(item, "name", None) or item.id
label = getattr(item, "title", None) or getattr(item, "name", None) or str(item)
print(f" - {label}")
if result.count > 5:
print(f" ... and {result.count - 5} more")
Expand Down
3 changes: 3 additions & 0 deletions musicbrainzpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from musicbrainzpy.annotation import annotation_to_markdown, annotation_to_text
from musicbrainzpy.auth import OAuthHandler, OAuthToken, build_authorization_url, generate_pkce
from musicbrainzpy.client import BrowseResult, MusicBrainzClient, SearchResult
from musicbrainzpy.coverart import CoverArtClient, SyncCoverArtClient
from musicbrainzpy.enums import OAuthScope
from musicbrainzpy.exceptions import (
AuthenticationError,
Expand All @@ -18,6 +19,7 @@
__all__ = [
"AuthenticationError",
"BrowseResult",
"CoverArtClient",
"InvalidRequestError",
"MusicBrainzClient",
"MusicBrainzError",
Expand All @@ -27,6 +29,7 @@
"OAuthToken",
"RateLimitedError",
"SearchResult",
"SyncCoverArtClient",
"SyncMusicBrainzClient",
"annotation_to_markdown",
"annotation_to_text",
Expand Down
Loading
Loading