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

how.portable.my.spotify songs