Airdrome migrates music libraries and scrobble/play history from cloud services (Spotify, Apple Music, Last.fm, ListenBrainz) to Navidrome, a self-hosted music server. It ingests metadata, deduplicates tracks, organizes files on disk, and syncs play counts, ratings, and playlists.
- Python 3.14+
- uv
- Docker (for the bundled PostgreSQL, which provides the
pg_trgmfuzzy matching used throughout deduplication and alias matching)
# Install dependencies into .venv
uv sync
# Start PostgreSQL (listens on port 5437)
docker compose up -dThen create a .env file at the project root (see Configuration). A minimal
config matching the bundled compose.yml:
DB_DSN = postgresql+psycopg://postgres:postgres@localhost:5437/postgres
LIBRARY_DIR = /path/to/your/organized/libraryThe database schema is applied automatically on the first CLI invocation — there's no manual migration step for normal use.
All settings load from a .env file at the project root (airdrome/conf.py).
| Variable | Required | Default | Purpose |
|---|---|---|---|
DB_DSN |
✅ | — | PostgreSQL connection string, e.g. postgresql+psycopg://postgres:postgres@localhost:5437/postgres |
DB_ECHO |
False |
Log every SQL statement (debugging) | |
LIBRARY_DIR |
✅ | — | Destination root for organized files. Must be empty on a fresh install. |
DUPLICATES_FILEPATH |
data/duplicates.json |
Default file for dedup-export / dedup-import |
|
NAVIDROME_DB_DSN |
None |
Path to Navidrome's SQLite database (required for the navidrome commands) |
|
NAVIDROME_USER |
None |
Navidrome username that play counts / ratings are written for | |
NAVIDROME_PORT |
4533 |
Port Airdrome probes to refuse syncing while Navidrome is running |
airdrome import auto-detects the source by inspecting the file/folder contents.
Use --as <name> to force one when detection is ambiguous or fails.
--as name |
Source | Format | Provides |
|---|---|---|---|
apple_xml |
Apple iTunes XML | Library.xml plist |
tracks, playlists |
apple_ms |
Apple Media Services | export zip/folder | tracks, playlists, scrobbles |
spotify |
Spotify | extended-streaming-history JSON | scrobbles |
listenbrainz |
ListenBrainz | .jsonl export |
scrobbles |
lastfm |
Last.fm | CSV export (artist,album,track,date) |
scrobbles |
folder |
Music folder | directory of .mp3/.m4a/.flac |
tracks |
A full migration runs roughly in this order. Every command is idempotent — re-running is
safe and only fills gaps. Add --dry-run/-n to any write command to roll back instead of
committing.
# 1. Import every source you have (one invocation, any mix of exports / folders)
airdrome import ./exports/itunes/Library.xml ./exports/Apple_Media_Services.zip \
./exports/spotify_history/ /mnt/music
# 2. Build the canonical graph from everything imported: unify Track/Playlist
# records, bind on-disk files, then augment/match/copy scrobbles into play history.
airdrome land # --threshold tunes fuzzy matching; --merge-playlists collapses dupes
# 3. Organize the bound files into LIBRARY_DIR (copies by default; --move to move)
airdrome organize # add --move to move instead of copy
# 4. Deduplicate canonical tracks (fuzzy trigram matching)
airdrome dedup # automatic, flag-set driven
airdrome dedup --review # batch, then open the TUI to adjust canons
airdrome dedup-export # back up confirmed groups to JSON (re-import after a DB rebuild)
# 5. Reconcile playlists across sources and Navidrome (stop Navidrome — it writes the backend)
airdrome sync all # sources -> canonical -> Navidrome; interactive on conflicts
# 6. Push play counts + ratings to Navidrome (stop Navidrome first — writes its SQLite DB directly)
airdrome navi pushRun any command with --help for its full options.
Read-only snapshot of where you are. Shows config sanity (database connectivity, LIBRARY_DIR
state, whether Navidrome is configured and currently running) and per-stage counts (imported
sources, canonical tracks/aliases/plays/playlists, files bound and organized, dedup groups,
backend playlist links). Never writes and never applies migrations — safe to run anytime, and
it reports an unreachable database instead of failing.
Auto-detect the source at each <path> and import its tracks, playlists, and scrobbles.
Accepts any number of paths; each is detected and ingested independently.
--as <name>— force a source for every path (see table above)--no-tracks/--no-playlists/--no-scrobbles— skip a data kind--dry-run,-n
Global flags: -v/--verbose shows per-item detail (file picks, misses); -q/--quiet
suppresses non-essential output.
Build the canonical graph from everything imported — run once, after all imports. Unifies source
tracks/playlists into canonical Track/Playlist records and binds on-disk files, then augments,
fuzzy-matches, and materializes scrobbles into TrackPlay play history. Idempotent.
--threshold,-t— fuzzy alias-match similarity (default0.4)--merge-playlists,-m— collapse same-name playlists into one canonical (newest anchors)--rebuild-playlists— drop and rebuild canonical playlists from source (discards backend-sync links)--dry-run,-n
Copy (default) or move bound files into LIBRARY_DIR; picks the best copy (bitrate, then container)
as each track's main.
--move,-m— move files instead of copying them--dry-run,-n
Rebuild canon_id from flag-sets + stored manual overrides.
--set/-s"artist,album,year"— comparison flag-set (repeatable; multiple sets union-merge their groups). With no--set, the recommended sets are used.--canon/-c— which group member becomes canon:added(earliest added, default) oryear(oldest release)--review/-r— after the batch pass, open the interactive TUI to adjust canons;--match <substring>filters the groups shown. Choices persist as manual overrides feeding the next run.
Round-trip confirmed duplicate groups to a portable JSON file (default DUPLICATES_FILEPATH).
Import is idempotent and matches groups by their member set, so your manual decisions survive a
database rebuild.
Reconcile playlists across remotes, with Airdrome as the source of truth. Cloud sources
(apple_xml, apple_ms) are read-only; navidrome is a read-write backend. sync all runs
sources first, then backends. Each playlist is merged against a per-remote base, so downstream
deletes stick and re-imports don't resurrect removed tracks. When remotes disagree on a track
(one added it, another removed it), an interactive resolver opens to pick a per-playlist
strategy: take a remote, keep ours, auto-merge, or abort.
--review/-r— open the resolver for every changed playlist, not just conflicts--dry-run/-n--yes/-y— skip the Navidrome-stopped confirmation
⚠️ Any scope that includesnavidromewrites its SQLite database directly. Stop Navidrome first — the CLI refuses to run while it's listening onNAVIDROME_PORT.sync apple_*is read-only and unguarded.
⚠️ Writes directly to Navidrome's SQLite database. Stop Navidrome first — the CLI refuses to run while it's listening onNAVIDROME_PORT. Pass--yes/-yto skip the prompt.
Pushes play counts + ratings for NAVIDROME_USER. (Playlists are reconciled with airdrome sync.)
Recompute the _norm text fields for tracks, aliases, and files (escape hatch for a
normalization-rule change, instead of a full reimport).
ruff check . # lint
ruff format . # format
uv run pytest # tests (require PostgreSQL running)
# Migrations — only when changing airdrome/models.py
uv run alembic revision --autogenerate -m "<message>"
uv run alembic upgrade head
uv run alembic downgrade -1