This service connects your Spotify account and YouTube account, fetches Spotify liked songs (GET /v1/me/tracks), finds best-matching YouTube videos, and appends them into a YouTube playlist.
- Incremental sync is default (
incremental=true): only new liked songs since last successful sync are processed. - One persistent YouTube playlist is reused across runs.
- Track and video mappings are stored locally to avoid duplicates and reduce quota usage.
- Matching uses multi-candidate scoring (title + artist + channel + duration + keyword boosts/penalties).
.tokens.json: Spotify/Google OAuth tokens.sync-state.json: sync cursor (lastSyncedAddedAt), persistent playlist id, synced track map
- Node.js 18+
- Spotify app credentials
- Google OAuth credentials with YouTube Data API v3 enabled
npm install
cp .env.example .env- Create a Spotify app in the Spotify Developer Dashboard.
- Add this redirect URI in Spotify app settings:
http://127.0.0.1:3000/auth/spotify/callback
- Create a Google OAuth client (Web application) in Google Cloud.
- Enable YouTube Data API v3 for that Google project.
- Add this redirect URI in Google OAuth client settings:
http://127.0.0.1:3000/auth/youtube/callback
- Copy credentials into
.env:SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRETGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET
Notes:
- If you download a Google
client_secret_*.jsonfile, keep it only as a local backup. This app does not read that JSON directly. - The app reads credentials from
.envonly. - Keep redirect URIs exactly the same in provider dashboards and
.env.
- Never commit
.env,.tokens.json,.sync-state.json, private keys, or client secret JSON files. - Use
.env.exampleas the template for new developers. - If a secret is exposed, rotate it in Spotify/Google dashboards immediately.
npm starthttp://127.0.0.1:3000/auth/spotifyhttp://127.0.0.1:3000/auth/youtube
Check:
curl http://127.0.0.1:3000/statusAfter Spotify is connected, you can export liked songs with metadata (title, artists, album, IDs, links, timestamps, etc.) to a local file.
Default JSON export:
npm run export:spotify-likedCSV export:
npm run export:spotify-liked:csvCustom output path and limit:
node src/export-liked-songs.js --format json --out exports/liked-songs.json --max 500Export a specific Spotify playlist (by name or id) to a separate JSON/CSV file.
Example for your Hindi playlist:
npm run export:spotify-playlist -- --playlist-name "Hindi" --out exports/hindi-playlist.jsonList your playlists first (name + id):
npm run export:spotify-playlist -- --list-playlistsBy playlist id:
npm run export:spotify-playlist -- --playlist-id <SPOTIFY_PLAYLIST_ID> --out exports/hindi-playlist.jsonNote: if playlist export says insufficient scope, reconnect Spotify once at /auth/spotify so token includes playlist read scopes.
curl -X POST http://127.0.0.1:3000/sync \
-H "Content-Type: application/json" \
-d '{"dryRun":true,"maxTracks":50,"searchMaxResults":10,"minMatchScore":35}'curl -X POST http://127.0.0.1:3000/sync \
-H "Content-Type: application/json" \
-d '{"maxTracks":50,"searchMaxResults":10,"minMatchScore":35,"privacyStatus":"private"}'Run this once to fill one YouTube playlist with your full Spotify liked library in batches of 50:
curl -sS -X POST http://127.0.0.1:3000/sync/state/reset \
-H "Content-Type: application/json" \
-d '{"keepPlaylist":false}'
while true; do
RES=$(curl -sS -X POST http://127.0.0.1:3000/sync \
-H "Content-Type: application/json" \
-d '{"incremental":false,"onlyUnsynced":true,"maxTracks":50,"searchMaxResults":10,"minMatchScore":35,"privacyStatus":"private"}')
echo "$RES" | jq '{message, playlistId, fetched: .totalSpotifyTracksFetched, added, notFound, duplicatesSkipped, failed}'
FETCHED=$(echo "$RES" | jq -r '.totalSpotifyTracksFetched // 0')
if [ "$FETCHED" -eq 0 ]; then
echo "Backfill complete."
break
fi
doneAfter backfill, use the incremental command only.
maxTracks(default50, max200)incremental(defaulttrue)searchMaxResults(default10)minMatchScore(default35)dryRun(defaultfalse)playlistId(force target playlist)forceNewPlaylist(defaultfalse)resetSyncState(defaultfalse)
GET /sync/state- inspect cursor and playlist statePOST /sync/state/reset- reset sync cursor/map- body:
{"keepPlaylist":true}(default true)
- body:
search.list: 100 units per trackvideos.list: ~1 unit per trackplaylistItems.insert: 50 units per added videoplaylists.insert: 50 units (only when creating a new playlist)