{"id":32184716,"url":"https://github.com/artbookspirit/tuneberry","last_synced_at":"2025-12-12T01:21:05.164Z","repository":{"id":184891193,"uuid":"622087279","full_name":"artbookspirit/tuneberry","owner":"artbookspirit","description":"ClojureScript bindings for Spotify Web API with retries, blocking mode and a tasty name.","archived":false,"fork":false,"pushed_at":"2024-04-14T15:17:38.000Z","size":216,"stargazers_count":2,"open_issues_count":0,"forks_count":1,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-10-21T23:55:52.485Z","etag":null,"topics":["async","clojurescript","spotify"],"latest_commit_sha":null,"homepage":"","language":"Clojure","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/artbookspirit.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2023-04-01T04:42:24.000Z","updated_at":"2025-05-01T14:09:58.000Z","dependencies_parsed_at":"2023-08-16T07:24:08.361Z","dependency_job_id":"b457248d-49e6-445d-a8c3-13324e45af1a","html_url":"https://github.com/artbookspirit/tuneberry","commit_stats":null,"previous_names":["artbookspirit/spojure","artbookspirit/tuneberry"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/artbookspirit/tuneberry","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artbookspirit%2Ftuneberry","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artbookspirit%2Ftuneberry/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artbookspirit%2Ftuneberry/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artbookspirit%2Ftuneberry/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/artbookspirit","download_url":"https://codeload.github.com/artbookspirit/tuneberry/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artbookspirit%2Ftuneberry/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":280354181,"owners_count":26316400,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-21T02:00:06.614Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["async","clojurescript","spotify"],"created_at":"2025-10-21T23:56:01.683Z","updated_at":"2025-10-21T23:56:04.546Z","avatar_url":"https://github.com/artbookspirit.png","language":"Clojure","funding_links":[],"categories":[],"sub_categories":[],"readme":"![tuneberry image](assets/tuneberry.jpg)\n\n# Tuneberry\n\n[![Clojars Project](https://img.shields.io/clojars/v/com.github.artbookspirit/tuneberry.svg)](https://clojars.org/com.github.artbookspirit/tuneberry)\n\n[ClojureScript](https://clojurescript.org/) bindings for\n[Spotify Web API](https://developer.spotify.com/documentation/web-api) with\nretries, blocking mode and a tasty name.\n\n\u003e [!IMPORTANT]\n\u003e Although currently mainly the _Player_ and _Search_ endpoints are covered, the\n\u003e library is fully operational. Expect more soon. Need a specific endpoint\n\u003e not covered? Report [an issue](https://github.com/artbookspirit/tuneberry/issues)\n\u003e or create a [pull request](https://github.com/artbookspirit/tuneberry/pulls).\n\n## Quickstart\n\nTo display your Spotify user ID, run the code below:\n\n```clojure\n(require '[cljs.core.async :refer [\u003c! go]]\n         '[tuneberry.core :refer [tuneberry]]\n         '[tuneberry.users :as u])\n\n(def token \"\u003cOAuth 2.0 Spotify access token\u003e\")\n(def tb (tuneberry token))\n(go (prn (:id (\u003c! (u/get-current-user-profile tb)))))\n```\n\nIf by any chance you don't have a valid OAuth 2.0 access token for Spotify Web API in\nyour clipboard, _Tuneberry_ [test runner](#testing) has been adapted to help\nyou get through this painful process in minutes.\n\n_Tuneberry_ is a collection of functions that wrap\n[Spotify Web API](https://developer.spotify.com/documentation/web-api)\nendpoints and return a [core.async](https://github.com/clojure/core.async) **channel** from which you can take\nan endpoint's response. The response is a JSON object turned into a ClojureScript\n(nested) map, where JSON keys are turned into keywords[^1].\n\nSince `get-current-user-profile` is a wrapper around the\n[Get Current User's Profile](https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile)\nendpoint, we get the Spotify user ID simply by calling `:id`\non the response.\n\n\u003e [!NOTE]\n\u003e Production configurations require token refreshing and are covered in one of\n\u003e the [later sections](#setups-with-token-refresh).\n\n## Passing Spotify API parameters\n\nWith _Tuneberry_, you pass url **query string parameters** as regular key-value pairs.\n\nIn the example below, we display the track listing for Katie Melua's\nalbum _Love and Money_ using parameters `q` (search query) and `type` of\nthe [Search for Item](https://developer.spotify.com/documentation/web-api/reference/search) endpoint:\n\n```clojure\n(require '[tuneberry.search :refer [search]]\n         '[clojure.pprint :refer [pprint]])\n\n(go (let [res (\u003c! (search tb\n                          :q \"artist:Katie Melua album:Love and Money\"\n                          :type \"track\"))]\n      (pprint (-\u003e\u003e (get-in res [:tracks :items])\n                   (sort-by :track_number)\n                   (map (juxt :track_number :uri :name))))))\n```\n\n...which should give an output similar to the one below:\n\n```clojure\n([1 \"spotify:track:4xuxjqgOKjquDbuKDy1hto\" \"Golden Record\"]\n [2 \"spotify:track:7vKMYuPq6wqU4Le5AR9Kit\" \"Quiet Moves\"]\n [3 \"spotify:track:0maYSd1pQFI4Ody2toTDxx\" \"14 Windows\"]\n [4 \"spotify:track:2eW1Axi6Ruo5OtqOA6SzWO\" \"Lie In the Heat\"]\n [5 \"spotify:track:1L6AqrUhtUH3qTV6ImvTNw\" \"Darling Star\"]\n [6 \"spotify:track:4GJerh35GCooDcZXQHc2x3\" \"Reefs\"]\n [7 \"spotify:track:4MgSTJHFsjayq60GASHru0\" \"First Date\"]\n [8 \"spotify:track:17Jrz3JIr9PDN4IEW3wSYw\" \"Pick Me Up\"]\n [9 \"spotify:track:53OWCM7g2k2Ol42ykqvOwF\" \"Those Sweet Days\"]\n [10 \"spotify:track:69puCjWb1rrocZBah5s3GR\" \"Love \u0026 Money\"])\n```\n\n**Body parameters** should be explicitly marked with the namespace `b/`[^2].\nTo relax a bit, let's play one of the above tracks using the endpoint\n[Start/Resume Playback](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback):\n\n```clojure\n(require '[tuneberry.player :as p])\n\n(let [lie-in-the-heat \"spotify:track:2eW1Axi6Ruo5OtqOA6SzWO\"]\n  (p/start-or-resume-playback tb :b/uris [lie-in-the-heat]))\n```\n\nMake sure that you have Spotify player running on at least one of your\ndevices, otherwise the experience may not be entirely relaxing.\n\n\u003e [!NOTE]\n\u003e Thanks to [improvements](https://clojure.org/news/2021/03/18/apis-serving-people-and-programs)\n\u003e added in Clojure/Script 1.11, you can also specify keyword arguments as a\n\u003e single map:\n\u003e\n\u003e ```clojure\n\u003e (search tb {:q \"artist:Katie Melua album:Love and Money\"\n\u003e              :type \"track\"})\n\u003e ```\n\n## Setting Tuneberry options\n\n_Tuneberry_ options configure various library features, such as\n[error suppression](#error-suppression)\nor [blocking mode](#blocking-mode).\n\nThey are kept inside the `tuneberry` object and may be specified **during its\ncreation**:\n\n```clojure\n(def berry (tuneberry token {:blocking true, :smart false, :max-retry 5}))\n```\n\nUnspecified options are set to their default values, if such exist\n(see [table below](#options-list)).\n\nThe `tuneberry` object is passed around to all API-calling functions\nas the sole source of configuration. However, if you want to **quickly\nadd or change** an option for a single API call, you may put it in the `o/`\nnamespace.\n\nBelow we use this method to locally disable the `:smart`\n[postprocessing](#postprocessing-with-smart-sel-and-post) option and receive a full http response map\n(not just the http body containing the actual endpoint response):\n\n```clojure\n(require '[clojure.pprint :refer [pprint]]\n         '[tuneberry.users :as u])\n\n(go (pprint (\u003c! (u/get-current-user-profile tb :o/smart false))))\n```\n\n```clojure\n{:status          200,\n :success         true,\n :body            {:id \"artbookspirit\", ...},\n :headers         {...},\n :trace-redirects [...],\n :error-code      :no-error,\n :error-text      \"\"}\n```\n\nDetailed descriptions of the features can be found in later sections.\n\n### Options list\n\n| Option             | Default value              | Description                                                                             |\n|--------------------|----------------------------|-----------------------------------------------------------------------------------------|\n| `:api-url`         | https://api.spotify.com/v1 | Common prefix of all Spotify Web API endpoints.                                         |\n| `:blocking`        | false                      | Turns on [blocking mode](#blocking-mode).                                               |\n| `:max-poll`        | 5                          | The maximum number of polls in blocking mode.                                           |\n| `:poll-delays-fn`  | `(100 200 400 ...)`        | Returns a lazy sequence of wait intervals between successive polls in blocking mode.    |\n| `:retry`           | `[500 502 503]`            | A list of [retry](#retries) criteria or `false`/`nil` to disable retries.               |\n| `:max-retry`       | 3                          | The maximum number of retries.                                                          |\n| `:retry-delays-fn` | `(500 1000 2000 ...)`      | Returns a lazy sequence of wait intervals between successive retries.                   |\n| `:smart`           | true                       | Turns on the [smart](#postprocessing-with-smart-sel-and-post) mode.                     |\n| `:sel`             | N/A                        | Turns on the `:sel` [postprocessing](#postprocessing-with-smart-sel-and-post).          |\n| `:sel-check`       | true                       | Specifies whether to return an error if the path passed with `:sel` returns `nil`.      |\n| `:post`            | N/A                        | Turns on the `:post` [postprocessing](#postprocessing-with-smart-sel-and-post).         |\n| `:post-check`      | true                       | Specifies whether to return an error if the function passed with `:post` returns `nil`. |\n\n## Error handling\n\nAll API functions return a [core.async](https://github.com/clojure/core.async) channel which eventually contains:\n\n- http response body on request success,\n- an `ExceptionInfo` object on request failure.\n\nErrors may come from a variety of sources, such as:\n\n- http errors,\n- [token refresh](#setups-with-token-refresh) errors,\n- limit of polls reached in [blocking mode](#blocking-mode),\n- limit of [retries](#retries) reached,\n- a `nil` [postprocessing](#postprocessing-with-smart-sel-and-post) result.\n\n\u003e [!NOTE]\n\u003e [ExceptionInfo](https://clojuredocs.org/clojure.core/ex-info)\n\u003e is a subclass of `js/Error` that allows you to easily convey any extra information\n\u003e in the form of a plain ClosureScript map. The lack of neccessity to create\n\u003e a custom error class hierarchy means wun[^3] less problem with JavaScript\n\u003e intricacies.\n\nBelow we try to read a non-existent\n[key sequence](#postprocessing-with-smart-sel-and-post) from the API response:\n\n```clojure\n(require '[tuneberry.player :as p])\n\n(go\n  (let [e (\u003c! (p/get-available-devices tb :o/sel [:foo :bar]))]\n    (prn e)))\n```\n\nThe result is an `ExceptionInfo` holding the failed key sequence and the\noriginal API response within its `data` property:\n\n```clojure\n{:message \"no response path\",\n :data    {:response {:devices\n                      [{:id                 \"39ee...\",\n                        :is_active          false,\n                        :is_private_session false,\n                        :is_restricted      false,\n                        :name               \"cuckoo\",\n                        :supports_volume    true,\n                        :type               \"Computer\",\n                        :volume_percent     100}]},\n           :path     [:foo :bar]}}\n```\n\n### Throwing exceptions with `\u003c?`\n\nTo avoid checking each API function response for `ExceptionInfo`, you can employ\nthe _Tuneberry_'s version of the commonly used `\u003c?` macro[^4].\n\n`\u003c?` works exactly like `\u003c!` except that if the value from the channel turns out\nto be an instance of `js/Error`, it immediately throws it. This allows\nyou to use **a regular try/catch** in the context of asynchronous channels.\n\nThe following short program, inspired by the fact that we still have at hand\na track listing for Katie Melua's album, checks if there is\nany Katie's song in the playback queue and adds one if there isn't one already.\n\n```clojure\n(require '[tuneberry.core :refer [\u003c?]]\n         '[tuneberry.player :as p])\n\n(go\n  (try\n    (let [queue (\u003c? (p/get-user-queue tb))\n          artists (-\u003e\u003e queue\n                       :queue\n                       (mapcat :artists)\n                       (map :name)\n                       set)\n          quiet-moves \"spotify:track:7vKMYuPq6wqU4Le5AR9Kit\"]\n      (when (not (contains? artists \"Katie Melua\"))\n        (\u003c? (p/add-item-to-playback-queue tb :uri quiet-moves))\n        (println \"Quiet Moves added!\")))\n    (catch js/Error e\n      (println \"Error caught:\" (ex-message e)))))\n```\n\nYou can check that it handles errors correctly by adding something like\n`:o/api-url \"https://api.spotify.com/omgwtf\"` to any of the API function calls and\nobserving the message:\n\n```\nError caught: HTTP 404: Service not found\n```\n\n## Features\n\n### Blocking mode\n\nFor many commands with side effects, the Spotify API works in a manner that\ncan be named _non-synchronous_ or _non-blocking_.\nIt seems that a `2XX` status code is returned by such endpoints as soon as\nan action has been accepted for execution, not when the related\nchanges have actually appeared in the system.\n\nFor example:\n\n- the [Get the User's Queue](https://developer.spotify.com/documentation/web-api/reference/get-queue)\n  endpoint called immediately\n  after [Add Item to Playback Queue](https://developer.spotify.com/documentation/web-api/reference/add-to-queue)\n  sometimes shows that the item is not yet present in the queue,\n- [Get Playback State](https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback)\n  called right\n  after [Skip To Next](https://developer.spotify.com/documentation/web-api/reference/skip-users-playback-to-next-track)\n  sometimes shows that the new track is not yet playing,\n- [Get Playback State](https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback)\n  called immediately\n  after [Pause Playback](https://developer.spotify.com/documentation/web-api/reference/pause-a-users-playback)\n  sometimes returns `is_playing = true`, which means that playback has not been stopped yet.\n\nSuch an API design has its advantages, increasing API's responsiveness\nand reducing the server load. However, there are cases, like when using the\n[player endpoints](https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback),\nwhere we want to know the moment when a given action has taken effect.\n\nSuppose we are writing an application to rate songs. We don't want to show\nthe user an active panel to enter a rating until we are sure that the song\ncurrently selected by the application (and not the previous one) is already playing.\nIn many situations like that it is better to update the UI\na little later, if it guarantees that it will be synchronized with the state\nof the player.\n\nBlocking mode is implemented by **polling**: for a given API function with\nside effects, another API endpoint is called in a loop (with backoffs) to\nprobe the system's state. The result isn't put into the returned channel\nuntil the state meets a specific condition.\n\nFor example, for `tuneberry.player/pause-playback` polling continues until\n`tuneberry.player/get-playback-state` returns `is_playing` as `false` (or the\nmaximum number of attempts is reached). Reactive code waiting on the returned\nchannel may fire a bit later, but never before the actual pause.\n\nBlocking mode is disabled by default. It follows the zero-overhead principle\nknown from `C++`: _You don't pay for what you don't use_, because the number\nof requests a Spotify application can send is subject to\n[rate limits](https://developer.spotify.com/documentation/web-api/concepts/rate-limits).\nHowever, it can save a lot of work by performing checks that would be placed\nin the application code anyway.\n\nTo enable the blocking mode, simply call:\n\n```clojure\n(tuneberry token :blocking true)\n```\n\nIf a given function supports the blocking mode, the release conditions can be\nfound in its description.\n\nSee also `:max-poll` and `:poll-delays-fn` in the [options list](#options-list).\n\n### Retries\n\nIf an API function fails due to an http error, _Tuneberry_ retries the failed call\nusing simple preconfigured retry criteria and backoff strategy.\n\nThe `:retry` option contains a list of retry criteria, each being one of:\n\n- a number `n` that must equal the http response status code for such criterion\n  to be met,\n- a vector `[n re]` where, in addition, a regular expression `re` needs to\n  match a substring of the error message, taken from the http body.\n\nIf any of the criteria is satisfied, a failed API function call\nis be retried up to `:max-retry` times. After that, an `ExceptionInfo`\nobject with message `retry limit reached` is returned.\n\n\u003e [!NOTE]\n\u003e The API function is repeated in its entirety, also when it consists of\n\u003e more than one http request, e.g. the actual API request and a number\n\u003e of polling requests in [blocking mode](#blocking-mode).\n\n\u003e [!IMPORTANT]\n\u003e See [below](#token-refresh-errors-and-retries) how to make token refresh\n\u003e errors also cause API functions' retries.\n\nFor illustrative purposes, let's break the API url once again, add retries for\nthe `404` response code and show the result of reaching the retry limit:\n\n```clojure\n(require\n  '[cljs.core.async :refer [\u003c! go]]\n  '[tuneberry.player :as p])\n\n(go\n  (let [e (\u003c! (p/get-playback-state\n                tb\n                :o/api-url \"https://api.spotify.com/meh\"\n                :o/retry [500 502 503 [404 #\"(?i).*not\\s+found\"]]))]\n    (println \"message:\" (ex-message e))\n    (println \"number of attempts:\" (-\u003e e ex-data :nr-attempts))\n    (println \"last http error:\" (-\u003e e ex-data :last-result ex-message))))\n```\n\n```\nmessage: retry limit reached\nnumber of attempts: 4\nlast http error: HTTP 404: Service not found\n```\n\nIf you want to disable retries altogether, set `:retry` to `false` or `nil`.\n\nThe **backoff strategy** is configured as the `:retry-delays-fn` function that\nretrns a lazy sequence of wait intervals between successive retries.\n\nBy default, it is binary exponential backoff with the initial interval of\n500 ms. That means _Tuneberry_ will pause for 500 ms before the first retry,\n1000 ms before the second, 2000 ms before the third, and so on...\nBefore the 31st retry, it will pause for about 17 years, which should be\nenough for Spotify dev team to bring back the service, if you only set\n`:max-retry` adequately.\n\n### Error suppression\n\nIf you try to either:\n\n- pause an already paused playback\n  using [Pause Playback](https://developer.spotify.com/documentation/web-api/reference/pause-a-users-playback),\n- resume an already resumed playback\n  using [Start/Resume Playback](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback),\n\nSpotify Web API will respond with an `403` error saying:\n`Player command failed: Restriction violated`.\n\nIt is doubtful that the described situation is an error at all,\nand handling the related exception in the application code may be cumbersome.\n\nFor this reason:\n\n1. _Tuneberry_ does not [wrap](#error-handling) these errors with an `ExceptionInfo` object,\n2. the `\u003c?` [macro](#throwing-exceptions-with-) does not throw an exception,\n3. as the http body is normally returned, you can still check whether the Spotify\n   API returned an error or not (no one will ever need it for anything).\n\n### Postprocessing with `:smart`, `:sel` and `:post`\n\nThese options specify the final transformations performed on the result map.\n\n**The `:smart` option returns for successful API calls only the http body,\ncontaining the actual endpoint response.** Since in the absence of errors\nthe complete http response map (see [example](#setting-tuneberry-options)) is usually not needed,\n`:smart` is enabled by default.\n\n**The `:sel` option performs `get-in` on the API response** using the given\nkey sequence.\nBeing able to return only the parts of the response we are interested in\noften results in cleaner code. Suppose we want to access several properties\nof a single recording:\n\n```clojure\n(require '[tuneberry.core :refer [\u003c?]]\n         '[tuneberry.search :refer [search]])\n\n(go (let [album (\u003c? (search tb\n                            :q \"artist:Katie Melua album:Love and Money\"\n                            :type \"album\"\n                            :o/sel [:albums :items 0]))]\n      (println \"name:\" (:name album))\n      (println \"release_date:\" (:release_date album))\n      (println \"total_tracks:\" (:total_tracks album))))\n```\n\n```\nname: Love \u0026 Money\nrelease_date: 2023-03-24\ntotal_tracks: 10\n```\n\nA variant without `:sel` would require an extra local binding:\n\n```clojure\nalbum (get-in res [:albums :items 0])\n```\n\nor the use of `get-in` in a single expression together with `search` and `\u003c?`,\nwhich obfuscates the code to a great extent.\n\nSince we usually use key sequences that always exist and contain some data,\nan error is returned when the sequence passed to `:sel` returns `nil` (see\nexample in [Error handling](#error-handling)).\nThis can be disabled by setting the `:sel-check` option to `false`.\n\n**The `:post` option** is very similar to `:sel`, except that it **allows you to specify\nany mapping function** that will be executed on the API response\n(see [Options list](#options-list)).\n\n## Setups with token refresh\n\nThe [Quickstart](#quickstart) section shows that the first parameter of the\n`tuneberry` function (`token-src`) can be a string containing an OAuth 2.0\naccess token. This allows you to quickly test the\nlibrary in the REPL, but it is not suitable for a production setup.\n\nIn production configurations, `token-src` should be a token function that\nreturns a [core.async](https://github.com/clojure/core.async) channel containing a valid OAuth 2.0\naccess token for Spotify Web API.\nThe token function is called before each use of the Spotify API and is\nexpected to read the access token from a secure location. If the access token\nhas expired, it should be refreshed before returning and safely stored back.\n\nThe access token can be obtained and refreshed using several OAuth flows,\nas described on the Spotify Web\nAPI [Authorization page](https://developer.spotify.com/documentation/web-api/concepts/authorization).\n_Tuneberry_ is tested with the most\nreliable [Authorization Code with PKCE](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow),\nbut should also work with other OAuth flows (if not, please\n[let me know](https://github.com/artbookspirit/tuneberry/issues)).\n\nThe builder function below called `make-token-fn` creates a token function\nthat is used in _Tuneberry_ tests:\n\n```clojure\n(defn make-token-fn [client-id access-token]\n  (let [token (atom access-token)]\n    (fn []\n      (go\n        (when (token-expired? @token)\n          (reset! token (\u003c! (refresh-access-token\n                              {:client-id     client-id\n                               :refresh-token (:refresh_token @token)}))))\n        (:access_token @token)))))\n```\n\n`make-token-fn` function\naccepts [Spotify app Client ID](https://developer.spotify.com/documentation/web-api/concepts/apps)\nneeded for token refreshing together with an access token map obtained via\nthe PKCE authorization, containing keys such as:\n\n- `:access_token` for Spotify Web API access,\n- `:expires_in` to check whether the access token has expired,\n- `:refresh_token` used in the refresh request.\n\nThe map (you can see an example at the end of the [Testing](#testing) section)\nis stored in the `token` atom where it can be refreshed\nby the returned token function each time the access token turns out to\nbe expired.\n\nFor _Tuneberry_ tests we don't need a \"secure location\" other than memory,\nbut in production setups you will probably want to securely persist\nthe access token map on disk or in a database.\n\nUsing the above builder is pretty straightforward:\n\n```clojure\n(tuneberry (make-token-fn client-id access-token))\n```\n\nIt is assumed that the access token returned by the token function will\ngive access to the set of [scopes](https://developer.spotify.com/documentation/web-api/concepts/scopes)\nrequired by the user. As with token lifetimes, _Tuneberry_ deliberately\ndoes not control authorization scopes explicitly.\n\n### Token refresh errors and retries\n\nHttp errors are [wrapped](#error-handling) by _Tuneberry_ with an `ExceptionInfo` object\ncontaining special keys `:http-status` and `:http-message` in its data map, e.g.:\n\n```clojure\n{:message \"HTTP 404: Service not found\",\n :data    {:http-status  404,\n           :http-message \"Service not found\",\n           :cause        ...}}\n```\n\nOnly if `:http-status` is present, the given `ExceptionInfo` is identified as\nan http error for which the [retry criteria](#retries) are checked\n(where `n` and `re` are compared with `:http-status` and `:http-message`,\nrespectively).\n\nIf you want token refresh errors to trigger retries,\nthey must be returned by the token function as such `ExceptionInfo` objects,\ncontaining at least `:http-status` in the data map (`:http-message` is optional).\n\nIf needed, also additional criteria must be added to `:retry`.\n\n## Testing\n\n_Tuneberry_ tests include both unit tests and online integration tests with\nSpotify Web API. They run in the browser using the [Shadow CLJS](https://github.com/thheller/shadow-cljs)\ntest runner generated for\nthe [`:browser-test` test target](https://shadow-cljs.github.io/docs/UsersGuide.html#target-browser-test).\n\nThe runner has been modified with the custom namespace `tuneberry.test.runner`\nso that it performs\nthe [authorization code PKCE flow](https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow)\nto obtain an access token used by tests to make API calls.\n\n**Since the access token is displayed in the web console, it can be easily\ncopied and used elsewhere, e.g. [in the REPL](#getting-a-repl).**\n\n### Prerequisites\n\nTo run the tests you will need:\n\n- [Node.js](https://nodejs.org/),\n- one of Java distributions,\n- a [Spotify](https://www.spotify.com/) account,\n- a Spotify app.\n\nCreating a Spotify app is rather quick: see\nthe [official guidelines](https://developer.spotify.com/documentation/web-api/concepts/apps).\nFor the authentication flow to work correctly, you need to enter\n\n```\nhttp://127.0.0.1:8021/callback\n```\n\nas the `Redirect URI`, which is the address of the Shadow CLJS test runner\nlocally on your computer.\n\n### Running the tests\n\nTo compile the tests and run the local test server, enter the _Tuneberry's_\nroot directory and execute:\n\n```clojure\nnpx shadow-cljs watch test\n```\n\nAfter you see the `Build complete` message in your terminal, open the browser\nand type\n\n```\nhttp://127.0.0.1:8021?client-id=\u003cClient ID\u003e\n```\n\ninto the URL bar, where `Client ID` is your Spotify\napp's [client id](https://developer.spotify.com/documentation/web-api/concepts/apps).\nThis is because the test runner needs to know on behalf of which application\nit will request the access token. The client id will be stored in the browser's\nlocal storage, so it only needs to be entered once.\n\n\u003e [!WARNING]\n\u003e _Tuneberry_ tests modify the state of the Spotify player:\n\u003e - turn off shuffle,\n\u003e - remove and add random tracks to the playback queue,\n\u003e - play tracks.\n\u003e\n\u003e Of course it's not harmful in any way, but make sure you're ok with it\n\u003e before running the tests.\n\nIf everything went well, you will be redirected to the Spotify login page\nand then asked to authorize scopes required by the tests.\n\nAfter you agree, the tests will be launched and their results displayed in\nthe browser window.\n\n**To get the newly received access token, open the web console, look for a\nmessage similar to the one below and copy `:access_token` from it:**\n\n```\nReceived access token:\n{:access_token \"\u003cOAuth 2.0 access token for Spotify Web API\u003e\",\n :token_type \"Bearer\",\n :expires_in 3600,\n :refresh_token \"...\",\n :scope \"user-modify-playback-state ...\",\n :expires_at ...}\n```\n\n### Getting a REPL\n\nAfter executing `npx shadow-cljs watch test` and opening http://127.0.0.1:8021\nin the browser, in a different terminal run:\n\n```\nnpx shadow-cljs cljs-repl test\n```\n\nIt's good to keep the web console open for logs and network errors.\n\n## License\n\nCopyright (C) 2023 Piotr Bartosik\n\nDistributed under the Eclipse Public License, the same as Clojure.\n\nPhoto\nby \u003ca href=\"https://unsplash.com/@jeremydgreat?utm_source=unsplash\u0026utm_medium=referral\u0026utm_content=creditCopyText\"\u003e\nJeremy Ricketts\u003c/a\u003e\non \u003ca href=\"https://unsplash.com/photos/h4zs8fbybq4?utm_source=unsplash\u0026utm_medium=referral\u0026utm_content=creditCopyText\"\u003e\nUnsplash\u003c/a\u003e.\n\n[^1]: _Tuneberry_ returns modified result maps from [cljs-http](https://github.com/r0man/cljs-http).\n\n[^2]: _Tuneberry_ follows a strategy to stand between the user and the API as\nlittle as possible, so it does not decide which parameters are to be sent\nin which way.\n\n[^3]: An inside joke for those familiar with very opinionated yet truly\nenlightening books by Douglas Crockford.\n\n[^4]: See for example: http://swannodette.github.io/2013/08/31/asynchronous-error-handling\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fartbookspirit%2Ftuneberry","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fartbookspirit%2Ftuneberry","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fartbookspirit%2Ftuneberry/lists"}