Self-hosted, always-on playlist sync for Spotify, Apple Music, and YouTube Music — plus a local, Jellyfin-ready audio mirror.
A free, open-source, self-hosted alternative to Soundiiz, TuneMyMusic, and FreeYourMusic that you own and run.
One-way mirror or full bidirectional (N-way) sync · one-off playlist transfers · ISRC-accurate matching · all from your browser
Quick Start · Features · Screenshots · Docker · How it works · Report Bug · Request Feature
Share this project
Set it up once — every playlist you curate stays mirrored across every service, in date-added order.
Note
Web app + headless CLI, one engine. Click through a browser UI to connect services, build syncs, and transfer playlists — or run it .env + cron style. Both drive the same sync core.
Table of contents
- ✨ Features
- 📸 Screenshots
- 🚀 Quick Start
- 🐳 Always running: Docker
- ⚙️ How it works
- 💿 Local download mirror (Jellyfin)
- 🔌 Connecting each service
- 🖥️ Headless CLI
- 🛡️ Safety rails
- 🗃️ Caching & song archive
- 🧱 Project layout
- 🩺 Troubleshooting
- 📄 License
SongMirror keeps your playlists identical everywhere without manual re-adding, one-by-one copying, or a paid cloud service holding your library. It is cross-platform, self-hosted, and open source.
- 🔁 True mirroring, not append-only — adds and removals. Choose a source of truth (Spotify by default) and the others follow it.
- ⇄ Bidirectional N-way sync — an add or removal on any connected service propagates to all the others, echo-free, behind removal guards.
- 🎯 ISRC-accurate matching — exact recording identity where available, with Unicode-aware fuzzy title/artist/duration fallbacks (feat-credit drift, "- 2015 Remaster" suffixes, non-Latin scripts, video-only uploads — all handled).
- 🎛️ Multiple named syncs — set up as many independent syncs as you like, each with its own services, playlists, schedule, and safety caps.
- ↪️ One-off transfers — copy any playlist from one service to another with a live progress bar; pause, resume, or stop mid-copy, and manually resolve unmatched tracks.
- 🌐 Followed playlists — sync and transfer playlists you follow but don't own, not just ones you created.
- 💿 Local download mirror — keep offline audio, one folder per playlist in Jellyfin's
AlbumArtist/Albumlayout, with covers and an auto-updated.m3u8. - 🛡️ Safety rails — dry-run by default, per-pass add/removal caps, net-loss protection, empty-snapshot guard, fail-closed on expired tokens.
- 🗃️ Ever-growing song archive — every track ever seen is recorded in a local SQLite database (name, artist, album, ISRC, raw metadata, first/last seen).
- 🐳 Runs anywhere — one
docker compose up -dfor the browser app, or plain CLI + cron / Task Scheduler.
Important
Self-hosted and private by design. Your listening data and credentials never leave your machine. The web UI has no login — bind it to your LAN and don't port-forward it to the internet.
One dashboard for every library — sync status, jobs, live activity, and service health
Set up any number of syncs — one-way or bidirectional — in a short wizard
Connect every service in your browser — one-click OAuth, guided token paste, or an API key
Browse and pair playlists across services
The fastest way to run it is Docker — the container serves the web UI and runs your syncs on schedule.
git clone https://github.com/ahnafnafee/songmirror.git
cd songmirror
docker compose up -dThen open http://localhost:8888 and connect your services in the browser. That's it — no .env to edit; everything is configured in the UI and saved under ./data.
Prefer running it without Docker?
uv sync
uv run uvicorn songmirror.web:app --host 0.0.0.0 --port 8080 # then open http://127.0.0.1:8080Requires
uv(Python 3.13+). For the local download mirror, alsouv tool install spotdland haveffmpegon PATH.
The Docker container is the recommended deployment: it serves the web UI, runs your syncs on their schedules, and restarts with the host. It runs as songmirror and persists all auth + caches in ./data.
docker compose up -d --build # build + start in the background
# open http://<host>:8888 and connect your services + create syncs in the browser
docker compose logs -f # watch it workNo .env is needed to start — everything is configured in the browser and saved under ./data. Connect Spotify / YouTube Music with one-click OAuth (the wizard shows the exact redirect URI to whitelist), paste your Apple Music tokens, add a Jellyfin API key — all from the Accounts page — then build your syncs on the Sync page.
| Port | The UI is published on host 8888 (the 8888:8080 mapping in docker-compose.yml; change the host side if it clashes). LAN-only — don't port-forward it to the internet; the UI has no login yet. |
| Persistence | ./data holds credentials, tokens, caches, and the song archive. Back it up to keep your setup across rebuilds. |
| Downloads | Set DOWNLOAD_DIR (in .env or your shell) to your host music dir (e.g. F:\Torrent\Music); compose bind-mounts it to /music. From Docker, set JELLYFIN_URL to http://host.docker.internal:8096. |
| Expired Apple tokens | Re-paste them on the Accounts page; no restart needed. |
Every pass, for each selected playlist name that exists on the source:
- Snapshot the source playlist (tracks, ISRCs, added-at dates).
- Reconcile the same-named playlist on each target — Apple Music (via the web player's amp-api) and YouTube Music (via the official YouTube Data API v3) — concurrently.
- Missing tracks are resolved (cached links → ISRC → scored search) and appended oldest-first; tracks gone from the source are removed behind guards.
- Optionally, spotDL syncs a local audio folder per playlist.
The default source of truth is Spotify, but one-way mode is provider-agnostic — Apple Music or YouTube Music can be the source instead.
Same hierarchy the cross-service tools use (TuneLink, MusicBrainz): hard identifier → search → fuzzy score.
- Cached link — once a source track is matched to a target's catalog id / video id, that link is stored and reused (immune to title drift).
- ISRC — exact recording identity where the service exposes it.
- Scored search — RapidFuzz
token_set_ratio+ Jaro-Winkler, over both the raw and romanized (anyascii) title and artist, anchored by duration. This handles, without hardcoding:- Multi-artist credits — one service lists every feature, another lists the primary (
Arijit Singh, Ved Sharma, …↔Arijit Singh). - Title decoration —
(feat. …),- 2015 Remaster,(From "…"), extra "Official Music Video" suffixes. - Transliteration — Cyrillic / Bengali / Greek / Arabic (
Камин↔Kamin,নেশার বোঝা↔Neshar Bojha). - Video-only tracks — YouTube search falls back to the
videosfilter for indie/OST tracks that live on YT only as uploads.
- Multi-artist credits — one service lists every feature, another lists the primary (
The duration anchor unlocks the looser title match, so a different version (Runaway - Piano Version) or a wrong-artist cover isn't accepted when its length disagrees. Tracks with no confident match are reported and skipped.
By default one provider is the source of truth and edits flow one way. In N-way mode every provider is a peer: add or remove a track on Spotify, Apple Music, or YouTube Music and the change propagates to the others.
Bidirectional sync is impossible statelessly, so each logical playlist's canonical membership is snapshotted after every clean pass. Each pass diffs every provider against that snapshot, unions the changes, and reconciles everyone to the result:
- Echo-free — a propagated add becomes part of the snapshot, so it's never bounced back.
- Add-wins on conflict — losing a song is worse than keeping an extra one.
- Read-collapse guard — if a provider suddenly reads far fewer tracks than the baseline (a transient API hiccup), it's skipped that pass so one bad read can't cascade a mass-delete.
- Same rails as one-way — per-pass
MAX_ADDS/MAX_REMOVALScaps and net-loss protection hold on every write side. - Removals are opt-in —
MAX_REMOVALSdefaults to 0, so a track that disappears from one provider (deleted there, or silently pulled by licensing) is kept on the others and only logged. Set a cap (or the UI's "Mirror removals" toggle) to propagate deletions.
Always dry-run first. Run without
--execute(or use Preview in the UI) and read the plan — it prints every proposed add/remove on every provider before anything is written.
Keep an offline audio copy of each synced playlist, one folder per playlist, via spotDL. Sync is true mirroring: new tracks are downloaded, removed tracks are deleted locally. The layout is Jellyfin-ready — point a Jellyfin music library at the download dir and both the tracks and the playlists appear, staying updated every pass:
<DOWNLOAD_DIR>/
<Playlist>/
<Playlist>.m3u8 # auto-(re)generated; Jellyfin imports it as a playlist
cover.jpg # the source playlist cover, highest resolution
<AlbumArtist>/
<Album>/
Artists - Title.mp3 # tagged + cover art embedded
Enable it by setting DOWNLOAD_DIR and installing spotDL + ffmpeg:
uv tool install spotdl # isolated CLI; or: pipx install spotdl
# ffmpeg required: winget install ffmpeg (or: spotdl --download-ffmpeg)- Incremental — after the first full download, only newly-added tracks are fetched; removed tracks (and their emptied album folders) are pruned. An interrupted run continues next pass.
- Newest-first
.m3u8— written in date-added order, newest at the top (setLOCAL_MIRROR_ORDER=oldestto flip). Rebuild covers / tags / mtimes from existing files withuv run main.py --refresh-local. - Playlist covers in Jellyfin — Jellyfin ignores a cover file next to an m3u, so set
JELLYFIN_URL+JELLYFIN_API_KEYand each pass uploads the real playlist cover via the Jellyfin API. - Audio quality — the source is YouTube, so without a YT Music Premium cookie the ceiling is ~128–160 kbps.
LOCAL_MIRROR_FORMAT=opuskeeps YouTube's native stream without an mp3 re-encode; a Premium cookie (LOCAL_MIRROR_COOKIE_FILE) unlocks 256 kbps AAC.
Downloading audio is for personal use of content you have access to — your call.
In the web app, the Accounts page walks you through each service and shows the exact values to paste. You supply your own API app credentials once — nothing is proxied through a third party.
- Create an app at https://developer.spotify.com/dashboard and copy its Client ID + Client Secret.
- Add a redirect URI — the connect wizard shows the exact one, e.g.
http://127.0.0.1:8888/oauth/spotify/callback(Docker) orhttp://127.0.0.1:8080/oauth/spotify/callback(direct run). Spotify only allows anhttpredirect on the loopback IP, so authorize viahttp://127.0.0.1:<port>, not a LAN IP.
The web UI requests read + write scopes up front (Spotify is a write target in N-way syncs and reverse transfers). The CLI reads Spotify read-only in one-way mode.
No Apple Developer account needed — two headers from music.apple.com are enough. Open https://music.apple.com, sign in, open DevTools → Network, play a song, filter for amp-api.music.apple.com, and from any request's headers copy:
authorization: Bearer eyJ...→ Bearer token (theeyJ...part, withoutBearer)media-user-token: ...→ User token (full value)
The connect wizard lets you paste the raw headers and parses the values for you. Tokens last months; re-paste them on the Accounts page when they expire.
Talks to the official YouTube Data API v3, whose OAuth refresh token is durable and survives restarts.
- In the Google Cloud console, create a project, enable YouTube Data API v3, and create an OAuth client of type TVs and Limited Input devices.
- On the OAuth consent screen, set Publishing status → In production (leaving it in "Testing" expires the token after 7 days).
- In the app, paste the client ID + secret and complete the on-screen device code.
Quota: the Data API allows 10,000 units/day (a search costs 100, an add/remove 50). Steady-state upkeep is cheap; a big first-time backlog can hit the cap and resume the next day.
Prefer .env + cron / Task Scheduler? The same engine runs headless.
uv sync
cp .env.example .env # fill in credentials
uv run main.py # dry run — prints every add/remove it *would* do
uv run main.py --execute # apply for realUseful flags:
uv run main.py --execute --playlists "Aurora,Chill" # only these pairs
uv run main.py --execute --loop --interval 15m # run forever
uv run main.py --execute --max-removals 100 # one-off larger cleanupKey env vars (see .env.example): SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET, APPLE_BEARER_TOKEN / APPLE_USER_TOKEN, PLAYLISTS, SYNC_INTERVAL, MAX_ADDS / MAX_REMOVALS, DOWNLOAD_DIR, SYNC_MODE=nway, PROVIDERS.
Removals are destructive, so they're guarded:
- Dry run is the default — nothing changes without
--execute(or the UI's real-sync action). - If the source returns 0 tracks for a playlist the target shows as non-empty, removals are skipped that pass (a transient API failure can't empty a playlist).
- Removals are off by default —
MAX_REMOVALS=0holds every removal back (logged, never applied), so a licensing takedown on one platform can't cascade a deletion to the rest. Opt in per sync with the "Mirror removals" toggle (or setMAX_REMOVALS), and even then more pending removals than the cap in one pass → all skipped and logged. - More than
MAX_ADDSpending additions → the rest continue next pass (giant one-burst backfills are what trip bot detection). - Net-loss protection — a target-side track resembling a source track that has no match on that service is held, not deleted.
- Any Apple
401/403aborts the pass immediately — no partial deletes on expired tokens.
Everything resolvable is cached so steady-state passes are near-instant: per-service resolve caches (ISRC + search, including misses), a snapshot_id-keyed track-list cache, hard identifier links in SQLite, and a per-pair snapshot-skip (unchanged since last clean sync).
Every pass also archives the metadata of every track it sees into song_cache.db — a SQLite file that only ever grows. Removed tracks stay archived with name, artist, album, duration, ISRC, raw snapshot JSON, and first/last-seen timestamps:
sqlite3 song_cache.db "SELECT name, artist, album, first_seen FROM songs ORDER BY first_seen DESC LIMIT 20"CLI entry: uv run main.py (thin shim) or python -m songmirror. Web entry: songmirror.web:app.
songmirror/
engine/ # provider-agnostic sync core (no web deps): runner, matching, targets/, spotify, downloads, archive
services/ # stateful services over the engine: accounts/ connectors, syncs, sync_service, transfers, playlists, settings
web/ # FastAPI app: thin HTTP/SSE over services/ (routers/)
frontend/ # React + Vite SPA (built and served by the API in production)
Adding a service (Tidal, Deezer, …): subclass MirrorTarget, implement ~8 methods, add its builder to engine/targets' _REGISTRY, and add a matching Connector under services/accounts. All reconciliation — diff, ordering, safety rails, logging, snapshot-skip — is inherited.
Missing required environment variable— fill in.env(CLI) or connect the service in the UI.Apple rejected … (401/403)— re-capture the two Apple tokens (they expire).- Spotify OAuth redirect mismatch — the redirect URI in your Spotify app must exactly match the one the wizard shows (including the port).
- A playlist isn't syncing — confirm it's in the sync's playlist scope and exists on the source (targets are auto-created on a real pass).
Copyright © 2026 Ahnaf An Nafee.
This project is MIT licensed.
