mbforge is a Go CLI that downloads the latest MusicBrainz JSON dump and builds a flattened libSQL metadata database for Oxygen/Chromakopia. mbforge sync keeps that database current by applying the hourly incremental JSON dumps from the MusicBrainz Live Data Feed, and mbforge prune applies the deletions and merges those dumps cannot convey.
It is intentionally narrow:
- Full builds plus hourly incremental sync and prune
- No raw JSON blobs
- No annotation import
mbforge build
-
Resolves the latest dump directory from
LATEST -
Downloads the selected
*.tar.xzarchives into--dump-dir -
Streams
xz -> tar -> JSONLwithout extracting the full dumps to disk -
--mirroraccepts any base URL serving the upstream path layout (LATEST+<dir>/<entity>.tar.xz), including an Azure blob container URL carrying a SAS query string, which is appended to every request -
If the mirror also serves
<dir>/<entity>.chunks.json, the entity is downloaded as pre-split zstd chunks (<dir>/<entity>.chunks/<name>.jsonl.zst) and decompressed on all cores instead of one serial xz stream; pass--no-chunksto force the original archives. The manifest format is:{ "format": "mbforge-chunks/1", "entity": "release", "metadata": { "json_dumps_schema_number": "...", // JSON_DUMPS_SCHEMA_NUMBER "replication_sequence": "...", // REPLICATION_SEQUENCE "schema_sequence": "...", // SCHEMA_SEQUENCE "timestamp": "..." // TIMESTAMP }, "chunks": [{"name": "000000.jsonl.zst", "size": 123}] }Each chunk is a standalone zstd stream of complete JSONL lines from the tar's
mbdump/<entity>member (lines never span chunks; ordering across chunks is irrelevant since import is unordered).metadatacarries the four dump metadata files from the tar root, so the chunked path never touches the tar. -
Imports artists (incl. artist–artist relationships), labels, works (incl. recording→work links), release groups, releases, embedded recordings/tracks, and standalone recordings
-
Defers secondary index creation until after the bulk load
-
Optionally builds a full-text search index with
--search-index; track rows are excluded by default (seembforge search-indexbelow), pass--search-index-tracksto include them -
Writes
_metawith dump and replication metadata -
Runs
PRAGMA optimizeandVACUUM
mbforge sync
- Applies MusicBrainz Live Data Feed incremental JSON dumps to a database built by
mbforge build - Requires a MetaBrainz Live Data Feed access token (
--tokenor$MBFORGE_LDF_TOKEN) - Resumes from
_meta.replication_sequenceand fetches every packet up to the current one reported by thereplication-infoendpoint - Applies one packet per transaction and advances
_meta.replication_sequencein the same transaction, so an interrupted sync can simply be rerun - A 404 for an entity archive means the packet has no changes for that entity and is skipped
- Runs in WAL mode with
synchronous=NORMAL, so the database stays safe for concurrent readers (the aggressive build-time pragmas are never used); note the database file remains in WAL mode afterwards - Updates the full-text search index rows for changed entities when the search index exists; track rows are only refreshed when
_meta.search_index_tracksis not"false", so an index built without tracks stays that way - Creates any tables added by newer mbforge versions (labels, works, relationship link tables) on databases built before them, so older databases keep syncing; their historical label/work rows still require a full rebuild to backfill
- Stops with an error instructing a full rebuild if a packet's
SCHEMA_SEQUENCEdiffers from_meta.schema_sequence - Pass
--with-pruneto runmbforge pruneafterwards in the same invocation
The incremental JSON dumps do not carry deletions or merges; mbforge prune covers those. With sync and prune both running hourly, full rebuilds are only needed when the MusicBrainz schema changes (a SCHEMA_SEQUENCE bump, roughly twice a year) or for disaster recovery.
mbforge prune
- Applies entity deletions and merges from the hourly RAW replication packets (
replication-<SEQ>-v2.tar.bz2, dbmirror2 format) of the MusicBrainz Live Data Feed, using the same access token assync - Deletes removed artists, labels, works, release groups, releases, recordings, and tracks, including their child rows and search-index rows
- Records merges in a
gid_redirectstable (entity_type,old_mbid,new_mbid) so chromakopia's catalog can forward lookups of merged MBIDs, and deletes the merged-away entity's rows - Resumes from
_meta.prune_replication_sequence, seeded from_meta.replication_sequenceon the first run; applies one packet per transaction, so an interrupted prune can simply be rerun - Packets reference merge targets by MusicBrainz row id, which the mbforge schema does not store. Most targets resolve from row data in the same packet; the rest are kept in
gid_redirects_pending, retried against ids harvested from later packets (mb_row_ids), and finally resolved via the MusicBrainz web service at one request per second (--resolve-remote=falseto disable) - Must not run concurrently with
syncagainst the same database: both are single-writer jobs. Run them back to back from one scheduler slot, or simply usembforge sync --with-prune
mbforge info
- Prints database path, size, core row counts, and
_metavalues
mbforge search
- Searches artists, labels, works, release groups, releases, recordings, and tracks from a single free-form query
- Accepts artist names, track titles, album titles, MBIDs, ISRCs, ISWCs, and release barcodes
- Labels and works are only covered by the fast indexed path, not the slow SQL fallback
- Uses the full-text search index when present
- Falls back to the older slower SQL path when the search index is absent
- With an index built without track rows (the default), the fast path returns no track results; recording rows cover title search, and the slow SQL fallback still searches tracks when no index exists
mbforge search-index
- Builds or rebuilds the full-text search index on an existing database
- Useful when you already finished a long
buildrun without--search-index - Excludes track rows by default: they are near-duplicates of recording titles and roughly 57M of the ~91M index rows, so leaving them out keeps the index small enough for a small node's page cache; pass
--search-index-tracksto include them - Records the choice in
_meta.search_index_trackssombforge syncdoes not reintroduce track rows on an index built without them
mbforge version
- Prints build-time version metadata
- Go 1.24+
- CGO-enabled build environment
- Network access to the MusicBrainz dump mirror
go build ./cmd/mbforgeExample:
mbforge build \
--output /mnt/nvme/metadata.db \
--dump-dir /mnt/nvme/mbdump \
--workers 16 \
--batch-size 5000 \
--search-index \
--verboseImport a subset of entities:
mbforge build \
--output ./musicbrainz.db \
--entities artist,release-groupApply the latest Live Data Feed packets (including deletions and merges) to an existing database:
export MBFORGE_LDF_TOKEN=your-metabrainz-token
mbforge sync \
--db /mnt/nvme/metadata.db \
--dump-dir /mnt/nvme/mbdump \
--with-pruneInspect a finished database:
mbforge info --db ./musicbrainz.dbSearch across the main entities:
mbforge search --db ./musicbrainz.db "nirvana"Build the fast search index on an existing database:
mbforge search-index --db ./musicbrainz.dbImport order:
artistlabelworkrelease-groupreleaserecording
Important detail: release.tar.xz contains the overwhelming majority of recordings at media[].tracks[].recording. recording.tar.xz only covers the standalone subset. mbforge imports both, and uses INSERT OR IGNORE for recordings so the two sources can coexist safely.
The same pattern applies to works: work.tar.xz is imported first so its full rows win, and works embedded in recording work-relations (in both recording.tar.xz and release.tar.xz) only fill gaps via INSERT OR IGNORE.
The importer uses multiple JSON parse workers and a single batched SQLite writer to avoid write contention.
- Areas are flattened inline instead of being normalized into separate tables.
- Labels are first-class rows in
labels(withlabel_aliases,label_tags,label_genres);release_labels.label_mbidjoins releases to them while keeping the flattenedlabel_name/catalog_numbercolumns. - Works live in
works(withwork_aliases,work_iswcs,work_tags);recording_workslinks recordings to works from theperformancerelationship, so covers/live versions of the same song connect through the shared work.attributesholds a sorted JSON array like["cover","live"](empty string when the relation has no attributes). recording_worksrows are extracted from both sides of the relationship (recording lines and work lines) and deduplicate viaINSERT OR IGNORE; rows from a work line may reference recordings imported later in the pipeline, which is whyrecording_works.recording_mbidcarries no foreign-key clause.artist_relationshipsstores artist–artist relationships (band membership, collaborations, teachers, …) with one row per direction as rendered in each artist's dump line: query it byartist_mbidto get everything that artist participates in.begin_date/end_date/attributesare part of the primary key because the same pair can hold the same relationship type over several stints (empty strings stand in for NULLs there, as inrelease_labels).- Secondary indexes are created after bulk import for speed.
- SQLite does not allow expressions inside a table primary key, so nullable key parts from the draft schema are normalized to empty strings for
artist_aliases.localeandrelease_labelskey columns.
deploy/cloud-init.yaml is a starting point for the one-shot build VM path:
- Install Go
- Clone the repo
- Build
mbforge - Build
metadata.dbon local NVMe - Copy the finished database to an attached disk
- Stop the VM
You still need to customize the final handoff step to your long-running sqld VM.