Skip to main content

Music Library

@magistr/music-libraryv2026.09.01.1· 9d agoMODELSWORKFLOWSREPORTS
01README

Multidimensional music library catalog built WITHOUT touching the filesystem: the inventory and raw tags come from an existing gonic scan index (gonic.db, read over SSH with sqlite3 -json -readonly), so no array disk is ever woken by a directory traversal.

On top of the raw rows the scan method builds a star-schema cube:

  • facts — one album resource per album directory (album → disc → track nesting, disc subdirs like CD1/ collapsed into their parent album)
  • dimensions — one artist resource per artist (name variants, album refs, genre/format/year span) plus rollup dimension resources for genres, years/decades, formats, and quality buckets (lossless / lossy-high / lossy-mid / lossy-low)
  • cross-cuts — an issues resource (untagged tracks, dirname-only albums, encoding fixes with before/after, DOS-mangled 8.3 names) and a library summary with totals and source breakdown

Every tag string passes through encoding recovery: legacy single-byte tags decoded as latin1 (cp1251 / koi8-r / cp866 / windows-125x / CJK mojibake such as "Êëàóäèî Ìîíòåâåðäè") are detected with jschardet and re-decoded; double-encoded UTF-8 is unwrapped. Tracks with missing tags fall back to directory/filename naming patterns ("1983. Artist - Album", "Artist - Album (Year) [FLAC]", "NN - Title", disc subdirs, bracketed artists, catalog-number prefixes).

The probe method deep-probes one file with ffprobe inside a docker container that has the music share mounted (all formats ffmpeg knows: mp3, flac, opus, m4a, ogg, ape, wma, wav, …), merging format-level and stream-level tags and reporting the encoding-fix trace — for debugging individual files.

The bpm method analyzes tempo per track with essentia (RhythmExtractor2013 multifeature) in a throwaway container per worker, reporting bpm, beat-detection confidence, beat-interval spread, key/scale and danceability. The confidence matters more than the bpm: the beat tracker imposes an even grid even on rubato or ambient material, so a track can look metronomic while having no pulse you could follow. Results carry over between runs, so a whole-library pass can be resumed.

Workflows: ships @magistr/music-wanted-sequence, the gated resolve -> sync -> derive sequence behind the "wanted" derivation. Installing this extension does NOT make it runnable: swamp extension pull / extension source add do not register a workflow file, so nothing under this name exists until you create it yourself with swamp workflow create and paste the body in from extensions/workflows/music-wanted.yaml. See that file's own description for the nine gates, the required dry run, and the portable three-command form.

02Release Notes

2026.09.01.1

Re-release of 2026.08.19.2, whose publish never reached the registry — the push that carried it also had an unrelated test failure in another extension, which skips the whole extension-publish job, and that job only fires on a version bump. Content is unchanged from 2026.08.19.2; its notes are carried forward below so the registry keeps a full record.

Fixes music-wanted-headphones-instance-implicit. model.version/ manifest.yaml move 2026.08.19.1 -> 2026.08.19.2.

NOT BREAKING: headphonesInstance and musicbrainzInstance become workflow inputs defaulting to headphones and musicbrainz — the values they were hardcoded to — so a run that passes neither binds exactly what it bound before. An operator whose headphones instance is named something else now retargets it with swamp workflow run music-wanted --input headphonesInstance=<name>, with no file edit, instead of hitting a gate that tells them to hand-edit every literal in the file.

The music-wanted workflow's resolve step passed only refresh to resolve-artists, so its headphonesInstance/musicbrainzInstance arguments bound silently to their model-side defaults while two gate recovery messages told the operator to check "the headphones seed instance" — an instance the workflow never named, passed, or verified. The resolve step now passes headphonesInstance: ${{ inputs.headphonesInstance }} and musicbrainzInstance: ${{ inputs.musicbrainzInstance }} explicitly, the wanted step now passes musicbrainzInstance explicitly, and a new gate 9 (preflight-seed, high, allowFailure: false) asserts the headphones artists seed is present and non-empty before the sync runs. The two retired recovery messages now name the instance the workflow actually passes and verifies, and no longer point at an instance the workflow never mentions elsewhere.

Every site that names the headphones instance — the gate's expr, its own data.query measurements, both recovery messages, and the two step arguments — reads the one input, so a retarget moves them together. Sites inside an already ${{ }}-wrapped interpolation use CEL string concatenation ('modelName == "' + inputs.headphonesInstance + '"') because ${{ }} cannot nest; the structural test normalizes both forms to the input name they read, so a site retargeted to a different input, or hardcoded back to a bare literal, is still caught.

The three step modelIdOrName targets stay plain scalars: a dynamic step target does not remove swamp's step-input validation, it keeps the check and makes it report passed: true while verifying nothing. Renaming the music or musicbrainz instance is still a file edit, per the README's ordered procedure.

No schema or resource shape change. swamp workflow validate reports the same 22 check names as before this change, and the added workflow inputs cost zero validation checks (measured).

03Models1
@magistr/music-libraryv2026.09.01.1extensions/models/music_library.ts

Global Arguments

ArgumentTypeDescription
hoststringHost with the music share (unraid)
sshUserstringSSH user
fn scan()
Build the multidimensional catalog from the gonic index: album facts, artist/genre/year/format/quality dimensions, issue worklists
fn dupes()
Find duplicate albums (same artist+title in different dirs, with a keep-best hint and reclaimable bytes) and duplicate tracks (same artist+title, near-equal duration)
fn verify()
Check playback integrity by decoding files with ffmpeg inside the container: full decode, or quick tail decode (seeks near the end using the indexed duration) — reports unreadable, corrupt, and truncated files
fn bpm()
Analyze tempo per track (essentia): bpm, beat-detection confidence, key/scale and danceability — the confidence tells a real pulse from a grid imposed on rubato
fn running()
Build a tunable running playlist from an existing bpm analysis: matches each track's tempo to your cadence at 1x/2x/half, gated on beat confidence so rubato and ambient never sneak in. Reads stored data — no audio is touched, so it is instant and re-tunable
fn probe()
Deep-probe one file with ffprobe (inside the container): full tags in all encodings, codec, sample rate — for debugging tag/encoding issues
fn wanted()
Pure derivation of the want-set (no network): diffs the MusicBrainz browse cache against the owned library cube via deriveWanted, using the artistMap resolve-artists wrote to identify each artist

Resources

library(infinite)— Library summary with dimension cardinalities
album(infinite)— Album fact: one directory with its discs and tracks
artist(infinite)— Artist dimension: albums, genres, formats, year span
dimension(infinite)— Rollup dimension (genres / years / formats / quality)
issues(infinite)— Data-quality worklists: untagged, dirname-only, encoding fixes
dupes(infinite)— Duplicate album and track clusters with keep/reclaim hints
verify(infinite)— Playback-integrity report: decode results, corrupt/truncated files
bpm(infinite)— Tempo analysis: bpm, beat-detection confidence, key and danceability per track
playlist(infinite)— Cadence-matched running playlist derived from a bpm resource
probe(infinite)— Deep ffprobe result for a single file
artistMap(infinite)— Cached artist-name to MusicBrainz-ID map: seeded from headphones, backfilled by a token-set MusicBrainz search, ambiguous/unresolved artists parked for human review
wanted(infinite)— Want-set derived by diffing the cached MusicBrainz discography against the owned library cube, recomputed from scratch on every run
04Workflows1
@magistr/music-wanted-sequence

Not runnable on install: neither `swamp extension pull` nor `swamp extension source add` registers a workflow file, so nothing under this name exists until you create it yourself. Create it with `swamp workflow create`, paste this file's body in, and invoke it under whatever name you gave it. Wires the three-step wanted sequence (resolve-artists -> sync-artist- discographies -> wanted) into one gated run, deliberately with NO `--fail-on`. The default fails the run on ANY assert, including the tw

preflightRead state before acting: confirm the library cube is populated and the headphones seed resolve-artists reads from is present and usable before committing to the ~35-minute sync, then read the persisted discography sync cursor for visibility (informational only — does not gate).
1.assert-artist-dimension-present
2.assert-album-dimension-present
3.assert-headphones-seed
4.read-discography-sync-cursor
resolveRun resolve-artists (no guard — the sibling's own refresh/ttlMs make it idempotent at the model layer), then gate 2 (resolve-produced-something) and gate 3 (artist-map-floor).
1.resolve-artistsmusic.resolve-artists
2.assert-resolve-produced-something
3.assert-artist-map-floor
syncRun sync-artist-discographies over THIS run's resolved artist list, then gate 4 (sync-coverage), gate 5 (sync-handoff) and gate 6 (catalog-completeness) — each depending ONLY on the sync step, never on each other, so a failing gate can never skip an unproven one.
1.sync-artist-discographiesmusicbrainz.sync-artist-discographies
2.assert-sync-coverage
3.assert-sync-handoff
4.assert-catalog-completeness
deriveRun wanted (depends on the sync JOB succeeding, so a failed gate SKIPS this job entirely), then the existence gate and the want-total band.
1.wantedmusic.wanted
2.assert-derive-existence
3.assert-want-total-band
05Reports3
@magistr/music-bpm-runningmodel
extensions/reports/bpm_running.ts

Cadence-matched running playlist from the latest tempo analysis: tracks whose beat essentia actually locked onto, matched to a 150-190 spm cadence at 1×, 2× or ½×, bucketed by cadence and ranked by beat confidence, plus the most metronomic albums and what was excluded and why.

musicbpmtemporunningplaylist
@magistr/music-verify-triagemodel
extensions/reports/verify_triage.ts

Triage of the latest playback-verification run: unreadable files, truncation split by cause, systematically damaged albums (with healthy-duplicate hints from the dupes resource), lossless corruption, and isolated glitches.

musicverifyintegritytriage
@magistr/music-wantedmodel
extensions/reports/wanted.ts

Gap report from the latest want-set: missing releases grouped by

musicwantedgapmusicbrainzquality
06Previous Versions7
2026.08.19.1
2026.08.07.1

2026.08.07.1

Fixes music-wanted-workflow-packaging, then hardens it against four code-review findings. model.version/manifest.yaml move 2026.08.05.2 -> 2026.08.07.1.

Ship the music-wanted workflow body as part of this package

Adds extensions/workflows/music-wanted.yaml (@magistr/music-wanted-sequence) under a new manifest workflows: key, so a 464-line artefact that previously existed in one copy only — in a homelab-repo tree with neither .git nor .jj — gets version control and diffability. Installing this does NOT make the workflow runnable: neither swamp extension pull nor extension source add registers a workflow file, so nothing exists under this name until you create it with swamp workflow create and paste the body in — stated in the manifest's Workflows section, the file's own first paragraph, and this README.

The packaged copy also corrects claims the repo-local body carried, fixed in the homelab copy too (outside this package): gate 6 (catalog-completeness) falsely claimed "fails only on a cold catalog, passes once warm" — the only recorded dry run (dc413fca, 2026-08-06) failed gates 4 AND 6, since resolve has no dryRun guard and resolves for real while batchSize: 0 caches nothing; the stale ~775 / 775 of 2258 figures understated the last real run's cost (1165 resolved) by ~50%; and the unconditional ext-canary-nightly 02:30-03:15 rule is replaced by the mechanism it was a special case of — a cold sync holds the musicbrainz lock for the whole pass, and swamp model method run waits only DEFAULT_LOCK_TIMEOUT_MS (60s) — dropped WITH THIS NOTE: ext-canary-nightly hasn't run since 2026-07-27 (absent from five days of scheduler logs), so the window guards nothing today, though the lock-contention hazard remains real. See the musicbrainz package's own CHANGELOG for the dry-run control's original documentation; it's now identical on both copies, not "repo-local" only.

wanted's missing-browse-cache throw no longer says "Repo-local"

The throw's last line named a "private homelab repo" that no longer holds the only copy. It now names the shipped file and create-and-paste procedure, keeping the substring swamp workflow run music-wanted (so music_library_methods_test.ts:1982 keeps passing) plus a new !includes("Repo-local") negative.

README and structural test

README: the "lives in that private repo" claim is replaced with the shipped-but-not-runnable statement, the eight gates, the dry run's true expected outcome (an INVARIANT, not a fixed tally — depends on how warm the catalog already is), the two literal instance names and how to retarget them, and the two-copy drift comparison. Gains extensions/workflows/music_wanted_workflow_test.ts — the only automated coverage for the packaged file (swamp workflow validate can't target a workflow with no id:; extension push --dry-run validates no workflow at all). Asserts the name, absence of a top-level id, all ten assert-step names and gate-leading messages, allowFailure pinned across EVERY step not just asserts (twelve false, one true), the job dependency edges (derive<-sync<-resolve<-preflight, each {type: succeeded}), the three model_method steps' literal targets, the corrected gate-6 sentence, and the absence of 775 / 2026-08-06 / ext-canary-nightly.

Test-suite hardening (two mutation-testing rounds)

A stray trailing quote in the browse-throw regression needle never matched the defect it names — fixed, verified against the mutation it exists to catch. A code-review pass then found the structural test's allowFailure check filtered to asserts only and never read job dependsOn, so flipping sync-artist-discographies to allowFailure: true or severing derive's dependency on sync both passed 9/0 uncaught (and both pass swamp workflow validate, DAG-shape-only). Fixed per the assertions above; both mutations verified RED, then reverted.

Deferred

Two follow-ups, both filed: music-wanted-stale-shared-strings (the stale ~775 figure at its remaining SIX sites — music-library/README.md:131 (this package's own README), musicbrainz/README.md:134, musicbrainz.ts:1761/:1773/:1819, music_library.ts:3894 — plus musicbrainz/README.md:139's own stale "private homelab repo" pointer) and music-wanted-headphones-instance-implicit (resolve passes only refresh to resolve-artists, relying on its headphonesInstance default).

Recorded but NOT filed: musicbrainz/README.md:138-140 still says "do not start a cold pass between 02:30 and 03:15" — the ext-canary-nightly window this package's docs replaced with the lock-contention mechanism. A sibling package's README outside this diff; neither issue tracks it, noted here so it isn't lost.

Added 1 workflows

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.

wanted: the missing-browse-cache throw named a nonexistent method

wanted's missing-MusicBrainz-browse-cache error told the operator to run swamp model method run <mbInstance> browse — there is no browse method (browse-release-groups / browse-releases / browse-recordings exist; browse is a resource spec name, not a method), so an operator who skipped the discography sync got unknown_method instead of an actionable fix. The throw now names the real runnable command — swamp model method run <mbInstance> sync-artist-discographies --input 'artistMbids:json=[...]' — plus the swamp data query extraction command (with its envelope shape, {"results": [[...]], "total": 1}) to build that artist list from this instance's own artistMap, and the repo-local music-wanted workflow line. No schema or resource shape change.

2026.08.05.1

2026.08.05.1

Fixes musicbrainz-ratelimit-runmodel-fanout, measured live: swamp model method run <instance> resolve-artists completed in 581s having fired ~1483 MusicBrainz searches over a library of 2258 artists — ~2.5 req/sec against MusicBrainz's documented 1 req/sec limit. The root cause: mbFetch's rate limiter (in @magistr/musicbrainz) is correct WITHIN one method invocation, but resolve-artists reached MusicBrainz through ~1483 SEPARATE context.runModel invocations of search-artist, each starting with no rate-limit memory. model.version and manifest.yaml move 2026.08.04.1 -> 2026.08.05.1.

resolve-artists: at most ONE runModel call per run, persisted as a reusable cache

searchMusicBrainzArtists and its isLatest selector are DELETED outright — no dual path. resolve-artists now collects every DISTINCT name needing a MusicBrainz verdict (seed-unresolved, and either uncached or past its TTL), builds one Lucene query per name via the existing escapeLuceneQuery, and issues EXACTLY ONE call to @magistr/musicbrainz's new search-artists-batch method — deterministically selecting its own result row by a generated batchId correlation identity (never isLatest/array order, which nothing in this codebase ever set). Zero names needing a search means zero runModel calls. That call passes limit: 25 explicitly — the candidate window is the SAME 25 the deleted per-artist search-artist path implicitly got from MusicBrainz's own /ws/2 default, not search-artists-batch's own narrower default of 10 (a deliberate choice for that method's other callers, left unchanged). Without the explicit override, a duplicate MBID ranked 11-25 would fall outside the window and matchArtist would auto-pick a single resolved MBID instead of correctly parking the artist as ambiguous.

resolve-artists also now LOADS its own prior output (context.readResource, a NEW capability requirement for this method, optional-chained so an absent prior — including the very first post-merge run — degrades to empty rather than throwing) and treats it as a persistent, load-modify-write aggregate: a seed-unresolved artist whose last search verdict (checkedAt) is younger than ttlMs (new arg, default 30 days) is reused VERBATIM (mbid/status/source/ candidates/checkedAt) without a fresh search — except the LIBRARY's current name always wins, so a renamed artist is never stuck with a stale display name. A converged re-run therefore costs ZERO MusicBrainz requests. Two new method arguments force a re-check: refresh (everything) and refreshKeys (specific artists, ordered FIRST in the batch so an explicit re-check can never be silently discarded by the maxQueries ceiling). An artist present in the library with no prior entry is written; an entry in the prior map whose artist is no longer in the library is dropped — only artists actually in the library are ever written.

checkedAt means exactly "the timestamp of the last MusicBrainz SEARCH that produced a verdict" — it is NEVER set on a seed match (seed coverage is free and re-derived every run, not a search verdict) and NEVER set when a query's fetch errored, was deferred, or was never reached this run (the prior value, still stale, is preserved so the next run retries). A genuine no-match (MusicBrainz returned zero candidates, no error) still gets a fresh checkedAt — it got a real verdict this run, just a negative one.

Completeness: pendingSearch / truncated / stopReason

search-artists-batch bounds one run by maxQueries (default 400, passed through) and a derived maxDurationMs backstop; a run that hits either ceiling — or an aborted signal, or a Retry-After backoff — defers the remainder rather than searching it. Without visibility into that, unresolved: 1083 on a first post-merge run would be indistinguishable from "MusicBrainz doesn't know 1083 artists". Three new OPTIONAL top-level fields on the written artistMappendingSearch (distinct artists that needed a verdict this run and didn't get one), truncated, stopReason — make that distinction observable: re-run resolve-artists until pendingSearch is 0 to confirm convergence (about 4 runs for a cold ~1459-1483-artist library at the default maxQueries). All three are optional in the schema (the live 2258-entry map predates them) and always set on write.

Schema: five mirrors move together

ArtistMapEntrySchema gains optional checkedAt; ArtistMapSchema gains optional pendingSearch/truncated/stopReason; wanted.ts's report-side ArtistMapEntry/ArtistMapContent interfaces mirror both; and — the mirror missed once already in an earlier draft of this fix — the LOCAL TypeScript type ArtistMapEntry that resolve-artists actually constructs against also gains checkedAt, or the object literal it builds trips a TS2353

Modified 1 models. Added 1 reports

2026.08.02.1

2026.08.02.1

Real-fixes all 6 latent bugs characterized by the test-only backfill below (tracked locally as music-library-latent-bugs, never filed to the Lab). music_library.ts is no longer byte-frozen; the model version and manifest.yaml both bump to 2026.08.02.1, with an identity upgrades[] entry (upgradeAttributes: (old) => old — no stored resource is reshaped, since every change is either a defaulted global/method argument or an additive BpmSchema field).

  • LB1 (MEDIUM) fixedverify's remote ffmpeg decode loop now wraps every file in the shell timeout command (sized from the new defaulted ffmpegDecodeTimeoutSec global arg, default 600s; 0 = no timeout), detected once per worker via command -v timeout so it degrades gracefully if the container lacks it — mirrors bpm's ANALYZE_PY signal.alarm(timeout). sshRun also gained an optional client-side AbortController transport ceiling (timer always cleared in finally, never AbortSignal.timeout()) as a belt-and-suspenders guard sized generously per worker so it only fires if the remote timeout itself failed or ssh/network wedged.
  • LB2 (MEDIUM) fixedverify/bpm/probe's single-path argument now resolves through two new shared, exported helpers — normalizeSegments (split on //\, drop ./empty, pop on .., throw when .. would escape the root — ported in spirit from obsidian-vault's segment guard) and confineContainerPath — instead of the old replace(/^\/+/, "") that stripped only leading slashes. A ../ traversal now throws Error("Path escapes music root: …") instead of resolving outside containerMusicRoot; a clean relative/absolute path still resolves identically to before.
  • LB3 (LOW) fixedprobe now guards JSON.parse on the ffprobe output: empty stdout throws a clean Error naming the file instead of a raw JSON.parse("") SyntaxError, and unparseable non-empty stdout is caught and rethrown as a typed Error too (mirrors juick's JSON.parse → typed-error pattern).
  • LB4 (LOW) fixedverify's US(0x1f)/RS(0x1e) record framing is now RS-safe: before splitting on \x1f, any \x1e-delimited fragment whose leading field is not a KNOWN cpath is treated as the tail of the previous record's ffmpeg output (which happened to contain a stray RS byte) and re-folded back onto it, rather than being parsed as an unmatchable orphan record. A corrupt file whose ffmpeg output embeds an RS byte is now correctly classified as errors instead of silently passing as ok.
  • LB5 (LOW) fixedbpmMedian now uses a proper median (new exported median() helper): an even-length bpm array averages the two middle sorted values instead of returning the upper one (bpms[Math.floor(n / 2)]).
  • LB6 (LOW) fixedbpm gained a maxTracks method arg (default 50000, deliberately far higher than verify's 2000 — see below) plus tracksTruncated/failuresTruncated on BpmSchema. All stats (bpmMedian, confidenceBands, bpmHistogram) are computed over the FULL carried-over + newly-analyzed set BEFORE any truncation; only the STORED tracks/failures arrays are capped, mirroring verify's problems: problemsTruncated ? problems.slice(0, 2000) : problems. The default is high (not verify's 2000) because capping the stored array degrades bpm's resume carry-over (a library bigger than the cap would re-analyze the overflow every run) and the running method's input; pass maxTracks: 0 for full resume fidelity on a very large library.
  • Pin flips in music_library_adversarial_test.ts: all 6 pin: KNOWN BUG tests are now fixed (music-library-latent-bugs LBn) assertions of the corrected behavior (LB1 also gained a quick-mode variant, an AbortSignal-transport-ceiling check, and a ffmpegDecodeTimeoutSec=0 disable check; LB2 gained a clean-relative-path anti-over-rejection positive; LB6 gained a default-does-not-truncate companion test). The 2 regression-pinned positives (P1 static-SQL, P2 shQuote + control-byte filename filtering), the hostile host/sshUser safety test, and both fixtures-secret-scan tests are UNCHANGED (byte-identical). New direct unit tests for normalizeSegments/confineContainerPath/median were added to music_library_coverage_test.ts.
2026.07.17.1

Merge pull request #58 from umag/feat/music-library-bpm-running

feat(music-library): bpm analysis + cadence-matched running playlists (2026.07.17.1)

Modified 1 models. Added 1 reports

2026.07.07.1

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.

07Stats
A
100 / 100
Downloads
1
Archive size
307.9 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
08Platforms
09Labels