Two-way playlist sync between Spotify and YouTube Music, with a local web UI and a CLI.
Two guarantees drive the whole design: it never deletes a song (sync only merges) and it never creates duplicates — running the same sync twice adds nothing the second time.
Demo mode runs the real engine, matcher and UI against two built-in sample libraries, in memory:
git clone https://github.com/AtakanUk/music-sync.git
cd music-sync
python -m venv .venv
.venv/bin/pip install -e . # Windows: .venv\Scripts\pip install -e .
.venv/bin/music-sync ui --demo # Windows: .venv\Scripts\music-sync ui --demoPick Road Trip on the Spotify side and press Preview. That one playlist runs into every matching rule described below, including a song the tool is unsure about and asks you to decide on.
The same song is spelled differently on each platform:
| Spotify | YouTube Music |
|---|---|
Bohemian Rhapsody - Remastered 2011 |
Bohemian Rhapsody (Official Video) |
Şımarık / Tarkan |
Simarik / Tarkan |
SICKO MODE / Travis Scott, Drake |
SICKO MODE (feat. Drake) / Travis Scott |
Blinding Lights / The Weeknd |
Blinding Lights / TheWeekndVEVO |
So matching by title — or by ID — isn't enough. And the opposite mistake is worse: a karaoke cover or a 20-minute live recording that looks like a match ends up silently in your playlist.
- Normalize — strip noise like
(Official Video),- Remastered 2011,[Lyric Video]; fold accented and Turkish characters (Şımarık→simarik); movefeat.artists from the title into the artist list; drop channel suffixes likeVEVOand- Topic. - Score —
0.45 × title + 0.40 × artist + 0.15 × durationwith rapidfuzz. Matching ISRCs short-circuit to 100. - Hard gates — a wrong match is worse than a missing one, so some candidates are rejected regardless of score:
- artist similarity < 50 → filters out covers and karaoke versions
- duration differs by > 25 s → a 5-minute track never matches a 22-minute live set
- live / remix / acoustic / … on only one side → a 25-point penalty
- Decide — ≥ 87 is accepted automatically · 70–87 is put to the user with the candidates side by side · < 70 is skipped and reported.
flowchart TD
T[Track from the source playlist] --> H{Already in the target?<br/>same ID or fuzzy match}
H -- yes --> HAVE[Count as already there]
H -- no --> C{Answer in the match cache?}
C -- known match --> ADD[Add to the target]
C -- known miss --> SKIP[Skip and report why]
C -- no --> S[Search the other platform<br/>and score each candidate]
S --> G{Passes the hard gates?}
G -- no --> SKIP
G -- "yes, score ≥ 87" --> ADD
G -- "yes, 70–87" --> ASK[Ask the user]
G -- "yes, below 70" --> SKIP
ASK -- picks one --> ADD
ASK -- none --> SKIP
Decisions are cached (state/match_cache.json), so a song is never asked about twice and repeat runs barely touch the network. Automatic "not found" results expire after 30 days in case the track gets added later; explicit rejections are permanent.
- Merge-only by construction. The provider interface has no remove operation at all, so "never deletes" is enforced by the types, not by care.
- Duplicates are checked by meaning, not by ID. Before adding a track, the same fuzzy matcher runs against the target playlist's current contents, so a remastered and an official-video version of a song are recognized as one. Tests prove a second identical run adds nothing.
- Two-way sync works on snapshots. Both sides are read before anything is written, so a song just added to A is never echoed back to B in the same run.
- Failures never poison the cache. A 401/429/5xx from Spotify or a dead YouTube Music session raises instead of returning "no results"; otherwise the song would be cached as "not found" for 30 days. Grey-zone songs skipped with
--yesare not cached either, so the next interactive run asks again. - The unofficial API is quarantined. YouTube Music has no public API; the
ytmusicapidependency lives behind the provider interface, so if Google changes its internals, the breakage stays in one file. The same interface made demo mode a single extra file. - A local web UI is still an attack surface. The server holds a Spotify token and a YouTube session cookie, and any website open in the same browser can send requests to
127.0.0.1. So it binds to loopback only, checks theHostheader (DNS rebinding), requires the app's ownOriginand a JSON content type on every POST (CSRF), escapes everything it reflects into HTML, and redacts cookies and tokens from every log line. - No web framework. The UI is the standard library's
http.serverplus vanilla JS. The engine blocks while it waits for a user's answer, so it runs on a worker thread and hands grey-zone questions to the browser through a small polling handshake (webui/jobs.py).
music_sync/
engine.py sync engine: resolve pairs, match, merge-only + duplicate-safe writes
matching/ normalize.py (cleanup) · matcher.py (scoring, gates) · cache.py
providers/ base.py (interface) · spotify.py · ytmusic.py · demo.py
webui/ server.py (HTTP + security) · jobs.py (background job) · static/
cli.py Typer CLI
report.py console summary + a Markdown report per run
logs.py console / file / live-panel logging with secret redaction
Spotify requires an app registration even for your own account:
- In the Spotify developer dashboard, click Create app.
- Set the Redirect URI to
http://127.0.0.1:8888/callback(notlocalhost; Spotify no longer accepts it) and select Web API. - Copy the Client ID from the app settings. No client secret is needed; the app uses the OAuth PKCE flow.
cp config.example.toml config.toml # then paste your Client ID into it
music-sync auth spotifySince February 2026, Spotify only lets development-mode apps run while the owner has an active Premium subscription.
There is no official API, so the tool borrows your browser session:
- Open a private window and sign in at music.youtube.com.
- Open DevTools (F12) → Network, type
browsein the filter and reload the page. - Click a
browserequest → Headers → Request Headers → switch on Raw, and copy all of it intoheaders.txt. - Close the private window without signing out. Google rotates session tokens while you browse, so headers from a window you keep using go stale within minutes; a closed private session lasts for months.
music-sync auth ytmusic --from-file headers.txt
music-sync doctor # live check of both sides
config.toml,state/andheaders.txtare git-ignored.headers.txtcontains a session cookie — treat it like a password and delete it once you are signed in.
The web UI is the easiest way: run music-sync ui (or double-click scripts\start-ui.bat on Windows), pick a source and a target, Preview the matches with their scores, then Confirm. The server stops itself when the tab closes or after 10 idle minutes.
The CLI covers the same ground:
music-sync sync --dry-run # always preview first
music-sync sync --direction both # two-way merge; missing playlists are created
music-sync sync --direction spotify-to-yt --playlist "Chill Vibes"
music-sync sync --direction both --liked # include liked songs
music-sync sync --only-configured --yes # playlists from config.toml, no questions (cron)
music-sync playlists # compare playlists on both sidesEvery run writes a Markdown report to reports/, listing what was matched to what and why each skipped song was skipped.
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .The suite runs offline in a few seconds, against fake providers and a real HTTP server on a random port. CI runs it on Ubuntu and Windows with Python 3.11–3.13. The ones that matter most:
tests/test_matcher.py— the hard gates catching covers, karaoke and live versionstests/test_engine.py— no deletes, no duplicates, idempotent runstests/test_webui_security.py— CSRF, DNS rebinding, XSS and path traversal, over real HTTPtests/test_logs.py— cookies and tokens never reach a log line
- Spotify playlists you follow but don't own can't be read through the API (since February 2026); they are reported, not synced.
- Platform-exclusive tracks and Spotify local files are skipped and listed in the report.
ytmusicapiis unofficial, so a change on Google's side can break the YouTube Music half until the library catches up.- Credentials, the match cache and reports live next to the code (
state/,reports/), so run it from a clone with an editable install.

