Copy and tag a classical music album using MusicBrainz metadata and Classical Extras tag conventions.
Given a MusicBrainz release MBID and a directory of source audio files, music-annotator fetches the full release metadata,
resolves the work hierarchy for each recording (movement → symphony → collection), classifies performers into CEA roles
(conductor, soloist, ensemble, …), and writes rich _cwp_* / _cea_* tags into copies of the files placed in a structured
destination tree. An archival identity triple (audio_hash, chromaprint_fp, acoustid_id) is written to every output file
and journal entry; the audit subcommand detects integrity issues and backfills missing identity fields across an
already-annotated library.
MusicBrainz API is expected to conform to the MusicBrainz XML Metadata
Schema and music-annotator validates the
returned data through pydantic models that are based on this contract in src/music_annotator/models.py. The MusicBrainz API
documentation is here.
Supported formats: FLAC (Vorbis Comments) and MP3 (ID3v2.4).
Requires Python ≥ 3.12.
pip install music-annotator
music-annotator apply <src_dir> <dest_dir> --release-id <MBID> --user-agent-email <EMAIL> [options]
music-annotator search <src_dir> [<src_dir> ...] <dest_dir> --user-agent-email <EMAIL> [options]
music-annotator prune <src_dir> [<src_dir> ...] <dest_dir> [-y]
music-annotator repath <dest_dir> [--dry-run]
music-annotator audit <dest_dir> [--enrich] [--re-resolve] [--acoustid-key KEY] [--dry-run]
music-annotator regroup <dest_dir> [--dry-run] [-y]
| Argument | Description |
|---|---|
src_dir |
Directory containing source audio files |
dest_dir |
Root destination directory |
--release-id MBID |
MusicBrainz release MBID (UUID) |
--user-agent-email EMAIL |
Contact e-mail for the MB API user-agent |
--user-agent-app STRING |
App token (AppName/Version, default: MusicAnnotator/<version>) |
--dry-run |
Log planned operations without writing files |
--acoustid-key KEY |
AcoustID API key for keyed fingerprint lookup (rung 5); when absent, fingerprint-based identification degrades gracefully to inconclusive |
--no-fetch-rels |
Skip per-recording lookups; produce minimal tags |
-d / --delete |
After a successful copy, prompt to delete the source directory |
-v / --verbose |
Enable DEBUG-level logging (must come before the subcommand) |
Same options as apply, minus --release-id, plus --limit N (default 10). Accepts one or more
src_dir positionals; all are processed in sequence against the same dest_dir.
| Argument | Description |
|---|---|
src_dir [src_dir ...] |
One or more source directories to inspect and potentially delete |
dest_dir |
Root destination directory (journal is read from here) |
-y / --yes |
Skip confirmation prompt and delete immediately |
Reads <dest_dir>/music_annotator_journal.json, performs exact presence checks on source and destination
files, then offers to delete src_dir.
| Argument | Description |
|---|---|
dest_dir |
Root of the annotated library (the same dest_dir used during ingest) |
--dry-run |
Preview planned moves without writing anything |
WARNING —
repathacts by default. A bare invocation without--dry-runmass-relocates every file in the library whose current path differs from the path the current policy would assign. Theaction="repathed"journal entries written during the run are the recovery record. Always preview with--dry-runfirst.
Walks dest_dir, reads the embedded tags from each audio file (offline — no MusicBrainz network
calls), recomputes the destination path using the current build_dest_path policy, and moves any
file whose path has changed. Each move follows the same SHA-256 + tag-round-trip verification
discipline as ingest: the source SHA is captured before the move, the destination SHA is verified
equal after the move, and _verify_copy confirms the tag round-trip — only then is a
action="repathed" journal entry appended. A crash mid-run leaves a complete audit trail of what
already moved. Collisions (two legacy paths mapping to one new path) are resolved by the same
acoustid+length-aware machinery used during ingest.
| Argument | Description |
|---|---|
dest_dir |
Root of the annotated library (the journal is read from here) |
--enrich |
Idempotent backfill mode: write missing audio_hash, chromaprint_fp, and acoustid_id to tags and journal |
--re-resolve |
With --enrich: also re-derive chromaprint_fp (and acoustid_id when --acoustid-key is set) for files that already have values |
--acoustid-key KEY |
AcoustID API key; enables acoustid_id re-resolution under --enrich --re-resolve |
--dry-run |
With --enrich: preview planned backfills without writing anything |
Bare audit <dest_dir> (read-only, no network calls): scans the journal and embedded tags for missing or mismatched
identity fields, recomputes audio_hash to detect audio drift (anchor changed → re-rip or replacement) vs tagging errors
(anchor stable but identity disagrees). Also groups action="tagged" entries to surface two fragmentation shapes — one work
directory populated from multiple release MBIDs (a regrouping candidate), and one release MBID whose tracks landed in multiple
work directories (a split release) — adjudicated by reading the embedded MUSICBRAINZ_ALBUMID tag. Reports only; never moves
files or writes the journal.
audit <dest_dir> --enrich: idempotent backfill — walks the journal's latest-destination view and writes missing
audio_hash / chromaprint_fp / acoustid_id to both the tag and a new action="enriched" journal entry. A second run
over an already-enriched library is a no-op.
audit <dest_dir> --enrich --re-resolve: also re-derives chromaprint_fp (and acoustid_id when --acoustid-key is
set) for files that already carry values, correcting stale or wrong identity fields while leaving audio_hash untouched as
the integrity anchor.
audit <dest_dir> --enrich --dry-run: preview planned backfills without writing anything.
| Argument | Description |
|---|---|
dest_dir |
Root of the annotated library (the journal is read from here) |
--dry-run |
Preview planned moves without writing anything |
-y / --yes |
Skip the confirmation prompt and move immediately |
Runs the audit confirmation and, for tag-confirmed split releases only, moves the scattered
files to their canonical destinations (recomputed from embedded tags via the same offline
build_dest_path engine repath uses). Unlike repath, regroup prompts for confirmation
before moving (use -y/--yes to skip, --dry-run to preview). Each move follows the same
SHA-256 + tag-round-trip provenance discipline as ingest and repath, and appends an
action="regrouped" journal entry — with the release MBID recorded — only after the move is fully
verified. This keeps the fragmentation detector accurate: every move re-journals, so a later audit
sees present state.
# Annotate with a known MBID
music-annotator apply \
~/Music/source/beethoven-9 ~/Music/tagged \
--release-id 1c1e6a95-7b43-4a62-b2b9-2c2a3e0e8b0e \
--user-agent-email me@example.com
# Annotate and offer to delete the source when done
music-annotator apply \
~/Music/source/beethoven-9 ~/Music/tagged \
--release-id 1c1e6a95-7b43-4a62-b2b9-2c2a3e0e8b0e \
--user-agent-email me@example.com --delete
# Search MB for a matching release, confirm, and apply
music-annotator search \
~/Music/source/beethoven-9 ~/Music/tagged \
--user-agent-email me@example.com
# Prune a source directory after confirming journal entries
music-annotator prune \
~/Music/source/beethoven-9 ~/Music/tagged
# Prune multiple source directories at once without interactive confirmation
music-annotator prune \
~/Music/source/beethoven-9 \
~/Music/source/brahms-1 \
~/Music/tagged --yes
# Preview which files would be moved to the current path policy (no changes written)
music-annotator repath ~/Music/tagged --dry-run
# Apply the current path policy to the whole library (moves files; journal is the recovery record)
music-annotator repath ~/Music/tagged
# Detect release fragmentation and identity issues (read-only; no network)
music-annotator audit ~/Music/tagged
# Backfill missing audio_hash / chromaprint_fp / acoustid_id across an existing library
music-annotator audit ~/Music/tagged --enrich
# Preview backfills without writing, then apply with AcoustID re-resolution
music-annotator audit ~/Music/tagged --enrich --dry-run
music-annotator audit ~/Music/tagged --enrich --re-resolve --acoustid-key <YOUR_KEY>
# Preview, then consolidate tag-confirmed split releases (prompts before moving)
music-annotator regroup ~/Music/tagged --dry-run
music-annotator regroup ~/Music/tagged<dest_root>/
<Composer lastnames> - <Conductor; Ensemble>/ ← Latin or native script
<Work title> [rel YYYY]/ ← publication year
cover.jpg ← original-resolution front cover (sidecar)
back.pdf / back.jpg ← back cover sidecar (if available)
booklet-1.pdf / booklet-1.jpg … ← booklet sidecar(s) (if available)
[nn - <Intermediate division>/] ← only when hierarchy depth ≥ 3
[nn - <Sub-intermediate division>/] ← only when hierarchy depth ≥ 4
nn - <Movement title>.<ext> ← 500px front cover embedded in file
nn: zero-padded 2 digits (3 if >99 siblings), gap-free per work-group. Leafnnis the per-group track index (CWP_MOVT_NUM), falling back totrack.position. Intermediate-dirnnis the per-group sibling index (CWP_INTER_INDEX_{i}), falling back to MBordering-key.MOVEMENTNUMBERin tag and title string: composer's global numbering across the whole work (e.g. No. 39 in the Handel Messiah). Distinct from the directory-localnnprefix.- Performer component: album-level conductor + ensemble names. Soloists are excluded except for
concerto works (
top_work.type == "Concerto"), where the soloist is part of the work's canonical identity and is promoted into the path — accumulated across all media of the release so a multi-disc concerto agrees on one directory. - Collection/cycle wrappers (Ring cycle, symphony cycles): excluded from filesystem, deferred to playlist generation.
[rel YYYY]: most-granular publication year from MB —recording.first-release-date→release_group.first_release_date→release.date. Omitted when no date is known.[rec YYYY]is reserved for a future data source providing actual session dates.- Cover art: 500px JPEG front cover embedded in every audio file (PICTURE block / APIC frame).
Original-resolution front, back, booklet, and medium images written as sidecar files in the
work directory with CAA source URLs recorded in the journal as
action="downloaded"entries.
- Fetch release — full track list, artists, labels, disc structure, cover art.
- Select medium — for a single-disc release the sole medium is used. For multi-disc releases the medium whose track count
matches the number of source files is selected automatically; if several mediums tie, a disc-number hint in the directory
name (e.g.
disc2) breaks the tie. A total mismatch raises an error asking the caller to supply the correct--release-idfor that disc. After selection, source files are sorted by name and zipped with tracks in medium order; a remaining count mismatch logs a warning but does not abort. - Per-track (skipped with
--no-fetch-rels):- Fetch recording artist relations (conductor, soloists, ensembles, …).
- Resolve the work linked via a
"performance"relation. - Walk the parent work chain (movement → top-level work) using
"parts"relations; cycle detection prevents infinite loops.
- Build tags — combine release, recording, and work data into
TrackTags. Tags written include the full_cwp_*/_cea_*classical-extras set plusisrc(fromMBRecording.isrc_list),audio_hash(algorithm-tagged decoded-audio hash, tagging-invariant), andchromaprint_fp(Chromaprint acoustic fingerprint, written at ingest whenfpcalcis available). - Movement numbers — assigned after all tracks are processed by grouping under each top-level work MBID.
- Write files — copy source to destination, apply tags, embed cover art, restore original atime/mtime.
All network calls (MusicBrainz API, Cover Art Archive, AcoustID) follow a two-layer defensive pattern:
- Exponential backoff retry — up to 6 attempts with 2ⁿ-second delays on HTTP 429, 500, 503, and 307 (transient redirect loop from the CAA/Internet Archive path). Non-retryable errors (4xx other than those above) fail immediately.
- Polite delay — 1 second after each successful call to respect the MB / CAA rate limit.
Download failures are treated as data integrity errors: if required remote content cannot be fetched after all retries, the release is not actioned and the error is logged to the user. The sole exception is a 404 on an individual Cover Art Archive image (image deleted after the listing was fetched), which logs a warning and skips that image.
| Package | Version | Purpose |
|---|---|---|
mutagen |
1.47 | FLAC / MP3 tag writing |
musicbrainzngs |
0.7.1 | MusicBrainz API client |
pydantic |
2.12.5 | Data validation and models |
structlog |
25.5.0 | Structured logging |
git clone https://github.com/jfindlay/music-annotator
cd music-annotator
python -m venv venv
venv/bin/pip install tox tox-uv# All checks (build, test, types, format, lint, upgrade)
venv/bin/tox -m analyze
# Tests only (with coverage)
venv/bin/tox -e test
# Auto-fix formatting
venv/bin/tox -m edit| Environment | Tool(s) | Purpose |
|---|---|---|
build |
setuptools | Build wheel |
test |
pytest + pytest-cov | Tests + 100% branch coverage |
check_type |
mypy (strict) | Static type checking |
check_format |
ruff | Import ordering + code formatting |
check_lint |
pylint | Lint (must score 10.00/10) |
check_upgrade |
pyupgrade | Enforce Python 3.12+ idioms |
fix_format |
pyupgrade + ruff | Auto-fix formatting in place |
GPL-3.0-or-later — see LICENSE.