WaxBin is a CGO-free Go library and waxbin CLI that owns the catalog database
and is the single source of truth for an audio collection. It indexes, organizes,
searches, browses, and tracks per-user state across music, audiobooks, and
podcasts. It runs standalone, much like beets, and is a clean dependency for
WaxDeck.
- No CGO, no external binaries. Cataloging is pure Go for every format via
[WaxLabel], and so is the analysis pass (decode, loudness, fingerprint, and
waveforms) via [WaxFlow]. The two libraries cover the same eight containers, so
WaxBin can decode every format it can tag-read, on every host.
fpcalcis the sole remaining optional subprocess, used only for AcoustID lookups in enrichment. - Hard scan/analyze boundary. Scanning is I/O-bound and never decodes PCM; loudness, fingerprinting, and peaks live only in a resumable analyze pass.
- Source of truth. Consumers read the catalog through WaxBin's canonical read API instead of rebuilding their own view of the filesystem.
- Stable identities. Every surfaced entity has an opaque sortable public id (ULID) that survives re-tag, move, and re-encode. Field provenance and locks keep enrichment and organize from ever overwriting curated data.
- Library + CLI, no network daemon. Storage is SQLite (WAL) on a local
filesystem, with a flock-based single-writer ownership seam. The only server is an
optional local control socket (
waxbin serve), a unix socket that lets a second terminal mutate the catalog while one process holds the write lock. There is no HTTP or network listener.
go build ./cmd/waxbin
# create the catalog and register a managed library root
./waxbin init --db ./catalog.db --root /music:managed
# index, analyze (ReplayGain/fingerprint/peaks), and organize
./waxbin scan --db ./catalog.db
./waxbin analyze --db ./catalog.db
./waxbin organize --db ./catalog.db --apply
# read it back
./waxbin browse newest --db ./catalog.db
./waxbin search --db ./catalog.db "midnight"
./waxbin stats --db ./catalog.dbRoots are declared as path[:mode[:media[:profile]]], for example
/music:managed:music, /audiobooks:managed:audiobook, /rips:in-place.
Config resolves with flag > env (WAXBIN_*) > JSON > default precedence.
Every data command supports --json (with a schemaVersion) and returns stable
exit codes (waxbin exit-codes).
| Area | Commands |
|---|---|
| Lifecycle | init, library add/library list, scan, analyze, watch, serve, doctor, jobs, version, exit-codes |
| Read / browse | query/ls (incl. --library, --tag KEY=VALUE, --tag-contains, --tag-present/--tag-missing, --limit-mode/--seed), browse <list>, facet --group-by (incl. tag.<KEY>, library, podcast, creditArtist, playlist), search (incl. --max-candidates, --library, --state), show, art (incl. --role), art roles, lyrics, stats [--year N], provenance, lock/unlock, entity info/entity list |
| Curation & editing | edit (incl. --batch), entity, credit, tag/tag keys, lyrics set, chapters, art set (incl. --role), art lock/art unlock |
| Ingest / organize | inbox, import, organize, profiles |
| Deletion / repair | trash, rm [--permanent], mark-missing [--force], merge, audit, diagnostics, upgrade |
| Portability | backup, restore, export, manifest, rebuild |
| Playlists / podcasts | playlist, smartplaylist, podcast, opml |
| Enrichment | enrich (MusicBrainz + Cover Art Archive; optional AcoustID; incl. --item/--entity) |
| Maintenance | db verify [--fix], db vacuum [--integrity], db thumbs [--older-than/--max-bytes], db migrate, db reset --yes, user, state |
waxbin scanis incremental: an unchanged file (same size + mtime) is skipped without re-hashing or re-parsing, and a file deleted on disk is reconciled tomissing, behind a survival gate that refuses to act on a transiently unavailable root, so a momentary mount loss cannot mark a whole library missing.scan --force(alias--full) re-hashes and re-parses everything. A deliberate large deletion (more than half a library) is held back by the survival gate; runscan --reconcile-deletionsto reconcile it once you have confirmed the files are really gone.waxbin watchkeeps the catalog in sync on a schedule (and, with--live, on filesystem events). Scheduled rescans are the primary mechanism because filesystem events are unreliable on WSL2, NFS, SMB, and bind mounts; a periodic full-content rescan (--full-interval) catches same-size/mtime-preserved edits the fast-path misses.watchis a foreground mode. A read-write WaxBin holds an exclusive advisory lock on the catalog for its whole lifetime, so whilewatchruns, every other mutating command in another terminal (organize,analyze,enrich,import,scan --force) is refused with an ownership conflict (read-only queries always work). Stop the watcher (Ctrl-C) to do manual mutation, or runwaxbin serveinstead when a second terminal needs to mutate concurrently (see Serving). Idle lock release is deliberately post-1.0.
waxbin serve [--socket <path>] opens the catalog read-write (taking the write lock)
and listens on a local unix control socket (default <db>.waxsock, created owner-only
0600). While it runs, other waxbin commands against the same catalog no longer fail
with an ownership conflict. They auto-detect the running server (advertised in the
lockfile) and dispatch through it: fast mutations (edit, lock, play state,
ratings/stars, playlists, user, merge) are proxied to the server, and the
heavier mutating commands borrow the lock through a maintenance-mode hand-off. Read
commands always run directly. This is a local socket only, with no network or HTTP
listener. The server runs until interrupted (Ctrl-C / SIGTERM).
waxbin auditreports quality and integrity problems: duplicate/split entities, inconsistent metadata, missing art/ReplayGain, unportable filenames, orphaned sidecars, case-insensitive path conflicts, invalid feeds, and derived-data drift.--integrityadds an on-disk bitrot (content-hash) and corrupt-audio pass. It reports only; it never deletes.waxbin merge <type> <survivor-pid> <loser-pid>...collapses duplicate artists / release-groups / albums / genres onto one survivor, re-pointing children (so play state and provenance ride along) and recomputing rollups.waxbin upgradegroups alt encodings of the same recording (by fingerprint), ranks each group by quality, and marks the keeper.waxbin db verify --fixrepairs derived-data drift (FTS, rollups, sort keys) and reclaims orphaned art.waxbin db vacuumGCs and compacts the database.waxbin stats --year 2025prints a per-user listening year-in-review.
The catalog is authoritative, and a curation edit changes the catalog first: it
auto-locks the field so a later scan or enrichment pass never re-derives over your
change, and records provenance: a hand edit is yours, and a caller that supplies its
own source (the Go and socket APIs, art set --source, lyrics set --source) has
that recorded instead. --keep-lock changes a value without touching its lock.
waxbin edit <pid> --set field=valueedits scalar metadata such as title, artist, album, year, and track/disc numbers.waxbin entity <pid> ...edits a normalized entity (an artist or album name, or a sort name) and its identifiers (ISRC, barcode, MusicBrainz IDs).waxbin credit <pid> ...curates contributor roles such as composer and performer.waxbin tag <pid> --key KEY --value Vsets a custom tag: a non-standard frame a file carries that WaxBin's typed model does not map, or one you add yourself.waxbin tag <pid>lists an item's tags, andwaxbin tag keyslists every custom-tag key in the catalog with per-key item counts.waxbin lyrics set,waxbin chapters, andwaxbin art setcurate lyrics, book chapters, and cover art.
Each of these edits the catalog and offers opt-in --write-back to also mirror the
change into the backing file(s) (see below). Write-back is best-effort: a file that
cannot be written returns a typed error naming the files, while the catalog edit still
stands. A book re-anchors its identity on write-back so a later rescan resolves the same
item, and a multi-file book is written across every part.
The catalog is always authoritative; these opt-in features mirror an edit back into files for external players, always preserving audio essence (an essence-verified write never alters the audio):
- The curation edits above (
edit,entity,credit,tag,lyrics set,chapters,art set) take--write-backto embed the committed change into the item's file(s). waxbin analyze --write-replaygain(orwrite_replaygain_tagsin config) writes computed track and album ReplayGain into files after album aggregation (REPLAYGAIN_*, or OpusR128_*).waxbin enrich --write-tags(orwrite_enrichment_tagsin config) writes what the pass filled into files: a book'sASIN/ISBN/PUBLISHERand a track'sGENRE. Without it those values live only in the catalog, which a rescan rebuilds from the file's tags, so the next retag clears them.- An organize profile with
tag_writecorrectsalbumArtist(literalVarious Artistsfor compilations) and disc/track numbering on disk as it moves files, skipping locked fields and re-tagging before the move so a failure aborts cleanly. stamp_item_pidadditionally stamps aWAXBIN_ITEM_PIDtag during organize, sorebuildcan restore original item identities from tags (essence-first: adopted only when unambiguous, minted fresh on any conflict). A full DB backup remains the real disaster-recovery artifact.
Custom tags are filterable and facetable, so they can power browse dimensions and allow/deny rules.
- Query filters:
waxbin query --tag MOOD=happy(equality),--tag-contains MOOD=hap(substring),--tag-present MYKEYand--tag-missing MYKEY(presence). A value may itself contain=, since only the first=splits key from value (--tag DISCOGS_RELEASE=id=12345). The same fields are available in smart-playlist rules astag.<KEY>with theis,isNot,in,notIn,isPresent,isMissing,contains,startsWith, andendsWithoperators. Ordered operators are rejected, since tag values are unordered text. - Facets and discovery:
waxbin facet --group-by tag.MOODcounts items per distinct value, andwaxbin tag keyslists the keys that exist to facet on. isNotis deny-list semantics.tag.X isNot Vmeans the item does not carry value V for key X. It does not mean "carries some value other than V". So an item taggedMOOD=[happy, sad]is not matched bytag.MOOD isNot happy, and an item with no MOOD tag at all is matched. That is exactly "deny when the forbidden value is present".- Case sensitivity. Equality (
is,isNot) is exact-case (BINARY). Substring (contains,startsWith,endsWith) is ASCII-case-insensitive (SQLiteLIKE). Facet value buckets are case-sensitive too, sohappyandHappyare distinct buckets; only tag keys are canonicalized to uppercase, never values. Folding browse buckets is the consumer's job. A tag key WaxBin already owns through a scalar, credit, or identifier surface (such asTITLEorISRC) is reserved and rejected as a tag field.
lib, err := waxbin.Open(ctx, waxbin.Options{
DBPath: "catalog.db",
Roots: []config.Root{{Path: "/music", Mode: model.ModeManaged}},
})
defer lib.Close()
if _, err := lib.Scan(ctx, waxbin.ScanRequest{}); err != nil { /* ... */ }
page, err := lib.Browse(ctx, read.ListNewest, read.BrowseOptions{Limit: 50})The stable facade lives in the root package; implementation subsystems live under
model/, store/sqlite/, identity/, decode/, analyze/, loudness/, peaks/,
fingerprint/, envelope/, query/, read/, art/, meta/, scan/, organize/,
inbox/, trash/, playback/, playlist/, podcast/, source/, enrich/,
audit/, jobs/, watch/, proxy/, port/, config/, pidpath/, waxerr/, and
internal/.
MIT. See LICENSE.