An open API service indexing awesome lists of open source software.

https://github.com/artbookspirit/tuneberry

ClojureScript bindings for Spotify Web API with retries, blocking mode and a tasty name.
https://github.com/artbookspirit/tuneberry

async clojurescript spotify

Last synced: 6 months ago
JSON representation

ClojureScript bindings for Spotify Web API with retries, blocking mode and a tasty name.

Awesome Lists containing this project

README

          

![tuneberry image](assets/tuneberry.jpg)

# Tuneberry

[![Clojars Project](https://img.shields.io/clojars/v/com.github.artbookspirit/tuneberry.svg)](https://clojars.org/com.github.artbookspirit/tuneberry)

[ClojureScript](https://clojurescript.org/) bindings for
[Spotify Web API](https://developer.spotify.com/documentation/web-api) with
retries, blocking mode and a tasty name.

> [!IMPORTANT]
> Although currently mainly the _Player_ and _Search_ endpoints are covered, the
> library is fully operational. Expect more soon. Need a specific endpoint
> not covered? Report [an issue](https://github.com/artbookspirit/tuneberry/issues)
> or create a [pull request](https://github.com/artbookspirit/tuneberry/pulls).

## Quickstart

To display your Spotify user ID, run the code below:

```clojure
(require '[cljs.core.async :refer [")
(def tb (tuneberry token))
(go (prn (:id ( [!NOTE]
> Production configurations require token refreshing and are covered in one of
> the [later sections](#setups-with-token-refresh).

## Passing Spotify API parameters

With _Tuneberry_, you pass url **query string parameters** as regular key-value pairs.

In the example below, we display the track listing for Katie Melua's
album _Love and Money_ using parameters `q` (search query) and `type` of
the [Search for Item](https://developer.spotify.com/documentation/web-api/reference/search) endpoint:

```clojure
(require '[tuneberry.search :refer [search]]
'[clojure.pprint :refer [pprint]])

(go (let [res (> (get-in res [:tracks :items])
(sort-by :track_number)
(map (juxt :track_number :uri :name))))))
```

...which should give an output similar to the one below:

```clojure
([1 "spotify:track:4xuxjqgOKjquDbuKDy1hto" "Golden Record"]
[2 "spotify:track:7vKMYuPq6wqU4Le5AR9Kit" "Quiet Moves"]
[3 "spotify:track:0maYSd1pQFI4Ody2toTDxx" "14 Windows"]
[4 "spotify:track:2eW1Axi6Ruo5OtqOA6SzWO" "Lie In the Heat"]
[5 "spotify:track:1L6AqrUhtUH3qTV6ImvTNw" "Darling Star"]
[6 "spotify:track:4GJerh35GCooDcZXQHc2x3" "Reefs"]
[7 "spotify:track:4MgSTJHFsjayq60GASHru0" "First Date"]
[8 "spotify:track:17Jrz3JIr9PDN4IEW3wSYw" "Pick Me Up"]
[9 "spotify:track:53OWCM7g2k2Ol42ykqvOwF" "Those Sweet Days"]
[10 "spotify:track:69puCjWb1rrocZBah5s3GR" "Love & Money"])
```

**Body parameters** should be explicitly marked with the namespace `b/`[^2].
To relax a bit, let's play one of the above tracks using the endpoint
[Start/Resume Playback](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback):

```clojure
(require '[tuneberry.player :as p])

(let [lie-in-the-heat "spotify:track:2eW1Axi6Ruo5OtqOA6SzWO"]
(p/start-or-resume-playback tb :b/uris [lie-in-the-heat]))
```

Make sure that you have Spotify player running on at least one of your
devices, otherwise the experience may not be entirely relaxing.

> [!NOTE]
> Thanks to [improvements](https://clojure.org/news/2021/03/18/apis-serving-people-and-programs)
> added in Clojure/Script 1.11, you can also specify keyword arguments as a
> single map:
>
> ```clojure
> (search tb {:q "artist:Katie Melua album:Love and Money"
> :type "track"})
> ```

## Setting Tuneberry options

_Tuneberry_ options configure various library features, such as
[error suppression](#error-suppression)
or [blocking mode](#blocking-mode).

They are kept inside the `tuneberry` object and may be specified **during its
creation**:

```clojure
(def berry (tuneberry token {:blocking true, :smart false, :max-retry 5}))
```

Unspecified options are set to their default values, if such exist
(see [table below](#options-list)).

The `tuneberry` object is passed around to all API-calling functions
as the sole source of configuration. However, if you want to **quickly
add or change** an option for a single API call, you may put it in the `o/`
namespace.

Below we use this method to locally disable the `:smart`
[postprocessing](#postprocessing-with-smart-sel-and-post) option and receive a full http response map
(not just the http body containing the actual endpoint response):

```clojure
(require '[clojure.pprint :refer [pprint]]
'[tuneberry.users :as u])

(go (pprint ( [!NOTE]
> [ExceptionInfo](https://clojuredocs.org/clojure.core/ex-info)
> is a subclass of `js/Error` that allows you to easily convey any extra information
> in the form of a plain ClosureScript map. The lack of neccessity to create
> a custom error class hierarchy means wun[^3] less problem with JavaScript
> intricacies.

Below we try to read a non-existent
[key sequence](#postprocessing-with-smart-sel-and-post) from the API response:

```clojure
(require '[tuneberry.player :as p])

(go
(let [e (> queue
:queue
(mapcat :artists)
(map :name)
set)
quiet-moves "spotify:track:7vKMYuPq6wqU4Le5AR9Kit"]
(when (not (contains? artists "Katie Melua"))
( (p/add-item-to-playback-queue tb :uri quiet-moves))
(println "Quiet Moves added!")))
(catch js/Error e
(println "Error caught:" (ex-message e)))))
```

You can check that it handles errors correctly by adding something like
`:o/api-url "https://api.spotify.com/omgwtf"` to any of the API function calls and
observing the message:

```
Error caught: HTTP 404: Service not found
```

## Features

### Blocking mode

For many commands with side effects, the Spotify API works in a manner that
can be named _non-synchronous_ or _non-blocking_.
It seems that a `2XX` status code is returned by such endpoints as soon as
an action has been accepted for execution, not when the related
changes have actually appeared in the system.

For example:

- the [Get the User's Queue](https://developer.spotify.com/documentation/web-api/reference/get-queue)
endpoint called immediately
after [Add Item to Playback Queue](https://developer.spotify.com/documentation/web-api/reference/add-to-queue)
sometimes shows that the item is not yet present in the queue,
- [Get Playback State](https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback)
called right
after [Skip To Next](https://developer.spotify.com/documentation/web-api/reference/skip-users-playback-to-next-track)
sometimes shows that the new track is not yet playing,
- [Get Playback State](https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback)
called immediately
after [Pause Playback](https://developer.spotify.com/documentation/web-api/reference/pause-a-users-playback)
sometimes returns `is_playing = true`, which means that playback has not been stopped yet.

Such an API design has its advantages, increasing API's responsiveness
and reducing the server load. However, there are cases, like when using the
[player endpoints](https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback),
where we want to know the moment when a given action has taken effect.

Suppose we are writing an application to rate songs. We don't want to show
the user an active panel to enter a rating until we are sure that the song
currently selected by the application (and not the previous one) is already playing.
In many situations like that it is better to update the UI
a little later, if it guarantees that it will be synchronized with the state
of the player.

Blocking mode is implemented by **polling**: for a given API function with
side effects, another API endpoint is called in a loop (with backoffs) to
probe the system's state. The result isn't put into the returned channel
until the state meets a specific condition.

For example, for `tuneberry.player/pause-playback` polling continues until
`tuneberry.player/get-playback-state` returns `is_playing` as `false` (or the
maximum number of attempts is reached). Reactive code waiting on the returned
channel may fire a bit later, but never before the actual pause.

Blocking mode is disabled by default. It follows the zero-overhead principle
known from `C++`: _You don't pay for what you don't use_, because the number
of requests a Spotify application can send is subject to
[rate limits](https://developer.spotify.com/documentation/web-api/concepts/rate-limits).
However, it can save a lot of work by performing checks that would be placed
in the application code anyway.

To enable the blocking mode, simply call:

```clojure
(tuneberry token :blocking true)
```

If a given function supports the blocking mode, the release conditions can be
found in its description.

See also `:max-poll` and `:poll-delays-fn` in the [options list](#options-list).

### Retries

If an API function fails due to an http error, _Tuneberry_ retries the failed call
using simple preconfigured retry criteria and backoff strategy.

The `:retry` option contains a list of retry criteria, each being one of:

- a number `n` that must equal the http response status code for such criterion
to be met,
- a vector `[n re]` where, in addition, a regular expression `re` needs to
match a substring of the error message, taken from the http body.

If any of the criteria is satisfied, a failed API function call
is be retried up to `:max-retry` times. After that, an `ExceptionInfo`
object with message `retry limit reached` is returned.

> [!NOTE]
> The API function is repeated in its entirety, also when it consists of
> more than one http request, e.g. the actual API request and a number
> of polling requests in [blocking mode](#blocking-mode).

> [!IMPORTANT]
> See [below](#token-refresh-errors-and-retries) how to make token refresh
> errors also cause API functions' retries.

For illustrative purposes, let's break the API url once again, add retries for
the `404` response code and show the result of reaching the retry limit:

```clojure
(require
'[cljs.core.async :refer [ e ex-data :nr-attempts))
(println "last http error:" (-> e ex-data :last-result ex-message))))
```

```
message: retry limit reached
number of attempts: 4
last http error: HTTP 404: Service not found
```

If you want to disable retries altogether, set `:retry` to `false` or `nil`.

The **backoff strategy** is configured as the `:retry-delays-fn` function that
retrns a lazy sequence of wait intervals between successive retries.

By default, it is binary exponential backoff with the initial interval of
500 ms. That means _Tuneberry_ will pause for 500 ms before the first retry,
1000 ms before the second, 2000 ms before the third, and so on...
Before the 31st retry, it will pause for about 17 years, which should be
enough for Spotify dev team to bring back the service, if you only set
`:max-retry` adequately.

### Error suppression

If you try to either:

- pause an already paused playback
using [Pause Playback](https://developer.spotify.com/documentation/web-api/reference/pause-a-users-playback),
- resume an already resumed playback
using [Start/Resume Playback](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback),

Spotify Web API will respond with an `403` error saying:
`Player command failed: Restriction violated`.

It is doubtful that the described situation is an error at all,
and handling the related exception in the application code may be cumbersome.

For this reason:

1. _Tuneberry_ does not [wrap](#error-handling) these errors with an `ExceptionInfo` object,
2. the `` [macro](#throwing-exceptions-with-) does not throw an exception,
3. as the http body is normally returned, you can still check whether the Spotify
API returned an error or not (no one will ever need it for anything).

### Postprocessing with `:smart`, `:sel` and `:post`

These options specify the final transformations performed on the result map.

**The `:smart` option returns for successful API calls only the http body,
containing the actual endpoint response.** Since in the absence of errors
the complete http response map (see [example](#setting-tuneberry-options)) is usually not needed,
`:smart` is enabled by default.

**The `:sel` option performs `get-in` on the API response** using the given
key sequence.
Being able to return only the parts of the response we are interested in
often results in cleaner code. Suppose we want to access several properties
of a single recording:

```clojure
(require '[tuneberry.core :refer []]
'[tuneberry.search :refer [search]])

(go (let [album ( (search tb
:q "artist:Katie Melua album:Love and Money"
:type "album"
:o/sel [:albums :items 0]))]
(println "name:" (:name album))
(println "release_date:" (:release_date album))
(println "total_tracks:" (:total_tracks album))))
```

```
name: Love & Money
release_date: 2023-03-24
total_tracks: 10
```

A variant without `:sel` would require an extra local binding:

```clojure
album (get-in res [:albums :items 0])
```

or the use of `get-in` in a single expression together with `search` and ``,
which obfuscates the code to a great extent.

Since we usually use key sequences that always exist and contain some data,
an error is returned when the sequence passed to `:sel` returns `nil` (see
example in [Error handling](#error-handling)).
This can be disabled by setting the `:sel-check` option to `false`.

**The `:post` option** is very similar to `:sel`, except that it **allows you to specify
any mapping function** that will be executed on the API response
(see [Options list](#options-list)).

## Setups with token refresh

The [Quickstart](#quickstart) section shows that the first parameter of the
`tuneberry` function (`token-src`) can be a string containing an OAuth 2.0
access token. This allows you to quickly test the
library in the REPL, but it is not suitable for a production setup.

In production configurations, `token-src` should be a token function that
returns a [core.async](https://github.com/clojure/core.async) channel containing a valid OAuth 2.0
access token for Spotify Web API.
The token function is called before each use of the Spotify API and is
expected to read the access token from a secure location. If the access token
has expired, it should be refreshed before returning and safely stored back.

The access token can be obtained and refreshed using several OAuth flows,
as described on the Spotify Web
API [Authorization page](https://developer.spotify.com/documentation/web-api/concepts/authorization).
_Tuneberry_ is tested with the most
reliable [Authorization Code with PKCE](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow),
but should also work with other OAuth flows (if not, please
[let me know](https://github.com/artbookspirit/tuneberry/issues)).

The builder function below called `make-token-fn` creates a token function
that is used in _Tuneberry_ tests:

```clojure
(defn make-token-fn [client-id access-token]
(let [token (atom access-token)]
(fn []
(go
(when (token-expired? @token)
(reset! token (
```

into the URL bar, where `Client ID` is your Spotify
app's [client id](https://developer.spotify.com/documentation/web-api/concepts/apps).
This is because the test runner needs to know on behalf of which application
it will request the access token. The client id will be stored in the browser's
local storage, so it only needs to be entered once.

> [!WARNING]
> _Tuneberry_ tests modify the state of the Spotify player:
> - turn off shuffle,
> - remove and add random tracks to the playback queue,
> - play tracks.
>
> Of course it's not harmful in any way, but make sure you're ok with it
> before running the tests.

If everything went well, you will be redirected to the Spotify login page
and then asked to authorize scopes required by the tests.

After you agree, the tests will be launched and their results displayed in
the browser window.

**To get the newly received access token, open the web console, look for a
message similar to the one below and copy `:access_token` from it:**

```
Received access token:
{:access_token "",
:token_type "Bearer",
:expires_in 3600,
:refresh_token "...",
:scope "user-modify-playback-state ...",
:expires_at ...}
```

### Getting a REPL

After executing `npx shadow-cljs watch test` and opening http://127.0.0.1:8021
in the browser, in a different terminal run:

```
npx shadow-cljs cljs-repl test
```

It's good to keep the web console open for logs and network errors.

## License

Copyright (C) 2023 Piotr Bartosik

Distributed under the Eclipse Public License, the same as Clojure.

Photo
by
Jeremy Ricketts

on
Unsplash
.

[^1]: _Tuneberry_ returns modified result maps from [cljs-http](https://github.com/r0man/cljs-http).

[^2]: _Tuneberry_ follows a strategy to stand between the user and the API as
little as possible, so it does not decide which parameters are to be sent
in which way.

[^3]: An inside joke for those familiar with very opinionated yet truly
enlightening books by Douglas Crockford.

[^4]: See for example: http://swannodette.github.io/2013/08/31/asynchronous-error-handling