- Rust 91.6%
- Shell 3%
- Nix 2.9%
- Python 2.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| examples | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
fmdfs
A FUSE filesystem that transparently serves synthetic audio metadata. It mounts a read-only mirror of a backing directory; tag-bearing files (MP3/ID3, OGG/Vorbis, FLAC, M4A) appear with tag blocks generated from a per-mount SQLite database, while the audio payload is streamed byte-for-byte from the original file on the backing filesystem — with one exception, told honestly under Known gaps below.
$ fmdfs mount /srv/music ~/music-fake
$ ffprobe ~/music-fake/albums/nightdrive/01.flac # sees synthetic tags
$ cmp ~/music/albums/nightdrive/01.flac ~/music-fake/albums/nightdrive/01.flac
# audio payload identical; only the tag region differs
Design
- Per-mount SQLite DB at
$XDG_DATA_HOME/fmdfs/<blake3(backing|mountpoint)>.db(or--db PATH). - Overrides are keyed by whole-file BLAKE3 of the backing file. The
backing tree is treated as immutable, so a cached
(path, size, mtime)match short-circuits re-hashing. - Live edits while mounted. The DB runs in WAL mode; the mount checks
PRAGMA data_versionbefore consulting its tag cache and drops the cache when another process has committed. (Note:data_versiononly moves for other connections' commits — the mount process is read-only, which is exactly this contract.) - Read path:
open()builds aReadPlan:PassThrough— stream the backing file verbatim (no override, or non-audio files).PrefixShim { prefix, audio_offset, tail_len }— serve a synthesized tag region, then pread the original audio payload after its real tag region. The kernel-visible file size isprefix + tail_len.MidShim { lead_len, head, audio_start, tail_len }— for M4A, where the tag lives in a mid-filemoov: servemdatfirst, then a rebuiltmoov(DB tags merged over original artwork,stcochunk offsets delta-shifted), then the tail. Layouts withmoof/sidx/mfrafall back toPassThroughrather than guess.
- One bad file costs its own request, not the mount. The fuser session
serves every client from a single event-loop thread, so a panic escaping a
handler is not one failed
read(): the mountpoint goes toENOTCONNfor every process using it and nothing recovers until systemd'sRestart=alwaysfires. Everything that sizes or indexes itself by a number it did not choose — the shim arithmetic and the parsers behind it,win_read's readahead window, the tagger's captured bytes, the attribute answer, both directory listings, the symlink target, and the organized view's render of the human-writtenoverridestable — runs inside oneguarded()panic boundary insrc/fs.rs, and the caller sends the reply, exactly once. The same rule covers the locks:State,Dband the view cache are reached only throughlock_state/lock_db/lock_view_cacheand never by a rawMutex::lock().unwrap(), because a panic caught under a lock still leaves that lock poisoned, and the nextunwrap()on it — ineffective_mtime, on the event loop, for every client — is the fatal one.stats.handler_paniccounts these; it is meant to read zero, and the sweep intests/fuse.rsasserts it does.
Schema
CREATE TABLE files ( -- hash cache, maintained by fmdfs
path TEXT PRIMARY KEY, -- relative to backing root
hash TEXT NOT NULL, -- hex blake3/256 of whole file
size INTEGER NOT NULL,
mtime INTEGER NOT NULL
);
CREATE TABLE overrides ( -- edited live by you/other tools
hash TEXT PRIMARY KEY,
title TEXT, artist TEXT, album TEXT, album_artist TEXT,
genre TEXT, date TEXT, composer TEXT, comment TEXT,
track INTEGER, track_total INTEGER, disk INTEGER, disk_total INTEGER
);
CREATE TABLE filemeta ( -- header-parsed tags, seeded by `fmdscan --seed`
path TEXT PRIMARY KEY, mtime INTEGER NOT NULL, format TEXT NOT NULL,
title TEXT, artist TEXT, ..., disk_total INTEGER -- same 12 columns
);
CREATE TABLE view_config ( -- organized-view settings (dir, template)
key TEXT PRIMARY KEY, value TEXT NOT NULL
);
Organized shadow view (--organize)
fmdfs mount --organize adds a reserved top-level directory (default
Library/) whose entire tree is computed from the database — it exists
only while mounted and is rebuilt from scratch whenever the DB epoch moves:
- Each file the DB knows (
filemeta, joined withoverridesfor effective tags) is rendered through a path template — default{artist}/{year}/{album}/{track} - {title}.{ext}— with sanitization (/→∕, dotfiles stripped, empty levels collapsed, collisions get stable(2)suffixes, titleless files fall back to their filename stem). - Every leaf is a symlink (relative, so resolution stays inside the mount) to the mirrored file. Players and tools that follow symlinks read the fully-shimmed file through the ordinary mirror path.
- Reorganizing = retagging. Edit a tag through either view (or any
sqlite client) and the file re-renders under its new home on the next
listing. There is no move or rename of library structure anywhere: the
view is read-only (
EROFSfor create/rename/unlink inside it;rename(2)would replace rather than follow a symlink anyway), and the backing tree never changes. - Seeding:
fmdscan --seed <db> <root>parses headers only (no whole-file reads) and fillsfilemetafor the whole library. - Turning it off:
--no-organize.--organizepersists itself intoview_config('enabled'), so simply not passing it cannot undo a previous organize mount — the DB resurrects the view.--no-organizeoverrides the persisted flag and writes the "off", so declarative config systems can actually switch it back. Nothing else changes: mirrored paths still get synthetic tags and artwork (both come from the same DB).
$ ./target/release/fmdscan --seed ~/.local/share/fmdfs/my.db /srv/music
$ ./target/release/fmdfs mount /srv/music ~/music-view --db ~/.local/share/fmdfs/my.db --organize
$ ls ~/music-view/Library # Artist/2019/Album/01 - Title.flac -> ../../../old/path.flac
Per-format tag strategy (implemented as stubs in src/meta/mod.rs)
| format | plan |
|---|---|
| MP3 | skip original ID3v2, prepend synthesized ID3v2.4 |
| FLAC | regenerate the metadata-block chain after fLaC, keep real StreamInfo |
| OGG | rebuild identification+comment pages with fresh CRCs, re-frame lacing |
| M4A | synthesize ftyp+moov padded to the exact original header length so stco/co64 offsets into the streamed mdat stay valid |
Album artwork
fmdfs can supply album art the file itself does not carry — the audio
bytes are still never touched; the artwork rides in the synthesized tag
region (FLAC PICTURE, ID3 APIC, M4A covr).
- Embedded art is preserved through every rewrite, with two exceptions the container
dictates. An
ID3v2.2tag keeps nothing: 3-character frame ids cannot be re-emitted inside the v2.4 container we synthesize without converting the payload, so itsPICframes are dropped rather than carried out of context. And a v2.3 frame whose payload is compressed or encrypted is dropped rather than copied, because the v2.4 flags would then describe its bytes wrongly. Grouped v2.3 frames are ordinary payloads and do survive — seekept_framesinmeta/id3.rs. fmdscan --seedrecords a per-directory winner asfilemeta.art_sidecar(rankedfront|cover|folder|album|scan|artwork|thumb, largest as tiebreak; images < 16 MiB — FLAC block lengths are 24-bit). The mount embeds it whenever the file carries none of its own.- Explicit per-file control via
overrides.artwork(hash-keyed, live in sqlite like every override):NULL= use the sidecar,''= serve artless (embedded art stripped from the served region), any other value = force this image (path relative to the backing root or absolute), replacing whatever is embedded.
A file that had been read before seeding may serve stale bytes until
the kernel revalidates it; injecting artwork changes the file size,
which forces that revalidation on its own — but size alone is not what
consumers cache on. effective_mtime also folds in the supplied image's
own mtime (and its directory's, which is the only thing that moves when
art is copied in with its timestamps preserved by cp -p/rsync). That
is the whole ballgame for a scanner-side cache: Navidrome records its
art resolution in item_artwork keyed by ref_mtime and re-resolves
only when that moves, so an overrides.artwork row added after a
"no art" verdict was stored was served forever and believed forever —
208 albums here, through full scans and restarts. The value is the
image's clock and never now(), which would re-dirty the page cache,
and every cache keyed on it, on every getattr. Ogg ignores artwork policy
(its shim is unimplemented; 12 files in the reference library).
Where the search stops today — measured over the reference library
(1,536 album directories, 2,852 images) by scripts/art_inventory.py:
| gap | albums | what it looks like |
|---|---|---|
| art on disk, unreachable | 45 | Album/Scans/Front.jpg, Album/Artwork/, or Album/cover.jpg with audio in Album/Disc 1/ |
| better class out of reach | 8 | back.jpg served while a front.jpg sits one level up |
| higher-res out of reach | 26 | 900x900 folder.jpg served, 2341x2338 scan in Scans/ |
| only art is over 16 MiB | 1 | a 17.9 MB PNG — FLAC PICTURE length is 24-bit, so nothing is served |
| no artwork at all | 251 | nothing colocated; most carry embedded art already |
Widening the search has to keep two exclusions: hidden hash-keyed pools
(.albumart/, .covers/ — 594 images keyed by content id, shared across the
whole Jungle library) are never an album's art, and art resting at the library
root is never adopted. 1,240 of 1,536 albums get supplied art today.
Automatic metadata import (MusicBrainz)
Importing tags from MusicBrainz / Picard instead of editing files one at a
time is researched in docs/picard-metadata-import.md,
with the probes that decided it in scripts/probe-*.sh. Headline: the DB is
the right seam, but a tagger whose tag region must grow cannot save through
the mount today — mutagen's insert_bytes needs truncate to actually move
the size, and our truncate is a deliberate no-op.
The receiving half has landed: a hash-keyed mbmeta table sits beneath
overrides, and Db::effective_tags resolves precedence as
overrides (whole-row, so a tagger can still delete a field) → else mbmeta
merged field-by-field over filemeta → else the file's own bytes. An importer
can complete what a file lacks and can never blank what it has; a NULL there
means "unknown", not "clear". See scripts/probe-mbmeta.sh.
The importing half has landed too: fmdimport (plan:
docs/fmdimport-plan.md). AcoustID fingerprint →
MusicBrainz release → mbmeta, with the two rules that keep a bad day from
becoming a bad library — a match must clear 0.85 and the album must agree on
one release (a lone file may not decide its own album), and a 503 is a failed
attempt, never "no match". A file we asked about and did not tag gets a
score-only mbmeta row: NULL tags, so it is served exactly as it was and simply
appears in --review-queue.
Composer and comment stay NULL in v1 (composer needs work relations; both
are DECISION 2's "never guess" list), and the v2 follow-ups are the on-demand
trigger (fmdimport --only <rel> fired by the mount, which is why --only
exists) and whatever composer turns out to be worth.
fmdimport reference
| flag | meaning |
|---|---|
--db PATH |
metadata DB; default derived from --backing/--mountpoint as /var/lib/fmdfs/<name>.db |
--backing PATH (repeatable) |
real files. The importer reads the backing tree, not the mount |
--mountpoint PATH |
only used to derive the default --db |
--dry-run |
print the plan; sends nothing, writes nothing — and needs no key, no contact, no fpcalc |
--json |
machine-readable summary |
--force |
re-ask resolved hashes — the only way past a raised --min-score |
--only REL[,REL] |
the on-demand trigger: skips the queue, never the audio gate |
--limit N |
kill switch: at most N files, but cut between albums, never through one — a group too big for the budget waits whole (deferred in the summary) |
--min-score F |
default 0.85; below it → score-only review row |
--album-agree F |
default 0.6; the modal release's share of the group |
--allow-singles |
tag a lone file from its own recording (off by default) |
--jobs N |
parallel fpcalc decodes (default 4); requests stay serialized |
--review-queue |
print untagged rows with their reason, then exit |
--coverage |
print the ids-vs-tags funnel, then exit; no key, no network |
--contact mailto:… |
required; else $FMDIMPORT_CONTACT |
--acoustid-key K |
else $ACOUSTID_KEY, else $CREDENTIALS_DIRECTORY/acoustid-key |
--acoustid-url, --mb-url |
mirrors and test servers |
Exit codes: 0 done or nothing to do · 2 configuration (no contact, no
key, no fpcalc) · 3 some files errored · 4 every request failed, or the
DB was unreachable.
Rate limits are not options: MusicBrainz 1 req/s, AcoustID 3 req/s, per-host
token buckets, Retry-After honoured, otherwise 1s/2s/4s/8s plus jitter.
--jobs parallelises decoding only and cannot buy request throughput — an
option that looked like a throughput dial would be a way to get the application
blocked.
Recapturing API fixtures
tests/fixtures/api/ holds two kinds of file, and the difference matters the
first time a parser changes:
- captures —
mb_release_black_messiah.json,mb_recording_aint_that_easy.json,acoustid_two_recordings.json,acoustid_unmapped.json: verbatim service responses, saved as sent. - synthetic —
mb_release_okcomputer.json,acoustid_match.json, … : invented content whose shape was checked against a capture.
When you change what the importer sends, re-capture instead of hand-editing:
$ export FMDIMPORT_CONTACT=mailto:you@example.com ACOUSTID_KEY=…
$ scripts/capture_api_fixture.py --print-request release <mbid> # what it would send
$ scripts/capture_api_fixture.py --write release <mbid> --name mb_release_x.json
$ scripts/capture_api_fixture.py acoustid "/path/to/a real track.flac" # to stdout
Then turn the shape you just saw into a test. Five bugs lived inside 250 passing
tests because the fixtures and the parser were written from the same imagination
(docs/fmdimport-plan.md has the table); captures are what caught them, and a
fixture you typed from memory is a test of your memory.
Deploying as a NixOS service
The flake ships a nixosModules.default that turns each library into a
restart-managed systemd mount plus a seeding timer — the server-side
"agent work" setup:
# configuration.nix (flakes input: inputs.fmdfs.flake = true)
{ inputs, ... }:
{
imports = [ inputs.fmdfs.nixosModules.default ];
services.fmdfs = {
enable = true;
contact = "mailto:you@example.com"; # required for import; see below
mounts.music = {
backing = "/mnt/nvmes/Music"; # real files, never written
mountpoint = "/mnt/library"; # mirror + Library/ view
organize = true; # default; false passes --no-organize
# template = "{artist}/{year}/{album}/{track} - {title}.{ext}";
seed.enable = true; # hourly header-parse refresh
import = {
interval = "weekly"; # OnCalendar
maxPerRun = 2000; # --limit, the kill switch
jobs = 8; # fpcalc decodes; requests stay 1/s
keyFile = "/var/lib/fmdfs/credentials/music-acoustid-key";
# allowSingles = true; # second pass only — see below
# maxRuntime = "8h"; # TimeoutStartSec, for one big first pass
};
};
};
}
Per mount the module creates fmdfs-<name>.service (mounts in the
foreground under systemd, remounts on crash, fusermount3 -u on stop),
fmdfs-seed-<name>.service + .timer (fmdscan --seed, Nice=10,
Persistent=true), and fmdfs-import-<name>.service + .timer
(fmdimport, Nice=15, IOSchedulingClass=idle, TimeoutStartSec=6h,
chromaprint on its PATH).
The import unit is a network client that happens to read audio, so it is
sandboxed like one and never inside the mount unit: ProtectSystem=strict,
ProtectHome=read-only, writable only in /var/lib/fmdfs, and ordered after
the seeder without wanting it. It reads the backing tree — the mount does not
have to be up. The AcoustID key arrives as a file via LoadCredential, never
as an option string (an option would put the secret in the Nix store, and keys
expire by design, so rotating one should be a file write, not a deploy).
RandomizedDelaySec (default 3h) is not decoration: Persistent=true with a
sharp OnCalendar is how a fleet of boxes gets the application blocked. With
contact = null no import unit is installed and the module warns instead of
scheduling a run that exits 2 at 03:00 — MusicBrainz blocks anonymous agents.
Options
Everything the module accepts, with defaults. mounts.<name> is one entry per
library; the unit names derive from it (fmdfs-<name>,
fmdfs-seed-<name>, fmdfs-import-<name>).
| option | default | notes |
|---|---|---|
enable |
false |
|
package |
flake package | fmdfs, fmdscan, fmdaudit, fmdimport |
contact |
null |
User-Agent for the importer; no import units without it |
mounts.<n>.backing |
— | real files; read, never written |
mounts.<n>.mountpoint |
— | where the mirror lives |
mounts.<n>.db |
/var/lib/fmdfs/<n>.db |
StateDirectory |
mounts.<n>.organize |
true |
DB-derived symlink tree |
mounts.<n>.template |
null |
null = fmdfs default; also live-editable in view_config |
mounts.<n>.allowOther |
true |
adds user_allow_other to fuse.conf |
mounts.<n>.seed.enable |
true |
fmdscan --seed |
mounts.<n>.seed.interval |
"hourly" |
OnCalendar |
mounts.<n>.import.enable |
true |
fmdimport |
mounts.<n>.import.interval |
"weekly" |
OnCalendar, plus OnBootSec=10min, Persistent=true |
mounts.<n>.import.maxPerRun |
null |
--limit, the kill switch |
mounts.<n>.import.jobs |
4 |
fpcalc decodes; requests stay 1 req/s |
mounts.<n>.import.allowSingles |
false |
second-pass only — see DECISION 3 |
mounts.<n>.import.minScore |
null → 0.85 |
|
mounts.<n>.import.albumAgree |
null → 0.6 |
|
mounts.<n>.import.maxRuntime |
"6h" |
TimeoutStartSec — systemd ignores RuntimeMaxSec for Type=oneshot, so this is what stops a run running forever |
mounts.<n>.import.keyFile |
/var/lib/fmdfs/credentials/<n>-acoustid-key |
point at config.sops.secrets.<k>.path if you use sops-nix |
mounts.<n>.import.contact |
services.fmdfs.contact |
per-mount override |
mounts.<n>.import.randomizedDelay |
"3h" |
RandomizedDelaySec |
The SQLite DB lives at /var/lib/fmdfs/<name>.db
(StateDirectory), overridable per mount. allowOther (default on)
adds user_allow_other to fuse.conf so players on other accounts can
see the mount. Tags are edited through the mount by any tagger, or
directly in the DB — the backing tree and the DB outlive every restart.
organize = false hides the Library/ tree (see --no-organize above) and
is the right setting when the only consumer is a scanner that keeps its own
tag database — Navidrome, Jellyfin, beets — since a path that moves on every
retag costs it play history and annotations. Leave seed.enable on either
way: artwork injection on the mirrored paths reads the same filemeta rows.
First run on a server
The key is the one thing the module cannot declare — get one from
https://acoustid.org/application. On a sops-nix host it can be declared
after all, and should be:
sops.secrets.fmdfs-acoustid-key = {
# sopsFile = ../secrets/secrets.yaml; # only if not your default secrets file
};
services.fmdfs.mounts.music.import.keyFile = config.sops.secrets.fmdfs-acoustid-key.path;
sops-nix decrypts it to /run/secrets/fmdfs-acoustid-key as root:root 0400
at activation, which is sufficient: the import unit runs as root and
LoadCredential reads the file before the sandbox is applied. The secret stays
out of the Nix store (rotation is sops edit + rebuild, not a chmod dance),
and the module warns if a keyFile ever points into /nix/store — a secret in
the store is world-readable and survives in the GC long after the config changes.
If the secrets are not in place yet, the unit skips with AssertPathExists rather
than failing every night.
Without sops-nix, make the file yourself:
$ sudo install -d -m 700 /var/lib/fmdfs/credentials
$ sudo sh -c 'umask 077; printf %s YOUR_KEY > /var/lib/fmdfs/credentials/music-acoustid-key'
Until that file exists the import unit skips rather than fails
(AssertPathExists), so systemctl status fmdfs-import-music points at the
missing file instead of exiting 2 every night. Then run the first pass by hand —
a timer is for maintenance, not for a 20,000-file backlog:
$ sudo nixos-rebuild switch
$ sudo systemctl start fmdfs-import-music.service # or run the binary directly
$ sudo fmdimport --db /var/lib/fmdfs/music.db --coverage # ids vs tags, no network
$ sudo fmdimport --db /var/lib/fmdfs/music.db --review-queue # what needs a human, and why
Budget ~3–4 h for a first pass over ~21,500 files: fpcalc decoding (divided by
import.jobs) plus 21,500 AcoustID POSTs at 3 req/s and one MusicBrainz
release GET per album at 1 req/s. It is resumable — Ctrl-C, the TimeoutStartSec
kill, or a reboot costs nothing, because import_state remembers what was asked
and fingerprints are recorded before any request is made.
Undoing a whole import is two statements, and nothing else:
$ sudo sqlite3 /var/lib/fmdfs/music.db 'delete from mbmeta; delete from import_state;'
overrides — what you typed by hand and the artwork you chose — is not a table
the importer can reach, which is asserted by tests rather than by care.
Development
Nix dev shell (pinned nixpkgs: rustc, cargo, clang, pkg-config, fuse3, cargo-nextest, sqlite):
$ scripts/check.sh # the gate: build, tests, fmt, scoped clippy, nix eval
$ nix develop
$ cargo build
$ cargo nextest run
$ cargo clippy --all-targets # pedantic lints enabled via [lints.clippy]
scripts/check.sh is the one command to run before committing (and the thing
to point an agent at): it re-enters the dev shell itself when cargo is not on
PATH, runs the suite offline, checks formatting across the tree, holds the
importer-owned files to zero clippy warnings (44 pedantic warnings predate the
importer elsewhere — widen that scope as they shrink, and do not make it
repo-wide until they are gone, because a gate that fails on day one gets
ignored), and evaluates the NixOS module, which is the only test a systemd unit
gets. It ends by printing what it deliberately cannot check offline: the live API
test, the artwork goldens, and fixture re-capture.
Where to change a thing, and what stops you getting it wrong
The guard column is the point: each invariant below is enforced by a named test, so a change that breaks one fails something specific, not something vague three layers away.
| to change | file | guarded by |
|---|---|---|
| what a panic is allowed to cost | src/fs.rs (guarded, lock_state/lock_db/lock_view_cache) |
a_contained_panic_under_the_state_lock_leaves_the_mount_serving (a caught panic must not poison a lock the event loop unwraps) + a_library_of_bad_shim_files_costs_their_own_reads_and_nothing_else (real fixtures x prefixes x lied length fields, all with overrides rows, mount liveness and handler_panic == 0 after every file) |
| what counts as audio (the junk gate) | src/meta/mod.rs AUDIO_FORMATS |
src/db.rs tests; one list for seeder, pending query and --coverage |
| what is pending, retry caps, skip rules | src/db.rs pending_imports / ImportOutcome |
src/db.rs tests; the SQL skip list is derived from is_resolved() so the two cannot drift |
| score & album-agreement thresholds | src/import/policy.rs Policy |
policy unit tests |
| album vote, stray/lone-file logic | policy::album_verdict, decide |
policy tests + tests/import_run.rs (real multi-file albums only — a one-file group cannot take the album path) |
| why a file was not tagged | policy::ReviewReason |
every_review_reason_says_something_different + the_reason_a_file_was_not_tagged_survives_into_the_review_queue |
503/retry policy, rate limits, backoff |
src/import/http.rs (classify, RateLimiter, backoff) |
tests/rate_limit.rs (fake clock: real sleeps would make the suite a lottery) + a_busy_server_is_an_attempt_not_a_no_match |
AcoustID request/parsing, duration tie-break |
src/import/acoustid.rs |
unit tests + acoustid_two_recordings.json (a capture) |
MusicBrainz → 12 mbmeta columns |
src/import/musicbrainz.rs parse_release/parse_recording |
unit tests, including the captured bodies — the synthetic ones passed while the live importer tagged nothing |
| CLI flags, env fallbacks, exit codes | src/bin/import.rs |
tests/import_run.rs; tests/no_overrides.rs also forbids SQL in the binary |
| what the importer may write | src/import/mod.rs (only set_mbmeta + record_import_attempt) |
tests/no_overrides.rs: seeded human layer compared byte-for-byte, plus a structural scan of the sources |
| systemd units, sandbox, timers, key handling | flake.nix fmdfsModule |
nixosConfigurations.fmdfs-test + the eval variants in the commit messages (nix eval .#nixosConfigurations.fmdfs-test.config.system.build.toplevel.drvPath) |
| API fixtures | scripts/capture_api_fixture.py |
tests/live_api.rs (--ignored; needs FMD_LIVE_AUDIO) |
Two rules the tests encode, because both were learned expensively: fixtures are
captured, not written (see the table at the top of
docs/fmdimport-plan.md), and the importer never
reaches overrides — the human layer wins whole-row, so an imported row can
complete a file but never blank what a person typed.
Manual mount smoke test:
$ mkdir -p /tmp/fmdfs-b /tmp/fmdfs-m
$ cp some.mp3 /tmp/fmdfs-b/
$ ./target/debug/fmdfs mount /tmp/fmdfs-b /tmp/fmdfs-m &
$ ls /tmp/fmdfs-m
$ ./target/debug/fmdfs inspect /tmp/fmdfs-b /tmp/fmdfs-m
$ fusermount3 -u /tmp/fmdfs-m
Library composition audit — classifies every file by magic bytes (not
extension), counts tag presence, cover art, ID3v1 tails, and structural
validity through the mount's own parsers; stdout is JSONL (notable files +
one summary line), stderr the human table ending in a PRIORITY line that
maps the counts onto the open feature gaps:
$ cargo build --release --bin fmdaudit
$ FMD_THREADS=16 ./target/release/fmdaudit /path/to/Music
Metadata import — fingerprints the backing files with fpcalc, asks AcoustID
what they are, fetches the answer from MusicBrainz, and writes it to mbmeta:
$ cargo build --release --bin fmdimport
$ ./target/release/fmdimport --db ~/.local/share/fmdfs/my.db \
--backing /srv/music --contact mailto:you@example.com --dry-run --json
$ ./target/release/fmdimport --db ~/.local/share/fmdfs/my.db \
--backing /srv/music --contact mailto:you@example.com --limit 50
$ ./target/release/fmdimport --db … --backing … --contact … --review-queue
--dry-run writes nothing and sends no request; --limit is the kill switch — it stops at album
boundaries, because the album vote needs every file in the album and a truncated batch does
not have them (a run that deferred files says so, and those files are untouched);
--only <rel> re-asks one file (rescue, and the future on-demand trigger);
--force re-asks hashes we already settled. Exit codes: 0 done, 2 nothing
is configured (no contact, no key, no fpcalc, dead key), 3 some files
errored, 4 nothing succeeded — the reason per file is in
import_state.last_error.
The offline suite cannot tell you whether the API is what you think it is —
only a live run can, and one earned its keep (see
docs/fmdimport-plan.md: five bugs, all of them
agreed to by every fixture). Point it at real audio — a file Picard users have
tagged, since this repo's own fixtures return zero results:
$ ACOUSTID_KEY=… FMDIMPORT_CONTACT=mailto:you@example.com \
FMD_LIVE_AUDIO=/mnt/library/D'Angelo/Black\ Messiah/01\ -\ Ain't\ That\ Easy.flac \
cargo test -- --ignored --nocapture live
It asserts invariants rather than a match rate (a flaky live assertion gets
deleted within a week and then the real behaviour is untested again): the request
count for one file, that a settled hash asks nothing on the second run, and that
an AcoustID hit with no MusicBrainz link — score ≈1.0, no tags, which is
what most web-sourced audio looks like — costs exactly one request.
To see where the library actually stands — how many files have a MusicBrainz
recording id, and how many have tags — use --coverage. It reads the DB only, so
it works before you have a key, and the two numbers are kept apart on purpose:
an id cannot be recovered later without asking again, tags can arrive next week.
$ fmdimport --db /var/lib/fmdfs/music.db --coverage
13 audio files the seeder has seen
asked 13 100.0% never asked 0 ← the next run's work
got an id 13 100.0% release id 12
tags written 12 92.3% id but no tags 1 ← --review-queue says why
outcomes matched 12 · no_match 1 · too_short 0 · error 0
2 × http 503
errors are attempts, not verdicts: those hashes stay pending until they pass or reach 3
The bottom of that funnel is the part that stops being hypothetical on a real library, because "we found nothing" has two causes with opposite remedies:
$ fmdimport --db /var/lib/fmdfs/music.db --coverage # a 22,573-file jungle library
44 AcoustID knows the fingerprint, nobody linked MusicBrainz: link the submission, or accept NULL tags
50 never fingerprinted at AcoustID: submitting the album once clears every row; no threshold changes this
Both land in no_match with NULL tags and look identical in mbmeta. One is
upstream cataloguing work; the other is a submission somebody can go and make —
a 2021 Elementz Of Noize album is in MusicBrainz complete with all 11
recordings, and AcoustID has never seen a single byte of it (lookup answers
{"status":"ok","results":[]}), so the correct outcome is "untagged, and no
threshold changes that". --review-queue prints the same two sentences per file
and leaves the score blank for the second, because there was nothing to score.
It writes mbmeta and import_state and nothing else. overrides — the
hand-edited layer — is checked byte-for-byte by tests/no_overrides.rs, which
fails if the importer's sources ever name the override write
path — so the guardrail is checked at review time, not at 03:00. The AcoustID key comes from --acoustid-key, $ACOUSTID_KEY, or
$CREDENTIALS_DIRECTORY/acoustid-key; requests are serialized at 1/s
(MusicBrainz) and 3/s (AcoustID) whatever --jobs says, because --jobs
parallelises decoding, which is local and free.
Artwork regression suite — sweeps every image under a library and freezes the
result as tests/fixtures/artwork.tsv, which tests/artwork_regression.rs
replays against src/art.rs (see the module docs there for the row kinds):
$ scripts/art_inventory.py /mnt/mission-nvmes/Music -o tests/fixtures/artwork.tsv
$ cargo test --test artwork_regression # offline, synthetic replay
$ cargo test --test artwork_regression -- --ignored # against the live mount
$ FMD_ART_LIBRARY=/path/to/Music cargo test --test artwork_regression -- --ignored
The fixture is a golden file. golden_winners_replay pins which sidecar every
album directory gets; the gap_ledger/resolution_ledger tests pin the
backlog counts, so widening discovery is a reviewed re-blessing and not silent
drift. Re-run the sweep after touching artwork policy and read the diff before
committing.
Status / known gaps
- Mount runs in the foreground only (no daemonization yet).
- Tag write-back: tagger writes are captured and turned into SQLite
overrides — the backing tree is never modified. Two envelopes are
accepted, because real taggers differ in how they save:
- In-place (
mutagenhead rewrite): a file opened for writing buffers its head window; on close, a leadingID3v2,fLaC+comment, orftyp…ilstblock is parsed and stored under the file's content hash, and later reads serve the new synthesized prefix. - Temp + rename (
Kid3/AtomicParsley/Mutagen, the common case):create(name.tmp)opens a RAM-only synthetic file,writefills it,rename(name.tmp, name)parses its captured head and upserts the DB, then discards the bytes. The destination inode is re-pointed at the real backing file so the kernel's post-rename view stays live. Audio-region bytes are always acknowledged and discarded, so whole-file-rewriting taggers also succeed — up toALLOC_CAP, the 64 MiB ceiling on one captured synthetic region (fs.rs). A write whose end lands past it answersEFBIGrather than letting a single tagger make the daemon allocate a whole album's worth of RAM. So a full-file save of an ordinary track succeeds and one past the ceiling says so, rather than trading the mount's memory for the tagger's convenience. Unlink of a temp aborts cleanly; nothing is ever written to the backing tree. Truncate on a real file is an acknowledged no-op (in-place taggersftruncateto the new size after rewriting the head; the backing file is never resized).mkdir/rmdirand renaming a real file returnEROFS. Not supported: tools that save viammapwriteback (AtomicParsley's--overWritemaps the file and mutates pages, which a non-writeback_ cacheFUSE mount cannot observe) — its default temp-file mode, and the plain temp+rename used by Kid3/Mutagen, both work.
- In-place (
- Known gap — a backing read failure is served as zeros.
win_read(fs.rs) fills its readahead window in a loop whose error arm isErr(_) => break, and the result is then zero-padded exactly as a read past EOF is. So anEIOfrom the backing device -- a failing disk, anEUCLEANfrom a corrupted mount underneath -- is handed to the player as silent digital silence with a successful status, and no log line says so. Reads past EOF are zeros by FUSE convention; an I/O error is not an EOF, and nothing above it can tell the two apart. Fixing it means threading a result out ofwin_readthrough its four call sites in the read path, which is a change to what the mount serves rather than to what this file says, so it is left here as a named gap rather than quietly reconciled. - Inodes are refcounted against kernel
nlookup:LOOKUP,"."/".."andcreatehand the kernel a reference,forgetreleases it, and zero evicts. Entries thatreaddirmerely named take none — replies here are plainREADDIR, notREADDIRPLUS, so the kernel is given nothing it could ever return — and those stay cached until the session'sdestroypurges the table. So the table tracks what the mount has listed since it was mounted, bounded for a library mount by the library, and drains what the kernel has released as it goes. It does not track only the live working set, which an earlier version of this note claimed. - M4A rewriting is structurally complete: DB tags become a rebuilt
moov(served mid-file viaReadPlan::MidShim), non-managedilstitems — artwork included — are merged forward, andstco/co64chunk offsets are shifted by the moov size delta for chunks that live past it. Validated againstffprobeon ffmpeg-produced files. Write-back recognizes a writtenftyp/ID3+ftyphead with anilst. Swept across the whole real library: 2005/2005 eligible M4As plan + reparse clean (re-run withfmdaudit --shimcheck). Fragmented (moof/sidx) or window-exceedingmoovlayouts fall back to passthrough by design. - Ogg read plans are
PassThroughfor now (12 files in the audited library — deliberately deprioritized): the shim needs page re-lacing with fresh CRCs and renumbered sequence numbers for the leading packets. - Tested against real files:
tests/fixtures/real/holds audio copied from the live library (FLAC, ID3 MP3, and a Serato-----trailing M4A) plus ffmpeg/lavf-produced Vorbis/Opus. The DB,plan_read, mount-serve, and Phase-B rename paths all run over those bytes (tests/db_real.rs,tests/phaseb.rs) — not just synthetic fixtures. - Import is album-conservative by design, and v1 stops short of two fields.
A file must clear
0.85and its album must agree on one release for tags to be written, so a lone file (fmdimport --only one.flac) lands in the review queue unless--allow-singlesis passed — that is DECISION 3 working, not a bug to file.composerandcommentare always NULL in v1 (composer needsworkrelations; guessing either is worse than NULL, because a wrong tag outranks the file's own forever). The on-demand trigger — the mount asking for a file it just served with nombmetarow — is the v2 follow-up, and--onlyis the seam it will hang off. Seedocs/fmdimport-plan.md.