Architecture
This document describes how passive-music-dl is structured and how a sync
cycle flows through it. It describes the code as it exists on main.
Module map
src/
├── passive_music_dl/
│ ├── config/ # TOML config loading & Pydantic models
│ ├── download/ # Serialised yt-dlp downloader
│ ├── metadata/ # MusicBrainz lookups & per-format taggers
│ ├── providers/ # Spotify & YouTube Music clients
│ │ ├── spotify/
│ │ └── youtube_music/
│ ├── scheduler/ # Polling loop & graceful shutdown
│ ├── state/ # SQLite database for track & playlist state
│ ├── sync/ # Playlist & full-filesystem sync
│ └── util/ # Shared constants
├── py_musicbrainz/ # Type-safe MusicBrainz API bindings
All I/O is asynchronous (asyncio + anyio). The only synchronous work is
yt-dlp, which runs in a worker thread via anyio.to_thread.run_sync.
Lifecycle
The entry point is passive_music_dl/__main__.py:
- Load config (
config/loader.py): reads and validatesconfig.tomlfrom the current working directory against the Pydantic models inconfig/models.py. A missing or invalid config exits with code 1. - Set up logging (
logger.py): configuresstructlogand silences noisy third-party loggers (httpx, yt-dlp, etc.). - Initialise the database (
state/database.py): opens the SQLite file and creates the schema. - Full scan (
sync/full_sync.py): reconciles the database with the files in the download directory. - Schedule sync cycles: the
schedulelibrary triggers a sync cycle everyinterval_minutes, guarded by ananyio.Lockso cycles never overlap. ACancellationTokenandGracefulShutdownhandler abort in-progress work on SIGINT/SIGTERM.
Sync cycle
sync/sync_cycle.py runs each enabled provider in turn. For every configured
playlist it calls sync/playlist_sync.py, which:
- Fetch new tracks - the provider's
fetch_new_tracks()returns only tracks whose IDs are not already in the database. - Resolve and download - each new track is turned into a
DownloadTask(provider track ID + YouTube video ID). Spotify tracks are resolved to YouTube videos viaspotdl's matcher; YouTube Music tracks are already videos.download/downloader.pyserialises the downloads. - Tag - for each downloaded file,
sync/playlist_sync._process_track: - builds
TrackMetadatafrom the provider's track data, - looks up the MusicBrainz recording ID
(
metadata/musicbrainz.py), - fetches album art from the provider URL or the Cover Art Archive,
- embeds everything via
metadata/async_audio_file.py, which dispatches to the per-format tagger (ID3 for MP3, MP4 for M4A, Vorbis comments for OGG / FLAC / Opus), - inserts the track and its playlist entry into the database.
- Commit change detection - the provider's
on_sync_completed()is called at the end of the playlist sync, even when some downloads failed. Spotify uses it to commit the playlist's pendingsnapshot_idto the in-memory cache.
Change detection
- Spotify uses the playlist
snapshot_idas a cheap change signal. Whenfetch_new_tracks()first sees a new snapshot it records it as pending; the snapshot is committed to the in-memory cache inon_sync_completed(), which runs after the download loop even if some downloads failed. A playlist whose committed snapshot matches the current one is skipped entirely on the next cycle. - Both providers check the database: a track is "new" only if its provider ID is not already recorded. This is what survives restarts.
- YouTube Music exposes no change token, so every cycle just diffs against the database.
Full scan
On startup, perform_full_scan() walks the download directory:
- Files present on disk but absent from the database are inserted by reading
their embedded IDs (
UniqueTrackIdentifiers). - Files recorded in the database but missing from disk are deleted from the database.
This keeps the database and the filesystem in agreement and lets a database recreated from scratch be repopulated from tagged files.
Downloads
download/yt_dlp.py wraps yt-dlp as a library:
format: bestaudio/best, extracted to the configured codec viaFFmpegExtractAudio.- Output template is
{output_dir}/{title}.{ext}- files are written flat into the download directory. - A progress hook raises
yt_dlp.utils.DownloadCancelledwhen theCancellationTokenfires; partial files are cleaned up.
Metadata
MusicBrainz lookups
metadata/musicbrainz.py uses the async py_musicbrainz client to search by
title and artist, then scores the results:
- Title score: exact match scores 1.0; extra words are tolerated only if they look like version qualifiers (remix, live, mix, ...).
- Artist coverage: the fraction of expected artists present in the recording's artist credit.
A recording is accepted only if title score ≥ 0.9 and artist coverage ≥ 0.5. The MusicBrainz recording ID is stored in the file's tags and the database.
Tag embedding
metadata/async_audio_file.py reads a file's bytes, parses it with mutagen,
and dispatches to one of three taggers:
| Format | Tags backend | Tagger module |
|---|---|---|
| MP3 | ID3 | id3_tagger.py |
| M4A | MP4 tags | mp4_tagger.py |
| OGG/FLAC/Opus | Vorbis comments | vorbis_tagger.py |
The embedded metadata includes standard text frames plus the provider and
MusicBrainz IDs in UFID frames (http://musicbrainz.org,
https://music.youtube.com, https://spotify.com owners). See
Data model.
State
See Data model and ADR-0003: Hybrid Filesystem-Based State Management for the rationale behind using embedded IDs plus a database rather than a JSON state file.