Skip to content

About

Two-way Spotify ↔ YouTube Music playlist sync that merges, never deletes, and never duplicates.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

music-sync

CI Python 3.11+ License: MIT

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.

Previewing a sync: every match with its score, and every skipped song with the reason

Try it without any account

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 --demo

Pick 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.

Why this is harder than it looks

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.

How matching works

  1. Normalize — strip noise like (Official Video), - Remastered 2011, [Lyric Video]; fold accented and Turkish characters (Şımarık → simarik); move feat. artists from the title into the artist list; drop channel suffixes like VEVO and - Topic.
  2. Score — 0.45 × title + 0.40 × artist + 0.15 × duration with rapidfuzz. Matching ISRCs short-circuit to 100.
  3. 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
  4. 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
Loading

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.

Design decisions

  • 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 --yes are not cached either, so the next interactive run asks again.
  • The unofficial API is quarantined. YouTube Music has no public API; the ytmusicapi dependency 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 the Host header (DNS rebinding), requires the app's own Origin and 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.server plus 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).

Architecture

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

Using it with your own accounts

1. Spotify

Spotify requires an app registration even for your own account:

  1. In the Spotify developer dashboard, click Create app.
  2. Set the Redirect URI to http://127.0.0.1:8888/callback (not localhost; Spotify no longer accepts it) and select Web API.
  3. 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 spotify

Since February 2026, Spotify only lets development-mode apps run while the owner has an active Premium subscription.

2. YouTube Music

There is no official API, so the tool borrows your browser session:

  1. Open a private window and sign in at music.youtube.com.
  2. Open DevTools (F12) → Network, type browse in the filter and reload the page.
  3. Click a browse request → Headers → Request Headers → switch on Raw, and copy all of it into headers.txt.
  4. 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/ and headers.txt are git-ignored. headers.txt contains a session cookie — treat it like a password and delete it once you are signed in.

3. Sync

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 sides

Every run writes a Markdown report to reports/, listing what was matched to what and why each skipped song was skipped.

A grey-zone match: the tool shows the candidate with its score and lets you decide

Tests

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 versions
  • tests/test_engine.py — no deletes, no duplicates, idempotent runs
  • tests/test_webui_security.py — CSRF, DNS rebinding, XSS and path traversal, over real HTTP
  • tests/test_logs.py — cookies and tokens never reach a log line

Limitations

  • 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.
  • ytmusicapi is 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.

License

MIT

About

Two-way Spotify ↔ YouTube Music playlist sync that merges, never deletes, and never duplicates.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages