How Portable is My Spotify Playlist?
This project involves setting up Spotify OAuth flow, designing a GraphQL schema, and transforming multiple REST calls into concise, single-call GraphQL requests. For the full code, visit Joey's GitHub repo at /januff/spotify-liked-songs-export.
During the recent Spotify protests, the ease with which one could transfer Spotify playlists to competing services came up a lot–but I was disappointed to discover, when I tried to migrate my playlists to Tidal, that the free versions of both recommended transfer apps have 250-song restrictions (and the paid versions are subscription apps, billed annually!)
I'm not above dishing out a few bucks for the sake of convenience, but given my recent workouts with the Spotify API, I couldn't help but wonder: just how much less convenient would building a personal playlist exporter really be?
Make no mistake: for you it'll be a breeze–assuming you're comfortable with React and at least curious about Remix–since I've reduced the process to just two problem sequences, elaborated below. (Also, one can still freely access playlist and other info after deactivating a premium Spotify membership.)
But it wasn't horribly inconvenient for me, either–credit mainly to Brittany Chiang, whose recent newline.co course Build a Spotify Connect App (free online at the moment) is a concise masterclass in best practices for REST API client-building. (And whose code and architecture I used as a starting point.) In particular, she walks us through best practices for two of the trickier prerequisites to robust API exploitation: building out an Authorization Code OAuth flow and masterminding a complex, multi-part API query using axios.all().
The two main modifications I made to Brittany's code were 1. adapting the OAuth flow to use Remix's routing and 2. orchestrating complex API calls with StepZen & GraphQL rather than Axios and REST (and focusing my query on Liked Songs, my most personally meaningful Spotify scorecard.)
Let's look at the two problem sequences I tackled: the authorization flow and the multi-part Spotify API request.
A Spotify OAuth flow in Remix: Using CookieSessionStorage
The authorization flow forced me to consider the proper storage of an auth_token in a Remix project, which led me to Remix's fairly painless approach to Session, createCookieSessionStorage in specific.
The first step in Spotify's Auth flow requires you to redirect the user to Spotify, after which Spotify will redirect the user right back to you, along with a code.
// remix > app > routes > login.tsx
import type { LoaderFunction } from "remix";
import { redirect } from "remix";
export const loader: LoaderFunction = async ({
request
}) => {
return redirect(
`https://accounts.spotify.com/authorize?client_id=${process.env.SPOTIFY_CLIENT_ID}&response_type=code&redirect_uri=http://localhost:3000/callback&scope=user-read-private%20user-library-read`);
}
We need to exchange the code Spotify sends back for an access_token
, the first of several interactions we'll be typing and refining using StepZen.
This step of authentication is the first (and only) that requires Basic Authentication, rather than the more common Bearer Authentication, and which therefore demands a base64-encoded ID/password pair in its Authorization header, an invariant string value I store in my StepZen config and summon as $buffer
.
// stepzen > spotify > spotify.graphql
type Spotify_Auth {
access_token: String
token_type: String
expires_in: Int
refresh_token: String
scope: String
}
type Query {
get_token_with_code(
code: String!
): Spotify_Auth
@rest(
configuration: "spotify_config"
method: POST
contenttype: "application/x-www-form-urlencoded"
endpoint: "https://accounts.spotify.com/api/token?code=$code&grant_type=authorization_code&redirect_uri=http://localhost:3000/callback"
headers: [{
name: "Authorization",
value: "Basic $buffer"
}]
)
}
In the Loader for my /callback
, I grab the code
from the url and query an access token using the Fetch API.
// remix > app > routes > callback.tsx
const url = new URL(request.url);
const code = url.searchParams.get("code");
let res = await fetch(`${process.env.STEPZEN_ENDPOINT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `${process.env.STEPZEN_API_KEY}`
},
body: JSON.stringify({
query: `
query MyQuery($code: String!) {
get_token_with_code(code: $code) {
access_token
}
}`,
variables: {
code: code,
},
}),
})
let data = await res.json();
That token is immediately extracted, set as a Cookie using getSession
, and persisted server-side using commitSession
.
// remix > app > routes > callback.tsx
let token = data.data.get_token_with_code.access_token;
const session = await getSession(
request.headers.get("Cookie")
);
session.set("token", token)
throw redirect(
"/tracks",
{
headers: {
'Set-Cookie': await commitSession(session),
},
}
Which will make the access_token
subsequently available to the loader at any route. Like my /tracks
route, to which we can now redirect immediately.
// remix > app > routes > callback.tsx
export default function Callback() {
return redirect('/tracks')
}
Designing a Spotify GraphQL schema with StepZen: Depaginating results
Next, we'll walk through depaginating my Liked Songs history at /tracks
.
The multi-part Spotify API request uses StepZen to transform Spotify's elaborate, multi-call REST sequences into concise, single-call GraphQL requests. Using GraphQL pagination, fetching my 519-track diary of Liked Songs boils down to a simple while statement in my Remix Loader.
Spotify returns an insane amount of basic track data, so whittling it down to a compact GraphQL type using StepZen is a life-saver. The most important data point from a data portability perspective is the ISRC: an International Standard Recording Code, which we can use to write playlists at Amazon Music, Apple Music, or any of the streaming services.
type Track {
added_at: String
track_id: String
track_name: String
artist_id: String
artist_name: String
popularity: Int
preview_url: String
isrc: String
}
type TrackEdge {
node: Track
cursor: String
}
type TrackConnection {
pageInfo: PageInfo!
edges: [TrackEdge]
}
type Query {
get_saved_tracks(
access_token: String!
first: Int! = 50
after: String! = ""
): TrackConnection
@rest(
endpoint: "https://api.spotify.com/v1/me/tracks?limit=$first&offset=$after"
headers: [{
name: "Authorization",
value: "Bearer $access_token"
}]
resultroot: "items[]"
pagination: {
type: OFFSET
setters: [{field:"total", path: "total"}]
}
setters: [
{ field: "track_id", path: "track.id" }
{ field: "track_name", path: "track.name" }
{ field: "artist_id", path: "track.artists[].id" }
{ field: "artist_name", path: "track.artists[].name" }
{ field: "popularity", path: "track.popularity" }
{ field: "preview_url", path: "track.preview_url" }
{ field: "isrc", path: "track.external_ids.isrc" }
]
)
}
StepZen implements the GraphQL pagination spec, so by setting options as above (notice the required TrackEdge
and TrackConnection
types) we can hit our StepZen GraphQL endpoint using standard cursor syntax.
// remix > app > routes > tracks.tsx
export async function getTracks(token: String, cursor: String = '') {
let res = await fetch(`${process.env.STEPZEN_ENDPOINT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `${process.env.STEPZEN_API_KEY}`
},
body: JSON.stringify({
query: `
query MyQuery($access_token: String!, $after: String!) {
get_saved_tracks(access_token: $access_token, after: $after) {
edges {
node {
added_at
artist_id
artist_name
isrc
popularity
preview_url
track_id
track_name
}
cursor
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
}`,
variables: {
access_token: token,
after: cursor
},
}),
})
let data = await res.json();
// console.log('data in function: ', data);
let tracks = data.data.get_saved_tracks;
// console.log('tracks in function', tracks);
return tracks;
}
This proves immediately useful in the loader for our /tracks
route, which uses a while
statement to keep track of the returned hasNextPage
boolean, until the request is fully depaginated.
// remix > app > routes > tracks.tsx
export const loader: LoaderFunction = async ({
request
}) => {
const session = await getSession(
request.headers.get("Cookie")
);
const token = session.get("token") || null;
// console.log('token: ', token)
let tracks = await getTracks(token);
// console.log('tracks in loader: ', tracks);
let edges = tracks.edges;
// console.log('edges in loader: ', edges.length);
let endCursor = tracks.pageInfo.endCursor;
// console.log('endCursor in loader: ', endCursor);
let hasNextPage = tracks.pageInfo.hasNextPage;
while (hasNextPage){
// console.log('endCursor: ', endCursor);
let moreTracks = await getTracks(token, endCursor)
let moreEdges = moreTracks.edges;
// console.log('moreEdges in loader: ', moreEdges.length);
Array.prototype.push.apply(edges, moreEdges);
console.log('edges after push ', edges.length);
let moreNext = moreTracks.pageInfo.hasNextPage;
// console.log('moreEdges in loader: ', moreEdges.length);
let moreCursor = moreTracks.pageInfo.endCursor;
// console.log('moreCursor in loader: ', moreCursor)
endCursor = moreCursor;
hasNextPage = moreNext;
;
}
return edges;
}
Mass presentable on first load (if not yet design-presentable) and suitable for cross-API transferability.
// remix > app > routes > tracks.tsx
export default function Tracks() {
const edges = useLoaderData();
// console.log(edges)
const tracks = edges?.map((track, i) =>
<li key={i}>
{track.node.artist_name}, “{track.node.track_name}”
<br></br>
<audio
controls
src={track.node.preview_url}>
</audio>
</li>
);
return (
<ul>
{tracks}
</ul>
)
}
Summary
Comparing a massive playlist like this against multiple streaming music APIs: that'll be the subject of the next installment–and another apt showcase for StepZen's benefits, as our options for multi-API contributions to a GraphQL data model get very interesting (ie. potentially quota-busting, if not tuned carefully.)