Code Monkey home page Code Monkey logo

soundify's Introduction

Soundify is a lightweight and flexible library for interacting with the Spotify API, designed to work seamlessly with TypeScript and support all available runtimes.

Getting Started | Error handling | Token refreshing | Pagination

Installation

The package doesn't depend on runtime specific apis, so you should be able to use it without any problems everywhere.

npm install @soundify/web-api
// deno.json
{
	"imports": {
		"@soundify/web-api": "https://deno.land/x/soundify/mod.ts"
	}
}

Install from JSR registry

deno add @soundify/web-api

Getting Started

Soundify has a very simple structure. It consists of a SpotifyClient capable of making requests to the Spotify API, along with a set of functions (like getCurrentUser) that utilize the client to make requests to specific endpoints.

import { getCurrentUser, search, SpotifyClient } from "@soundify/web-api";

const client = new SpotifyClient("YOUR_ACCESS_TOKEN");

const me = await getCurrentUser(client);
console.log(me);

const result = await search(client, "track", "Never Gonna Give You Up");
console.log(result.tracks.items.at(0));

Compared to the usual OOP way of creating API clients, this approach has several advantages. The main one is that it is tree-shakable. You only ship code you use. This may be not that important for server-side apps, but I'm sure frontend users will thank you for not including an extra 10kb of crappy js into your bundle.

import {
	getAlbumTracks,
	getArtist,
	getArtistAlbums,
	getRecommendations,
	SpotifyClient,
} from "@soundify/web-api";

const client = new SpotifyClient("YOUR_ACCESS_TOKEN");

const radiohead = await getArtist(client, "4Z8W4fKeB5YxbusRsdQVPb");
console.log(`Radiohead popularity - ${radiohead.popularity}`);

const pagingResult = await getArtistAlbums(client, radiohead.id, { limit: 1 });
const album = pagingResult.items.at(0)!;
console.log(`Album - ${album.name}`);

const tracks = await getAlbumTracks(client, album.id, { limit: 5 });
console.table(
	tracks.items.map((track) => ({
		name: track.name,
		duration: track.duration_ms,
	})),
);

const recomendations = await getRecommendations(client, {
	seed_artists: [radiohead.id],
	seed_tracks: tracks.items.map((track) => track.id).slice(0, 4),
	market: "US",
	limit: 5,
});
console.table(
	recomendations.tracks.map((track) => ({
		artist: track.artists.at(0)!.name,
		name: track.name,
	})),
);

Error handling 📛

import { getCurrentUser, SpotifyClient, SpotifyError } from "@soundify/web-api";

const client = new SpotifyClient("INVALID_ACCESS_TOKEN");

try {
	const me = await getCurrentUser(client);
	console.log(me);
} catch (error) {
	if (error instanceof SpotifyError) {
		error.status; // 401

		const message = typeof error.body === "string"
			? error.body
			: error.body?.error.message;
		console.error(message); // "Invalid access token"

		error.response.headers.get("Date"); // You can access the response here

		console.error(error);
		// SpotifyError: 401 Unauthorized (https://api.spotify.com/v1/me) : Invalid access token
		return;
	}

	// If it's not a SpotifyError, then it's some type of network error that fetch throws
	// Or can be DOMException if you abort the request
	console.error("We're totally f#%ked!");
}

Rate Limiting 🕒

If you're really annoying customer, Spotify may block you for some time. To know what time you need to wait, you can use Retry-After header, which will tell you time in seconds. More about rate limiting↗

To handle this automatically, you can use waitForRateLimit option in SpotifyClient. (it's disabled by default, because it may block your code for unknown time)

const client = new SpotifyClient("YOUR_ACCESS_TOKEN", {
	waitForRateLimit: true,
	// wait only if it's less than a minute
	waitForRateLimit: (retryAfter) => retryAfter < 60,
});

Authorization

Soundify doesn't provide any tools for authorization, because that would require to write whole oauth library in here. We have many other battle-tested oauth solutions, like oauth4webapi or oidc-client-ts. I just don't see a point in reinventing the wheel 🫤.

Despite this, we have a huge directory of examples, including those for authorization. OAuth2 Examples↗

Token Refreshing

import { getCurrentUser, SpotifyClient } from "@soundify/web-api";

// if you don't have access token yet, you can pass null to first argument
const client = new SpotifyClient(null, {
	// but you have to provide a function that will return a new access token
	refresher: () => {
		return Promise.resolve("YOUR_NEW_ACCESS_TOKEN");
	},
});

const me = await getCurrentUser(client);
// client will call your refresher to get the token
// and only then make the request
console.log(me);

// let's wait some time to expire the token ...

const me = await getCurrentUser(client);
// client will receive 401 and call your refresher to get new token
// you don't have to worry about it as long as your refresher is working
console.log(me);

Pagination

To simplify the process of paginating through the results, we provide a PageIterator and CursorPageIterator classes.

import { getPlaylistTracks, SpotifyClient } from "@soundify/web-api";
import { PageIterator } from "@soundify/web-api/pagination";

const client = new SpotifyClient("YOUR_ACCESS_TOKEN");

const playlistIter = new PageIterator(
	(offset) =>
		getPlaylistTracks(client, "37i9dQZEVXbMDoHDwVN2tF", {
			// you can find the max limit for specific endpoint
			// in spotify docs or in the jsdoc comments of this property
			limit: 50,
			offset,
		}),
);

// iterate over all tracks in the playlist
for await (const track of playlistIter) {
	console.log(track);
}

// or collect all tracks into an array
const allTracks = await playlistIter.collect();
console.log(allTracks.length);

// Want to get the last 100 items? No problem
const lastHundredTracks = new PageIterator(
	(offset) =>
		getPlaylistTracks(
			client,
			"37i9dQZEVXbMDoHDwVN2tF",
			{ limit: 50, offset },
		),
	{ initialOffset: -100 }, // this will work just as `Array.slice(-100)`
).collect();
import { getFollowedArtists, SpotifyClient } from "@soundify/web-api";
import { CursorPageIterator } from "@soundify/web-api/pagination";

const client = new SpotifyClient("YOUR_ACCESS_TOKEN");

// loop over all followed artists
for await (
	const artist of new CursorPageIterator(
		(opts) => getFollowedArtists(client, { limit: 50, after: opts.after }),
	)
) {
	console.log(artist.name);
}

// or collect all followed artists into an array
const artists = await new CursorPageIterator(
	(opts) => getFollowedArtists(client, { limit: 50, after: opts.after }),
).collect();

// get all followed artists starting from Radiohead
const artists = await new CursorPageIterator(
	(opts) => getFollowedArtists(client, { limit: 50, after: opts.after }),
	{ initialAfter: "4Z8W4fKeB5YxbusRsdQVPb" }, // let's start from Radiohead
).collect();

Other customizations

import { SpotifyClient } from "@soundify/web-api";

const client = new SpotifyClient("YOUR_ACCESS_TOKEN", {
	// You can use any fetch implementation you want
	// For example, you can use `node-fetch` in node.js
	fetch: (input, init) => {
		return fetch(input, init);
	},
	// You can change the base url of the client
	// by default it's "https://api.spotify.com/"
	beseUrl: "https://example.com/",
	middlewares: [(next) => (url, opts) => {
		// You can add your own middleware
		// For example, you can add some headers to every request
		return next(url, opts);
	}],
});

Contributors ✨

All contributions are very welcome ❤️ (emoji key)

Artem Melnyk
Artem Melnyk

🚧
danluki
danluki

💻
Andrii Zontov
Andrii Zontov

🐛
Brayden Babbitt
Brayden Babbitt

🐛

soundify's People

Contributors

allcontributors[bot] avatar braydenbabbitt avatar danluki avatar lwjerri avatar mellkam avatar renovate[bot] avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

soundify's Issues

[Bug]: `PageIterator` skip last track

For example, I have a playlist with 45 tracks, and when I use PageIterator for getPlaylistTracks I receive a partial tracks list. Here minimal code to reproduce the problem:

const playlistTracksIterator = new PageIterator((opts) =>
  getPlaylistTracks(client, PLAYLIST_ID, opts),
);
const playlistTracksList = await playlistTracksIterator.collect();

console.log(preparedTracksList.length); // 44, because last track is missed.

Functions or class methods for api calls?

Currently, api calls are presented as separate functions, such as this one.

export const getCurrentUserProfile = (spotifyClient: ISpotifyClient) => {
    return spotifyClient.fetch<UserPrivate>("/me");
};

// Usage example
const spotifyClient = new SpotifyClient(...);
const user = await getCurrentUserProfile(spotifyClient);

But in most api libraries, you can see a single class that contains all the api calls, so you can just do that.

const spotifyAPI = new SpotifyAPI()
const user = await spotifyAPI.getCurrentUserProfile();

What do you prefer to use?
Please write what you think about it. You can just "prefer functions" or "prefer class".

From my point of view, I can highlight some pros and cons:

  • Pros
    • Easy to treeshake in a web bundle.
    • Decomposition. Much easier to support a bunch of functions than one big class (in my opinion).
  • Cons.
    • A class is just easier to use. You can pass it throughout your application and use it. It's awkwardly to pass a client every time just to use call. Idk

Add the ability to call endpoint functions from the class, instead of separate functions

The problem is that the endpoints are a bunch of separate functions that need to pass the client as the first argument each time. In code it look like this:

import { SpoitifyClient, getCurrentUserProfile, getUserProfile } from "soundify-web-api";

const client = new SpotifyClient("ACCESS_TOKEN");

const currentUser = await getCurrentUserProfile(client);
const user123 = await getUserProfile(client, "some-user-id-123");

The obvious question would be: why can't we do that?

import { SpotifyClient } from "soundify-web-api";

const client = new SpotifyCleint("ACCESS_TOKEN");

const currentUser = await client.getCurrentUserProfile();
const user123 = await client.getUserProfile("some-user-id-123");

The answer is this. We want to have a treeshake on the client and not import all the functions we don't need. Plus, personally I don't like to write one huge class with hundreds of methods.
But for the backend, it's much easier to use this type of composition. It would be great for implementing dependencies and a good developer experience. So what can we do?

Tests for the API

describe("getEpisode", async () => {
  test("returns an episode object", async () => {
    const episode_id = "123";
    const market = "EN" as Market;
    const spotify_episode_response = {
      description:
        "A Spotify podcast sharing fresh insights on important topics of the moment—in a way only Spotify can. You’ll hear from experts in the music, podcast and tech industries as we discover and uncover stories about our work and the world around us.",
      html_description:
        "<p>A Spotify podcast sharing fresh insights on important topics of the moment—in a way only Spotify can. You’ll hear from experts in the music, podcast and tech industries as we discover and uncover stories about our work and the world around us.</p>",
      duration_ms: 1686230,
      explicit: false,
      external_urls: {
        spotify: "string"
      },
      href: "https://api.spotify.com/v1/episodes/5Xt5DXGzch68nYYamXrNxZ",
      id: "5Xt5DXGzch68nYYamXrNxZ",
      images: [
        {
          url: "https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228",
          height: 300,
          width: 300
        }
      ],
      is_externally_hosted: false,
      is_playable: false,
      language: "en",
      languages: ["fr", "en"],
      name: "Starting Your Own Podcast: Tips, Tricks, and Advice From Anchor Creators",
      release_date: "1981-12-15",
      release_date_precision: "day",
      resume_point: {
        fully_played: false,
        resume_position_ms: 0
      },
      type: "episode",
      uri: "spotify:episode:0zLhl3WsOCQHbe1BPTiHgr",
      restrictions: {
        reason: "string"
      },
      show: {
        available_markets: ["string"],
        copyrights: [
          {
            text: "string",
            type: "string"
          }
        ],
        description: "string",
        html_description: "string",
        explicit: false,
        external_urls: {
          spotify: "string"
        },
        href: "string",
        id: "string",
        images: [
          {
            url: "https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228",
            height: 300,
            width: 300
          }
        ],
        languages: ["string"],
        media_type: "string",
        name: "string",
        publisher: "string",
        type: "show",
        uri: "string",
        total_episodes: 0
      }
    };

    const mockedFetch = vi.fn();
    mockedFetch.mockResolvedValue(spotify_episode_response);
    const client: HTTPClient = {
      fetch: mockedFetch
    };

    const result = await getEpisode(client, episode_id, market);

    expect(client.fetch).toHaveBeenCalledWith(
      `/episodes/${episode_id}`,
      "json",
      { query: { market } }
    );
    expect(result).toBeDefined();
    expect(result).toMatchObject(spotify_episode_response);
  });
});

There is what i got for getEpisode, wanna see you opinion is this enough or we need more tests for api methods. Because I think it's not full. Or, I just want to see you @MellKam code for this tests as a example.

Automatic pagination

Paginator will create request for every 20 tracks.

const playlistTracks = new Paginator((opts) =>
  getPlaylistTracks(client, "6LPdVFowaGmmHu3nb20Mg3", opts)
);

for await (const track of playlistTracks) {
  console.log(track);
}

You can change the chunk size by setting limit in second argument.

const playlistTracks = new Paginator(
  (opts) => getPlaylistTracks(client, "6LPdVFowaGmmHu3nb20Mg3", opts),
  { limit: 50 }
);

v0.1.0 TODO

Authorization flows

  • Authorization Code Flow - AuthCode namespace
  • Implicit Grant Flow - ImplicitGrant namespace
  • Client Credentials Flow - ClientCredentials namespace
  • Authorization code with PKCE - PKCEAuthCode namespace

API coverage

Examples

Other

  • Ability to retry when 5xx or 429 is received. Example below
const client = new SpotifyClient("ACCESS_TOKEN", {
  retry5xx: {
    times: 5,
    delay: 0,
  },
  retry429: {
    times: 3,
    delay: 300,
  }
})

Future TODO

  • Make tests with json schema validation to ensure that types are correct. Maybe it futures releases.
  • Shows, Episodes, Audiobooks, Chapters, Player endpoints

Action Required: Fix Renovate Configuration

There is an error with this repository's Renovate configuration that needs to be fixed. As a precaution, Renovate will stop PRs until it is resolved.

Location: .github/renovate.json
Error type: The renovate configuration file contains some invalid settings
Message: "fileMatch" may not be defined at the top level of a config and must instead be within a manager block

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Detected dependencies

github-actions
.github/workflows/ci.yaml
  • actions/checkout v3
  • actions/setup-node v3
  • pnpm/action-setup v2
  • actions/checkout v3
  • actions/setup-node v3
  • pnpm/action-setup v2
  • codecov/codecov-action v3
  • actions/checkout v3
  • actions/setup-node v3
  • pnpm/action-setup v2
.github/workflows/npm-publish.yaml
  • actions/checkout v3
  • actions/setup-node v3
  • pnpm/action-setup v2
npm
examples/next-ssr/package.json
  • cookies-next 2.1.2
  • next 13.4.6
  • react 18.2.0
  • react-dom 18.2.0
  • @tanstack/react-query 4.29.14
  • typescript 5.1.3
examples/node-express-auth/package.json
  • cookie-parser 1.4.6
  • express 4.18.2
  • @types/cookie-parser 1.4.3
  • @types/express 4.17.17
  • typescript 5.1.3
examples/react-implicit-grant/package.json
  • @tanstack/react-query 4.29.14
  • react 18.2.0
  • react-dom 18.2.0
  • react-router-dom 6.13.0
  • @vitejs/plugin-react 3.1.0
  • typescript 5.1.3
  • vite 4.3.9
examples/react-pkce-auth/package.json
  • @tanstack/react-query 4.29.14
  • react 18.2.0
  • react-dom 18.2.0
  • react-router-dom 6.13.0
  • @vitejs/plugin-react 3.1.0
  • typescript 5.1.3
  • vite 4.3.9
package.json
  • @types/node 20.3.1
  • all-contributors-cli 6.26.0
  • envalid 7.3.1
  • prettier 2.8.8
  • rimraf 5.0.1
  • typescript 5.1.3
  • vite 4.3.9
  • vitest 0.32.2
  • @vitest/coverage-c8 0.32.2
  • vitest-fetch-mock 0.2.2
  • @faker-js/faker 8.0.2
  • pnpm 8.6.3

  • Check this box to trigger a request for Renovate to run again on this repository

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.