Skip to content

jfindlay/music-annotator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

168 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

music-annotator

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).

Installation

Requires Python ≥ 3.12.

pip install music-annotator

Usage

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]

apply — copy and tag for a known release MBID

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)

search — search MusicBrainz, confirm, and apply

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.

prune — verify journal and delete annotated source directories

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.

repath — re-path an annotated library to the current path policy

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 — repath acts by default. A bare invocation without --dry-run mass-relocates every file in the library whose current path differs from the path the current policy would assign. The action="repathed" journal entries written during the run are the recovery record. Always preview with --dry-run first.

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.

audit — detect integrity issues and backfill identity fields in an annotated library

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.

regroup — consolidate confirmed split releases

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.

Examples

# 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

Destination directory layout

<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. Leaf nn is the per-group track index (CWP_MOVT_NUM), falling back to track.position. Intermediate-dir nn is the per-group sibling index (CWP_INTER_INDEX_{i}), falling back to MB ordering-key.
  • MOVEMENTNUMBER in tag and title string: composer's global numbering across the whole work (e.g. No. 39 in the Handel Messiah). Distinct from the directory-local nn prefix.
  • 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-daterelease_group.first_release_daterelease.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.

How it works

  1. Fetch release — full track list, artists, labels, disc structure, cover art.
  2. 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-id for 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.
  3. 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.
  4. Build tags — combine release, recording, and work data into TrackTags. Tags written include the full _cwp_* / _cea_* classical-extras set plus isrc (from MBRecording.isrc_list), audio_hash (algorithm-tagged decoded-audio hash, tagging-invariant), and chromaprint_fp (Chromaprint acoustic fingerprint, written at ingest when fpcalc is available).
  5. Movement numbers — assigned after all tracks are processed by grouping under each top-level work MBID.
  6. Write files — copy source to destination, apply tags, embed cover art, restore original atime/mtime.

Rate limiting and download reliability

All network calls (MusicBrainz API, Cover Art Archive, AcoustID) follow a two-layer defensive pattern:

  1. 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.
  2. 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.

Dependencies

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

Development

git clone https://github.com/jfindlay/music-annotator
cd music-annotator
python -m venv venv
venv/bin/pip install tox tox-uv

Running checks

# 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

Tox environments

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

License

GPL-3.0-or-later — see LICENSE.

About

Annotate directories and files/tracks with MusicBrainz data+coverart

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages