Last active
June 9, 2026 18:59
-
-
Save ritmo-v0/a847d54c3e2637df25416713b85ea3ab to your computer and use it in GitHub Desktop.
A simple TypeScript example of Spotify's `search` Web API.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { readFile } from "fs/promises"; | |
| // Types & Interfaces | |
| type Song = SongData & { | |
| search_result: SearchResult[]; | |
| }; | |
| type SongData = { | |
| name: string; | |
| artist: string; | |
| }; | |
| // # Modify based on your needs | |
| type SearchResult = Omit<TrackItem, "type" | "artists" | "duration_ms" | "external_urls"> & { | |
| artists: Array<string>; | |
| }; | |
| // # Add more keys based on your needs | |
| // Reference: https://developer.spotify.com/documentation/web-api/reference/search | |
| type SpotifySearchResponse = { | |
| tracks: { | |
| items: Array<TrackItem>; | |
| } | |
| }; | |
| type TrackItem = { | |
| id: string; | |
| name: string; | |
| type: "track"; | |
| artists: Array<SimplifiedArtist>; | |
| uri: string; | |
| href: string; | |
| duration_ms: number; | |
| external_urls: Record<string, string>; | |
| }; | |
| type SimplifiedArtist = { | |
| id: string; | |
| name: string; | |
| type: "artist"; | |
| uri: string; | |
| href: string; | |
| external_urls: Record<string, string>; | |
| }; | |
| type SearchOptions = { | |
| limit?: number; | |
| }; | |
| // Constants & Variables | |
| const JSON_FILE = "SONG_DATA.json"; | |
| // ! Plz make sure to load secrets from .env* files other than local usage | |
| const CLIENT_ID = "YOUR_SPOTIFY_CLIENT_ID"; | |
| const CLIENT_SECRET = "YOUR_SPOTIFY_CLIENT_SECRET"; | |
| const REDIRECT_URI = "http://127.0.0.1:8080/callback"; | |
| const AUTH_URL = `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=playlist-modify-public%20playlist-modify-private`; | |
| const CODE = "YOUR_CODE_FROM_AUTH_URL"; | |
| const BEARER_TOKEN = "YOUR_BEARER_TOKEN_FROM_getAccessToken"; | |
| // Request an Access Token | |
| // Reference: https://developer.spotify.com/documentation/web-api/tutorials/getting-started#request-an-access-token | |
| async function getAccessToken() { | |
| const token = await fetch("https://accounts.spotify.com/api/token", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| body: new URLSearchParams({ | |
| grant_type: "client_credentials", | |
| client_id: CLIENT_ID, | |
| client_secret: CLIENT_SECRET, | |
| }) | |
| }).then(res => res.json()); | |
| return token.access_token; | |
| } | |
| async function getScopedAccessToken() { | |
| const token = await fetch("https://accounts.spotify.com/api/token", { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`, | |
| "Content-Type": "application/x-www-form-urlencoded" | |
| }, | |
| body: new URLSearchParams({ | |
| grant_type: "authorization_code", | |
| code: CODE, | |
| redirect_uri: REDIRECT_URI, | |
| }) | |
| }).then(res => res.json()); | |
| return token.access_token; | |
| } | |
| // # Write your own data loading logic here | |
| async function getSongData(): Promise<Array<SongData>> { | |
| const data = await readFile(JSON_FILE, "utf8"); | |
| return JSON.parse(data); | |
| } | |
| // Fetch result from `Search` Web API | |
| // ! You might consider adding a delay between (batch) searches to avoid 429s | |
| async function fetchSongSearchResult(song: SongData, { | |
| limit = 3, | |
| }: SearchOptions = {}): Promise<Array<TrackItem>> { | |
| const name = song.name; | |
| const artist = song.artist; | |
| const fetchURL = "https://api.spotify.com/v1/search?" + new URLSearchParams({ | |
| q: `track:${name} artist:${artist}`, | |
| type: "track", | |
| limit: String(limit), | |
| }).toString(); | |
| const res = await fetch(fetchURL, { | |
| headers: { "Authorization": `Bearer ${BEARER_TOKEN}` }, | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} - ${res.statusText}`); | |
| const data: SpotifySearchResponse = await res.json(); | |
| return data.tracks.items; | |
| } | |
| async function addSongToPlaylist(playlistId: string, uri: string | string[]) { | |
| const uris = Array.isArray(uri) ? uri : [uri]; | |
| const res = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Bearer ${BEARER_TOKEN}`, | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ uris }), | |
| }); | |
| if (!res.ok) { | |
| const errorBody = await res.text(); | |
| throw new Error(`HTTP ${res.status} - ${res.statusText}\n${errorBody}`); | |
| } | |
| console.log(`Added ${uris.length} songs to playlist ${playlistId}`); | |
| } | |
| async function fetchSongsFromPlaylist(playlistId: string) { | |
| const tracks = []; // full track ID list in playlist order | |
| const seenTrackIdToIndex = new Map(); // track.id => first index | |
| const duplicates = {}; // id => { firstIndex, duplicateIndexes } | |
| let offset = 0; | |
| let hasMore = true; | |
| while (hasMore) { | |
| const res = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks?limit=100&offset=${offset}`, { | |
| headers: { | |
| "Authorization": `Bearer ${BEARER_TOKEN}`, | |
| } | |
| }); | |
| const data = await res.json(); | |
| for (const item of data.items) { | |
| const track = item.track; | |
| if (!track) continue; | |
| const id = track.id; | |
| const currentIndex = tracks.length; | |
| if (seenTrackIdToIndex.has(id)) { | |
| const firstIndex = seenTrackIdToIndex.get(id); | |
| if (!duplicates[id]) { | |
| duplicates[id] = { | |
| firstIndex, | |
| duplicateIndexes: [] | |
| }; | |
| } | |
| duplicates[id].duplicateIndexes.push(currentIndex); | |
| } else { | |
| seenTrackIdToIndex.set(id, currentIndex); | |
| } | |
| tracks.push(id); | |
| } | |
| hasMore = data.next !== null; | |
| offset += 100; | |
| } | |
| return { tracks, duplicates }; | |
| } | |
| // # Example Usage | |
| // In this example, I have the order No. from "Ritmo's Secret Library" as the key of each SongData object, | |
| // hence the return type `Record<string, SongData>` of `getSongData()`. | |
| try { | |
| // const token = await getAccessToken(); | |
| // console.log(`๐ Access Token: ${token}\n`); | |
| const songs = await getSongData(); | |
| const data: any[] = []; | |
| for (const [index, song] of songs.entries()) { | |
| console.log(`๐ Searching for song: ${song.name} / ${song.artist}...`); | |
| const trackItems = await fetchSongSearchResult(song); | |
| const search_result = trackItems.map(item => ({ | |
| id: item.id, | |
| name: item.name, | |
| artists: item.artists.map(artist => artist.name), | |
| uri: item.uri, | |
| })); | |
| const fullSongData: Song = { | |
| ...song, | |
| search_result, | |
| }; | |
| data.push(fullSongData); | |
| } | |
| console.log(JSON.stringify(data, null, 4)); | |
| } catch (error) { | |
| console.error("ERR:", error); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment