Download a Spotify Exportify CSV playlist with yt-dlp using a generated work CSV as the persistent state file.
Get the playlist CSV from https://exportify.app/ and place the exported file in exportify.app inside this project.
- Uses a two-file workflow: source CSV from Exportify plus a sibling
_work.csvstate file. - Searches YouTube Music only using yt-dlp for better relevance.
- Can run in resolve-only mode, saving YouTube Music matches into the work CSV without downloading audio.
- Uses SpotDL-style weighted matching (duration proximity + title/artist overlap scoring instead of hard rejects).
- Extracts audio and requests MP3 at 320 kbps during download.
- Embeds metadata into output files from CSV fields (title, artist, album, date, ISRC, row ID, row key, Spotify track ID).
- Automatically embeds cover artwork from a local sidecar image when one exists, or fetches the best YouTube thumbnail URL and embeds it directly when needed.
- Records
artwork_status = embeddedin the work CSV when artwork is successfully embedded. - Can reuse saved
resolvedrows and download them later without repeating YouTube Music search. - By default, rows with tracking data are skipped unless a mode explicitly continues from
resolvedor force-redownload is used. - Streams live row-by-row console logs so you can see what track is being checked, downloaded, skipped, or failing.
- Includes reconcile utilities to scan existing audio files, restore CSV paths, and refresh metadata without downloading.
- Supports processing order by persistent work CSV
idusing--id-order(priority,ascending,descending, or default CSV order). - Configurable yt-dlp throttling profile (rate limiting, sleep intervals) tuned by default for YouTube Music compliance.
- Put your raw Exportify CSV in
exportify.appand treat it as source data. - On first run,
main.pycreates<playlist>_work.csvand sends that file to the downloader. - On later runs, source fields are synced into
<playlist>_work.csvand new rows are appended. - Each
_work.csvrow gets a persistentidcolumn as the first column; new rows are assigned the next available numeric ID. - Downloads go into
<playlist>/, not<playlist>_work/, even when the downloader is running from<playlist>_work.csv. - Folder runs skip
_work.csvfiles when source CSVs exist to avoid double-processing. - If a folder only contains
_work.csvfiles, those are processed directly.
On first run, these columns are appended to the work CSV if missing:
- id
- row_key
- download_status
- artwork_status
- youtube_url
- selected_title
- selected_duration_s
- duration_delta_s
- output_file
- attempted_at
- error_message
download_status values:
- resolved: A YouTube Music match was chosen and saved in the work CSV, but download is disabled or not yet run.
- downloaded: File successfully downloaded and exists on disk. Row is complete. Skipped on subsequent runs (unless
--force-redownloadis used). - unresolved: No safe candidate match found within duration tolerance. Row is skipped and not retried unless explicitly re-running.
- error: Search, download, or metadata write failed. Row is skipped and marked for potential manual review.
- retry: The row hit a transient YouTube rate limit (e.g., HTTP 429). Should be retried on a later rerun to allow the rate limit to clear.
- skipped: Runtime-only counter for rows already marked as downloaded or complete (not stored in CSV).
artwork_status values:
- embedded: Artwork was successfully embedded, either from a local sidecar image or a fetched thumbnail URL.
- (empty): No matching image file was found, or artwork embedding has not yet been attempted for this row.
row_key is an explicit identity key per row:
sp:<spotify_track_id>when Track URI includes a Spotify track ID.fp:<hash>fallback when Track URI is missing.#2,#3, and so on for duplicate rows with the same base identity.
Export your playlist CSV from https://exportify.app/, place it in exportify.app, and run from this project folder:
python main.pyDefaults are loaded from downloader.config.json.
- Edit
downloader.config.jsonto keep your preferred settings in one place. - CLI flags always override config values.
- You can point to another config with
--config-path. Limitis the maximum number of rows to process in one run.0means no limit.DownloadEnabledcontrols whether rows stop after resolution or continue into audio download.SleepRequestsis the yt-dlp delay between requests. The current config default is1.1.
Example:
python main.py --config-path ./downloader.config.jsonCommon options:
python main.py --csv-folder ./exportify.app --duration-tolerance 12 --search-results 8
python main.py --csv-path ./exportify.app/3_dnb_dance_floor.csv --resolve-only
python main.py --csv-path ./exportify.app/3_dnb_dance_floor.csv --limit 5
python main.py --csv-path ./exportify.app/3_dnb_dance_floor.csv --sleep-requests 2.0
python main.py --csv-folder ./exportify.app --force-redownload
python main.py --csv-folder ./exportify.app --cookies-from-browser edge
python main.py --csv-folder ./exportify.app --cookies-file "./music youtube cookies.txt"
python main.py --csv-path ./exportify.app/3_dnb_dance_floor.csvDefault config currently ships with:
Limit: 0DownloadEnabled: trueSleepRequests: 1.1LimitRate: 4MThrottledRate: 50KSleepInterval: 10MaxSleepInterval: 35IdOrder: priority(untouched rows first, retry rows last)
- Title, artist, album, track, and other tags are written back to audio files using ffmpeg.
- Cover artwork is embedded from sidecar image files (
.jpg,.png,.webp) when they already exist next to the audio file. If no sidecar is present, the downloader fetches the best thumbnail URL from YouTube metadata and embeds it directly with ffmpeg. Embedding is best-effort and non-blocking. artwork_statusin the work CSV is set toembeddedwhen artwork is successfully applied.- Metadata
trackis set to the persistent work CSVidwhen available. - Additional tags include
row_id,row_key,spotify_track_id, and combinedcomment. - For Windows compatibility, tags are written at both container and stream metadata levels.
reconcile_metadata.pycan backfill artwork for existing files using local sidecar thumbnails or by refetching the best thumbnail URL from each row'syoutube_url.
main.py: Root launcher wrapper for normal folder/CSV runs.tools/reconcile_csv_files.py: Wrapper for the reconcile utility.exportify_downloader/scripts/reconcile_metadata.py: Packaged metadata-only reconcile command.tools/check_tags.py: One-off utility to inspect ID3 tags on a single file.tools/embed_artwork.py: One-off utility to bulk-embed sidecar artwork into a folder of MP3s.tools/download_youtube_music_url.py: One-off utility to download a single YouTube or YouTube Music track with config defaults.tools/find_new_tracks.py: Compare a newly downloaded folder against an original library folder and report likely missing tracks.tools/bpm_analysis.py: Estimate BPM across a folder using multiple analyzers and write a comparison CSV.exportify_downloader/launcher/: Packaged launcher config, runner, and main orchestration.exportify_downloader/core/: Packaged downloader, matcher, CSV state, metadata, yt-dlp, and utility logic.exportify_downloader/scripts/: Packaged maintenance script implementations.downloader.config.json: Default tuning values used by the Python launcher.music youtube cookies.txt: Optional cookies file used automatically when present.
- If YouTube returns HTTP 403, run with
--cookies-from-browser edge(or your browser). - If you see rate-limit behavior, increase
--sleep-requests(for example 1.5 to 3.0). - If you see repeated
try again lateror session rate-limit errors, the downloader now marks the row withdownload_status=retry, then stops early so the rest of the run is not flooded with the same transient error. - Rerun later and those
retryrows will be attempted again automatically without clearing CSV fields. - If some titles appear blank in Windows Explorer, retag files and refresh Explorer metadata cache.
- If a row is
downloadedbutoutput_fileis blank, reconcile the path then retag.
See also:
docs/METADATA_TAGGING.mddocs/MAINTENANCE.md
Additional engineering notes live in docs/IMPLEMENTATION.md.