Skip to content

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:

  1. Load config (config/loader.py): reads and validates config.toml from the current working directory against the Pydantic models in config/models.py. A missing or invalid config exits with code 1.
  2. Set up logging (logger.py): configures structlog and silences noisy third-party loggers (httpx, yt-dlp, etc.).
  3. Initialise the database (state/database.py): opens the SQLite file and creates the schema.
  4. Full scan (sync/full_sync.py): reconciles the database with the files in the download directory.
  5. Schedule sync cycles: the schedule library triggers a sync cycle every interval_minutes, guarded by an anyio.Lock so cycles never overlap. A CancellationToken and GracefulShutdown handler 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:

  1. Fetch new tracks - the provider's fetch_new_tracks() returns only tracks whose IDs are not already in the database.
  2. Resolve and download - each new track is turned into a DownloadTask (provider track ID + YouTube video ID). Spotify tracks are resolved to YouTube videos via spotdl's matcher; YouTube Music tracks are already videos. download/downloader.py serialises the downloads.
  3. Tag - for each downloaded file, sync/playlist_sync._process_track:
  4. builds TrackMetadata from the provider's track data,
  5. looks up the MusicBrainz recording ID (metadata/musicbrainz.py),
  6. fetches album art from the provider URL or the Cover Art Archive,
  7. 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),
  8. inserts the track and its playlist entry into the database.
  9. 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 pending snapshot_id to the in-memory cache.

Change detection

  • Spotify uses the playlist snapshot_id as a cheap change signal. When fetch_new_tracks() first sees a new snapshot it records it as pending; the snapshot is committed to the in-memory cache in on_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 via FFmpegExtractAudio.
  • Output template is {output_dir}/{title}.{ext} - files are written flat into the download directory.
  • A progress hook raises yt_dlp.utils.DownloadCancelled when the CancellationToken fires; 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.