Skip to main content

Music Library

@magistr/music-libraryv2026.08.07.1· 4d 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 eight gates, the required dry run, and the portable three-command form.

02Release Notes

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.

03Models1
@magistr/music-libraryv2026.08.07.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 before committing to the ~35-minute sync, and read the persisted discography sync cursor for visibility (informational only — does not gate).
1.assert-artist-dimension-present
2.assert-album-dimension-present
3.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 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.

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
302.5 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