English · العربية · Türkçe · Español · 简体中文 · Français · Português · Deutsch · 日本語 · हिन्दी · বাংলা · Bahasa Indonesia · 한국어 · Italiano · Tiếng Việt
Self-hosted, always-on playlist sync for Spotify, TIDAL, Qobuz, Deezer, Amazon Music, 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, multi-source merge, authoritative-group, 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
- 📦 Playlist metadata backups
- 💿 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.
- ⇆ Authoritative groups — trust two or more services (for example Spotify + Apple Music) while every other selected service remains a destination-only mirror.
- ⇄ Bidirectional N-way sync — an add or removal on any connected service propagates to all the others, echo-free, behind removal guards.
- ⇉ Multi-source merge sync — schedule the deduplicated union of library playlists and public playlist URLs into one destination, without saving or following the public lists.
- ♥ Liked and favorite tracks — sync each service's built-in liked collection across all seven music providers, either into the destination's own favorites or a new named playlist.
- 🎯 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.
- 🕒 Append or preserve order — copies land at the end of the destination by default, fast and additive. Switch on Preserve Recently Added order to rewrite the tracks after the oldest new one so date-added order matches the source.
- 🔗 Transfer from a link — paste a public playlist URL from any connected service and copy it straight across. No need to save or follow it first.
- 🌐 Followed playlists — sync and transfer playlists you follow but don't own, not just ones you created.
- 📦 Scheduled metadata backups — archive an account's entire playlist library on its own schedule under persistent app data, with JSON/XML, retention limits, and visible success/failure history. One-off downloads and import-ready Soundiiz JSON remain available too.
- 💿 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).
- 🧭 Editable match history — browse, correct, and delete every cached track match per service from the Mappings page, including the "no match" results that would otherwise stay unmatched forever.
- 🐳 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, multi-source merge, authoritative-group, 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 — Compose pulls the published image, serves the web UI, and runs your syncs on schedule.
For a persistent installation with automatic restarts:
git clone https://github.com/ahnafnafee/songmirror.git
cd songmirror
docker compose up -dOr try the public GHCR image directly without cloning the repository:
docker run --rm -d --name songmirror -p 127.0.0.1:8888:8080 ghcr.io/ahnafnafee/songmirror:latestThen open http://localhost:8888 and connect your services in the browser. The Compose setup needs no .env to start; everything is configured in the UI and saved under ./data.
The direct docker run option is disposable: docker stop songmirror removes the container and its configuration. Use Compose for a durable installation with persistent credentials, caches, and downloads, or see the container image guide for tags and digest pinning.
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.
SongMirror supports English, Arabic, Turkish, Spanish, Simplified Chinese, French, Portuguese, German, Japanese, Hindi, Bengali, Indonesian, Korean, Italian, and Vietnamese. On first launch, it uses the first supported language in your browser's language preferences, including regional variants, and falls back to English. Change the language at Settings → General → Language; your choice is saved in this browser and survives reloads. Choose Automatic (browser) to follow your browser preferences again. Arabic uses a right-to-left interface. Playlist names, artist names, provider names, credentials, and diagnostic logs keep their original values.
The Docker container is the recommended deployment: it serves the web UI, runs your syncs on their schedules, and restarts with the host. Compose pulls ghcr.io/ahnafnafee/songmirror:latest, runs it as songmirror, and persists all auth + caches in ./data.
docker compose up -d # pull the published image + start in the background
# open http://<host>:8888 and connect your services + create syncs in the browser
docker compose logs -f # watch it workTo update, run docker compose up -d --pull always. To build the current checkout instead, run docker compose up -d --build. See the container image guide for tags, digest pinning, direct pulls, verification, updates, and rollback.
No .env is needed to start — everything is configured in the browser and saved under ./data. OAuth, partner-token, and API-key setup all live on the Accounts page; each wizard explains the service-specific prerequisites and exact callback URI. Then build your syncs on the Sync page.
Opening SongMirror from another computer works at http://<server>:8888. The default Spotify connection uses a pasted sp_dc web session, so it needs no developer app or callback URL. If you intentionally use the legacy developer-app OAuth fallback behind Docker or a reverse proxy, set the browser-visible base URL in .env:
SPOTIFY_AUTH_MODE=oauth
SPOTIFY_CLIENT_ID=your-client-id
SPOTIFY_CLIENT_SECRET=your-client-secret
SONGMIRROR_PUBLIC_URL=https://music.example.comSongMirror will then advertise https://music.example.com/oauth/spotify/callback; register that exact URI in the Spotify app dashboard and recreate the container with docker compose up -d --force-recreate. A reverse-proxy base path is supported too (for example, https://example.com/songmirror). Spotify requires HTTPS for every non-loopback redirect; plain HTTP is accepted only with literal loopback addresses such as 127.0.0.1, not a LAN IP or localhost.
| Image | ghcr.io/ahnafnafee/songmirror:latest supports AMD64 and ARM64. Each build is also published with a commit-specific sha-... tag; Git tags such as v1.2.3 additionally publish 1.2.3, 1.2, and 1. Use the container image guide to pin an immutable digest. |
| 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, the song archive, and scheduled playlist snapshots under playlist_backups/. Back it up to keep your setup and archives 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 sessions | Renewable sessions recover on the next scheduled or manual pass. TIDAL web-player sessions renew from the captured refresh token; Qobuz and Apple Music tokens must still be re-pasted when rejected. No restart is 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 every selected, connected target concurrently through that service's account-authorized playlist API.
- 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 — any connected playlist peer 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.
A Merge sources job combines one or more explicit playlists into one chosen destination. Each source can come from a connected account's library or a pasted public provider URL; the latter is resolved to a provider and playlist id once, so the playlist does not need to be saved or followed and scheduled runs do not replay an arbitrary URL.
- One membership union — all constituents are read before the destination is reconciled. Shared ISRCs are one recording; without an ISRC, exact/conservative title, artist, version, and duration evidence deduplicates overlaps.
- Deterministic order — source descriptor priority first, then the order returned by each source playlist. The first occurrence owns the destination position and display metadata; later copies only enrich missing identity metadata.
- Union-safe removals — a destination track can be removed only when a complete pass finds it absent from every constituent source. A failed, truncated, malformed, unavailable, or unknowably empty source disables every removal for that pass, while safe additions from readable sources may continue.
- Append-only by default — leave Remove tracks absent from every source off to keep all destination-only tracks. Turning it on opts into the normal per-pass removal cap after the complete-read guard passes.
Merge jobs currently target one provider playlist; the separate Spotify-led local download/Jellyfin mirror is not available for an aggregate job.
Use an authoritative group when you actively curate the same logical playlist on two or more services, but want every other selected service to follow them. A typical setup is Spotify + Apple Music as authorities, with TIDAL, Qobuz, Deezer, Amazon Music, and YouTube Music as mirrors.
- Membership comes only from authorities — a track added on Spotify or Apple Music propagates to the other authority and every mirror. A track added only on a mirror is drift; it is never imported back into the authorities.
- One order authority — choose which authority supplies playlist names and the ordering of additions. The other authorities still contribute membership changes.
- Confirmed removals propagate from either authority — an absence must appear in two consecutive complete reads before it can delete anything. A simultaneous authority-side addition wins over a removal.
- Mirrors never get a vote — deleting a track from a mirror repairs that mirror; it does not delete the track from Spotify or Apple Music.
- Safe first pass — every authority set has its own baseline. Its first successful pass may add missing tracks, but holds all removals until a later pass proves the baseline is stable.
- Fail closed — if any authority is disconnected, unreadable, or its playlist cannot be opened/created, that logical playlist is skipped instead of silently falling back to fewer authorities.
Removal writes remain opt-in and capped. Enable Mirror removals for the job (or set MAX_REMOVALS in headless mode) if mirrors should be pruned to match the authoritative set.
By default one provider is the source of truth and edits flow one way. In N-way mode every selected provider is a peer: add or remove a track on any one 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.
On a sync's Playlists step, select the source service's built-in liked collection. SongMirror then asks where it should go on every selected destination: directly into that service's own liked/favorite collection, or into a new playlist whose suggested name you can edit. A new selection is liked-only; turn on Also sync every regular playlist or pick individual playlists to include both.
This works across Spotify Liked Songs, TIDAL/Qobuz/Deezer Favorite Tracks, Amazon Music My Likes, Apple Music Favorite Songs, and YouTube Music Liked Music. The same one-way, authoritative-group, and N-way reconciliation paths and safety caps apply. As with ordinary playlists, removal writes remain off by default until Mirror removals is enabled.
TIDAL's signed-in web-player grant handles both ordinary playlists and native Favorite Tracks when it carries r_usr and w_usr. Capturing the complete sign-in token response gives SongMirror the refresh token as well as the short-lived Bearer, so the session can renew automatically.
Some of these integrations use the providers' first-party web interfaces and can change without notice; the feasibility assessment records the API and distribution constraints for each provider.
Backups do not require a second provider or a sync job:
-
On Settings → Playlist backups, use Add backup at the top to add a connected account. Choose JSON or XML, then select a frequency such as daily or weekly. Custom intervals use a number and a unit. Keep backups offers retention presets, a custom count, or All backups.
-
Backups default to
data/playlist_backups/<account-profile-id>/(or/data/playlist_backups/<account-profile-id>/in Docker). Click Backup folder for the built-in folder picker, or choose Enter path manually. A custom folder still gets a separate subfolder for each account. Use default backup folder restores the default. Changing locations affects future backups; old files stay where they are. Retention and Download latest apply to the selected location. Removing a schedule never deletes saved files. -
Settings → Downloads & Jellyfin → Download folder uses the same built-in picker and manual entry. Choose a folder accessible to your Jellyfin library. Downloads follow each opted-in sync's schedule on the Sync tab. The picker displays configured host paths (for example,
F:\Torrent\Music) while retaining their Docker mapping (/music) internally. Existing download mounts are unchanged. Additional host folders must first be shared as Docker bind mounts; choosing an unmounted folder shows an error and leaves the current setting unchanged. -
The same Settings card shows the next run, stored snapshot count, last successful file and counts, and the most recent failure. Back up now queues a safe on-demand run; Download latest retrieves the newest persisted snapshot.
-
On the Playlists page, use Export on a service card to download every playlist from that service in one versioned JSON or XML file.
-
Open a playlist to export only that playlist. Its Soundiiz option follows Soundiiz's documented JSON import shape, so the downloaded track list can be uploaded through Soundiiz's Import Playlist → From File flow.
-
SongMirror JSON/XML preserves playlist order and names plus provider track/occurrence IDs, available ISRCs, artists, albums, album track positions, durations, added dates, artwork links, and unavailable-entry markers. ID-less catalog ghosts remain in the backup instead of disappearing. Files contain no cookies, tokens, request headers, previews, or streaming-file URLs.
Manual exports are downloaded by the browser to the device running the UI. Scheduled exports use the existing application-data volume, so no second host path or container mount is required. Backup reads queue behind syncs and transfers instead of accessing provider clients concurrently. The schema_version field lets future releases evolve the lossless format without making old snapshots ambiguous.
Click a folder field or Browse… to open the built-in picker. Use Locations, clickable breadcrumbs, Back, Forward, and Up one folder to navigate. Click a folder to select it; double-click, press Enter, or use its arrow to open it. Search filters the current folder. Enter a folder path accepts a full address. Select folder updates the draft; save the settings or schedule to apply it. Cancel leaves the draft unchanged. No desktop helper or additional process is required.
New folder creates a named subfolder in the currently open location, then opens it. Existing items are never overwritten. Cancelling name entry creates nothing; cancelling the picker after creation leaves the new folder on disk. Your saved backup or download location changes only after selecting and saving. In Docker, the picker explains which paths are shared and shows both the container path and its configured computer path when available.
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. Selectingflacchanges the output container but cannot turn a lossy source into lossless audio.
Monochrome's current FLAC path uses browser-gated, single-use playback resources rather than a stable, provider-authorized file-export API, so SongMirror does not automate it. Use the local mirror only for content you own or are otherwise authorized to copy.
In the web app, the Accounts page walks you through each service and shows the exact values to paste. Nothing is proxied through a third party.
SongMirror refreshes credentials just in time, not with a separate token-refresh timer. Every manual or scheduled sync pass validates the connectors it uses and renews supported access tokens before the first request (or once after an authentication rejection). It is normal for a short-lived access token to expire between passes—the durable refresh token or renewal cookie is what matters. The Accounts page validates status when it loads or regains focus, but it is not the background keep-alive; enabled sync schedules are.
| Service | Renewal behavior |
|---|---|
| Spotify | The default connection mints a web-player access token from the saved sp_dc cookie on demand and retries with a new token after a 401; the underlying signed-in session can still be revoked. Legacy developer-app OAuth remains supported for existing installs. |
| TIDAL | The imported web-player access token renews automatically through auth.tidal.com using the refresh token from the sign-in response. SongMirror keeps the existing refresh token when a response omits it and persists a rotated token when TIDAL returns one. Logout or revocation still requires a fresh capture. |
| Qobuz | The pasted X-User-Auth-Token is used until Qobuz rejects it, then must be captured again. |
| Deezer | The short-lived Pipe JWT renews automatically from the saved refresh-token before use and once after a 401/403; rotated renewal state is persisted. |
| Amazon Music | The web access token renews through /pandaToken using the captured browser user agent, referer, and allowlisted cookies. The current POST config.json?skipToken=false flow bootstraps device context when needed, and rotated cookies are persisted. Logout, security changes, or server-side revocation still require a fresh capture. |
| Apple Music | The pasted Bearer and Media-User-Token cannot be renewed by SongMirror and must be captured again after rejection. |
| YouTube Music | Data API OAuth refreshes automatically within 60 seconds of expiry. Browser mode attempts Google's cookie rotation whenever a sync target is built; an already-expired browser session must be exported again. |
| Jellyfin | The API key has no access-token refresh cycle; replace it only if it is revoked or deleted. |
- Sign in at https://open.spotify.com.
- Open browser DevTools (
F12) → Application (Chrome/Edge) or Storage (Firefox) → Cookies →https://open.spotify.com. - Copy the value of the
sp_dccookie and paste it into Accounts → Spotify.
That single signed-in web session handles library browsing, playlist reads and writes, and catalog search. It does not require a Spotify developer app, API key, or Premium account. Treat sp_dc like a password: SongMirror stores it in its private data directory, but the integration uses Spotify's internal web-player operations and can need maintenance if Spotify changes them. Existing developer-app OAuth credentials remain a compatible fallback.
- Open TIDAL's web player, open DevTools → Network, and enable Preserve log.
- Sign out and sign back in, then filter the Network list for
oauth2/token. - Select the successful
auth.tidal.com/v1/oauth2/tokenrequest. In Payload (Chrome/Edge) or Request (Firefox), copy theclient_idform value into SongMirror's Web-player client ID field. - Open the request's Response tab and copy its complete JSON into Web-player token response. It should include both
access_tokenandrefresh_token. - Connect. SongMirror immediately exercises the refresh grant and refuses to report success if that client ID cannot renew it.
The OAuth client ID is request metadata and is not the numeric cid claim inside TIDAL's access token. SongMirror extracts only the access token, refresh token, client ID, scopes, expiry, and catalog country; unrelated response data is discarded. It renews just before expiry and once after an authentication rejection through https://auth.tidal.com/v1/oauth2/token, preserving refresh-token rotation. The older OpenAPI request-header paste remains compatible, but because it contains no refresh token it still needs to be re-pasted after expiry. Only catalog metadata and the signed-in user's playlists are used—playback assets stay outside this integration.
Sign in at https://play.qobuz.com, open DevTools → Network, and filter for api.json/0.2. Choose any request containing X-App-Id and X-User-Auth-Token—including an authenticated album/story request—then copy its request headers or copy it as cURL and paste it into the wizard. SongMirror persists only those two values, sends them using the same header-based flow as the web player, and discards cookies and unrelated browser metadata. No business API approval or user id is required; existing partner credentials remain a compatible environment fallback.
The adapter uses catalog search and playlist endpoints only—it does not request stream or file URLs.
Sign in at https://www.deezer.com, open DevTools → Network, and reload the page. Filter for auth.deezer.com/login/renew, copy that request's headers (or copy it as cURL), and paste it into the renewal field. Firefox may instead copy the request cookies as a bare semicolon-delimited block; that shape is accepted too. SongMirror retains only the dedicated refresh-token cookie and uses it to renew Deezer's short-lived Pipe JWT automatically. You may also paste a current pipe.deezer.com/api request as an immediate bootstrap, but it is not required when renewal is configured. Playlist additions and removals both use the renewable Pipe session; no arl cookie is needed. Existing developer OAuth tokens remain a compatible environment fallback.
No developer approval is required for the default connector. It uses the same authenticated GraphQL and token-renewal routes as the Amazon Music web player:
- Sign in at https://music.amazon.com and open DevTools → Network.
- Reload the page, filter for
config.json, and select the signed-in request. (pandaTokenworks too when it appears, but it is not required.) - Choose Copy request headers or Copy as cURL, then paste it into the renewal field. Keep the complete
User-Agent,Referer, andCookieheaders so SongMirror can replay the same browser context. - Optionally copy the signed-in
config.jsonResponse into the bootstrap field; SongMirror can normally fetch that device context using the renewal session.
SongMirror derives the same AmznMusic authorization value locally and refreshes it through music.amazon.com/pandaToken before expiry or once after an authentication rejection. During connection it uses the current browser-style config request when device context is needed, requires /pandaToken to mint an access token, and rejects the connection if Amazon revokes the Music renewal cookie. It stores only the browser user agent, language, Music referer, a named allowlist of Amazon authentication/session cookies, and limited Music-client device context; analytics, experiment, AWS-console, CSRF, and other unrelated browser data are discarded. Those retained cookies are still sensitive, so keep SongMirror private on your LAN. A logout, password/security change, or Amazon-side revocation can still require one fresh capture.
This is an unsupported first-party web-client interface and Amazon can change it without notice. The documented Amazon Music Web API is still a closed beta; approved partner credentials remain an optional fallback when configured through environment variables.
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.
An Apple ID without an active Apple Music subscription can still connect in Catalog-only mode. In that mode, paste a public Apple Music playlist link on Transfers to copy it into another connected service. Apple library browsing, scheduled syncing, and using Apple Music as a transfer destination still require the paid CloudLibrary privilege; SongMirror shows those operations as unavailable instead of treating the valid catalog credentials as expired.
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 cleanup
uv run main.py --execute --sync-mode group --sync-source spotify \
--authorities spotify,apple --providers spotify,apple,tidal,ytmusicKey env vars (see .env.example): the credentials for whichever providers you use, PLAYLISTS, SYNC_INTERVAL, MAX_ADDS / MAX_REMOVALS, DOWNLOAD_DIR, SYNC_MODE, SYNC_SOURCE, SYNC_AUTHORITIES, and 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. MAX_ADDSlimits every timestamp-producing write in a sync pass, including chronology repair. If an older recovered match needs a larger suffix replay than the cap allows, SongMirror defers it to the next pass rather than making it appear newest or causing a giant provider burst. A one-off transfer has no next pass, so it never defers: it copies every requested track, appending in source order unless you switch on "Preserve Recently Added order" for that transfer, which spends whatever the repair costs.- A chronology repair stages a duplicate copy before retiring the original. On a service whose delete takes every copy of a song, that keeper count has to be right, so Apple Music re-reads until the staged copies are visible and refuses to retire anything against a read that still trails its own writes. Deezer skips the repair entirely and always appends: it has no positional insert either, so replaying an order it cannot express is not worth the risk to the destination. The transfer form greys its order switch out there and says why.
- Net-loss protection — a target-side track resembling a source track that has no match on that service is held, not deleted.
- Any provider authentication failure aborts that provider's pass immediately — no partial deletes on expired tokens.
- A merge job must finish every constituent source read before deleting from its destination; any partial/failed source snapshot forces that pass into append-only behavior.
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"Each service keeps its own resolve cache, mapping a normalized title|artist key to the catalog id it matched on
that service. A match is reused forever, and so is a "no match" result, which is what makes a track that failed
to match once stay unmatched on every later pass.
The Mappings page in the web UI exposes those caches directly, per service:
- search the whole cache by title, artist, or resolved id
- filter to entries set by hand (a match you chose in the transfer conflict editor) or to no match entries
- correct a wrong id by pasting the right track's link, or delete a mapping so the next pass looks it up again
- clear every "no match" entry for a service in one action, so a batch of failed lookups gets another try
When a cleared miss resolves later, simply appending it would make the old song appear newest. For playlist destinations, SongMirror instead replays that song and the already-present newer suffix oldest-to-newest, then removes the older copies. Providers do not allow clients to restore the original timestamps, but this preserves their relative Recently added order. Native liked/favorite collections remain membership-only and are never replayed.
Edits are refused with a clear message while a sync is running, because a pass holds the cache in memory for its whole duration and would overwrite them on completion.
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 another service: subclass MirrorTarget, implement ~8 methods, add its builder to engine/targets' _REGISTRY and its class to _CLASSES, 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.- TIDAL reports
Expired— sign out and back in atlisten.tidal.com, then paste both theclient_idfrom theoauth2/tokenrequest payload and its complete Response JSON in Accounts. A copied OpenAPI request has only the short-lived Bearer and cannot renew. - TIDAL reports HTTP 429 — this is a temporary rate limit, not an expired sign-in. SongMirror honors the provider retry delay and caches account health checks instead of repeatedly probing the API.
- Qobuz or Apple reports
Expired/401/403— these pasted sessions have no renewable secret; capture a fresh signed-in request or token in Accounts. - TIDAL says the token lacks liked-track access — capture a fresh signed-in web-player token response carrying
r_usrandw_usr. - Deezer renewal fails — capture a fresh
auth.deezer.com/login/renewrequest (or itsrefresh-tokencookie). A current Pipe Bearer alone is only a temporary bootstrap. - Amazon Music renewal fails — capture a fresh signed-in
POST /config.json?skipToken=falserequest with its completeUser-Agent,Referer, andCookieheaders. The response JSON is optional. - YouTube Music browser mode expires — export fresh browser request headers. For the most durable unattended setup, use Data API OAuth with an in-production consent screen.
- Spotify reports Expired — sign in again at
open.spotify.comand paste a freshsp_dccookie in Accounts. - 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.
