Self-hosted music tagger and library organizer for Docker
Drop an audio file. Get a perfectly tagged, organized result — automatically.
dragontag identifies every file against MusicBrainz (with optional AcoustID fingerprinting), writes a complete, casing-exact tag set, embeds cover art from the Cover Art Archive, fetches synced lyrics from LRCLIB, and moves the file into a clean Artist / Album / 01. Title.flac structure.
High-confidence matches flow through hands-free. Low-confidence files land in a review queue where you pick the right candidate, resolve conflicts, or fill in missing fields — all from the browser.
| Feature | Description |
|---|---|
| MusicBrainz-first | Short-circuits on an existing MUSICBRAINZ_TRACKID; otherwise searches by title / artist / album / duration with progressive fallback to maximise hit rate |
| AcoustID fingerprint | Toggleable acoustic fingerprint fallback using fpcalc (bundled in the image) when text search comes up empty |
| Confidence scoring | Matches above the threshold are tagged and moved automatically; everything else goes to review |
| Release consensus | Near-tied candidates resolve to one deterministic release per album — Official status, then the release your library already uses for that release group, then the larger edition — so tracks of one album can't scatter across MusicBrainz editions and split into multiple album listings |
| Feature | Description |
|---|---|
| Format coverage | FLAC · MP3 (ID3v2.4) · WAV (ID3 chunk) · M4A / MP4 |
| Cover art | Best available resolution from Cover Art Archive, resized to ≤ 1200 px, embedded in the file and written as cover.jpg |
| Lyrics | Synced LRC or plain text from LRCLIB, embedded per-format |
| Advisory tagging | Explicit content auto-classified from lyrics and written as ITUNESADVISORY |
| Smart formatting | Title Case, qualifier parenthesization (Song Live → Song (Live)), grammar fixes (ALL-CAPS, contractions, possessives) |
| Genre filter | MB community tags filtered against a ~1500-entry canonical genre list — kills noise like "billboard top 100"; clean tags survive as a fallback |
| Link to existing album | Manually link a track's edit form to an album already in your library — inherits album, album artist, disc/track totals, and MB album IDs without any new MusicBrainz lookup; title, artist, and track number are left untouched |
| Feature | Description |
|---|---|
| Organize | Moves all tracks to their canonical paths based on current filename templates; picks up manual edits after a scan |
| Library scan | Indexes existing on-disk files into the DB — useful after editing tags outside dragontag |
| One tagging pass | Retag runs every file through the same identify → tag → move pipeline as the drop folder. Album folders are identified as a unit: one MusicBrainz release is elected per folder and every track is tagged from it, so release-level tags (album ID, release group, album artist, date, media, type/status) can never scatter across editions and split the album in players. Uncertain groups and files not on the elected release land in review |
| Helpers & reports | Individual per-folder actions: fetch lyrics/covers, extract covers, tag advisories, fix genres, ReplayGain (in-place, single-field, never move files), plus read-only reports (verify integrity, validate tags, find duplicates, find missing tracks), prune junk, and cleanup |
| Cleanup & quarantine | The Cleanup action merges case-twin artist folders (fakemink/Fakemink — strict case/punctuation equality, never fuzzy) and edition-suffix twin album folders (Afraid / Afraid - Single / Afraid (Deluxe)), and moves dead folders + leftover non-audio into a quarantine folder (<library>/.dragontag-trash). Cover art is conservative: only byte-identical duplicates are quarantined — distinct images always stay with their album. Report-only by default; the apply variant never deletes and never touches audio. Ingest also reuses existing case-variant/edition folders (race-proof, fail-closed on I/O errors) to prevent new twins |
| Damaged-library recovery | Scan → Retag → Cleanup (apply): the retag pass unifies split albums and produces clean names; cleanup merges leftover twin folders |
| Completions page | Library health in one place: coverage meters (lyrics/tagged/covers/genres) over live sections (songs without lyrics with per-row LRCLIB fetch, untagged files, duplicate tracks + twin album folders, tag problems) and snapshot sections with refresh jobs (incomplete albums vs MusicBrainz, missing covers/genres from a read-only file scan). Rows deep-link into fixes; the page never moves or deletes files |
| Library table | Column sorting and pagination (10 / 25 / 50 / 100 / 200); explicit advisory badge on each row |
| Feature | Description |
|---|---|
| Drop & ingest | Drag-and-drop in the browser or drop files into the watched folder — both hit the same pipeline |
| Non-blocking retag | Queueing a folder re-tag returns instantly — the enqueue walk runs as a background job with progress and cancel |
| Review queue | Low-confidence matches, missing RELEASETYPE, and destination conflicts surface a candidate picker with scores and links, manual MB search, and action buttons |
| Dry-run mode | Preview destination paths and assembled tags without touching any files — global setting plus per-run overrides on Library actions |
| Change history | Every tag-write is recorded; revert a file's tags in place or move it back to its original directory with one click |
| Keyboard shortcuts | / focuses search, ? opens the key reference, g then a letter navigates between pages, and every page wires its own keys (shown in the status bar at the bottom) for its primary actions |
| Feature | Description |
|---|---|
| Scheduling | Standard cron expressions for scans, organizes, folder re-tags, lyrics/cover fetches, cleanup, and backups — with run-now, next-run display, and live plain-English descriptions. Expressions are read in your display timezone (TZ / in-app setting), so "At 06:00 AM" fires at 6 AM your time |
| Webhooks | Discord-compatible webhook on job completion or error |
| Universal progress bar | Live progress line under the nav on every page: percentage, item counts, and current file |
| Feature | Description |
|---|---|
| Scan filters | Regex patterns (by filename), excluded directories, and excluded files — all applied to the watcher, scanner, and bulk re-tag; clearable with one click |
| Backup / restore | Versioned tarball of the DB, settings, password hash, and AcoustID key; restore from the UI or the restore_backup CLI |
| Change retention | Configurable cap on audit-log rows (0 = unlimited) |
| First-run wizard | Set credentials and AcoustID key from the browser on first boot |
| SQLite-backed | All jobs and history survive container restarts |
| API docs | Auth-guarded Swagger UI at /api-docs and raw schema at /openapi.json |
git clone https://github.com/chropic/dragontag.git
cd dragontag
# 1. Hash a password for the web UI
mkdir -p secrets config
python -m dragontag.tools.hash_password 'your-password' > secrets/password.txt
# 2. (Optional) AcoustID key for fingerprint fallback
echo 'your-acoustid-key' > secrets/acoustid_key.txt
# 3. Point /library and /drop at your actual paths, then start
$EDITOR docker-compose.yml
docker compose up -dOpen http://localhost:7593 and log in. First boot redirects to /setup if no password is configured yet.
Building locally — swap
image:forbuild: .indocker-compose.yml.
| Mount | Contents |
|---|---|
/library |
Destination root — files land at Album Artist/Album/[Disc N/]NN. Title.ext |
/drop |
Watched ingest folder — files dropped here are queued automatically |
/config |
SQLite DB, settings.json, password hash, AcoustID key |
| Variable | Purpose |
|---|---|
DRAGONTAG_USERNAME |
Web UI login (default admin) |
DRAGONTAG_PASSWORD_FILE |
Path to argon2-hashed password file (Docker secret recommended) |
DRAGONTAG_PASSWORD |
Plain-text password — dev/testing only |
DRAGONTAG_SESSION_SECRET_FILE |
Session signing secret; falls back to an ephemeral value |
DRAGONTAG_ACOUSTID_KEY_FILE |
Path to AcoustID API key file |
DRAGONTAG_LIBRARY_PATH |
Override /library mount |
DRAGONTAG_DROP_PATH |
Override /drop mount |
DRAGONTAG_CONFIG_PATH |
Override /config mount |
TZ |
Timezone for displayed timestamps and cron schedule interpretation, e.g. America/New_York. Locks the Settings UI's timezone field when set — remove it to enable the in-app override instead |
Migration note: Variables were renamed from the
AIO_prefix toDRAGONTAG_. Update yourdocker-compose.ymlaccordingly.
Direct Docker use accepts the proxy/browser Host header by default. If dragontag
sits behind HTTPS, set DRAGONTAG_ALLOWED_HOSTS to the public host (or a
comma-separated allowlist),
DRAGONTAG_TRUSTED_PROXY_IPS to the proxy's source IP, and
DRAGONTAG_SESSION_COOKIE_SECURE=true. Request and per-upload limits are
configured through DRAGONTAG_MAX_REQUEST_BYTES and
DRAGONTAG_MAX_UPLOAD_BYTES (both default to 2 GiB).
The Settings page covers everything below — changes are written atomically to /config/settings.json:
- AcoustID fingerprint on/off, auto-apply confidence threshold
- Network timeout for outbound MusicBrainz/AcoustID calls (default 15s, so a stalled connection can't wedge the worker)
- Filename templates (single-disc and multi-disc) and artist-folder split separators
- Per-tag multi-value separators (
ARTIST,GENRE,LABEL, …) - Genre limit, casing, and canonical-list junk filter
- Fields to skip at write time across all formats
- Watcher on/off, ignore patterns, settle window (a file is ingested only once its size stops changing, so partial SMB/NFS transfers aren't read half-written)
- Cover-art minimum width and release-group fallback toggle
- Discord webhook URL and on/off per event type
- Dry-run mode, scan filters, change-retention cap, log verbosity (0–4)
- Backup download and validated restore
- MusicBrainz user-agent and server (for self-hosted mirrors)
- Display timezone override — only editable when the container has no
TZenvironment variable set;TZalways wins
Drop file / upload
│
▼
┌─────────────────┐
│ Watcher / UI │──── enqueue ────► Job row (SQLite)
└─────────────────┘ │
▼
┌──────────────────────────────────────┐
│ Pipeline │
│ │
│ 1. Read existing tags + filename │
│ 2. MBID short-circuit (if present) │
│ else MusicBrainz text search │
│ else AcoustID fingerprint │
│ │
│ 3. Score top candidate │
│ ≥ threshold ──► auto-apply │
│ < threshold ──► review queue │
│ │
│ 4. Assemble TrackTags from MB │
│ 5. Fetch cover art (CAA) │
│ 6. Fetch lyrics (LRCLIB) │
│ 7. Write tags (format-aware) │
│ 8. Move into library + cover.jpg │
│ 9. Fire webhook (if configured) │
└──────────────────────────────────────┘
Files in the review queue show the top 5 MB candidates with scores and links. Destination conflicts get replace / rename / skip / skip + delete buttons; the last option keeps the existing library file and permanently deletes the incoming duplicate after confirmation.
- Atomic tag writes — every tag write (full re-tag, single-field updates, and revert) is performed on a temp copy in the same directory and then atomically swapped in. A crash mid-write can only ever damage the throwaway temp, never your original audio file. Cover-art sidecars (
cover.jpg) are written the same way. - Verified moves — a file move confirms the destination's byte count matches the source, catching a silently-truncated write over a flaky network volume.
- Stalled-task recovery — a background task with no progress heartbeat for 15 minutes and no live worker thread is reaped to
error, so a hung run can't block future scheduled work while a slow-but-healthy one (say, a long backup) is left alone; bounded network timeouts keep the single worker from hanging in the first place. - Honest failure reporting — if a move has to be rolled back and the rollback itself fails (the original spot is occupied), dragontag reports exactly where the file ended up instead of claiming success, and logs it critically.
dragontag writes a rich, source-traceable tag set — every field comes directly from MusicBrainz or one of its associated services. Nothing is inferred or guessed. The Vorbis Comment field names below are used for FLAC; MP3, WAV, and M4A map these to their native containers (ID3v2.4 frames and TXXX for non-standard fields; M4A atoms and ----:com.apple.iTunes: freeform atoms).
The canonical schema lives in schema.py.
| Field | What's stored |
|---|---|
TITLE |
Track title, exactly as MusicBrainz records it |
ARTIST |
Performing artist(s) — one value per artist (native multi-value) |
ARTISTS |
Same as ARTIST; written separately for players that prefer this field |
ARTISTSORT |
Artist sort names from MB (e.g. "Bowie, David") |
ALBUM |
Release title |
album_artist |
Album-level artist(s) — the name the folder is grouped under |
ALBUMARTISTSORT |
Album artist sort names |
COMPOSER · LYRICIST · ARRANGER · CONDUCTOR |
From MB recording and work relationships |
DATE |
Release date |
ORIGINALDATE · ORIGINALYEAR |
First release date of the release group (the original year the album came out, not this specific edition) |
track |
Track number as NN/TT; also written as TRACKTOTAL and TOTALTRACKS (totals omitted when unknown) |
disc |
Disc number as N/T; also written as DISCTOTAL and TOTALDISCS (totals omitted when unknown) |
GENRE |
Top community-voted MB tags, filtered for quality |
LABEL · MEDIA · BARCODE · ISRC |
Label, format, barcode, and recording ISRC from MB |
RELEASECOUNTRY · RELEASESTATUS · RELEASETYPE · SCRIPT |
Release metadata from MB |
LYRICS |
Synced .lrc or plain text from LRCLIB |
ITUNESADVISORY |
0 = clean · 1 = explicit (auto-classified from lyrics) |
ACOUSTID_ID |
AcoustID fingerprint match, or carried over from a pre-existing tag |
MUSICBRAINZ_TRACKID |
MB recording ID — the primary link back to the source |
MUSICBRAINZ_RELEASETRACKID · _ALBUMID · _ALBUMARTISTID · _ARTISTID · _RELEASEGROUPID |
Full MB provenance trail |
TAGGER |
tagged via dragontag/x.y.z |
git clone https://github.com/chropic/dragontag.git
cd dragontag
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
# Run tests
pytest -v
# Dev server
DRAGONTAG_LIBRARY_PATH=./library DRAGONTAG_DROP_PATH=./drop \
DRAGONTAG_CONFIG_PATH=./config DRAGONTAG_USERNAME=dev DRAGONTAG_PASSWORD=dev \
uvicorn dragontag.app.main:app --reload --port 7593The UI is a self-hosted terminal/TUI design (JetBrains Mono, monochrome, .dt-* panel
primitives) — no CDN. The stylesheet is compiled ahead of time and committed:
# After editing templates or frontend/app.input.css, rebuild dragontag/app/web/static/app.css
bash frontend/build_css.shSee frontend/README.md for the .dt-* component layer and font details.
# After changing models.py
DRAGONTAG_CONFIG_PATH=./config alembic revision --autogenerate -m "describe change"
# Apply
DRAGONTAG_CONFIG_PATH=./config alembic upgrade headdragontag/app/main.pycomposes FastAPI routes and the HTMX web surface.config.py,db.py, andmodels.pyown configuration and persisted state.ingest/orchestrates queued files;identify/resolves metadata;tagging/renders and writes it;library/owns paths, moves, indexing, and user-requested maintenance.tasks.pyandscheduler.pyrun tracked background and scheduled work.dragontag/app/web/andfrontend/contain templates, vendored assets, and stylesheet sources.
For implementation work, start with AGENTS.md. Stable data flow
and extension points are documented in
docs/ARCHITECTURE.md; file-safety and correctness
contracts are in docs/INVARIANTS.md. These guides point
to source symbols and representative tests instead of duplicating exhaustive
inventories.
The pytest suite is self-contained and avoids live MusicBrainz, AcoustID, and
lyrics services. Test modules follow the subsystem or regression behavior they
cover, so search tests/ by the symbol or contract being changed. Run focused
tests while iterating and pytest -q before handoff. Representative safety
tests are linked from docs/INVARIANTS.md.