A FUSE filesystem that transparently serves synthetic audio metadata
  • Rust 91.6%
  • Shell 3%
  • Nix 2.9%
  • Python 2.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-30 15:12:56 -04:00
docs import: the album key was artist, and the library is mostly compilations 2026-10-24 20:32:11 -04:00
examples nixos: the AcoustID key belongs in sops-nix, and the module says so 2026-10-24 18:31:48 -04:00
scripts nixos: the import unit could not start, and four checks now make sure of it 2026-10-24 18:49:32 -04:00
src fs: injected artwork never moved the served mtime, so consumers kept their no-art verdict 2026-10-30 15:12:56 -04:00
tests fs: injected artwork never moved the served mtime, so consumers kept their no-art verdict 2026-10-30 15:12:56 -04:00
.gitignore Scaffold fmdfs: FUSE read-only mirror with SQLite-backed synthetic tags 2026-09-13 14:46:17 -04:00
Cargo.lock import: the AcoustID/MusicBrainz client, with the network behind a trait 2026-10-24 18:31:48 -04:00
Cargo.toml fmdimport: the binary, and 30 integration tests that never touch a socket 2026-10-24 18:31:48 -04:00
flake.lock Scaffold fmdfs: FUSE read-only mirror with SQLite-backed synthetic tags 2026-09-13 14:46:17 -04:00
flake.nix nixos: the import unit could not start, and four checks now make sure of it 2026-10-24 18:49:32 -04:00
README.md fs: injected artwork never moved the served mtime, so consumers kept their no-art verdict 2026-10-30 15:12:56 -04:00

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_version before consulting its tag cache and drops the cache when another process has committed. (Note: data_version only moves for other connections' commits — the mount process is read-only, which is exactly this contract.)
  • Read path: open() builds a ReadPlan:
    • 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 is prefix + tail_len.
    • MidShim { lead_len, head, audio_start, tail_len } — for M4A, where the tag lives in a mid-file moov: serve mdat first, then a rebuilt moov (DB tags merged over original artwork, stco chunk offsets delta-shifted), then the tail. Layouts with moof/sidx/mfra fall back to PassThrough rather 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 to ENOTCONN for every process using it and nothing recovers until systemd's Restart=always fires. 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-written overrides table — runs inside one guarded() panic boundary in src/fs.rs, and the caller sends the reply, exactly once. The same rule covers the locks: State, Db and the view cache are reached only through lock_state/lock_db/lock_view_cache and never by a raw Mutex::lock().unwrap(), because a panic caught under a lock still leaves that lock poisoned, and the next unwrap() on it — in effective_mtime, on the event loop, for every client — is the fatal one. stats.handler_panic counts these; it is meant to read zero, and the sweep in tests/fuse.rs asserts 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 with overrides for 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 (EROFS for 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 fills filemeta for the whole library.
  • Turning it off: --no-organize. --organize persists itself into view_config('enabled'), so simply not passing it cannot undo a previous organize mount — the DB resurrects the view. --no-organize overrides 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.2 tag keeps nothing: 3-character frame ids cannot be re-emitted inside the v2.4 container we synthesize without converting the payload, so its PIC frames 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 — see kept_frames in meta/id3.rs.
  • fmdscan --seed records a per-directory winner as filemeta.art_sidecar (ranked front|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:
    1. In-place (mutagen head rewrite): a file opened for writing buffers its head window; on close, a leading ID3v2, fLaC+comment, or ftyp…ilst block is parsed and stored under the file's content hash, and later reads serve the new synthesized prefix.
    2. Temp + rename (Kid3/AtomicParsley/Mutagen, the common case): create(name.tmp) opens a RAM-only synthetic file, write fills 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 to ALLOC_CAP, the 64 MiB ceiling on one captured synthetic region (fs.rs). A write whose end lands past it answers EFBIG rather 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 taggers ftruncate to the new size after rewriting the head; the backing file is never resized). mkdir/rmdir and renaming a real file return EROFS. Not supported: tools that save via mmap writeback (AtomicParsley's --overWrite maps the file and mutates pages, which a non-writeback_ cache FUSE mount cannot observe) — its default temp-file mode, and the plain temp+rename used by Kid3/Mutagen, both work.
  • Known gap — a backing read failure is served as zeros. win_read (fs.rs) fills its readahead window in a loop whose error arm is Err(_) => break, and the result is then zero-padded exactly as a read past EOF is. So an EIO from the backing device -- a failing disk, an EUCLEAN from 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 of win_read through 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, "."/".." and create hand the kernel a reference, forget releases it, and zero evicts. Entries that readdir merely named take none — replies here are plain READDIR, not READDIRPLUS, so the kernel is given nothing it could ever return — and those stay cached until the session's destroy purges 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 via ReadPlan::MidShim), non-managed ilst items — artwork included — are merged forward, and stco/co64 chunk offsets are shifted by the moov size delta for chunks that live past it. Validated against ffprobe on ffmpeg-produced files. Write-back recognizes a written ftyp/ID3+ftyp head with an ilst. Swept across the whole real library: 2005/2005 eligible M4As plan + reparse clean (re-run with fmdaudit --shimcheck). Fragmented (moof/sidx) or window-exceeding moov layouts fall back to passthrough by design.
  • Ogg read plans are PassThrough for 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.85 and 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-singles is passed — that is DECISION 3 working, not a bug to file. composer and comment are always NULL in v1 (composer needs work relations; 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 no mbmeta row — is the v2 follow-up, and --only is the seam it will hang off. See docs/fmdimport-plan.md.