Skip to content

Latest commit

Β 

History

241 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

SongMirror

SongMirror

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

CI License Python Docker
Stars Forks Issues Last commit

Share this project

Set it up once β€” every playlist you curate stays mirrored across every service, in date-added order.

SongMirror demo β€” logo reveal, dashboard, one-way and bidirectional sync setup, live playlist transfers, and ISRC-accurate matching across seven music services

β–Ά Watch the 1080p version

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

TOC


✨ Features

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.
  • β™₯ 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.
  • 🌐 Followed playlists β€” sync and transfer playlists you follow but don't own, not just ones you created.
  • πŸ“¦ Portable metadata backups β€” download one playlist or a service's entire library as ordered, versioned JSON/XML; single playlists also export as import-ready Soundiiz JSON.
  • πŸ’Ώ Local download mirror β€” keep offline audio, one folder per playlist in Jellyfin's AlbumArtist/Album layout, 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 -d for 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.

πŸ“Έ Screenshots

One dashboard for every library β€” sync status, jobs, live activity, and service health

SongMirror dashboard showing sync status, configured jobs, live activity, and health for Spotify, TIDAL, Qobuz, Deezer, Amazon Music, Apple Music, YouTube Music, and Jellyfin

Set up any number of syncs β€” one-way, authoritative-group, or bidirectional β€” in a short wizard

The SongMirror setup wizard selecting services for a bidirectional sync across Spotify, TIDAL, Qobuz, Deezer, Amazon Music, Apple Music, and YouTube Music

Connect every service in your browser β€” one-click OAuth, guided token paste, or an API key

The Accounts page for connecting Spotify, TIDAL, Qobuz, Deezer, Amazon Music, Apple Music, YouTube Music, and Jellyfin

Browse and pair playlists across services

Browsing playlists across connected services with cover art and track counts

πŸš€ Quick Start

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

Or 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:latest

Then 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:8080

Requires uv (Python 3.13+). For the local download mirror, also uv tool install spotdl and have ffmpeg on PATH.

🐳 Always running: Docker

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 work

To 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.com

SongMirror 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, 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 sessions Renewable sessions recover on the next scheduled or manual pass. TIDAL web, Qobuz, and Apple Music tokens must be re-pasted on the Accounts page when rejected; no restart is needed.

βš™οΈ How it works

Every pass, for each selected playlist name that exists on the source:

  1. Snapshot the source playlist (tracks, ISRCs, added-at dates).
  2. Reconcile the same-named playlist on every selected, connected target concurrently through that service's account-authorized playlist API.
  3. Missing tracks are resolved (cached links β†’ ISRC β†’ scored search) and appended oldest-first; tracks gone from the source are removed behind guards.
  4. 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.

Matching

Same hierarchy the cross-service tools use (TuneLink, MusicBrainz): hard identifier β†’ search β†’ fuzzy score.

  1. 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).
  2. ISRC β€” exact recording identity where the service exposes it.
  3. 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 videos filter for indie/OST tracks that live on YT only as uploads.

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.

Authoritative groups

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.

Bidirectional (N-way) sync

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_REMOVALS caps and net-loss protection hold on every write side.
  • Removals are opt-in β€” MAX_REMOVALS defaults 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.

Liked and favorite tracks

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 pasted web-player Bearer handles both ordinary playlists and native Favorite Tracks when it carries the web player's user read/write grants (r_usr and w_usr). This path does not use a TIDAL developer app, client ID, or API key. If an older or read-only browser token lacks either grant, capture a fresh signed-in OpenAPI request in Accounts.

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.

πŸ“¦ Playlist metadata backups

The Playlists page can download a fresh snapshot without requiring a second provider or a sync job:

  • Use Local backup on a service card to save 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.

Exports are downloaded by the browser to the device running the UI; SongMirror does not need write access to a host backup directory. The schema_version field lets future releases evolve the lossless format without making old snapshots ambiguous.

πŸ’Ώ Local download mirror (Jellyfin)

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 (set LOCAL_MIRROR_ORDER=oldest to flip). Rebuild covers / tags / mtimes from existing files with uv run main.py --refresh-local.
  • Playlist covers in Jellyfin β€” Jellyfin ignores a cover file next to an m3u, so set JELLYFIN_URL + JELLYFIN_API_KEY and 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=opus keeps YouTube's native stream without an mp3 re-encode; a Premium cookie (LOCAL_MIRROR_COOKIE_FILE) unlocks 256 kbps AAC. Selecting flac changes 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.

πŸ”Œ Connecting each service

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.

Credential renewal

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 A pasted web-player Bearer cannot be renewed and must be captured again after expiry. It does not require a developer app, client ID, or API key.
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.

Spotify

  1. Sign in at https://open.spotify.com.
  2. Open browser DevTools (F12) β†’ Application (Chrome/Edge) or Storage (Firefox) β†’ Cookies β†’ https://open.spotify.com.
  3. Copy the value of the sp_dc cookie 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.

TIDAL

Sign in at https://listen.tidal.com, open DevTools β†’ Network, open a playlist, and filter for openapi.tidal.com/v2. Copy a request's headers (or copy it as cURL) into the wizard. SongMirror keeps only the Bearer token and two-letter catalog country. That signed-in web session supports ordinary playlists and native Favorite Tracks reads, additions, and removals; no TIDAL developer app, client ID, or API key is used.

Only catalog metadata and the signed-in user's playlists are used; playback assets are outside this integration. Browser tokens are short-lived, so re-paste when the account reports Expired.

Qobuz

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.

Deezer

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.

Amazon Music

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:

  1. Sign in at https://music.amazon.com and open DevTools β†’ Network.
  2. Reload the page, filter for config.json, and select the signed-in request. (pandaToken works too when it appears, but it is not required.)
  3. Choose Copy request headers or Copy as cURL, then paste it into the renewal field. Keep the complete User-Agent, Referer, and Cookie headers so SongMirror can replay the same browser context.
  4. Optionally copy the signed-in config.json Response 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.

Apple Music

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 (the eyJ... part, without Bearer )
  • 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.

YouTube Music

Talks to the official YouTube Data API v3, whose OAuth refresh token is durable and survives restarts.

  1. 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.
  2. On the OAuth consent screen, set Publishing status β†’ In production (leaving it in "Testing" expires the token after 7 days).
  3. 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.

πŸ–₯️ Headless CLI

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 real

Useful 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,ytmusic

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

πŸ›‘οΈ Safety rails

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=0 holds 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 set MAX_REMOVALS), and even then more pending removals than the cap in one pass β†’ all skipped and logged.
  • More than MAX_ADDS pending 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 provider authentication failure aborts that provider's pass immediately β€” no partial deletes on expired tokens.

πŸ—ƒοΈ Caching & song archive

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"

🧱 Project layout

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 add a matching Connector under services/accounts. All reconciliation β€” diff, ordering, safety rails, logging, snapshot-skip β€” is inherited.

🩺 Troubleshooting

  • Missing required environment variable β€” fill in .env (CLI) or connect the service in the UI.
  • TIDAL, 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 web-player token lacks liked-track access β€” capture a fresh signed-in openapi.tidal.com/v2 request in Accounts. Favorite Tracks needs the web session's r_usr and w_usr grants; it does not use a developer API key.
  • Deezer renewal fails β€” capture a fresh auth.deezer.com/login/renew request (or its refresh-token cookie). A current Pipe Bearer alone is only a temporary bootstrap.
  • Amazon Music renewal fails β€” capture a fresh signed-in POST /config.json?skipToken=false request with its complete User-Agent, Referer, and Cookie headers. 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.com and paste a fresh sp_dc cookie 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).

πŸ“„ License

Copyright Β© 2026 Ahnaf An Nafee.
This project is MIT licensed.

About

SongMirror: self-hosted playlist sync for Spotify, Apple Music, YouTube Music, Amazon Music, Deezer, TIDAL, Qobuz & Jellyfin. Free, open-source Soundiiz / TuneMyMusic alternative with N-way sync, transfers, and ISRC-accurate matching.

Topics

Resources

Stars

21 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages