Musicbrainz
MusicBrainz metadata — search, look up, and browse artists, release groups, releases, recordings, and labels via the MusicBrainz Web Service v2.
Model:
@magistr/musicbrainz— typed search/lookup/browse over the MusicBrainz JSON API (built-in 1 req/sec rate limiting, enforced WITHIN one method invocation, with a single 503/Retry-After retry — callers that would otherwise fan out across invocations should use search-artists-batch, which loops MANY queries inside one invocation instead), a generic entity search, a cursored and resumable sync-artist-discographies fan-out that caches each artist's full release-group discography, plus Bandcamp-to-MusicBrainz helpers that scrape a Bandcamp discography and generate release-editor seed URLs for releases missing from MusicBrainz.
2026.08.07.1
Fixes musicbrainz-search-resource-collision. model.version and
manifest.yaml move 2026.08.05.2 -> 2026.08.07.1.
Breaking change: each typed search method now writes its own resource instance
Five sibling methods (search-artist, search-release-group, search-release,
search-recording, search-label) all wrote the shared instance search under
five different specs, so whichever ran last silently won. Each now writes its
own instance via a new SEARCH_INSTANCE_NAMES registry:
# before
data.latest("musicbrainz", "search").attributes.artists
# after
data.latest("musicbrainz", "search-artist").attributes.artists(same for the other four). The deprecation window below covers search-artist
ONLY — the other four move immediately, so read them at their new instance
names from this version on. No spec was reshaped; only the instance argument was
wrong. The artists spec does gain two optional marker fields, described next.
Deprecated: instance search
search-artist also writes the historical search instance (spec artists,
unchanged shape) as a time-bounded alias, marked deprecated: true /
supersededBy: "search-artist" — present ONLY on the search row, never on
search-artist's own. Removed no earlier than 2026-09-07 (tracked as
musicbrainz-search-alias-removal), never "this version only" — five versions
shipped in the eight days before this one. Detect:
swamp data query 'modelName == "musicbrainz" && name == "search" && isLatest' --select 'attributes.timestamp' --json.
A second, unrelated collision (find-missing's missingReleases /
seed-all-missing's seedUrls, both keyed on artistMbid) is OUT OF SCOPE and
tracked as musicbrainz-missing-seed-instance-collision; pre-existing search
rows are left in place.
Folded below: this pass's model.version bump and schema/instance-name changes
belong to the fix above, not to the review-fix content that follows — that
covers only report wording and test-suite hardening.
@magistr/musicbrainz-discography-sync report: coverage wording is now conditional on which run the state belongs to
renderCoverage hard-coded "this run" in its full-coverage line and its resume
clause. Render case (c) (this execution wrote no sync state, so the report falls
back to the PREVIOUS run's state) fed it that previous run's numbers regardless,
so a failed run with a full-coverage previous state printed "Full coverage —
every requested artist was visited this run." — contradicting the report's own
"does NOT describe this one" lead banner in the same document. renderCoverage
now takes an optional describesThisRun (default true); case (c) passes
false and both lines are reworded to never claim anything about "this run"
when they are not describing it.
Test-suite hardening (mutation-testing findings)
discographySyncState's written payload is now also validated against the resource's own DECLARED schema (model.resources.discographySyncState.schema), not just the test stub's captured call arguments — closes a blind spot where deletinguncoveredCountfrom the schema left the suite green.- The report's duplicate-MBID-count test now asserts the exact rendered phrase
(
"8 duplicate MBID(s)"), not a bareincludes("8"), which was separately satisfied by the unrelated2026-08-04timestamp in the same markdown. - The "must not still suggest search-artist as the fix" and "must not use
--query" negative assertions in the missing-artistMbidstest are broadened: the search-artist check is no longer qualified to one hardcoded instance name (a suggestion against a different instance name used to slip through), and the--querycheck no longer requires a trailing space (so--query=can no longer slip through either). - Added a case-(c) test and prefix assertion pinning that a failed execution which wrote no state still carries the "This run FAILED. " prefix in the report's lead banner.
All four hardened assertions were verified by re-introducing the exact mutant they target and confirming the suite fails, then reverting.
Operating-procedure documentation for music-wanted (main-repo workflow)
Unrelated to this package's own code: re-run
swamp workflow run music-wanted --input dryRun=true after any edit to that
workflow — the only thing that exercises its CEL gate semantics. Lesson: this
CEL engine (@marcbachmann/cel-js) rejects has() when its receiver is a
function-call result; rewrite has(data.latest(...).attributes.cursor) as
data.latest(...) == null || data.latest(...).attributes.cursor != null.
| Argument | Type | Description |
|---|---|---|
| query | string | Lucene search query |
| limit? | number | |
| offset? | number |
Resources
Coverage surface over sync-artist-discographies' discographySyncState: this run's numbers when this execution wrote the state (with a loud partial-pass banner on a failed run), the previous run's numbers with an explicit 'does NOT describe this one' disclaimer when this execution wrote no state, a failure-led banner (never the 'run the method' instruction) when a fresh instance's first sync run FAILED before writing anything, one line naming just the timestamp for any other method, and nothing a
2026.08.05.2
Fixes music-wanted-sequence-not-wired. model.version and manifest.yaml
move 2026.08.05.1 -> 2026.08.05.2.
Breaking change: sync-artist-discographies no longer falls back to a cached search result
Before this change, running sync-artist-discographies with no artistMbids
argument silently fell back to the artists cached by this instance's last
search-artist run — an implicit dependency on the search resource, whose
writer is five different methods with incompatible shapes. Live, that produced a
one-artist "successful" sync with no error at all. The method now throws
immediately when artistMbids is absent or empty, naming the runnable command:
swamp model method run <name> sync-artist-discographies --input 'artistMbids:json=["<mbid>","<mbid>"]',
plus the swamp data query extraction command (and its envelope shape —
{"results": [[...]], "total": 1}, not a bare array) to build that list from a
@magistr/music-library instance's artist-map.
batchSize now defaults to the whole deduped list, not 10
artistMbids is deduped at list-resolution time — requested is the DISTINCT
count, requestedRaw the raw input length — and batchSize defaults to
requested rather than 10. One run is now a single complete pass by default
(~35 minutes / ~775 requests for a cold ~775-artist list at 1 req/sec) instead
of a 78-batch cold pass at the old default. The cursor still exists for two
cases: a deliberate partial via an explicit smaller batchSize, and an
interrupted pass.
The cursor is now keyed to the list it indexes
discographySyncState gains a listFingerprint (length-prefixed FNV-1a over
the deduped list) and persists the distinct requested count. A persisted
cursor is only resumed from when BOTH match this run's list; otherwise
(including every state written before this change, none of which carry a
fingerprint) the run restarts at offset 0. This closes the defect this issue was
filed about: a cursor left at offset 1 by a one-artist list was silently applied
to the real 775-artist list and skipped index 0 forever.
One definition of run coverage: covered and remaining
Eight new optional discographySyncState fields — requested, requestedRaw,
listFingerprint, startOffset, covered, remaining, uncovered,
uncoveredCount — record one run's coverage against its requested list.
covered = processedCount + skippedCount; remaining = requested - covered.
remaining is deliberately NOT requested - cursor.offset, which is
algebraically identical to a formula that always reports 0 for a run that never
reached its full list. uncovered / uncoveredCount record which requested
MBIDs have no cached discography at all, computed from stored data rather than
any counter.
A crash now leaves a record
The state write moves into a finally covering the whole post-loop accounting
block, and execute's return moves out of the try — so a throw partway
through a batch still persists an accurate cursor, coverage and uncovered set,
and a successful run's returned dataHandles always names the handle it wrote
rather than risking {dataHandles: [undefined]}.
New report: @magistr/musicbrainz-discography-sync
A new default model-scope report rendering discographySyncState's coverage,
bound to the execution that produced it (never claims a run's numbers unless
that run's own write is present in context.dataHandles), with an independent
cross-check against the actual cached rg-by-artist-* rows.
Added 1 reports
2026.08.05.1
Fixes musicbrainz-ratelimit-runmodel-fanout, measured live:
swamp model method run music resolve-artists completed in 581s having fired
~1483 MusicBrainz searches — ~2.5 req/sec against the documented 1 req/sec
limit. mbFetch's rate limiter is correct WITHIN one method invocation (each
await mbFetch(...) call shares module-level spacing state with every other
call in the SAME invocation) but has no memory ACROSS separate
context.runModel invocations — each one starts with lastRequest = null, so a
caller fanning out one runModel call per item loses spacing entirely, even
though the limiter itself was never broken. model.version and manifest.yaml
move 2026.08.04.1 -> 2026.08.05.1.
New method: search-artists-batch
Collapses the fan-out instead of persisting the limiter: takes MANY Lucene
artist queries and loops internally over the existing mbFetch, so the
already-correct in-process limiter becomes correct for the WHOLE workload by
construction — no persisted cursor, no read/write per request, no change to how
spacing is computed. Bounded by maxQueries (default 400 — the designed stop)
and a maxDurationMs slow-upstream backstop DERIVED from it (a new pure
exported deriveMaxDurationMs(maxQueries, minIntervalMs, explicit?):
explicit ?? Math.ceil(maxQueries * minIntervalMs * 1.5) + 30_000, so raising
maxQueries also raises the backstop, while an explicit value is honoured
verbatim with no floor), an already-aborted context.signal, and a new
MusicBrainzBackoffError (see below) — each stop recorded in a stopReason
(complete / max-queries / max-duration / aborted / backoff) with the
untried remainder pushed to a new deferred[] array. A per-query fetch failure
is isolated (recorded with an error, batch continues); a
MusicBrainzBackoffError stops the WHOLE batch instead, because that query
never got a verdict. The written artistSearchBatch resource (new,
garbageCollection: 3 — deliberately below every other spec's 10, since it is
one potentially-large document per run) is ALWAYS produced, including on every
early stop, so completed work survives. Two new pure helpers back the planning:
dedupeQueries (order-preserving, idempotent) and
planSearchBatch(queries, maxQueries) -> {batch, deferred, truncated} (first
maxQueries entries in input order — this is what makes an explicitly requested
re-check placed first in the caller's query list immune to being silently
dropped by the ceiling).
Payload budget: a new projection, not the full artist document
search-artist and search-artists-batch now share one extracted body,
searchArtistsOnce (module-private), so the artist-search HTTP contract —
including offset, still forwarded end-to-end — lives in exactly one place.
search-artist is UNCHANGED behaviourally: same arguments, same full
MusicBrainz artist objects written to the same artists/search resource.
search-artists-batch instead writes a new exported
projectArtistCandidates(artists) projection — {id, name, sort-name} per hit,
dropping area/begin-area/life-span/aliases/tags/etc — because it
writes ONE document per run and the full shape risked the datastore's 16MB
per-document limit on a large batch (measured: one unprojected search-artist
row at the default limit is ~15.6KB; projected, a hit is ~110-130 bytes).
Breaking change: retryAfterBackoffMs returns a discriminated result
retryAfterBackoffMs(retryAfterHeader, minIntervalMs, maxBackoffMs = 60_000)
now returns {kind: "sleep", ms} or {kind: "stop", retryAfterMs} instead of a
bare number of milliseconds — the whole Retry-After contract lives in this one
pure classifier, and mbFetch only acts on the classification, never re-parsing
the header itself. mbFetch behaviour changes for EVERY existing method, not
just the new one: a Retry-After within maxBackoffMs still sleeps and retries
exactly once, as before; a Retry-After EXCEEDING maxBackoffMs now throws a
new exported MusicBrainzBackoffError (carrying retryAfterMs) immediately,
without sleeping and without retrying — under the fan-out collapse, a single
hostile or misconfigured multi-hour Retry-After would otherwise stall a whole
batch invocation for that long while holding both the musicbrainz model lock
and the caller's. mbFetch also gains an optional 5th signal?: AbortSignal
parameter: checked before the rate-limit reservation, composed into the per-call
AbortController via AbortSignal.any (never replacing the existing 30s
client-side timeout), and raced against the Retry-After backoff sleep (no
longer an uninterruptible setTimeout) — this is the only way a long batch's
--timeout can be enforced INSIDE an in-flight request or backoff sleep, not
just between queries. All four-argument callers (every existing method) are
Modified 1 models
2026.07.31.1
Everything below was accumulated as Unreleased until now. This release bundles
two pieces of work: the wave-2b test backfill to the STANDARD.md five-suite
quality bar (ext-quality-test-backfill), and the
musicbrainz-ssrf-and-latent-bugs security fix for the SSRF in the Bandcamp
scraper's fetchPage(). model.version and manifest.yaml move 2026.07.16.2
-> 2026.07.31.1 to reflect the actual source change (the test backfill alone
was behavior-preserving and had not needed a bump).
Security fix: SSRF via bandcampUrl (HIGH)
Closes the HIGH-severity SSRF in fetchPage(), which previously fetched any
caller-supplied or scraped URL with no scheme or host allowlist, allowing an
internal/loopback/metadata-service target passed as bandcampUrl (or a
second-order albumUrl scraped from a discography page) to be fetched verbatim
and its parsed content returned to the caller.
- Added a module-private
assertBandcampUrl(raw)guard: requiresprotocol === "https:"andhostname === "bandcamp.com"orhostname.endsWith(".bandcamp.com")(dot-anchored, soevil.bandcamp.com.attacker.comandnotbandcamp.comare both rejected); throws a clearInvalid Bandcamp URL/Refusing to fetch non-Bandcamp hosterror otherwise, never a raw parser exception. fetchPage()now calls the guard before the initial request AND fetches withredirect: "manual", re-validating everyLocationheader against the same guard before following it (bounded to 5 hops) — this closes the redirect-based bypass where a legitimate*.bandcamp.comURL 302s to an internal address, which a default auto-followingfetch()would otherwise chase transparently.- The guard is centralized in
fetchPage, so all five existing call sites (seed-from-bandcamp'sbandcampUrl;find-missingandseed-all-missing'sbcUrland second-orderalbumUrl) inherit the fix with no call-site changes. A second-orderalbumUrlthat fails the guard still falls into the existingcatchand gets embedded as an inert seed-URL string parameter — no fetch occurs there, so it is not an SSRF vector. - Behavior tradeoff: custom-domain (CNAME-to-Bandcamp) sites are now
rejected by the host allowlist, since only
bandcamp.comand*.bandcamp.comare recognized. Accepted given the security priority and the overwhelmingly common*.bandcamp.comcase. - No
AbortSignaltimeout was added (tracked separately as a deferred MED undermusicbrainz-ssrf-and-latent-bugs, along with the other latent bugs noted below) — this change is scoped to the SSRF fix only. musicbrainz_adversarial_test.ts: flipped the two SSRF characterization pins from asserting the vulnerable behavior to asserting the fix (assertRejects+ zero fetch calls), and added positive coverage: a legit*.bandcamp.comfetch is unaffected; a bandcamp-to-bandcamp redirect is followed; a bandcamp-to-internal-address redirect is rejected on the redirect hop;file://andhttp://(non-https) inputs are rejected on scheme; and the dot-anchored hostname allowlist rejects spoofed hosts while still allowing barebandcamp.com. All other tests are unchanged — 119 tests total, all green.
Test backfill (wave-2b, ext-quality-test-backfill)
- Added
extensions/models/musicbrainz_test.ts(contract-fixture),musicbrainz_methods_test.ts(methods),musicbrainz_adversarial_test.ts(adversarial),musicbrainz_coverage_test.ts(coverage),musicbrainz_property_test.ts(property-invariant-flow) — 0 tests before this backfill, 114 after (119 after the SSRF fix above). - Added
fixtures/— pure doc-derived, synthetic MusicBrainz WS v2 JSON wire fixtures (artist/release-group/release/recording/label search, artist and release-group lookup, browse-release-groups, browse-releases, generic search, a 404 and a 503 error body) plus five synthetic Bandcamp HTML fixtures underfixtures/bandcamp/(.tsstring-export modules, not.html, to keep the default test task free of--allow-read), plusPROVENANCE.md. No live call was made against any MusicBrainz instance or real Bandcamp page while authoring these fixtures; every value is synthetic (fake MBIDs00000000-...-00000000000N, invented "Fixture"-prefixed names, synthetic Bandcamp subdomains). - The extension's built-in 1 req/sec MusicBrainz rate limiter is both
neutralized AND explicitly characterized under
@std/testingFakeTime (first call incurs no wait; a second call soon after schedules a wait of at least 1100ms; spacing collapses toward zero as elapsed time grows) — see the three dedicatedRATE LIMITER:tests inmusicbrainz_methods_test.ts. - Several already-shipped gaps were PINNED by the backfill (characterized as current behavior, not fixed) and tracked separately for follow-up under `music
Release 2026.07.16.2 — align model versions with manifests
Maintenance release across the @magistr extensions. For most packages this
carries no functional change: the only edit is the model's version: field,
brought back in line with its manifest version so the published model type
version and the package version no longer drift.
Functional changes in this release are limited to:
anime-cron: normalizeTitle now strips a ": subtitle" suffix and a trailing parenthesized year before comparison, fixing dedup false-misses where the torrent title carries a subtitle or year that the AniList romaji does not.
arckit: first publish. Standalone ArcKit port — a 12-phase architecture governance state machine with 65 bundled templates, driven by a bundled skill.
Also tracks three extensions (kaiten, observability-agent, music-library) that previously existed only as untracked working-tree directories, recovered from stashes.
Added 1, removed 1 models
Merge pull request #5 from umag/extensions/jscad-stl-pair
extensions: add 15 more @magistr extensions + auto-discover CI
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License declared1/1earned
- Verified public repository2/2earned