sift-music 0.9.2

Music tagger and library organiser: MusicBrainz matching, tag writing, cover art, fb2k-style paths
Documentation

sift

Matches a folder of music against MusicBrainz, writes its tags, embeds cover art and files it into a library. A library and a CLI; the CLI reads its own config or an existing beets config.yaml, so it can stand in for beet import.

The library is published to crates.io as sift-music, since sift was taken; it is still imported as sift:

sift = { package = "sift-music", version = "0.9" }
$ mise use -g github:radiosilence/sift        # or brew install radiosilence/sift/sift,
                                              # or a static binary from the releases
                                              # (macOS: Apple silicon only)
$ sift migrate-config                         # ~/.config/beets → ~/.config/sift
$ sift import ~/Downloads/some-album          # uses ~/.config/sift/config.yaml
$ sift match ~/Downloads/some-album           # show candidates, change nothing
$ sift import --search-id <mbid> <dir>        # apply a specific release
$ sift update                                 # index the library
$ sift ls -a year:1990..1999 format:FLAC      # beets query syntax
$ sift move --pretend                         # what re-filing would change
$ sift duplicates --bin ~/music-bin           # spare copies out of the library
$ sift modify -a album:untru album=Untrue      # fix tags, re-file what moves
$ sift import -L artist:burial                # match library albums again
$ sift mbsync year:2020..                     # refresh from MusicBrainz
$ sift missing ; sift bad ; sift stats         # gaps, damaged audio, totals

Using the services politely

MusicBrainz, the Cover Art Archive, Discogs and LRCLIB are run by small teams and volunteers, and are shared by every tagger that calls them. sift paces itself against each, but some of that depends on how it is run.

  • Run one sift at a time. Each process paces MusicBrainz (one request a second, slower when refused) and Discogs on its own, so two imports in parallel ask twice as fast. Queue folders instead; the service's global budget is shared with everyone else.
  • Keep the cache. Responses are kept under the cache directory (~/Library/Caches/sift on macOS, ~/.cache/sift on Linux) for a day for searches and a week for releases. Deleting it makes the next import of the same albums ask again.
  • Embedding the library: reuse one Importer for the process rather than building one per album, since each holds its own pacing gate, and set Config::musicbrainz_contact to your own project URL or email so MusicBrainz can reach whoever is sending the requests.
  • Add what is missing. An album with no match is usually missing from MusicBrainz rather than mismatched. Adding the release there, instead of importing with --as-is, fixes it for every tagger.

Why not beets

beets is the reference: the match weights, query syntax, config keys and path templates follow it, and its distance tests are ported. sift exists for the places beets does not fit.

  • It is a library first. koan and slsk-mcp file downloads by calling sift as a Rust dependency, and expose its commands over GraphQL and MCP. From a Rust service, beets is a subprocess: a Python environment and its plugins installed beside every deployment, and output to parse.
  • One static binary, the same on a Mac and a Raspberry Pi, installed with mise or Homebrew.
  • The files are the library. beets keeps metadata in library.db and queries that, so a tag changed by another tool is invisible until beet update. sift's index is rebuilt from the files and can be deleted at any time.

An existing beets config and library work unchanged, so the two can be run side by side and beets dropped when sift covers what it was used for.

Config

sift uses the first of these that exists, unless -c names one:

  1. $SIFT_CONFIG
  2. ~/.config/sift/config.yaml
  3. $BEETSDIR/config.yaml
  4. ~/.config/beets/config.yaml

sift's own config has the same keys as beets', so a beets config keeps working as it is. sift reads directory, include, import.move/import.copy, paths.default and paths.comp, replace (in file order), asciify_paths, original_date, per_disc_numbering, match.strong_rec_thresh, the plugins it acts on (fetchart, importadded, ftintitle, discogs), the fetchart/embedart size, quality and ratio options, ftintitle and discogs options. Everything else is ignored rather than rejected.

include: paths are relative to the including file and are read first, so a tracked base file can hold the shared settings and an untracked per-machine file the rest:

# ~/.config/sift/base.yaml
paths:
  default: "%album artist%/['('%year%') ']%album%/$num(%tracknumber%,2) %title%"
original_date: true
plugins: [fetchart, discogs]
# ~/.config/sift/config.yaml
include: [./base.yaml]
directory: /Volumes/music
discogs:
  user_token: ...

sift migrate-config writes a sift config from a beets one (--from, default the beets locations above) into ~/.config/sift (--to): each file under the same relative name, its include: list kept, only the keys sift reads, and path templates translated to fb2k syntax. Comments are not carried over, and it refuses to overwrite any file already there.

Discogs is consulted as a second source, as beets' discogs plugin does, when MusicBrainz has no match under match.strong_rec_thresh and the plugins list includes discogs. It needs a personal access token, from discogs.user_token or the DISCOGS_TOKEN environment variable, generated at https://www.discogs.com/settings/developers; discogs.index_tracks (default off) prefixes a medley's sub-tracks with the enclosing index track's title, as beets' option of the same name does.

Path templates are fb2k-style — the template language koan uses, whose engine this crate carries. A beets template ($albumartist/%if{$year,($year) }$album) is translated on load, so an existing config files albums where beets would have. %aunique{} is dropped: a destination that already exists is refused instead of disambiguated.

Decisions

  • Matching is beets-shaped — a weighted distance over album, artist and per-track title and length, with penalties for missing and extra tracks — but tracks are paired by disc and track number when the files carry them, and by cheapest title-and-length pairing only when they do not. The length allowance is beets' 10 s (full penalty at 30 s) or 2% (6%) of the track, whichever is larger, since a long side routinely differs by more than ten seconds between editions. An album is applied without asking only when it is below the threshold and complete; otherwise the candidates are returned, with what the distance is made of in the log.

  • As-is imports are gated. Filing by the files' own tags (--as-is) requires one album and one artist across every file and a title and distinct track number for each; otherwise nothing moves and the reason is given, since a library filed from bad tags is worse than an album waiting.

  • Every artist on a credit is tagged. artist and albumartist keep the credit as it reads ("A × B"); ARTISTS, ALBUMARTISTS and the MusicBrainz artist ids hold each artist, as Picard writes them, so a player can list a collaboration under both artists without parsing the string. Files tagged before this, or by their own tags, are credited by Importer::credit (part of enrich): from the release when MusicBrainz has it, otherwise by splitting the credit only where every part is an artist the library already holds, since "&", "," and " x " also occur inside band names.

  • Library writes can be previewed. Importer::credit_plan, Importer::enrich_plan and Importer::rematch return what credit, enrich and reimport would write (each file's credits and their source, genres, each track's lyrics, the release a re-import would apply and why it might stop for review) without changing a file, so a front end can show a change before making it. Gain is the exception: measuring it decodes every file, so it is only done to write it. Importer::retag_plan lists every field a re-tag from a chosen release would change in each file, where each file and the album's other files would go, and whether the cover would be replaced. A hand edit is previewed with Track::apply and meta::diff. Library::set_added restamps an album a re-import moved, so it keeps the date it first arrived.

  • One copy of each track. A folder holding an album in two formats, or with (1) duplicates, is imported from the best copy of each track.

  • Nothing is overwritten. Every destination is planned and checked before any file changes, including case-insensitive collisions on macOS. Each file claims its name with create_new before it moves, and a move across filesystems goes through a synced, length-checked temporary file.

  • Untrusted tags cannot escape the library: a slash in a value never becomes a directory, empty or relative components are refused rather than dropped, and the final path is checked against the library root.

  • Copying leaves the source byte-identical; tags are written to the copy.

  • MusicBrainz is called at most once a second with an identifying user agent. The service also has a global budget shared by every client, and refuses everyone when it runs out, so pacing adapts: refusals slow requests down, successes speed them back up, and a nearly spent budget is waited out. Refusals, server errors and dropped connections are retried; responses are cached on disk so a retried import asks for nothing twice. Cover art comes from the Cover Art Archive's own thumbnails, so no image is decoded or re-encoded here.

  • The library index is derived, never authoritative. sift update reads the files into SQLite so queries are milliseconds instead of a read of every file; it holds nothing the files do not, and beets' own library.db is never read or written, since its schema is beets' internals. Queries are evaluated in Rust rather than translated to SQL, so their semantics are beets' exactly.

  • Changing the library is album-at-a-time and planned first. move and duplicates --bin check every destination before any file moves and leave an album alone rather than move part of it. Duplicates are moved to a bin, not deleted.

Many of these came from koan, where each was a bug first.

Development

just hooks installs a pre-commit hook that formats; just check runs what CI runs. A change adds a changelog fragment under changelog.d/unreleased/ (see changelog.d/README.md) rather than editing CHANGELOG.md, which is generated, so open pull requests do not conflict on it.

CI and the release are one workflow, .github/workflows/ci-cd.yml. Its changes job reads the diff (against the base for a pull request, against the last main commit whose run passed for a push) and says which domains it touches: rust (code, manifests, toolchain), dist (the Linux release binaries, built on a pull request only when the dependencies, toolchain or CI change) and apple (the macOS job, held on a pull request for what Linux cannot check). Each job runs only for its domain, so most jobs skip on a documentation change; that is expected. The ruleset requires one status, Checks, which passes when every job either passed or was skipped. Changes under .github/ count for every domain, and Markdown counts for none. To add a domain, give changes an output and an allowlist of paths for it, put the new job under that output's if:, and add the job to Checks's needs.

just release patch|minor|major opens a release pull request with the version bumped and the changelog written; the Release workflow does the same from GitHub. Merging it publishes sift-music to crates.io, then the GitHub release with the binaries, then the Homebrew formula. CI creates the tag; tags are never pushed by hand. If a publish fails, running CI on main by hand retries whatever is not yet released.