Local fork of scdl-org/scdl, a SoundCloud downloader that wraps yt-dlp and syncs with your local files.
Extended with YouTube audio/video support and bulk playlist sync.
Requirements: Python 3, ffmpeg
uv sync --dev
.\.venv\Scripts\Activate.ps1python -m venv .venv
. .\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pipx install scdl
pipx upgrade scdlpython src/sync_sc_playlists.py # sync all SoundCloud playlists
python src/cleanup_short_tracks.py --delete # purge any GO+ 30s snips
python src/sync_yt_playlists.py --delay 15 # sync all YouTube audio playlists/!\ make sure to be logged-in in your browser and have it preferably closed (cookies are specified in ytdl.cfg)
# Sync one playlist (downloads new, marks removed as [unsync])
scdl -l https://soundcloud.com/artist/sets/playlist-name --sync
# Sync all playlists listed in src/sc-playlists-list.md
python src/sync_sc_playlists.py
python src/sync_sc_playlists.py --delay 15 # slower inter-playlist delay (default 8s ±50%)
python src/sync_sc_playlists.py --max-errors 3 # abort a playlist after N consecutive errors (default 5)
python src/sync_sc_playlists.py --force-all # full sync every playlist (also detects removals → [unsync])
python src/sync_sc_playlists.py --resume # resume last interrupted run
python src/sync_sc_playlists.py --verbose # show all scdl/yt-dlp output unfiltered
# Run a subset (indices = line order in sc-playlists-list.md, stable across runs)
python src/sync_sc_playlists.py --only 2 5 # run only playlists 2 and 5
python src/sync_sc_playlists.py --skip 3 7 # skip playlists 3 and 7
python src/sync_sc_playlists.py --from 4 # start from playlist 4
# One-off downloads
scdl -l https://soundcloud.com/artist/track-name # single track
scdl -l https://soundcloud.com/artist/sets/playlist-name # full playlist
scdl -l https://soundcloud.com/artist -a # all tracks + reposts
scdl -l https://soundcloud.com/artist -f # likes
scdl -l https://soundcloud.com/artist -t # uploads only
scdl me -f # your own likes (requires auth)Archives auto-named and stored in archive_trackers/sc/.
By default sync_sc_playlists.py avoids scanning entire playlists. SoundCloud returns newest-added tracks first, so new songs always appear at the top of a playlist. The fast-path exploits this:
Assumes new tracks are appended to the end of the playlist (oldest = position 1, newest = position N). This is the typical behaviour for user-curated SC playlists.
yt-dlp --flat-playlist --print id --playlist-items 1:3 --quiet <url>--flat-playlistfetches the playlist index from SC's API — no audio downloaded.--print idprints only the track ID of each entry, one per line.--playlist-items 1:3limits to the 3 oldest tracks (1-based, inclusive).
These 3 IDs are checked against the local archive. Since they are the oldest songs in the playlist, they should always be in the archive. If none match, the playlist may have been replaced or radically reordered → fall back to full sync.
N = number of lines in archive_trackers/sc/<playlist>.txt
Each archive line is soundcloud <track_id> <filepath>, so N is simply the count of downloaded tracks. New songs, if any, must be at positions N+1, N+2, … on SC.
yt-dlp --flat-playlist --print id --playlist-items {N+1}:{N+1} --quiet <url>- If this returns nothing → playlist unchanged → skip entirely (no scdl call).
- If this returns an ID → new songs start at N+1 → proceed to Step 3.
scdl -l <url> --sync --hide-progress -o {N+1} ...-o N+1tells scdl to start at position N+1 (--playlist-items N+1:internally).SyncDownloadHelpersees that-ois set and marks all existing archive entries that weren't downloaded this run as "not evaluated" — so they are preserved in the archive instead of being falsely marked[unsync].
Total API calls per playlist in fast-path: ≤ 4 (3 for sanity + 1 for frontier check), versus up to hundreds for a full sync.
--force-all disables the fast-path and runs a full scdl --sync for every playlist. This is the only mode that detects tracks removed from a playlist and marks them [unsync].
Archives auto-named and stored in archive_trackers/sc/.
-l [url] URL can be track/playlist/user
-a Download all tracks of user (including reposts)
-p Download all playlists of a user
-o [offset] Start from item N in playlist (starting with 1)
--force-metadata Re-embed metadata on already-downloaded files
--sync Auto-managed archive: downloads new, marks removed as [unsync]
-t Download all uploads of a user (no reposts)
-f Download all favorites (likes) of a user
-C Download all tracks commented on by a user
-s [search_query] Search and use the first result
-r Download all reposts of user
-c Continue if a downloaded file already exists
--download-archive [file] Keep track of track IDs and skip already-downloaded files
--flac Convert lossless originals to FLAC
--original-art Download full-res artwork instead of 500×500 JPEG
--original-name Keep original filename on original-quality downloads
--no-original Only download mp3/m4a/opus, skip original files
--only-original Only download tracks with an original file available
--opus Prefer opus streams over mp3
--onlymp3 Download only mp3 files
--name-format [format] Custom filename format (use "-" to pipe to stdout)
--playlist-name-format [format] Custom filename format for playlist tracks
--overwrite Overwrite existing files
--strict-playlist Abort if one track in a playlist fails
--no-playlist Skip playlist entries, download only tracks
--add-description Save track description to a .txt sidecar file
--path [path] Custom download directory
--min-size [size] Skip tracks smaller than size (k/m/g)
--max-size [size] Skip tracks larger than size (k/m/g)
--no-album-tag Prevent shared cover art across tracks from same album
--extract-artist Set artist tag from title (e.g. "Artist - Title" format)
--yt-dlp-args [argstring] Forward extra args to yt-dlp
--client-id [id] Override the SoundCloud client_id
--auth-token [token] Override the auth token
--debug Verbose logging
# Sync one playlist
python src/ytdl.py -l https://www.youtube.com/playlist?list=PLxxx --sync
# Resume from item N (e.g. after an interruption — skips items 1 to N-1)
python src/ytdl.py -l URL --sync -o 123
# Sync all playlists listed in src/yt-playlists.md
python src/sync_yt_playlists.py
python src/sync_yt_playlists.py --delay 15Files land in playlists/yt/<playlist name>/. Archives in archive_trackers/yt/.
# Sync one playlist
python src/ytdl.py -l https://www.youtube.com/playlist?list=PLxxx --sync --video
# Sync all playlists listed in src/yt-video-playlists.md
python src/sync_yt_playlists.py --video
python src/sync_yt_playlists.py --video --delay 15Files land in playlists/yt-video/<playlist name>/. Archives in archive_trackers/yt-video/.
| File | Content |
|---|---|
<playlist_id>.txt |
sync archive (tracks downloaded + file paths) |
<playlist_id>.failed |
tracks that failed (archive_id + URL) |
<playlist_id>.errors.log |
raw error output, appended per run |
Removed-from-playlist tracks are renamed with [unsync] prefix (same as SoundCloud).
Find your OAuth token: log into SoundCloud → F12 → Storage → Cookies → oauth_token.
Format: 2-322xxx-31626xxx1-SJsONuxxxelkKD
Add to scdl/scdl.cfg (or the system config, see below):
[scdl]
auth_token = 2-322xxx-...Required for GO+ tracks (256 kbps AAC) and original-quality downloads.
Config file locations:
- Windows:
C:\Users\<username>\.config\scdl\scdl.cfg - Mac/Linux:
~/.config/scdl/scdl.cfg - If
XDG_CONFIG_HOMEis set:$XDG_CONFIG_HOME/scdl/scdl.cfg
yt-dlp reads browser cookies for age-restricted or private content. Set in ytdl.cfg:
[ytdl]
cookies_from_browser = firefox:C:\Users\$USER$\AppData\Roaming\librewolf\Profiles\xxxx.default-defaultClose LibreWolf (or your browser) before syncing. Open browsers rotate session cookies mid-download and invalidate them after ~150 items on large playlists.
Workflow:
- Open LibreWolf → log into YouTube (refresh session)
- Close LibreWolf completely
- Run the sync
The duration filter (duration>30 in scdl.cfg) skips 30 s preview snips automatically.
After each run, three files are written to archive_trackers/sc/:
| File | Content |
|---|---|
<playlist>.txt |
sync archive (downloaded tracks + file paths) |
<playlist>.failed |
tracks that failed, tagged by reason |
<playlist>.premium |
tracks skipped as ≤30 s snips |
Tags in .failed:
| Tag | Meaning |
|---|---|
[GO+] |
Full track behind SoundCloud GO+ paywall (policy=SNIP) |
[MONETIZE] |
Ad-gated stream yt-dlp cannot negotiate (e.g. major-label uploads) |
[BLOCKED] |
Geo/copyright block (policy=BLOCK) |
[FAIL] |
Any other error, with raw error message appended |
Run python src/cleanup_short_tracks.py (dry run) or --delete to purge existing snips.
playlists/
sc/ # SoundCloud audio, one subfolder per playlist
yt/ # YouTube audio (m4a), one subfolder per playlist
yt-video/ # YouTube video (mp4), one subfolder per playlist
archive_trackers/
sc/ # per-playlist .txt, .failed, .premium
yt/ # per-playlist .txt, .failed, .errors.log
yt-video/ # per-playlist .txt, .failed, .errors.log
src/
sc-playlists-list.md # SoundCloud playlist URLs (gitignored)
yt-playlists.md # YouTube audio playlist URLs (gitignored)
yt-video-playlists.md # YouTube video playlist URLs (gitignored)
scdl/scdl.cfg # SC config: client_id, auth_token, path, name_format (gitignored)
ytdl.cfg # YT config: cookies_from_browser, path, video_path (gitignored)
Archive line format: soundcloud <track_id> <absolute_path> (SC) or youtube <video_id> <absolute_path> (YT).