Skip to main content

Musicbrainz

@magistr/musicbrainzv2026.08.07.1· 4d agoMODELSREPORTS
01README

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.
02Release Notes

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.

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 deleting uncoveredCount from 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 bare includes("8"), which was separately satisfied by the unrelated 2026-08-04 timestamp in the same markdown.
  • The "must not still suggest search-artist as the fix" and "must not use --query" negative assertions in the missing-artistMbids test 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 --query check 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.

03Models1
@magistr/musicbrainzv2026.08.07.1extensions/models/musicbrainz.ts
fn search(query: string, limit?: number, offset?: number)
Search any entity type (area, event, instrument, place, series, work, etc.)
ArgumentTypeDescription
querystringLucene search query
limit?number
offset?number

Resources

search(infinite)— Search results
entity(infinite)— Entity lookup detail
browse(infinite)— Browse results for linked entities
discographySyncState(infinite)— Resumable cursor state for sync-artist-discographies
artists(infinite)— Artist results
releaseGroups(infinite)— Release group results
releases(infinite)— Release results
recordings(infinite)— Recording results
labels(infinite)— Label results
artistSearchBatch(infinite)— One search-artists-batch run: per-query PROJECTED candidate hits (id/name/sort-name only, never the full MusicBrainz artist document — this resource is one document per run, and the full shape would risk MongoDB's 16MB document limit on a large batch), the deferred remainder past maxQueries, and a stopReason distinguishing a fully-served batch from one cut short by max-queries/max-duration/an aborted signal/a Retry-After backoff
seedUrls(infinite)— MusicBrainz release editor seed URLs generated from Bandcamp
missingReleases(infinite)— Releases found on Bandcamp but missing from MusicBrainz
04Reports1
@magistr/musicbrainz-discography-syncmodel
extensions/reports/discography_sync.ts

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

musicbrainzdiscographysynccoverage
05Previous Versions5
2026.08.05.2

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

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

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: requires protocol === "https:" and hostname === "bandcamp.com" or hostname.endsWith(".bandcamp.com") (dot-anchored, so evil.bandcamp.com.attacker.com and notbandcamp.com are both rejected); throws a clear Invalid Bandcamp URL / Refusing to fetch non-Bandcamp host error otherwise, never a raw parser exception.
  • fetchPage() now calls the guard before the initial request AND fetches with redirect: "manual", re-validating every Location header against the same guard before following it (bounded to 5 hops) — this closes the redirect-based bypass where a legitimate *.bandcamp.com URL 302s to an internal address, which a default auto-following fetch() would otherwise chase transparently.
  • The guard is centralized in fetchPage, so all five existing call sites (seed-from-bandcamp's bandcampUrl; find-missing and seed-all-missing's bcUrl and second-order albumUrl) inherit the fix with no call-site changes. A second-order albumUrl that fails the guard still falls into the existing catch and 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.com and *.bandcamp.com are recognized. Accepted given the security priority and the overwhelmingly common *.bandcamp.com case.
  • No AbortSignal timeout was added (tracked separately as a deferred MED under musicbrainz-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.com fetch is unaffected; a bandcamp-to-bandcamp redirect is followed; a bandcamp-to-internal-address redirect is rejected on the redirect hop; file:// and http:// (non-https) inputs are rejected on scheme; and the dot-anchored hostname allowlist rejects spoofed hosts while still allowing bare bandcamp.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 under fixtures/bandcamp/ (.ts string-export modules, not .html, to keep the default test task free of --allow-read), plus PROVENANCE.md. No live call was made against any MusicBrainz instance or real Bandcamp page while authoring these fixtures; every value is synthetic (fake MBIDs 00000000-...-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/testing FakeTime (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 dedicated RATE LIMITER: tests in musicbrainz_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
2026.07.16.2

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

2026.05.25.1

Merge pull request #5 from umag/extensions/jscad-stl-pair

extensions: add 15 more @magistr extensions + auto-discover CI

06Stats
A
100 / 100
Downloads
1
Archive size
170.3 KB
  • 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
07Platforms
08Labels