├── test ├── test_helper.exs ├── spotify │ ├── category_test.exs │ ├── seed_test.exs │ ├── auth_request_test.exs │ ├── paging_test.exs │ ├── profile_test.exs │ ├── cookies_test.exs │ ├── recommendation_test.exs │ ├── playlist_track_test.exs │ ├── authorization_test.exs │ ├── credentials_test.exs │ ├── personalization_test.exs │ ├── library_test.exs │ ├── track_test.exs │ ├── search_test.exs │ ├── album_test.exs │ ├── playlist_test.exs │ ├── responder_test.exs │ ├── artist_test.exs │ └── player_test.exs ├── doc_test.exs ├── spotify_ex_test.exs ├── authentication_client_mock.exs └── authorization_flow_test.exs ├── LICENSE.txt ├── .formatter.exs ├── config ├── dev.exs ├── spotify.exs ├── test.exs └── config.exs ├── .gitignore ├── lib ├── spotify │ ├── history.ex │ ├── context.ex │ ├── authentication_error.ex │ ├── device.ex │ ├── currently_playing.ex │ ├── playback.ex │ ├── seed.ex │ ├── audio_features.ex │ ├── auth_request.ex │ ├── episode.ex │ ├── helpers.ex │ ├── playlist_track.ex │ ├── authentication_client.ex │ ├── paging.ex │ ├── client.ex │ ├── follow.ex │ ├── authorization.ex │ ├── cookies.ex │ ├── recommendation.ex │ ├── search.ex │ ├── responder.ex │ ├── profile.ex │ ├── categories.ex │ ├── credentials.ex │ ├── personalization.ex │ ├── authentication.ex │ ├── track.ex │ ├── library.ex │ ├── artist.ex │ ├── album.ex │ ├── player.ex │ └── playlist.ex └── spotify_ex.ex ├── docs ├── scopes.md └── oauth.md ├── .github └── workflows │ └── elixir.yml ├── CHANGELOG.md ├── mix.exs ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | This project is licensed under the terms of the MIT license. 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :spotify_ex, auth_client: Spotify.Authentication 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | todo.md 8 | /config/config.secret.exs 9 | /tmp 10 | -------------------------------------------------------------------------------- /lib/spotify/history.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.History do 2 | defstruct ~w[ 3 | track 4 | played_at 5 | context 6 | ]a 7 | end 8 | -------------------------------------------------------------------------------- /lib/spotify/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Context do 2 | defstruct ~w[ 3 | uri 4 | href 5 | external_urls 6 | type 7 | ]a 8 | end 9 | -------------------------------------------------------------------------------- /lib/spotify/authentication_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AuthenticationError do 2 | defexception [:message] 3 | 4 | def exception(message) do 5 | %__MODULE__{message: message} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/spotify/device.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Device do 2 | defstruct ~w[ 3 | id 4 | is_active 5 | is_private_session 6 | is_restricted 7 | name 8 | type 9 | volume_percent 10 | ]a 11 | end 12 | -------------------------------------------------------------------------------- /config/spotify.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :spotify_ex, 4 | auth_client: Spotify.Authentication, 5 | callback_url: "http://localhost:4200/authenticate", 6 | scopes: ["playlist-modify-public", "playlist-modify-private"] 7 | -------------------------------------------------------------------------------- /lib/spotify/currently_playing.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.CurrentlyPlaying do 2 | defstruct ~w[ 3 | actions 4 | context 5 | currently_playing_type 6 | is_playing 7 | item 8 | progress_ms 9 | timestamp 10 | ]a 11 | end 12 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | Path.wildcard("test/*mock*") 4 | |> Enum.each(&Code.require_file("../#{&1}", __DIR__)) 5 | 6 | Code.ensure_loaded(Plug.Conn) 7 | Code.ensure_loaded(HTTPoison.Response) 8 | 9 | config :spotify_ex, auth_client: AuthenticationClientMock 10 | -------------------------------------------------------------------------------- /lib/spotify/playback.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Playback do 2 | defstruct ~w[ 3 | actions 4 | context 5 | currently_playing_type 6 | device 7 | is_playing 8 | item 9 | progress_ms 10 | repeat_state 11 | shuffle_state 12 | timestamp 13 | ]a 14 | end 15 | -------------------------------------------------------------------------------- /test/spotify/category_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.CategoryTest do 2 | use ExUnit.Case 3 | alias Spotify.Category 4 | 5 | test "%Category{}" do 6 | expected = ~w[href icons id name]a 7 | actual = %Category{} |> Map.from_struct() |> Map.keys() 8 | 9 | assert actual == expected 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/spotify/seed_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.SeedTest do 2 | use ExUnit.Case 3 | 4 | test "%Seed{}" do 5 | actual = %Spotify.Seed{} |> Map.from_struct() |> Map.keys() 6 | expected = ~w[afterFilteringSize afterRelinkingSize href id initialPoolSize type]a 7 | 8 | assert actual == expected 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/spotify/seed.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Seed do 2 | @moduledoc """ 3 | Spotify can make recommendations by providing seed data. The response contains 4 | a seed object. 5 | """ 6 | defstruct ~w[ 7 | afterFilteringSize 8 | afterRelinkingSize 9 | href 10 | id 11 | initialPoolSize 12 | type 13 | ]a 14 | end 15 | -------------------------------------------------------------------------------- /docs/scopes.md: -------------------------------------------------------------------------------- 1 | You must be explicit about the permissions your users have when handling 2 | Spotify account data. These permissions are set during the authorization 3 | request. You can read about them 4 | [here](https://developer.spotify.com/documentation/general/guides/authorization/scopes/). To set your 5 | scopes, add them to the list in your ```spotify.exs``` file, 6 | -------------------------------------------------------------------------------- /test/spotify/auth_request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AuthRequestTest do 2 | use ExUnit.Case 3 | 4 | test "headers/0" do 5 | headers = [ 6 | {"Authorization", "Basic #{Spotify.encoded_credentials()}"}, 7 | {"Content-Type", "application/x-www-form-urlencoded"} 8 | ] 9 | 10 | assert(Spotify.AuthRequest.headers() == headers) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/doc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DocTest do 2 | use ExUnit.Case 3 | 4 | doctest Spotify.Album 5 | doctest Spotify.Artist 6 | doctest Spotify.Category 7 | doctest Spotify.Follow 8 | doctest Spotify.Personalization 9 | doctest Spotify.Player 10 | doctest Spotify.Playlist 11 | doctest Spotify.Profile 12 | doctest Spotify.Recommendation 13 | doctest Spotify.Search 14 | doctest Spotify.Track 15 | end 16 | -------------------------------------------------------------------------------- /lib/spotify/audio_features.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AudioFeatures do 2 | defstruct ~w[ 3 | acousticness 4 | analysis_url 5 | danceability 6 | duration_ms 7 | energy 8 | id 9 | instrumentalness 10 | key 11 | liveness 12 | loudness 13 | mode 14 | speechiness 15 | tempo 16 | time_signature 17 | track_href 18 | type 19 | uri 20 | valence 21 | ]a 22 | end 23 | -------------------------------------------------------------------------------- /lib/spotify/auth_request.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AuthRequest do 2 | @moduledoc false 3 | 4 | @url "https://accounts.spotify.com/api/token" 5 | 6 | def post(params) do 7 | HTTPoison.post(@url, params, headers()) 8 | end 9 | 10 | def headers do 11 | [ 12 | {"Authorization", "Basic #{Spotify.encoded_credentials()}"}, 13 | {"Content-Type", "application/x-www-form-urlencoded"} 14 | ] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/spotify/episode.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Episode do 2 | defstruct ~w[ 3 | audio_preview_url 4 | description 5 | duration_ms 6 | explicit 7 | external_urls 8 | href 9 | id 10 | images 11 | is_externally_hosted 12 | is_playable 13 | language 14 | languages 15 | name 16 | release_date 17 | release_date_precision 18 | resume_point 19 | show 20 | type 21 | uri 22 | ]a 23 | end 24 | -------------------------------------------------------------------------------- /lib/spotify_ex.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify do 2 | @moduledoc false 3 | 4 | def client_id, do: Application.get_env(:spotify_ex, :client_id) 5 | def secret_key, do: Application.get_env(:spotify_ex, :secret_key) 6 | def current_user, do: Application.get_env(:spotify_ex, :user_id) 7 | 8 | def callback_url do 9 | Application.get_env(:spotify_ex, :callback_url) |> URI.encode_www_form() 10 | end 11 | 12 | def encoded_credentials, do: :base64.encode("#{client_id()}:#{secret_key()}") 13 | end 14 | -------------------------------------------------------------------------------- /test/spotify/paging_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.PagingTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.Paging 5 | 6 | test "%Paging{}" do 7 | expected = ~w[cursors href items limit next offset previous total]a 8 | actual = %Paging{} |> Map.from_struct() |> Map.keys() 9 | 10 | assert actual == expected 11 | end 12 | 13 | test "response/2" do 14 | collection = [%Spotify.Playlist{}] 15 | actual = Paging.response(%{}, collection) 16 | expected = %Paging{items: collection} 17 | 18 | assert actual == expected 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/spotify/profile_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.ProfileTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.Profile 5 | 6 | test "%Spotify.Profile{}" do 7 | actual = 8 | ~w[birthdate country display_name email external_urls followers href id images product type uri]a 9 | 10 | expected = %Profile{} |> Map.from_struct() |> Map.keys() 11 | 12 | assert actual == expected 13 | end 14 | 15 | test "build_response/1" do 16 | response = %{"display_name" => "foo"} 17 | actual = Profile.build_response(response) 18 | expected = %Profile{display_name: "foo"} 19 | 20 | assert actual == expected 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/spotify/cookies_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.CookiesTest do 2 | use ExUnit.Case 3 | doctest Spotify.Cookies 4 | alias Spotify.Cookies 5 | 6 | test "#set_refresh_cookie" do 7 | conn = 8 | %Plug.Conn{cookies: %{"spotify_refresh_token" => "foo"}} 9 | |> Cookies.set_refresh_cookie("token123") 10 | 11 | assert(Cookies.get_refresh_token(conn) == "token123") 12 | end 13 | 14 | test "#set_access_cookie" do 15 | conn = 16 | %Plug.Conn{cookies: %{"spotify_access_token" => "foo"}} 17 | |> Cookies.set_access_cookie("token123") 18 | 19 | assert(Cookies.get_access_token(conn) == "token123") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/spotify/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Helpers do 2 | @moduledoc false 3 | 4 | def query_string(nil), do: "" 5 | def query_string([]), do: "" 6 | def query_string(params), do: "?" <> URI.encode_query(params) 7 | 8 | @doc """ 9 | Converts a map of string keys to a map of atoms and turns it into a struct 10 | """ 11 | def to_struct(kind, attrs) do 12 | struct = struct(kind) 13 | 14 | struct 15 | |> Map.to_list() 16 | |> Enum.reduce(struct, fn {key, _}, acc -> 17 | result = Map.fetch(attrs, Atom.to_string(key)) 18 | 19 | case result do 20 | {:ok, value} -> %{acc | key => value} 21 | :error -> acc 22 | end 23 | end) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/spotify/recommendation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.RecommendationTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Recommendation, 6 | Seed, 7 | Track 8 | } 9 | 10 | test "%Recommendation{}" do 11 | actual = %Recommendation{} |> Map.from_struct() |> Map.keys() 12 | expected = ~w[seeds tracks]a 13 | 14 | assert actual == expected 15 | end 16 | 17 | test "build_response/1" do 18 | response = %{"seeds" => [%{"id" => "foo"}], "tracks" => [%{"id" => "bar"}]} 19 | actual = Recommendation.build_response(response) 20 | expected = %Recommendation{tracks: [%Track{id: "bar"}], seeds: [%Seed{id: "foo"}]} 21 | 22 | assert actual == expected 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/spotify_ex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpotifyTest do 2 | use ExUnit.Case 3 | doctest Spotify 4 | 5 | test "client_id/0" do 6 | assert Application.get_env(:spotify_ex, :client_id) == Spotify.client_id() 7 | end 8 | 9 | test "secret_key/0" do 10 | assert Application.get_env(:spotify_ex, :secret_key) == Spotify.secret_key() 11 | end 12 | 13 | test "callback_url/0" do 14 | assert Spotify.callback_url() == 15 | Application.get_env(:spotify_ex, :callback_url) |> URI.encode_www_form() 16 | end 17 | 18 | test "encoded_credentials/0" do 19 | assert Spotify.encoded_credentials() == 20 | :base64.encode("#{Spotify.client_id()}:#{Spotify.secret_key()}") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/spotify/playlist_track.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Playlist.Track do 2 | @moduledoc """ 3 | Requesting track info from a specific playlist 4 | """ 5 | import Spotify.Helpers 6 | use Spotify.Responder 7 | 8 | defstruct ~w[ 9 | added_at 10 | added_by 11 | is_local 12 | track 13 | ]a 14 | 15 | @doc """ 16 | Implements the hook expected by the Responder behaviour 17 | """ 18 | def build_response(body) do 19 | tracks = body["items"] 20 | 21 | tracks = 22 | Enum.map(tracks, fn track -> 23 | track_info = to_struct(__MODULE__, track) 24 | Map.put(track_info, :track, to_struct(Spotify.Track, track_info.track)) 25 | end) 26 | 27 | Spotify.Paging.response(body, tracks) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | name: Build and test 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Elixir 18 | uses: erlef/setup-beam@v1 19 | with: 20 | elixir-version: '1.12.3' # Define the elixir version [required] 21 | otp-version: '24' # Define the OTP version [required] 22 | - name: Restore dependencies cache 23 | uses: actions/cache@v4 24 | with: 25 | path: deps 26 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 27 | restore-keys: ${{ runner.os }}-mix- 28 | - name: Install dependencies 29 | run: mix deps.get 30 | - name: Run tests 31 | run: mix test 32 | -------------------------------------------------------------------------------- /test/spotify/playlist_track_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Playlist.TrackTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.Playlist.Track, as: PlaylistTrack 5 | 6 | alias Spotify.{ 7 | Track, 8 | Paging 9 | } 10 | 11 | test "%Spotify.Playlist.Track{}" do 12 | expected = ~w[added_at added_by is_local track]a 13 | actual = %PlaylistTrack{} |> Map.from_struct() |> Map.keys() 14 | 15 | assert actual == expected 16 | end 17 | 18 | test "build_response/1" do 19 | actual = PlaylistTrack.build_response(response()) 20 | 21 | track = %Track{name: "foo"} 22 | expected = %Paging{items: [%PlaylistTrack{added_at: "2014-08-18T20:16:08Z", track: track}]} 23 | 24 | assert actual == expected 25 | end 26 | 27 | def response do 28 | %{ 29 | "items" => [ 30 | %{ 31 | "added_at" => "2014-08-18T20:16:08Z", 32 | "track" => %{"name" => "foo"} 33 | } 34 | ] 35 | } 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/spotify/authentication_client.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AuthenticationClient do 2 | @moduledoc false 3 | 4 | alias Spotify.{ 5 | AuthenticationError, 6 | AuthRequest, 7 | Credentials 8 | } 9 | 10 | alias HTTPoison.{ 11 | Error, 12 | Response 13 | } 14 | 15 | def post(params) do 16 | with {:ok, %Response{status_code: _code, body: body}} <- AuthRequest.post(params), 17 | {:ok, response} <- Poison.decode(body) do 18 | case response do 19 | %{"error_description" => error} -> 20 | raise(AuthenticationError, "The Spotify API responded with: #{error}") 21 | 22 | success_response -> 23 | {:ok, Credentials.get_tokens_from_response(success_response)} 24 | end 25 | else 26 | {:error, %Error{reason: reason}} -> 27 | {:error, reason} 28 | 29 | _generic_error -> 30 | raise(AuthenticationError, "Error parsing response from Spotify") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.0 4 | 5 | - SpotifyEx now uses a Credentials struct internally to remove the coupling to 6 | Plug.Conn. This allows us to use the library with tools other than the 7 | browser, like slack bots. Thanks adamzaninovich! 8 | 9 | - Fix an infinite loop in Playlist.add_tracks 10 | - On HTTP error in the authentication client, return a 3 element tuple instead 11 | of 2, since we won't return a conn. This is the only breaking API change. 12 | 13 | - Removed the following functions in `Spotify.Cookies`: 14 | 1. `Spotify.Cookies.get_cookies_from_response/1` in favor of `Spotify.Credentials.get_tokens_from_response/1' 15 | 2. `Spotify.Cookies.get_access_cookie/1` in favor of `Spotify.Credentials.get_access_token/1` 16 | 3. `Spotify.Cookies.get_refresh_cookie/1` in favor of `Spotify.Credentials.get_refresh_token/1` 17 | 18 | ## 1.0.3 19 | 20 | - Use `>=` instead of `~>` for Poison dependancy to Allow Poison 2.0 21 | - Bump Plug to 1.2.0 22 | 23 | -------------------------------------------------------------------------------- /lib/spotify/paging.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Paging do 2 | @moduledoc """ 3 | Spotify wraps collections in a paging object in order to handle pagination. 4 | Requesting a collection will send the collection back in the `items` key, 5 | along with the paging links. 6 | """ 7 | 8 | import Spotify.Helpers 9 | 10 | @doc """ 11 | Paging Struct. The Spotify API returns collections in a Paging 12 | object, with the collection in the `items` key. 13 | """ 14 | defstruct ~w[ 15 | href 16 | items 17 | limit 18 | next 19 | offset 20 | previous 21 | total 22 | cursors 23 | ]a 24 | 25 | @doc """ 26 | Takes the response body from an API call that returns a collection. 27 | Param items should be structs from that collections types, for example 28 | getting a collection playlists, items should be [%Spotify.Playlist{}, ...] 29 | Replaces the map currently items with the collection. 30 | 31 | Not every collection is wrapped in a paging object. 32 | """ 33 | def response(body, items) do 34 | to_struct(__MODULE__, body) |> Map.put(:items, items) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/spotify/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Client do 2 | @moduledoc false 3 | 4 | def get(conn_or_creds, url) do 5 | HTTPoison.get(url, get_headers(conn_or_creds)) 6 | end 7 | 8 | def put(conn_or_creds, url, body \\ "") do 9 | HTTPoison.put(url, body, put_headers(conn_or_creds)) 10 | end 11 | 12 | def post(conn_or_creds, url, body \\ "") do 13 | HTTPoison.post(url, body, post_headers(conn_or_creds)) 14 | end 15 | 16 | def delete(conn_or_creds, url) do 17 | HTTPoison.delete(url, delete_headers(conn_or_creds)) 18 | end 19 | 20 | def get_headers(conn_or_creds) do 21 | [{"Authorization", "Bearer #{access_token(conn_or_creds)}"}] 22 | end 23 | 24 | def put_headers(conn_or_creds) do 25 | [ 26 | {"Authorization", "Bearer #{access_token(conn_or_creds)}"}, 27 | {"Content-Type", "application/json"} 28 | ] 29 | end 30 | 31 | defp access_token(conn_or_creds) do 32 | Spotify.Credentials.new(conn_or_creds).access_token 33 | end 34 | 35 | def post_headers(conn_or_creds), do: put_headers(conn_or_creds) 36 | def delete_headers(conn_or_creds), do: get_headers(conn_or_creds) 37 | end 38 | -------------------------------------------------------------------------------- /lib/spotify/follow.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Follow do 2 | @moduledoc """ 3 | Follow users or artists 4 | """ 5 | 6 | use Spotify.Responder 7 | import Spotify.Helpers 8 | alias Spotify.Client 9 | 10 | @doc """ 11 | Add the current user as a follower of one or more artists or other Spotify users. 12 | [Spotify Documentation](https://developer.spotify.com/web-api/follow-artists-users/) 13 | 14 | **Required Params**: `type`, `ids` 15 | 16 | **Method**: `PUT` 17 | 18 | Spotify.Follow.follow(conn, type: "artist", ids: "1,4") 19 | # => :ok 20 | 21 | """ 22 | def follow(conn, params) do 23 | url = follow_url(params) 24 | conn |> Client.put(url) |> handle_response 25 | end 26 | 27 | @doc """ 28 | Add the current user as a follower of one or more artists or other Spotify users. 29 | 30 | iex> Spotify.Follow.follow_url(ids: "1,4", type: "artist") 31 | "https://api.spotify.com/v1/me/following?ids=1%2C4&type=artist" 32 | """ 33 | def follow_url(params) do 34 | "https://api.spotify.com/v1/me/following" <> query_string(params) 35 | end 36 | 37 | # The only endpoint has an empty response 38 | def build_response(body), do: body 39 | end 40 | -------------------------------------------------------------------------------- /test/spotify/authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AuthorizationTest do 2 | use ExUnit.Case 3 | doctest Spotify.Authorization 4 | 5 | setup do 6 | Application.put_env(:spotify_ex, :client_id, "123") 7 | Application.put_env(:spotify_ex, :scopes, ["foo-bar", "baz-qux"]) 8 | Application.put_env(:spotify_ex, :callback_url, "http://localhost:4000") 9 | end 10 | 11 | describe ".url" do 12 | test "with scopes" do 13 | url = 14 | "https://accounts.spotify.com/authorize?client_id=123&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A4000&scope=foo-bar%20baz-qux" 15 | 16 | assert(Spotify.Authorization.url() == url) 17 | end 18 | 19 | test "without scopes" do 20 | Application.put_env(:spotify_ex, :scopes, []) 21 | 22 | url = 23 | "https://accounts.spotify.com/authorize?client_id=123&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A4000" 24 | 25 | assert(Spotify.Authorization.url() == url) 26 | end 27 | end 28 | 29 | test ".scopes" do 30 | assert Spotify.Authorization.scopes() == "foo-bar%20baz-qux" 31 | end 32 | 33 | test ".callback_url" do 34 | assert Spotify.callback_url() == "http%3A%2F%2Flocalhost%3A4000" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/spotify/credentials_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.CredentialsTest do 2 | use ExUnit.Case, async: true 3 | 4 | @atoken "access_token" 5 | @rtoken "refresh_token" 6 | @creds %Spotify.Credentials{access_token: @atoken, refresh_token: @rtoken} 7 | 8 | test "new/2 returns a Spotify.Credentials struct when given tokens" do 9 | assert @creds == Spotify.Credentials.new(@atoken, @rtoken) 10 | end 11 | 12 | describe "new/1 returns a Spotify.Credentials struct" do 13 | test "when given a Plug.Conn" do 14 | conn = 15 | Plug.Test.conn(:post, "/authenticate") 16 | |> Plug.Conn.fetch_cookies() 17 | |> Plug.Conn.put_resp_cookie("spotify_access_token", @atoken) 18 | |> Plug.Conn.put_resp_cookie("spotify_refresh_token", @rtoken) 19 | 20 | assert @creds == Spotify.Credentials.new(conn) 21 | end 22 | 23 | test "when given a Spotify.Credentials struct" do 24 | assert @creds == Spotify.Credentials.new(@creds) 25 | end 26 | end 27 | 28 | test "get_tokens_from_response/1 returns a Spotify.Credentials struct" do 29 | response = %{"access_token" => @atoken, "refresh_token" => @rtoken} 30 | assert @creds == Spotify.Credentials.get_tokens_from_response(response) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :spotify_ex, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:spotify_ex, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # import_config "config.secret.exs" 25 | import_config "spotify.exs" 26 | # It is also possible to import configuration files, relative to this 27 | # directory. For example, you can emulate configuration per environment 28 | # by uncommenting the line below and defining dev.exs, test.exs and such. 29 | # Configuration from the imported file will override the ones defined 30 | # here (which is why it is important to import them last). 31 | # 32 | if Mix.env() != :docs, do: import_config("#{Mix.env()}.exs") 33 | config :spotify_ex, auth_client: Spotify.Authentication 34 | -------------------------------------------------------------------------------- /lib/spotify/authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Authorization do 2 | @moduledoc """ 3 | Authorizes your app with Spotify 4 | 5 | Spotify needs to verify your client id, and that your redirect uri 6 | matches what you set in your app settings (in the Spotify App dashboard). 7 | This is an external call, url provided by the `url` function. 8 | """ 9 | 10 | @doc """ 11 | If you specified scopes in your config, uses scoped auth. 12 | Otherwise, unscoped. Use this function to make the redirect to 13 | Spotify for authorization. 14 | 15 | ## Example: 16 | 17 | defmodule OAuthController do 18 | # ... 19 | 20 | def authorize do 21 | redirect conn, external: Spotify.Authorization.url 22 | end 23 | end 24 | """ 25 | def url do 26 | if scopes() != "" do 27 | scoped_auth() 28 | else 29 | scopeless_auth() 30 | end 31 | end 32 | 33 | @doc false 34 | def scopes do 35 | Application.get_env(:spotify_ex, :scopes) 36 | |> Enum.join(" ") 37 | |> URI.encode() 38 | end 39 | 40 | @doc false 41 | def scoped_auth do 42 | "https://accounts.spotify.com/authorize?client_id=#{Spotify.client_id()}&response_type=code&redirect_uri=#{ 43 | Spotify.callback_url() 44 | }&scope=#{scopes()}" 45 | end 46 | 47 | @doc false 48 | def scopeless_auth do 49 | "https://accounts.spotify.com/authorize?client_id=#{Spotify.client_id()}&response_type=code&redirect_uri=#{ 50 | Spotify.callback_url() 51 | }" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/spotify/personalization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.PersonalizationTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Artist, 6 | Paging, 7 | Personalization, 8 | Track 9 | } 10 | 11 | describe "build_response/1" do 12 | test "User requests top artists" do 13 | actual = Personalization.build_response(response_with_artists()) 14 | artists = [%Artist{name: "foo", type: "artist"}, %Artist{name: "bar", type: "artist"}] 15 | 16 | expected = %Paging{items: artists} 17 | 18 | assert actual == expected 19 | end 20 | 21 | test "User requests top tracks" do 22 | actual = Personalization.build_response(response_with_tracks()) 23 | tracks = [%Track{name: "foo", type: "track"}, %Track{name: "bar", type: "track"}] 24 | expected = %Paging{items: tracks} 25 | 26 | assert actual == expected 27 | end 28 | end 29 | 30 | test "%Spotify.Personalization{}" do 31 | expected = ~w[href items limit next previous total]a 32 | actual = %Personalization{} |> Map.from_struct() |> Map.keys() 33 | 34 | assert actual == expected 35 | end 36 | 37 | def response_with_artists do 38 | %{ 39 | "items" => [ 40 | %{"name" => "foo", "type" => "artist"}, 41 | %{"name" => "bar", "type" => "artist"} 42 | ] 43 | } 44 | end 45 | 46 | def response_with_tracks do 47 | %{ 48 | "items" => [ 49 | %{"name" => "foo", "type" => "track"}, 50 | %{"name" => "bar", "type" => "track"} 51 | ] 52 | } 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/spotify/library_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.LibraryTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Library, 6 | Paging, 7 | Track 8 | } 9 | 10 | describe "build_response/1 when body contains a list of tracks" do 11 | test "returns a collection of Spotify.Tracks" do 12 | response = tracks_response() 13 | 14 | expected = %Paging{ 15 | href: "https://api.spotify.com/v1/me/tracks?offset=0&limit=20", 16 | items: [%Track{name: "foo"}, %Track{name: "bar"}], 17 | limit: 20, 18 | next: "https://api.spotify.com/v1/me/tracks?offset=20&limit=20", 19 | offset: 0, 20 | previous: nil, 21 | total: 62 22 | } 23 | 24 | actual = Library.build_response(response) 25 | 26 | assert actual == expected 27 | end 28 | end 29 | 30 | describe "build_response/1 when body is a list of booleans" do 31 | test "returns a list of booleans" do 32 | expected = [false, false] 33 | actual = Library.build_response([false, false]) 34 | 35 | assert actual == expected 36 | end 37 | end 38 | 39 | def tracks_response do 40 | %{ 41 | "href" => "https://api.spotify.com/v1/me/tracks?offset=0&limit=20", 42 | "items" => [ 43 | %{"track" => %{"name" => "foo"}}, 44 | %{"track" => %{"name" => "bar"}} 45 | ], 46 | "limit" => 20, 47 | "next" => "https://api.spotify.com/v1/me/tracks?offset=20&limit=20", 48 | "offset" => 0, 49 | "previous" => nil, 50 | "total" => 62 51 | } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/spotify/cookies.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Cookies do 2 | @moduledoc """ 3 | Convenience setters and getters for auth cookies 4 | """ 5 | 6 | @doc """ 7 | Sets the refresh token and access token and returns the conn. 8 | 9 | ## Example: 10 | credentials = %Spotify.Credentials{refresh_token: rt, access_token: at} 11 | Spotify.Cookies.set_cookies(conn, credentials) 12 | """ 13 | def set_cookies(conn, credentials) do 14 | conn 15 | |> set_refresh_cookie(credentials.refresh_token) 16 | |> set_access_cookie(credentials.access_token) 17 | end 18 | 19 | @doc """ 20 | Sets the refresh token 21 | """ 22 | def set_refresh_cookie(conn, string) 23 | 24 | def set_refresh_cookie(conn, nil), do: conn 25 | 26 | def set_refresh_cookie(conn, refresh_token) do 27 | Plug.Conn.put_resp_cookie(conn, "spotify_refresh_token", refresh_token) 28 | end 29 | 30 | @doc """ 31 | Sets the access token 32 | """ 33 | def set_access_cookie(conn, string) 34 | def set_access_cookie(conn, nil), do: conn 35 | 36 | def set_access_cookie(conn, access_token) do 37 | Plug.Conn.put_resp_cookie(conn, "spotify_access_token", access_token) 38 | end 39 | 40 | @doc """ 41 | Gets the access token 42 | """ 43 | def get_access_token(conn) 44 | 45 | def get_access_token(conn) do 46 | conn.cookies["spotify_access_token"] 47 | end 48 | 49 | @doc """ 50 | Gets the access token 51 | """ 52 | def get_refresh_token(conn) 53 | 54 | def get_refresh_token(conn) do 55 | conn.cookies["spotify_refresh_token"] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/authentication_client_mock.exs: -------------------------------------------------------------------------------- 1 | defmodule HTTPoison.Response do 2 | defstruct ~w[body headers status_code]a 3 | end 4 | 5 | defmodule AuthenticationClientMock do 6 | def post(%{"error_description" => _}) do 7 | {:ok, failed_response()} 8 | end 9 | 10 | def post(_params) do 11 | {:ok, successful_response()} 12 | end 13 | 14 | defp failed_response do 15 | %HTTPoison.Response{ 16 | body: "{\"error\":\"invalid_client\",\"error_description\":\"Invalid client\"}", 17 | status_code: 400 18 | } 19 | end 20 | 21 | defp successful_response do 22 | %HTTPoison.Response{ 23 | body: 24 | "{\"access_token\":\"access_token\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"refresh_token\":\"refresh_token\",\"scope\":\"playlist-read-private\"}", 25 | headers: [ 26 | {"Server", "nginx"}, 27 | {"Date", "Thu, 21 Jul 2016 16:52:38 GMT"}, 28 | {"Content-Type", "application/json"}, 29 | {"Content-Length", "397"}, 30 | {"Connection", "keep-alive"}, 31 | {"Keep-Alive", "timeout=10"}, 32 | {"Vary", "Accept-Encoding"}, 33 | {"Vary", "Accept-Encoding"}, 34 | {"X-UA-Compatible", "IE=edge"}, 35 | {"X-Frame-Options", "deny"}, 36 | {"Content-Security-Policy", "default-src 'self'; script-src 'self' foo"}, 37 | {"X-Content-Security-Policy", "default-src 'self'; script-src 'self' foo"}, 38 | {"Cache-Control", "no-cache, no-store, must-revalidate"}, 39 | {"Pragma", "no-cache"}, 40 | {"X-Content-Type-Options", "nosniff"}, 41 | {"Strict-Transport-Security", "max-age=31536000;"} 42 | ], 43 | status_code: 200 44 | } 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :spotify_ex, 7 | version: "2.3.0", 8 | elixir: ">= 1.12.0", 9 | description: description(), 10 | package: package(), 11 | build_embedded: Mix.env() == :prod, 12 | start_permanent: Mix.env() == :prod, 13 | source_url: "https://github.com/jsncmgs1/spotify_ex", 14 | homepage_url: "https://github.com/jsncmgs1/spotify_ex", 15 | deps: deps(), 16 | docs: [extras: ["README.md"]] 17 | ] 18 | end 19 | 20 | defp package do 21 | [ 22 | maintainers: ["Jason Cummings"], 23 | licenses: ["MIT"], 24 | links: %{ 25 | "GitHub" => "https://www.github.com/jsncmgs1/spotify_ex", 26 | "Example Phoenix App" => "https://www.github.com/jsncmgs1/spotify_ex_test" 27 | } 28 | ] 29 | end 30 | 31 | defp description do 32 | """ 33 | An Elixir wrapper for the Spotify Web API. 34 | """ 35 | end 36 | 37 | # Configuration for the OTP application 38 | # 39 | # Type "mix help compile.app" for more information 40 | def application do 41 | [] 42 | end 43 | 44 | # Dependencies can be Hex packages: 45 | # 46 | # {:mydep, "~> 0.3.0"} 47 | # 48 | # Or git/path repositories: 49 | # 50 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 51 | # 52 | # Type "mix help deps" for more examples and options 53 | defp deps do 54 | [ 55 | {:httpoison, "~> 1.0"}, 56 | {:poison, "~> 3.1"}, 57 | {:plug, ">= 1.4.5"}, 58 | {:mock, "~> 0.1.1", only: :test}, 59 | {:ex_doc, "~> 0.28.4", only: :dev}, 60 | {:inch_ex, "~> 0.5.6", only: :docs} 61 | ] 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/spotify/track_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.TrackTest do 2 | use ExUnit.Case 3 | alias Spotify.Track 4 | 5 | test "%Spotify.Track{}" do 6 | attrs = 7 | ~w[album artists available_markets disc_number duration_ms explicit external_ids href id is_playable linked_from name popularity preview_url track_number type uri]a 8 | 9 | expected = %Track{} |> Map.from_struct() |> Map.keys() 10 | assert expected == attrs 11 | end 12 | 13 | describe "build_response/1 when audio features are requested" do 14 | test "API returns a single item" do 15 | response = %{"album" => "foo"} 16 | 17 | expected = %Track{album: "foo"} 18 | actual = Track.build_response(response) 19 | 20 | assert expected == actual 21 | end 22 | 23 | test "API returns a collection" do 24 | response = %{"tracks" => [%{"album" => "foo"}, %{"album" => "bar"}]} 25 | 26 | expected = [%Track{album: "foo"}, %Track{album: "bar"}] 27 | actual = Track.build_response(response) 28 | 29 | assert expected == actual 30 | end 31 | end 32 | 33 | describe "build_response/1 when tracks are requested" do 34 | test "API returns a single item" do 35 | response = %{"energy" => "foo"} 36 | expected = %Spotify.AudioFeatures{energy: "foo"} 37 | actual = Track.build_response(response) 38 | 39 | assert expected == actual 40 | end 41 | 42 | test "API returns a collection" do 43 | response = %{"audio_features" => [%{"energy" => "foo"}, %{"energy" => "bar"}]} 44 | expected = [%Spotify.AudioFeatures{energy: "foo"}, %Spotify.AudioFeatures{energy: "bar"}] 45 | actual = Track.build_response(response) 46 | 47 | assert expected == actual 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/spotify/search_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.SearchTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Album, 6 | Artist, 7 | Paging, 8 | Playlist, 9 | Search, 10 | Track 11 | } 12 | 13 | describe "build_response/1" do 14 | test "responds with artists" do 15 | response = %{"artists" => %{"items" => [%{"name" => "foo"}]}} 16 | 17 | expected = %Paging{items: [%Artist{name: "foo"}]} 18 | actual = Search.build_response(response) 19 | 20 | assert actual == expected 21 | end 22 | 23 | test "responds with tracks" do 24 | response = %{"tracks" => %{"items" => [%{"name" => "foo"}]}} 25 | 26 | expected = %Paging{items: [%Track{name: "foo"}]} 27 | actual = Search.build_response(response) 28 | 29 | assert actual == expected 30 | end 31 | 32 | test "responds with playlists" do 33 | response = %{"playlists" => %{"items" => [%{"name" => "foo"}]}} 34 | 35 | expected = %Paging{items: [%Playlist{name: "foo"}]} 36 | actual = Search.build_response(response) 37 | 38 | assert actual == expected 39 | end 40 | 41 | test "responds with albums" do 42 | response = %{"albums" => %{"items" => [%{"name" => "foo"}]}} 43 | 44 | expected = %Paging{items: [%Album{name: "foo"}]} 45 | actual = Search.build_response(response) 46 | 47 | assert actual == expected 48 | end 49 | 50 | test "responds with albums, artists, tracks and playists" do 51 | response = %{ 52 | "artists" => %{"items" => [%{"name" => "artist"}]}, 53 | "tracks" => %{"items" => [%{"name" => "track"}]}, 54 | "playlists" => %{"items" => [%{"name" => "playlist"}]}, 55 | "albums" => %{"items" => [%{"name" => "album"}]} 56 | } 57 | 58 | expected = %Paging{ 59 | items: [ 60 | %Album{name: "album"}, 61 | %Artist{name: "artist"}, 62 | %Playlist{name: "playlist"}, 63 | %Track{name: "track"} 64 | ] 65 | } 66 | 67 | actual = Search.build_response(response) 68 | 69 | assert actual == expected 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/spotify/recommendation.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Recommendation do 2 | @moduledoc """ 3 | Spotify can make recommendations for you by providing seed data. The 4 | recommendation comes with tracks and the seed object 5 | 6 | *Note: The possibilities here are quite large. Please read the spotify documentation. 7 | 8 | https://developer.spotify.com/web-api/get-recommendations/ 9 | """ 10 | use Spotify.Responder 11 | import Spotify.Helpers 12 | 13 | alias Spotify.{ 14 | Client, 15 | Recommendation, 16 | Seed, 17 | Track 18 | } 19 | 20 | defstruct ~w[ 21 | tracks 22 | seeds 23 | ]a 24 | 25 | @doc """ 26 | Create a playlist-style listening experience based on seed artists, tracks and genres. 27 | 28 | **The response generated by this can vary. Your milage may vary. Because of this, you may want 29 | to use `get_recommendations_url` and use your own implementation for this function.** 30 | 31 | [Spotify Documentation](https://developer.spotify.com/web-api/get-recommendations/) 32 | 33 | **Method**: `GET` 34 | 35 | **Params**: **The params for this endpoint are complex. Refer to spotify docs** 36 | 37 | Spotify.Recommendation.get_recommendations(seed_artists: "1,2" energy: "6") 38 | # => { :ok, %Recommendation{tracks: tracks, seeds: seeds} } 39 | """ 40 | def get_recommendations(conn, params) do 41 | url = get_recommendations_url(params) 42 | conn |> Client.get(url) |> handle_response 43 | end 44 | 45 | @doc """ 46 | Create a playlist-style listening experience based on seed artists, tracks and genres. 47 | 48 | iex> Spotify.Recommendation.get_recommendations_url(limit: 5) 49 | "https://api.spotify.com/v1/recommendations?limit=5" 50 | """ 51 | def get_recommendations_url(params) do 52 | "https://api.spotify.com/v1/recommendations" <> query_string(params) 53 | end 54 | 55 | @doc """ 56 | Implements the hook required by the Responder behavior. 57 | """ 58 | def build_response(body) do 59 | seeds = Enum.map(body["seeds"], &to_struct(Seed, &1)) 60 | tracks = Enum.map(body["tracks"], &to_struct(Track, &1)) 61 | 62 | %Recommendation{tracks: tracks, seeds: seeds} 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/spotify/search.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Search do 2 | @moduledoc """ 3 | Spotify search endpoints. Spotify allows querying for artists, albums, playlists, and tracks. 4 | """ 5 | 6 | use Spotify.Responder 7 | import Spotify.Helpers 8 | 9 | alias Spotify.{ 10 | Album, 11 | Artist, 12 | Client, 13 | Paging, 14 | Playlist, 15 | Track 16 | } 17 | 18 | @keys ~w[albums artists playlists tracks] 19 | 20 | @doc """ 21 | Search for a playlist. 22 | [Spotify Documentation](https://developer.spotify.com/web-api/search-item/) 23 | 24 | **Method**: `GET` 25 | 26 | **Required Params:** `q`, `type` 27 | 28 | **Optional Params:** `limit`, `offset`, `market` 29 | 30 | Spotify.Search.query(conn, q: "foo", type: "playlist") 31 | # => {:ok, %{ items: [%Spotify.Playlist{..} ...]}} 32 | """ 33 | def query(conn, params) do 34 | url = query_url(params) 35 | conn |> Client.get(url) |> handle_response 36 | end 37 | 38 | @doc """ 39 | Search for a playlist, artist, album, or track. 40 | iex> Spotify.Search.query_url(q: "foo", type: "playlist") 41 | "https://api.spotify.com/v1/search?q=foo&type=playlist" 42 | """ 43 | def query_url(params) do 44 | "https://api.spotify.com/v1/search" <> query_string(params) 45 | end 46 | 47 | @doc """ 48 | Implements the hook required by the Responder behaviour 49 | """ 50 | def build_response(body) do 51 | body 52 | |> map_paging 53 | |> append_items 54 | end 55 | 56 | defp map_paging(body), do: {Paging.response(body, []), body} 57 | 58 | defp append_items({paging, body}) do 59 | body 60 | |> Map.take(@keys) 61 | |> Map.to_list() 62 | |> Enum.flat_map_reduce([], &reducer/2) 63 | |> update_paging(paging) 64 | end 65 | 66 | defp reducer({key, data}, acc), do: {map_to_struct(key, data["items"]), acc} 67 | defp update_paging({items, _rest}, paging), do: paging |> Map.put(:items, items) 68 | 69 | defp map_to_struct("artists", artists), do: Artist.build_artists(artists) 70 | defp map_to_struct("tracks", tracks), do: Track.build_tracks(tracks) 71 | defp map_to_struct("playlists", playlists), do: Playlist.build_playlists(playlists) 72 | defp map_to_struct("albums", albums), do: Enum.map(albums, &to_struct(Album, &1)) 73 | end 74 | -------------------------------------------------------------------------------- /lib/spotify/responder.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Responder do 2 | @moduledoc """ 3 | Receives http responses from the client and handles them accordingly. 4 | 5 | Spotify API modules (Playlist, Album, etc) `use Spotify.Responder`. When a request 6 | is made they give the endpoint URL to the Client, which makes the request, 7 | and pass the response to `handle_response`. Each API module must build 8 | appropriate responses, so they add Spotify.Responder as a behaviour, and implement 9 | the `build_response/1` function. 10 | """ 11 | 12 | @callback build_response(map) :: any 13 | 14 | defmacro __using__(_) do 15 | quote do 16 | def handle_response({:ok, %HTTPoison.Response{status_code: code, body: ""}}) 17 | when code in 200..299, do: :ok 18 | 19 | # special handling for 'too many requests' status 20 | # in order to know when to retry 21 | def handle_response( 22 | {message, %HTTPoison.Response{status_code: 429, body: body, headers: headers}} 23 | ) do 24 | {retry_after, ""} = 25 | headers 26 | |> Enum.find(fn {key, value} -> String.downcase(key) == "retry-after" end) 27 | |> Kernel.elem(1) 28 | |> Integer.parse() 29 | 30 | {message, Map.put(Poison.decode!(body), "meta", %{"retry_after" => retry_after})} 31 | end 32 | 33 | def handle_response({message, %HTTPoison.Response{status_code: code, body: body}}) 34 | when code in 400..499 do 35 | {message, Poison.decode!(body)} 36 | end 37 | 38 | def handle_response({:ok, %HTTPoison.Response{status_code: _code, body: body, headers: headers}}) do 39 | case get_content_type(headers) do 40 | "application/json" -> 41 | response = body |> Poison.decode!() |> build_response 42 | {:ok, response} 43 | _ -> 44 | :ok 45 | end 46 | end 47 | 48 | def handle_response({:error, %HTTPoison.Error{reason: reason}}) do 49 | {:error, reason} 50 | end 51 | 52 | defp get_content_type(headers) do 53 | headers 54 | |> Enum.find(fn {key, _value} -> String.downcase(key) == "content-type" end) 55 | |> case do 56 | {_key, content_type} -> 57 | content_type |> String.split(";") |> List.first() |> String.trim() 58 | nil -> 59 | "text/plain" 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/spotify/profile.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Profile do 2 | @moduledoc """ 3 | Endpoints for retrieving information about a user’s profile. 4 | 5 | There are two functions for each endpoint, one that actually makes the request, 6 | and one that provides the endpoint: 7 | 8 | Spotify.Playist.create_playlist(conn, "foo", "bar") # makes the POST request. 9 | Spotify.Playist.create_playlist_url("foo", "bar") # provides the url for the request. 10 | 11 | https://developer.spotify.com/web-api/user-profile-endpoints/ 12 | """ 13 | 14 | defstruct ~w[ 15 | birthdate 16 | country 17 | display_name 18 | email 19 | external_urls 20 | followers 21 | href 22 | id 23 | images 24 | product 25 | type 26 | uri 27 | ]a 28 | 29 | alias Spotify.Client 30 | use Spotify.Responder 31 | import Spotify.Helpers 32 | 33 | @doc """ 34 | Get detailed profile information about the current user (including the current user’s username). 35 | [Spotify Documentation](https://developer.spotify.com/web-api/get-current-users-profile/) 36 | 37 | **Method**: `GET` 38 | 39 | Uses your auth token to find your profile. 40 | Spotify.Profile.me(conn) 41 | # => { :ok, %Spotify.Profile{..} } 42 | """ 43 | def me(conn) do 44 | conn |> Client.get(me_url()) |> handle_response 45 | end 46 | 47 | @doc """ 48 | Get detailed profile information about the current user (including the current user’s username). 49 | 50 | iex> Spotify.Profile.me_url 51 | "https://api.spotify.com/v1/me" 52 | """ 53 | def me_url, do: "https://api.spotify.com/v1/me" 54 | 55 | @doc """ 56 | Get public profile information about a Spotify user. 57 | [Spotify Documentation](https://developer.spotify.com/web-api/get-users-profile/) 58 | 59 | **Method**: `GET` 60 | 61 | Spotify.Profile.user(conn, "123") 62 | # => { :ok, %Spotify.Profile{..} } 63 | """ 64 | def user(conn, user_id) do 65 | url = user_url(user_id) 66 | conn |> Client.get(url) |> handle_response 67 | end 68 | 69 | @doc """ 70 | Get public profile information about a Spotify user. 71 | 72 | iex> Spotify.Profile.user_url("123") 73 | "https://api.spotify.com/v1/users/123" 74 | """ 75 | def user_url(user_id), do: "https://api.spotify.com/v1/users/#{user_id}" 76 | 77 | @doc """ 78 | Implements the hook expected by the Responder behaviour 79 | """ 80 | def build_response(body) do 81 | to_struct(__MODULE__, body) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/spotify/album_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.AlbumTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Album, 6 | Paging, 7 | Track 8 | } 9 | 10 | test "%Spotify.Album{}" do 11 | expected = ~w[album_type artists available_markets copyrights external_ids 12 | external_urls genres href id images label name popularity release_date 13 | release_date_precision tracks type]a 14 | 15 | actual = %Album{} |> Map.from_struct() |> Map.keys() 16 | assert actual == expected 17 | end 18 | 19 | describe "build_response/1" do 20 | test "when a collection of albums is requested" do 21 | actual = Album.build_response(albums_response()) 22 | expected = [%Album{id: "foo", tracks: %Paging{items: [%Track{name: "foo"}]}}] 23 | 24 | assert actual == expected 25 | end 26 | 27 | test "when a single album is requested" do 28 | actual = Album.build_response(album_response()) 29 | expected = %Album{album_type: "foo", tracks: %Paging{items: [%Track{name: "foo"}]}} 30 | 31 | assert actual == expected 32 | end 33 | 34 | test "when a collection of tracks is requested" do 35 | actual = Album.build_response(tracks_response()) 36 | expected = %Paging{items: [%Track{track_number: "foo"}]} 37 | 38 | assert actual == expected 39 | end 40 | 41 | test "when a collection of new releases is requested" do 42 | actual = Album.build_response(new_releases()) 43 | expected = %Paging{href: "link", items: [%Album{name: "foo"}]} 44 | 45 | assert actual == expected 46 | end 47 | end 48 | 49 | def tracks_response do 50 | %{"items" => [%{"track_number" => "foo"}]} 51 | end 52 | 53 | def albums_response do 54 | %{ 55 | "albums" => [ 56 | %{ 57 | "id" => "foo", 58 | "tracks" => %{ 59 | # tracks is a paging object, the actual tracks are in 60 | # the items key 61 | "items" => [%{"name" => "foo"}] 62 | } 63 | } 64 | ] 65 | } 66 | end 67 | 68 | def album_response do 69 | %{ 70 | "album_type" => "foo", 71 | "tracks" => %{ 72 | # tracks is a paging object, the actual tracks are in 73 | # the items key 74 | "items" => [%{"name" => "foo"}] 75 | } 76 | } 77 | end 78 | 79 | def new_releases do 80 | %{ 81 | "albums" => %{ 82 | "href" => "link", 83 | "items" => [%{"name" => "foo"}] 84 | } 85 | } 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Elixir CI](https://github.com/jsncmgs1/spotify_ex/workflows/Elixir%20CI/badge.svg) 2 | [![Inline docs](http://inch-ci.org/github/jsncmgs1/spotify_ex.svg?branch=master)](http://inch-ci.org/github/jsncmgs1/spotify_ex) 3 | 4 | # SpotifyEx 5 | **Elixir Wrapper for the Spotify Web API** 6 | 7 | This is no longer under development, though I'm happy to merge bug fixes and reasonable feature additions. Please ask before adding new features so you don't waste your time. 8 | 9 | ## [Documentation](https://hexdocs.pm/spotify_ex/api-reference.html) 10 | 11 | ## Installation 12 | 13 | 1. Add spotify_ex to your list of dependencies in `mix.exs`: 14 | 15 | ```elixir 16 | def deps do 17 | [{:spotify_ex, "~> 2.3.0"}] 18 | end 19 | ``` 20 | 21 | 2. Ensure spotify_ex is started before your application: 22 | 23 | ```elixir 24 | def application do 25 | [applications: [:spotify_ex]] 26 | end 27 | ``` 28 | 29 | This wrapper covers the [Spotify Web 30 | API](https://developer.spotify.com/web-api/endpoint-reference/). 31 | 32 | Follow the abovementioned link. On the left you'll notice that the API is broken into 33 | sections, such as Artists, Albums, Playlists, etc. This wrapper does its best 34 | to keep endpoints in modules mapped to their corresponding section. However, 35 | Spotify duplicates many of its endpoints. For example, there is an endpoint to 36 | obtain an artist's albums that is listed under both Artists and Albums. 37 | 38 | Endpoints are not duplicated here, however. If you don't see an endpoint, it can be found in a 39 | module that's also related to that endpoint. In other words, if you don't see an endpoint for "get artists albums" 40 | in the `Artist` module, check `Albums`. 41 | 42 | These duplicate endpoints may get aliased in the future to have a 1-1 mapping 43 | with the docs. 44 | 45 | ## Usage 46 | 47 | [docs](https://hexdocs.pm/spotify_ex/api-reference.html) 48 | 49 | **A basic Phoenix example can be found at 50 | [SpotifyExTest](http://www.github.com/jsncmgs1/spotify_ex_test)** 51 | 52 | ## OAuth 53 | [Oauth README](https://github.com/jsncmgs1/spotify_ex/blob/master/docs/oauth.md) 54 | 55 | ### Scopes 56 | 57 | [Scopes README](https://github.com/jsncmgs1/spotify_ex/blob/master/docs/scopes.md) 58 | 59 | ## Contributing 60 | 61 | All contributions are more than welcome! I will not accept a PR without tests 62 | if it looks like something that should be tested (which is pretty much 63 | everything.) Development is done on the `development` branch, and moved to 64 | `master` for release on hexpm. Code must be formatted using `hex format`. 65 | -------------------------------------------------------------------------------- /test/spotify/playlist_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.PlaylistTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Paging, 6 | Playlist 7 | } 8 | 9 | describe "build_response/1" do 10 | test "the API returns a playlist element" do 11 | actual = Playlist.build_response(response_body_with_playlist_element()) 12 | 13 | expected = %Paging{ 14 | href: "https://api.spotify.com/v1/users/anuser/playlists?offset=0&limit=20", 15 | items: [%Playlist{name: "foo"}, %Playlist{name: "bar"}], 16 | limit: 20, 17 | next: "https://api.spotify.com/v1/users/anuser/playlists?offset=20&limit=20", 18 | offset: 0, 19 | previous: nil, 20 | total: 62 21 | } 22 | 23 | assert actual == expected 24 | end 25 | 26 | test "the API returns a items collections without a playlists root" do 27 | actual = Playlist.build_response(response_body_without_playlist_element()) 28 | 29 | expected = %Paging{ 30 | href: "https://api.spotify.com/v1/users/anuser/playlists?offset=0&limit=20", 31 | items: [%Playlist{name: "foo"}, %Playlist{name: "bar"}], 32 | limit: 20, 33 | next: "https://api.spotify.com/v1/users/anuser/playlists?offset=20&limit=20", 34 | offset: 0, 35 | previous: nil, 36 | total: 62 37 | } 38 | 39 | assert actual == expected 40 | end 41 | end 42 | 43 | test "%Spotify.Playlist{}" do 44 | expected = ~w[collaborative description external_urls followers 45 | href id images name owner public snapshot_id tracks type uri]a 46 | 47 | actual = %Playlist{} |> Map.from_struct() |> Map.keys() 48 | assert actual == expected 49 | end 50 | 51 | def response_body_with_playlist_element do 52 | %{ 53 | "playlists" => %{ 54 | "href" => "https://api.spotify.com/v1/users/anuser/playlists?offset=0&limit=20", 55 | "items" => [%{"name" => "foo"}, %{"name" => "bar"}], 56 | "limit" => 20, 57 | "next" => "https://api.spotify.com/v1/users/anuser/playlists?offset=20&limit=20", 58 | "offset" => 0, 59 | "previous" => nil, 60 | "total" => 62 61 | } 62 | } 63 | end 64 | 65 | def response_body_without_playlist_element do 66 | %{ 67 | "href" => "https://api.spotify.com/v1/users/anuser/playlists?offset=0&limit=20", 68 | "items" => [%{"name" => "foo"}, %{"name" => "bar"}], 69 | "limit" => 20, 70 | "next" => "https://api.spotify.com/v1/users/anuser/playlists?offset=20&limit=20", 71 | "offset" => 0, 72 | "previous" => nil, 73 | "total" => 62 74 | } 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/authorization_flow_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OathAuthorizationFlow do 2 | use ExUnit.Case 3 | import Mock 4 | use Plug.Test 5 | 6 | alias Spotify.{ 7 | Authentication, 8 | AuthenticationError 9 | } 10 | 11 | doctest Spotify.Authentication 12 | 13 | defmacro with_auth_mock(block) do 14 | quote do 15 | with_mock Spotify.AuthRequest, post: fn params -> AuthenticationClientMock.post(params) end do 16 | unquote(block) 17 | end 18 | end 19 | end 20 | 21 | describe "posting to Spotify" do 22 | test "A body with an error raises Authentication error" do 23 | with_mock Spotify.AuthRequest, 24 | post: fn _params -> 25 | AuthenticationClientMock.post(%{"error_description" => "bad client id"}) 26 | end do 27 | conn = conn(:post, "/authenticate", %{"code" => "valid"}) 28 | conn = Plug.Conn.fetch_cookies(conn) 29 | 30 | assert_raise AuthenticationError, "The Spotify API responded with: Invalid client", fn -> 31 | Authentication.authenticate(conn, conn.params) 32 | end 33 | end 34 | end 35 | end 36 | 37 | describe "authentication" do 38 | test "a successful attemp sets the cookies" do 39 | with_auth_mock do 40 | conn = conn(:post, "/authenticate", %{"code" => "valid"}) 41 | conn = Plug.Conn.fetch_cookies(conn) 42 | 43 | assert {:ok, new_conn} = Authentication.authenticate(conn, conn.params) 44 | assert new_conn.cookies["spotify_access_token"] == "access_token" 45 | assert new_conn.cookies["spotify_refresh_token"] == "refresh_token" 46 | end 47 | end 48 | 49 | test "a failing attempt raises an error" do 50 | msg = "No code provided by Spotify. Authorize your app again" 51 | conn = conn(:post, "/authenticate", %{"not_a_code" => "foo"}) 52 | 53 | assert_raise AuthenticationError, msg, fn -> 54 | Authentication.authenticate(conn, conn.params) 55 | end 56 | end 57 | end 58 | 59 | describe "refreshing the access token" do 60 | test "with a refresh token present" do 61 | with_auth_mock do 62 | conn = 63 | conn(:post, "/authenticate") 64 | |> Plug.Conn.fetch_cookies() 65 | |> Plug.Conn.put_resp_cookie("spotify_refresh_token", "refresh_token") 66 | 67 | assert {:ok, new_conn} = Authentication.refresh(conn) 68 | assert new_conn.cookies["spotify_access_token"] == "access_token" 69 | assert new_conn.cookies["spotify_refresh_token"] == "refresh_token" 70 | end 71 | end 72 | 73 | test "without a refresh token" do 74 | conn = conn(:post, "/authenticate") |> Plug.Conn.fetch_cookies() 75 | assert :unauthorized == Authentication.refresh(conn) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/spotify/categories.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Category do 2 | @moduledoc """ 3 | Functions for retrieving information about categories on Spotify. 4 | 5 | There are two functions for each endpoint; one that actually makes the 6 | request, and one that provides the endpoint: 7 | 8 | Spotify.Category.get_categories(conn, params) # makes the GET request 9 | Spotify.Category.get_categories_url(params) # provides the url for the request 10 | 11 | https://developer.spotify.com/web-api/browse-endpoints/ 12 | """ 13 | 14 | use Spotify.Responder 15 | import Spotify.Helpers 16 | 17 | alias Spotify.{ 18 | Category, 19 | Client, 20 | Paging 21 | } 22 | 23 | defstruct ~w[ 24 | href 25 | icons 26 | id 27 | name 28 | ]a 29 | 30 | @doc """ 31 | Get a list of categories used to tag items in Spotify 32 | [Spotify Documentation](https://developer.spotify.com/web-api/get-list-categories/) 33 | 34 | **Method**: `GET` 35 | 36 | **Optional Params**: `country`, `locale`, `limit`, `offset` 37 | 38 | Spotify.Category.get_categories(conn, params) 39 | # => { :ok, %Paging{items: [%Category{}, ...]} } 40 | """ 41 | def get_categories(conn, params \\ []) do 42 | url = get_categories_url(params) 43 | conn |> Client.get(url) |> handle_response 44 | end 45 | 46 | @doc """ 47 | Get a list of categories used to tag items in Spotify 48 | 49 | iex> Spotify.Category.get_categories_url(country: "US") 50 | "https://api.spotify.com/v1/browse/categories?country=US" 51 | """ 52 | def get_categories_url(params \\ []) do 53 | "https://api.spotify.com/v1/browse/categories" <> query_string(params) 54 | end 55 | 56 | @doc """ 57 | Get a single category used to tag items in Spotify 58 | [Spotify Documentation](https://developer.spotify.com/web-api/get-category/) 59 | 60 | **Method**: `GET` 61 | 62 | **Optional Params**: `country`, `locale` 63 | 64 | Spotify.Category.get_category(conn, id) 65 | # => {:ok, %Category{}} 66 | """ 67 | def get_category(conn, id, params \\ []) do 68 | url = get_category_url(id, params) 69 | conn |> Client.get(url) |> handle_response 70 | end 71 | 72 | @doc """ 73 | Get a single category used to tag items in Spotify 74 | 75 | iex> Spotify.Category.get_category_url("4") 76 | "https://api.spotify.com/v1/browse/categories/4" 77 | """ 78 | def get_category_url(id, params \\ []) do 79 | "https://api.spotify.com/v1/browse/categories/#{id}" <> query_string(params) 80 | end 81 | 82 | def build_response(body) do 83 | case body do 84 | %{"categories" => categories} -> build_categories(body, categories["items"]) 85 | category -> to_struct(Category, category) 86 | end 87 | end 88 | 89 | def build_categories(body, categories) do 90 | categories = Enum.map(categories, &to_struct(Category, &1)) 91 | Paging.response(body, categories) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/spotify/credentials.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Credentials do 2 | @moduledoc """ 3 | Provides a struct to hold token credentials from Spotify. 4 | 5 | These consist of an access token, used to authenticate requests to the Spotify 6 | web API, as well as a refresh token, used to request a new access token when 7 | it expires. 8 | 9 | You can use this struct in the place of a `Plug.Conn` struct anywhere in this 10 | library's functions with one caveat: If you use a `Plug.Conn`, these tokens 11 | will be persisted for you in browser cookies. However, if you choose to use 12 | `Spotify.Credentials`, it will be your responsibility to persist this data 13 | between uses of the library's functions. This is convenient if your use case 14 | involves using this library in a situation where you don't have access to a 15 | `Plug.Conn` or a browser/cookie system. 16 | 17 | ## Example: 18 | 19 | defmodule SpotifyExample do 20 | @moduledoc "This example uses an `Agent` to persist the tokens" 21 | 22 | @doc "The `Agent` is started with an empty `Credentials` struct" 23 | def start_link do 24 | Agent.start_link(fn -> %Spotify.Credentials{} end, name: CredStore) 25 | end 26 | 27 | defp get_creds, do: Agent.get(CredStore, &(&1)) 28 | 29 | defp put_creds(creds), do: Agent.update(CredStore, fn(_) -> creds end) 30 | 31 | @doc "Used to link the user to Spotify to kick off the auth process" 32 | def auth_url, do: Spotify.Authorization.url 33 | 34 | @doc "`params` are passed to your callback endpoint from Spotify" 35 | def authenticate(params) do 36 | creds = get_creds() 37 | {:ok, new_creds} = Spotify.Authentication.authenticate(creds, params) 38 | put_creds(new_creds) # make sure to persist the credentials for later! 39 | end 40 | 41 | @doc "Use the credentials to access the Spotify API through the library" 42 | def track(id) do 43 | credentials = get_creds() 44 | {:ok, track} = Track.get_track(credentials, id) 45 | track 46 | end 47 | end 48 | """ 49 | alias Spotify.Credentials 50 | 51 | defstruct ~w[ 52 | access_token 53 | refresh_token 54 | ]a 55 | 56 | @doc """ 57 | Returns a Spotify.Credentials struct from either a Plug.Conn or a Spotify.Credentials struct 58 | """ 59 | def new(conn_or_credentials) 60 | def new(creds = %Credentials{}), do: creds 61 | 62 | def new(conn = %Plug.Conn{}) do 63 | conn = Plug.Conn.fetch_cookies(conn) 64 | access_token = conn.cookies["spotify_access_token"] 65 | refresh_token = conn.cookies["spotify_refresh_token"] 66 | Credentials.new(access_token, refresh_token) 67 | end 68 | 69 | @doc """ 70 | Returns a Spotify.Credentials struct given tokens 71 | """ 72 | def new(access_token, refresh_token) do 73 | %Credentials{access_token: access_token, refresh_token: refresh_token} 74 | end 75 | 76 | @doc """ 77 | Returns a Spotify.Credentials struct from a parsed response body 78 | """ 79 | def get_tokens_from_response(map) 80 | 81 | def get_tokens_from_response(response) do 82 | Credentials.new(response["access_token"], response["refresh_token"]) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/spotify/personalization.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Personalization do 2 | @moduledoc """ 3 | Endpoints for retrieving information about the user’s listening habits 4 | 5 | Some endpoints return collections. Spotify wraps the collection in a paging object, 6 | this API does the same. A single piece of data will not be wrapped. 7 | 8 | There are two functions for each endpoint, one that actually makes the request, 9 | and one that provides the endpoint: 10 | 11 | Spotify.Playist.create_playlist(conn, "foo", "bar") # makes the POST request. 12 | Spotify.Playist.create_playlist_url("foo", "bar") # provides the url for the request. 13 | 14 | https://developer.spotify.com/web-api/web-api-personalization-endpoints/ 15 | """ 16 | 17 | import Spotify.Helpers 18 | use Spotify.Responder 19 | 20 | alias Spotify.{ 21 | Client, 22 | Paging 23 | } 24 | 25 | defstruct ~w[ 26 | items 27 | next 28 | previous 29 | total 30 | limit 31 | href 32 | ]a 33 | 34 | @doc """ 35 | Get the current user’s top artists based on calculated affinity. 36 | [Spotify Documentation](https://developer.spotify.com/web-api/get-users-top-artists-and-tracks/) 37 | 38 | **Method**: `GET` 39 | 40 | **Optional Params**: `limit`, `offset`, `time_range` 41 | 42 | Spotify.Personalization.top_artists(conn) 43 | { :ok, artists: [%Spotify.Artist{..}...], paging: %Paging{next:...} } 44 | """ 45 | def top_artists(conn, params \\ []) do 46 | url = top_artists_url(params) 47 | conn |> Client.get(url) |> handle_response 48 | end 49 | 50 | @doc """ 51 | Get the current user’s top artists based on calculated affinity. 52 | 53 | iex> Spotify.Personalization.top_artists_url(limit: 5, time_range: "medium_term") 54 | "https://api.spotify.com/v1/me/top/artists?limit=5&time_range=medium_term" 55 | """ 56 | def top_artists_url(params \\ []) do 57 | url() <> "artists" <> query_string(params) 58 | end 59 | 60 | @doc """ 61 | Get the current user’s top tracks based on calculated affinity. 62 | [Spotify Documentation](https://developer.spotify.com/web-api/get-users-top-artists-and-tracks/) 63 | 64 | **Method**: `GET` 65 | 66 | **Optional Params**: `limit`, `offset`, `time_range` 67 | 68 | Spotify.Personalization.top_tracks(conn) 69 | { :ok, tracks: [%Spotify.Tracks{..}...], paging: %Paging{next:...} } 70 | 71 | """ 72 | def top_tracks(conn, params \\ []) do 73 | url = top_tracks_url(params) 74 | conn |> Client.get(url) |> handle_response 75 | end 76 | 77 | @doc """ 78 | Get the current user’s top tracks based on calculated affinity. 79 | 80 | iex> Spotify.Personalization.top_tracks_url 81 | "https://api.spotify.com/v1/me/top/tracks" 82 | """ 83 | def top_tracks_url(params \\ []) do 84 | url() <> "tracks" <> query_string(params) 85 | end 86 | 87 | @doc """ 88 | Base URL 89 | """ 90 | def url do 91 | "https://api.spotify.com/v1/me/top/" 92 | end 93 | 94 | @doc """ 95 | Implements the hook expected by the Responder behaviour 96 | """ 97 | def build_response(body) do 98 | items = 99 | Enum.map(body["items"], fn item -> 100 | case item["type"] do 101 | "artist" -> build_artist_struct(item) 102 | "track" -> build_track_struct(item) 103 | end 104 | end) 105 | 106 | paging = to_struct(Paging, body) 107 | Map.put(paging, :items, items) 108 | end 109 | 110 | @doc false 111 | def build_artist_struct(artist) do 112 | to_struct(Spotify.Artist, artist) 113 | end 114 | 115 | @doc false 116 | def build_track_struct(track) do 117 | to_struct(Spotify.Track, track) 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/spotify/authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Authentication do 2 | @moduledoc """ 3 | Authenticates the Spotify user. 4 | 5 | After your app is authorized, the user must be authenticated. A redirect 6 | URL is specified in the config folder. This is the URL that Spotify 7 | redirects to after authorization, and should ultimately end up hitting 8 | this module's `authenticate` function. If the authorization is successful, 9 | the param `code` will be present. 10 | 11 | If a refresh token still exists, the client will refresh the access token. 12 | 13 | You have the option to pass either a Plug.Conn or a Spotify.Credentials struct into 14 | these functions. If you pass Conn, the auth tokens will be saved in cookies. 15 | If you pass Credentials, you will be responsible for persisting the auth tokens 16 | between requests. 17 | """ 18 | alias Spotify.{ 19 | AuthenticationError, 20 | Credentials, 21 | Cookies 22 | } 23 | 24 | @doc """ 25 | Authenticates the user 26 | 27 | The authorization code must be present from spotify or an exception 28 | will be raised. The token will be refreshed if possible, otherwise 29 | the app will request new access and request tokens. 30 | 31 | ## Example: ## 32 | Spotify.authenticate(conn, %{"code" => code}) 33 | # {:ok, conn} 34 | 35 | Spotify.authenticate(conn, %{"not_a_code" => invalid}) 36 | # AuthenticationError, "No code provided by Spotify. Authorize your app again" 37 | 38 | Spotify.authenticate(auth, params) 39 | # {:ok, auth} 40 | """ 41 | def authenticate(conn_or_auth, map) 42 | 43 | def authenticate(conn = %Plug.Conn{}, params) do 44 | {:ok, auth} = conn |> Credentials.new() |> authenticate(params) 45 | {:ok, Cookies.set_cookies(conn, auth)} 46 | end 47 | 48 | def authenticate(auth, %{"code" => code}) do 49 | auth |> body_params(code) |> Spotify.AuthenticationClient.post() 50 | end 51 | 52 | def authenticate(_, _) do 53 | raise AuthenticationError, "No code provided by Spotify. Authorize your app again" 54 | end 55 | 56 | @doc """ 57 | Attempts to refresh your access token if the refresh token exists. Returns 58 | `:unauthorized` if there is no refresh token. 59 | """ 60 | def refresh(conn_or_auth) 61 | 62 | def refresh(conn = %Plug.Conn{}) do 63 | with {:ok, auth} <- conn |> Credentials.new() |> refresh do 64 | {:ok, Cookies.set_cookies(conn, auth)} 65 | end 66 | end 67 | 68 | def refresh(%Credentials{refresh_token: nil}), do: :unauthorized 69 | def refresh(auth), do: auth |> body_params |> Spotify.AuthenticationClient.post() 70 | 71 | @doc """ 72 | Checks for refresh and access tokens 73 | 74 | ## Example: ## 75 | 76 | defmodule PlayListController do 77 | plug :check_tokens 78 | 79 | def check_tokens do 80 | unless Spotify.Authentication.tokens_present?(conn) do 81 | redirect conn, to: authorization_path(:authorize) 82 | end 83 | end 84 | end 85 | """ 86 | def tokens_present?(conn_or_auth) 87 | def tokens_present?(%Credentials{access_token: nil}), do: false 88 | def tokens_present?(%Credentials{refresh_token: nil}), do: false 89 | def tokens_present?(%Credentials{}), do: true 90 | def tokens_present?(conn), do: conn |> Credentials.new() |> tokens_present? 91 | 92 | @doc false 93 | def authenticated?(%Credentials{access_token: token}), do: token 94 | def authenticated?(conn), do: conn |> Credentials.new() |> authenticated? 95 | 96 | @doc false 97 | def body_params(%Credentials{refresh_token: token}) do 98 | "grant_type=refresh_token&refresh_token=#{token}" 99 | end 100 | 101 | @doc false 102 | def body_params(%Credentials{refresh_token: nil}, code) do 103 | "grant_type=authorization_code&code=#{code}&redirect_uri=#{Spotify.callback_url()}" 104 | end 105 | 106 | def body_params(auth, _code), do: body_params(auth) 107 | end 108 | -------------------------------------------------------------------------------- /docs/oauth.md: -------------------------------------------------------------------------------- 1 | The Spotify API follows the OAuth 2 spec, providing 3 potential authentication flows: 2 | 3 | - [Authorization Code flow](https://developer.spotify.com/web-api/authorization-guide/#authorization_code_flow) 4 | - [Client Credentials Flow](https://developer.spotify.com/web-api/authorization-guide/#client_credentials_flow) 5 | - [Implicit Grant Flow](https://developer.spotify.com/web-api/authorization-guide/#implicit_grant_flow) 6 | 7 | To connect with the Spotify API, first you must register your app with Spotify, 8 | and get your **Client ID** and **Client Secret**, which are necessary for 9 | authentication. 10 | 11 | In ```/config```, create ```config/secret.exs``` and ```config/spotify.exs``` files 12 | 13 | ```elixir 14 | # /config/secret.exs 15 | 16 | use Mix.Config 17 | 18 | config :spotify_ex, client_id: "" 19 | secret_key: "" 20 | ``` 21 | 22 | ```elixir 23 | # /config/spotify.exs 24 | 25 | use Mix.Config 26 | 27 | config :spotify_ex, user_id: "", 28 | scopes: "", 29 | callback_url: "" 30 | ``` 31 | 32 | Add the secret file to your .gitignore, and import it in config.exs 33 | 34 | ```elixir 35 | import "config.secret.exs" 36 | import "spotify.secret.exs" 37 | ``` 38 | 39 | ## Authorization Flow 40 | 41 | First your application must be *authorized* by Spotify. SpotifyEx will use the 42 | client ID, callback URI, and scopes set in your config file to generate the 43 | authorization endpoint. 44 | 45 | ```elixir 46 | defmodule SpotifyExTest.AuthorizationController do 47 | use SpotifyExTest.Web, :controller 48 | 49 | def authorize(conn, _params) do 50 | redirect conn, external: Spotify.Authorization.url 51 | end 52 | end 53 | ``` 54 | 55 | This will take you to the Spotify Authorization page. After authorizing your 56 | app, you will then be directed to authenticate as a Spotify User. When 57 | successful, you will be redirected to the callback uri you set in the config 58 | file. 59 | 60 | ```elixir 61 | #config/spotify.exs 62 | 63 | config :spotify_ex, scopes: ["playlist-read-private", "playlist-modify-private" "# more scopes"] 64 | ``` 65 | 66 | OAuth requires identical redirect URIs to use for the authorization and 67 | authentication steps. When you attempt to authenticate with Spotify, if 68 | successful, Spotify needs to know where to send the user afterwards, which 69 | is what the redirect URI is used for. 70 | 71 | ```elixir 72 | config :spotify_ex, callback_url: "http://www.your-api.com/auth-endpoint" 73 | ``` 74 | 75 | Set it in your config file. Now that your application is *authorized*, the user 76 | must be *authenticated*. Spotify is going to send an authorization code in the 77 | query string to this endpoint, which should then send that code to Spotify to 78 | request an **access token** and a **remember token**. 79 | 80 | ```elixir 81 | config :spotify_ex, callback_url: "http://localhost:4000/authenticate" 82 | ``` 83 | 84 | Authenticate like this: 85 | 86 | ```elixir 87 | Spotify.Authentication.authenticate(conn, params) 88 | ``` 89 | 90 | `Spotify.Authentication.authenticate` will look for `params["code"]`,the code 91 | sent back by Spotify after authorization request. If successful, the user will 92 | be redirected to the URL set in the ```spotify.exs``` file, where you can 93 | handle different responses. 94 | 95 | ```elixir 96 | defmodule SpotifyExTest.AuthenticationController do 97 | use SpotifyExTest.Web, :controller 98 | 99 | def authenticate(conn, params) do 100 | case Spotify.Authentication.authenticate(conn, params) do 101 | {:ok, conn } -> 102 | # do stuff 103 | redirect conn, to: "/whereever-you-want-to-go" 104 | { :error, reason, conn }-> redirect conn, to: "/error" 105 | end 106 | end 107 | end 108 | ``` 109 | 110 | The authentication module will set refresh and access tokens in a cookie. The 111 | access token expires every hour, so you'll need to check your reponses for 401 112 | errors. Call `Spotify.Authentication.refresh`, if there is a refresh token 113 | present. If not, you'll need to redirect back to authorization. 114 | -------------------------------------------------------------------------------- /lib/spotify/track.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Track do 2 | @moduledoc """ 3 | Functions for retrieving information about one or more tracks. 4 | 5 | There are two functions for each endpoint; one that actually makes the 6 | request, and one that provides the endpoint: 7 | 8 | Spotify.Track.audio_features(conn, ids: "1, 3") # makes the GET request 9 | Spotify.Track.audio_features_url(ids: "1, 3") # provides the url for the request 10 | 11 | https://developer.spotify.com/web-api/track-endpoints/ 12 | """ 13 | 14 | alias Spotify.{ 15 | Client, 16 | Track 17 | } 18 | 19 | import Spotify.Helpers 20 | use Spotify.Responder 21 | 22 | defstruct ~w[ 23 | album 24 | artists 25 | available_markets 26 | disc_number 27 | duration_ms 28 | explicit 29 | external_ids 30 | href 31 | id 32 | is_playable 33 | linked_from 34 | name 35 | popularity 36 | preview_url 37 | track_number 38 | type 39 | uri 40 | ]a 41 | 42 | @doc """ 43 | Get audio features for several tracks 44 | [Spotify Documentation](https://developer.spotify.com/web-api/get-several-audio-features/) 45 | 46 | **Method**: `GET` 47 | 48 | Spotify.Track.audio_features(conn, ids: "1, 3") 49 | # => {:ok [%Spotify.AudioFeatures, ...]} 50 | """ 51 | def audio_features(conn, params) when is_list(params) do 52 | url = audio_features_url(params) 53 | conn |> Client.get(url) |> handle_response 54 | end 55 | 56 | def audio_features(conn, id) do 57 | url = audio_features_url(id) 58 | conn |> Client.get(url) |> handle_response 59 | end 60 | 61 | def audio_features_url(params) when is_list(params) do 62 | "https://api.spotify.com/v1/audio-features" <> query_string(params) 63 | end 64 | 65 | @doc """ 66 | Get audio features for a track 67 | 68 | iex> Spotify.Track.audio_features_url("1") 69 | "https://api.spotify.com/v1/audio-features/1" 70 | """ 71 | def audio_features_url(id) do 72 | "https://api.spotify.com/v1/audio-features/#{id}" 73 | end 74 | 75 | @doc """ 76 | Get several tracks 77 | [Spotify Documentation](https://developer.spotify.com/web-api/get-several-tracks/) 78 | 79 | Spotify.Track.get_tracks(conn, ids: "1,3") 80 | # => { :ok , [%Spotify.Track{}, ...] } 81 | **Method**: `GET` 82 | """ 83 | def get_tracks(conn, params) do 84 | url = get_tracks_url(params) 85 | conn |> Client.get(url) |> handle_response 86 | end 87 | 88 | @doc """ 89 | Get a track 90 | [Spotify Documentation](https://developer.spotify.com/web-api/get-track/) 91 | 92 | **Method**: `GET` 93 | 94 | **Optional Params**: `market` 95 | 96 | Spotify.get_track(conn, id) 97 | # => { :ok , %Spotify.Track{} } 98 | 99 | """ 100 | def get_track(conn, id) do 101 | url = get_track_url(id) 102 | conn |> Client.get(url) |> handle_response 103 | end 104 | 105 | @doc """ 106 | Get several tracks 107 | 108 | iex> Spotify.Track.get_tracks_url(ids: "1,3") 109 | "https://api.spotify.com/v1/tracks?ids=1%2C3" 110 | 111 | """ 112 | def get_tracks_url(params) do 113 | "https://api.spotify.com/v1/tracks" <> query_string(params) 114 | end 115 | 116 | @doc """ 117 | Get a track 118 | 119 | iex> Spotify.Track.get_track_url("1") 120 | "https://api.spotify.com/v1/tracks/1" 121 | 122 | """ 123 | def get_track_url(id) do 124 | "https://api.spotify.com/v1/tracks/#{id}" 125 | end 126 | 127 | @doc """ 128 | Implements the hook expected by the Responder behaviour 129 | """ 130 | def build_response(body) do 131 | case body do 132 | %{"audio_features" => audio_features} -> build_audio_features(audio_features) 133 | %{"tracks" => tracks} -> build_tracks(tracks) 134 | %{"album" => _} -> to_struct(Track, body) 135 | %{"energy" => _} -> to_struct(Spotify.AudioFeatures, body) 136 | end 137 | end 138 | 139 | @doc false 140 | def build_tracks(tracks) do 141 | Enum.map(tracks, &to_struct(Track, &1)) 142 | end 143 | 144 | @doc false 145 | def build_audio_features(audio_features) do 146 | Enum.map(audio_features, &to_struct(Spotify.AudioFeatures, &1)) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/spotify/responder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenericMock do 2 | @behaviour Spotify.Responder 3 | use Spotify.Responder 4 | 5 | def some_endpoint(response) do 6 | handle_response(response) 7 | end 8 | 9 | def build_response(body) do 10 | Spotify.Helpers.to_struct(Spotify.Playlist, body) 11 | end 12 | end 13 | 14 | defmodule Spotify.ResponderTest do 15 | use ExUnit.Case 16 | 17 | describe "handle_response" do 18 | test "with 200/201 status and an empty body" do 19 | assert GenericMock.some_endpoint(success_empty_body()) == :ok 20 | end 21 | 22 | test "with 200/201 status and JSON content-type with body" do 23 | expected = {:ok, %Spotify.Playlist{name: "foo"}} 24 | assert GenericMock.some_endpoint(success_with_json_body()) == expected 25 | end 26 | 27 | test "with 200/201 status and JSON content-type with charset parameter" do 28 | expected = {:ok, %Spotify.Playlist{name: "foo"}} 29 | assert GenericMock.some_endpoint(success_with_json_body_and_charset()) == expected 30 | end 31 | 32 | test "with 200/201 status and non-JSON content-type" do 33 | assert GenericMock.some_endpoint(success_with_text_body()) == :ok 34 | end 35 | 36 | test "with 200/201 status and no content-type header" do 37 | assert GenericMock.some_endpoint(success_with_no_content_type()) == :ok 38 | end 39 | 40 | test "with 200/201 status and case-insensitive content-type header" do 41 | expected = {:ok, %Spotify.Playlist{name: "foo"}} 42 | assert GenericMock.some_endpoint(success_with_uppercase_content_type()) == expected 43 | end 44 | 45 | test "with 400 status and a body" do 46 | expected = {:error, %{"error" => "foo"}} 47 | assert GenericMock.some_endpoint(error()) == expected 48 | end 49 | 50 | test "with 429 too many requests, body and the header in downcase" do 51 | assert GenericMock.some_endpoint(too_many_requests_error(99, "retry-after")) == 52 | too_many_requests_error_expect() 53 | end 54 | 55 | test "with 429 too many requests, body and the header in uppercase" do 56 | assert GenericMock.some_endpoint(too_many_requests_error(99, "Retry-After")) == 57 | too_many_requests_error_expect() 58 | end 59 | 60 | test "with arbitrary HTTPoison error" do 61 | assert GenericMock.some_endpoint({:error, %HTTPoison.Error{reason: :timeout}}) == 62 | {:error, :timeout} 63 | end 64 | end 65 | 66 | defp too_many_requests_error(retry_after_value, header_name) do 67 | {:error, 68 | %HTTPoison.Response{ 69 | body: Poison.encode!(%{error: %{message: "API rate limit exceeded", status: 429}}), 70 | status_code: 429, 71 | headers: [{header_name, Integer.to_string(retry_after_value)}] 72 | }} 73 | end 74 | 75 | defp too_many_requests_error_expect() do 76 | {:error, 77 | %{ 78 | "error" => %{"message" => "API rate limit exceeded", "status" => 429}, 79 | "meta" => %{"retry_after" => 99} 80 | }} 81 | end 82 | 83 | defp error do 84 | {:error, %HTTPoison.Response{body: Poison.encode!(%{error: "foo"}), status_code: 400}} 85 | end 86 | 87 | defp success_empty_body do 88 | {:ok, %HTTPoison.Response{body: "", status_code: 200, headers: []}} 89 | end 90 | 91 | defp success_with_json_body do 92 | {:ok, %HTTPoison.Response{ 93 | body: Poison.encode!(%{name: "foo"}), 94 | status_code: 200, 95 | headers: [{"Content-Type", "application/json"}] 96 | }} 97 | end 98 | 99 | defp success_with_json_body_and_charset do 100 | {:ok, %HTTPoison.Response{ 101 | body: Poison.encode!(%{name: "foo"}), 102 | status_code: 200, 103 | headers: [{"Content-Type", "application/json; charset=utf-8"}] 104 | }} 105 | end 106 | 107 | defp success_with_text_body do 108 | {:ok, %HTTPoison.Response{ 109 | body: "Plain text response", 110 | status_code: 200, 111 | headers: [{"Content-Type", "text/plain"}] 112 | }} 113 | end 114 | 115 | defp success_with_no_content_type do 116 | {:ok, %HTTPoison.Response{ 117 | body: "Some response without content type", 118 | status_code: 200, 119 | headers: [] 120 | }} 121 | end 122 | 123 | defp success_with_uppercase_content_type do 124 | {:ok, %HTTPoison.Response{ 125 | body: Poison.encode!(%{name: "foo"}), 126 | status_code: 200, 127 | headers: [{"CONTENT-TYPE", "application/json"}] 128 | }} 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/spotify/artist_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.ArtistTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Artist, 6 | Paging, 7 | Track 8 | } 9 | 10 | test "%Spotify.Artist{}" do 11 | expected = ~w[external_urls followers genres href id images name popularity type uri]a 12 | actual = %Artist{} |> Map.from_struct() |> Map.keys() 13 | 14 | assert actual == expected 15 | end 16 | 17 | describe "build_response/1" do 18 | test "it returns a collection of artists" do 19 | expected = [%Artist{name: "foo"}, %Artist{name: "bar"}] 20 | actual = Spotify.Artist.build_response(artists_response()) 21 | 22 | assert actual == expected 23 | end 24 | 25 | test "it returns an artist" do 26 | response = %{"name" => "foo"} 27 | expected = %Artist{name: "foo"} 28 | actual = Spotify.Artist.build_response(response) 29 | 30 | assert actual == expected 31 | end 32 | 33 | test "it returns booleans" do 34 | response = [true, false] 35 | expected = [true, false] 36 | actual = Spotify.Artist.build_response(response) 37 | 38 | assert actual == expected 39 | end 40 | 41 | test "it returns tracks" do 42 | expected = [%Track{name: "foo"}, %Track{name: "bar"}] 43 | actual = Spotify.Artist.build_response(tracks_response()) 44 | 45 | assert actual == expected 46 | end 47 | 48 | test "it returns a paged struct" do 49 | expected = %Paging{ 50 | items: [ 51 | %Artist{ 52 | external_urls: %{ 53 | "spotify" => "https://open.spotify.com/artist/5dg3YtsiR8ux6amJv9m9AG" 54 | }, 55 | followers: %{"href" => nil, "total" => 12336}, 56 | genres: [ 57 | "europop", 58 | "swedish alternative rock", 59 | "swedish idol pop", 60 | "swedish pop" 61 | ], 62 | href: "https://api.spotify.com/v1/artists/5dg3YtsiR8ux6amJv9m9AG", 63 | id: "5dg3YtsiR8ux6amJv9m9AG", 64 | images: [ 65 | %{ 66 | "height" => 640, 67 | "url" => "https://i.scdn.co/image/d1e83fee8c4aa5396a9d263a679b6d8e280fa53a", 68 | "width" => 640 69 | } 70 | ], 71 | name: "Erik Grönwall", 72 | popularity: 41, 73 | type: "artist", 74 | uri: "spotify:artist:5dg3YtsiR8ux6amJv9m9AG" 75 | } 76 | ], 77 | next: nil, 78 | total: 5, 79 | cursors: %{"after" => nil}, 80 | limit: 50, 81 | href: "https://api.spotify.com/v1/me/following?type=artist&limit=50" 82 | } 83 | 84 | actual = Spotify.Artist.build_response(artist_response_with_pagination()) 85 | assert actual == expected 86 | end 87 | end 88 | 89 | def artist_response_with_pagination do 90 | %{ 91 | "artists" => %{ 92 | "items" => [ 93 | %{ 94 | "external_urls" => %{ 95 | "spotify" => "https://open.spotify.com/artist/5dg3YtsiR8ux6amJv9m9AG" 96 | }, 97 | "followers" => %{ 98 | "href" => nil, 99 | "total" => 12336 100 | }, 101 | "genres" => [ 102 | "europop", 103 | "swedish alternative rock", 104 | "swedish idol pop", 105 | "swedish pop" 106 | ], 107 | "href" => "https://api.spotify.com/v1/artists/5dg3YtsiR8ux6amJv9m9AG", 108 | "id" => "5dg3YtsiR8ux6amJv9m9AG", 109 | "images" => [ 110 | %{ 111 | "height" => 640, 112 | "url" => "https://i.scdn.co/image/d1e83fee8c4aa5396a9d263a679b6d8e280fa53a", 113 | "width" => 640 114 | } 115 | ], 116 | "name" => "Erik Grönwall", 117 | "popularity" => 41, 118 | "type" => "artist", 119 | "uri" => "spotify:artist:5dg3YtsiR8ux6amJv9m9AG" 120 | } 121 | ], 122 | "next" => nil, 123 | "total" => 5, 124 | "cursors" => %{ 125 | "after" => nil 126 | }, 127 | "limit" => 50, 128 | "href" => "https://api.spotify.com/v1/me/following?type=artist&limit=50" 129 | } 130 | } 131 | end 132 | 133 | def artists_response do 134 | %{ 135 | "artists" => [ 136 | %{"name" => "foo"}, 137 | %{"name" => "bar"} 138 | ] 139 | } 140 | end 141 | 142 | def tracks_response do 143 | %{ 144 | "tracks" => [ 145 | %{"name" => "foo"}, 146 | %{"name" => "bar"} 147 | ] 148 | } 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/spotify/library.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Library do 2 | @moduledoc """ 3 | Functions for retrieving information about, and managing, tracks that the 4 | current user has saved in their "Your Music" library. If you are looking 5 | for functions for retrieving the current user's saved albums, see 6 | Spotify.Album 7 | 8 | There are two function for each endpoint; one that actually makes the 9 | request, and one that provides the endpoint: 10 | 11 | Spotify.Library.get_saved_tracks(conn, params) # makes the GET request 12 | Spotify.Library.get_saved_tracks_url(params) # provides the url for the request 13 | 14 | https://developer.spotify.com/web-api/library-endpoints/ 15 | """ 16 | 17 | use Spotify.Responder 18 | import Spotify.Helpers 19 | 20 | alias Spotify.{ 21 | Client, 22 | Paging, 23 | Track 24 | } 25 | 26 | @doc """ 27 | Save one or more tracks to the current user’s library. 28 | [Spotify Documentation](https://developer.spotify.com/web-api/save-tracks-user/) 29 | 30 | **Method**: PUT 31 | 32 | **Optional Params**: 33 | * `ids` - A comma-separated string of the Spotify IDs. Maximum: 50 IDs. 34 | 35 | iex> Spotify.Library.save_tracks(conn, ids: "1,4") 36 | :ok 37 | """ 38 | def save_tracks(conn, params) do 39 | url = saved_tracks_url(params) 40 | 41 | conn 42 | |> Client.put(url) 43 | |> handle_response 44 | end 45 | 46 | @doc """ 47 | Get a list of the songs saved in the current user's library. 48 | [Spotify Documentation](https://developer.spotify.com/web-api/get-users-saved-tracks/) 49 | 50 | **Method**: GET 51 | 52 | **Optional Params**: 53 | * `limit` - The maximum number of objects to return. Default: 20. 54 | Minimum: 1. Maximum: 50 55 | * `offset` - The index of the first object to return. 56 | Default: 0 (i.e., the first object). Use with limit to get the next set of objects. 57 | * `market` - An ISO 3166-1 alpha-2 country code. 58 | 59 | iex> Spotify.Library.get_saved_tracks(conn, limit: "1") 60 | {:ok, [%Spotify.Track{}]} 61 | """ 62 | def get_saved_tracks(conn, params \\ []) do 63 | url = saved_tracks_url(params) 64 | 65 | conn 66 | |> Client.get(url) 67 | |> handle_response 68 | end 69 | 70 | @doc """ 71 | Remove one or more tracks from the current user's library. 72 | [Spotify Documentation](https://developer.spotify.com/web-api/remove-tracks-user/) 73 | 74 | **Method**: DELETE 75 | 76 | **Optional Params**: 77 | * `ids` - A comma-separated string of the Spotify IDs. Maximum: 50 IDs. 78 | 79 | iex> Spotify.Library.remove_saved_tracks(conn, ids: "1,4") 80 | :ok 81 | """ 82 | def remove_saved_tracks(conn, params \\ []) do 83 | url = saved_tracks_url(params) 84 | 85 | conn 86 | |> Client.delete(url) 87 | |> handle_response 88 | end 89 | 90 | @doc """ 91 | Get url for saved tracks in the current user’s library. 92 | 93 | iex> Spotify.Library.save_tracks_url(ids: "1,4") 94 | "https://api.spotify.com/v1/me/tracks?ids=1%2C4" 95 | """ 96 | def saved_tracks_url(params) do 97 | "https://api.spotify.com/v1/me/tracks" <> query_string(params) 98 | end 99 | 100 | @doc """ 101 | Check if one or more tracks is already saved in the current user’s library. 102 | 103 | **Method**: GET 104 | 105 | **Optional Params**: 106 | * `ids` - A comma-separated string of the Spotify IDs. Maximum: 50 IDs. 107 | 108 | iex> Spotify.Library.check_saved_tracks(conn, ids: "1,4") 109 | {:ok, [true, false]} 110 | """ 111 | def check_saved_tracks(conn, params) do 112 | url = check_saved_tracks_url(params) 113 | 114 | conn 115 | |> Client.get(url) 116 | |> handle_response 117 | end 118 | 119 | @doc """ 120 | Get url to check if one or more tracks is already saved in the current user’s library. 121 | 122 | iex> Spotify.Library.check_saved_tracks_url(id: "1,4") 123 | "https://api.spotify.com/v1/me/tracks/contains?ids=1%2C4" 124 | """ 125 | def check_saved_tracks_url(params) do 126 | "https://api.spotify.com/v1/me/tracks/contains" <> query_string(params) 127 | end 128 | 129 | @doc """ 130 | Implement the callback required by the Responder behavior 131 | """ 132 | def build_response(body) do 133 | case body do 134 | %{"items" => _tracks} = response -> build_paged_response(response) 135 | booleans_or_error -> booleans_or_error 136 | end 137 | end 138 | 139 | @doc false 140 | defp build_paged_response(response) do 141 | %Paging{ 142 | href: response["href"], 143 | items: build_tracks(response["items"]), 144 | limit: response["limit"], 145 | next: response["next"], 146 | offset: response["offset"], 147 | previous: response["previous"], 148 | total: response["total"] 149 | } 150 | end 151 | 152 | @doc false 153 | def build_tracks(tracks) do 154 | tracks 155 | |> Enum.map(fn %{"track" => items} -> items end) 156 | |> Track.build_tracks() 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/spotify/artist.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Artist do 2 | @moduledoc """ 3 | Functions for retrieving information about artists and for 4 | managing a user’s followed artists. 5 | 6 | There are two functions for each endpoint, one that actually makes the request, 7 | and one that provides the endpoint: 8 | 9 | Spotify.Playist.create_playlist(conn, "foo", "bar") # makes the POST request. 10 | Spotify.Playist.create_playlist_url("foo", "bar") # provides the url for the request. 11 | 12 | https://developer.spotify.com/web-api/artist-endpoints/ 13 | """ 14 | 15 | import Spotify.Helpers 16 | use Spotify.Responder 17 | 18 | alias Spotify.{ 19 | Artist, 20 | Client, 21 | Paging, 22 | Track 23 | } 24 | 25 | defstruct ~w[ 26 | external_urls 27 | followers 28 | genres 29 | href 30 | id 31 | images 32 | name 33 | popularity 34 | type 35 | uri 36 | ]a 37 | 38 | @doc """ 39 | Get Spotify catalog information for a single artist identified by their unique Spotify ID. 40 | [Spotify Documenation](https://developer.spotify.com/web-api/get-artist/) 41 | 42 | **Method**: `GET` 43 | 44 | Spotify.Artist.get_artist(conn, "4") 45 | # => { :ok, %Artist{}} 46 | """ 47 | def get_artist(conn, id) do 48 | url = get_artist_url(id) 49 | conn |> Client.get(url) |> handle_response 50 | end 51 | 52 | @doc """ 53 | Get Spotify catalog information for a single artist identified by their unique Spotify ID. 54 | 55 | iex> Spotify.Artist.get_artist_url("4") 56 | "https://api.spotify.com/v1/artists/4" 57 | """ 58 | def get_artist_url(id) do 59 | "https://api.spotify.com/v1/artists/#{id}" 60 | end 61 | 62 | @doc """ 63 | Get Spotify catalog information for several artists based on their Spotify IDs. 64 | [Spotify Documenation](https://developer.spotify.com/web-api/get-several-artists/) 65 | 66 | **Method**: `GET` 67 | 68 | **Required Params**: `ids` 69 | 70 | Spotify.Artist.get_artists(conn, ids: "1,4") 71 | # => { :ok, [%Artist{}, ...] } 72 | """ 73 | def get_artists(conn, params = [ids: _ids]) do 74 | url = get_artists_url(params) 75 | conn |> Client.get(url) |> handle_response 76 | end 77 | 78 | @doc """ 79 | Get Spotify catalog information for several artists based on their Spotify IDs. 80 | 81 | iex> Spotify.Artist.get_artists_url(ids: "1,4") 82 | "https://api.spotify.com/v1/artists?ids=1%2C4" 83 | """ 84 | def get_artists_url(params) do 85 | "https://api.spotify.com/v1/artists" <> query_string(params) 86 | end 87 | 88 | @doc """ 89 | Get Spotify catalog information about an artist’s top tracks by country. 90 | [Spotify Documenation](https://developer.spotify.com/web-api/get-artists-top-tracks/) 91 | 92 | **Method**: `GET` 93 | 94 | **Required Params**: `country` 95 | 96 | Spotify.get_top_tracks(conn, "4", country: "US") 97 | # => { :ok, [%Track{}, ...] } 98 | """ 99 | def get_top_tracks(conn, id, params) do 100 | url = get_top_tracks_url(id, params) 101 | conn |> Client.get(url) |> handle_response 102 | end 103 | 104 | @doc """ 105 | Get Spotify catalog information about an artist’s top tracks by country. 106 | 107 | iex> Spotify.Artist.get_top_tracks_url("4", country: "US") 108 | "https://api.spotify.com/v1/artists/4/top-tracks?country=US" 109 | """ 110 | def get_top_tracks_url(id, params) do 111 | "https://api.spotify.com/v1/artists/#{id}/top-tracks" <> query_string(params) 112 | end 113 | 114 | @doc """ 115 | Get Spotify catalog information about artists similar to a given artist. 116 | [Spotify Documenation](https://developer.spotify.com/web-api/get-related-artists/) 117 | 118 | ** Method **: `GET` 119 | 120 | Spotify.Artist.get_related_artists(conn, "4") 121 | # => { :ok, [ %Artist{}, ... ] } 122 | """ 123 | def get_related_artists(conn, id) do 124 | url = get_related_artists_url(id) 125 | conn |> Client.get(url) |> handle_response 126 | end 127 | 128 | @doc """ 129 | Get Spotify catalog information about artists similar to a given artist. 130 | 131 | iex> Spotify.Artist.get_related_artists_url("4") 132 | "https://api.spotify.com/v1/artists/4/related-artists" 133 | """ 134 | def get_related_artists_url(id) do 135 | "https://api.spotify.com/v1/artists/#{id}/related-artists" 136 | end 137 | 138 | @doc """ 139 | Get the current user’s followed artists. 140 | 141 | **Method**: `GET` 142 | 143 | **Optional Params**: `type`, `limit`, `after` 144 | 145 | Spotify.Artist.artists_I_follow_url(conn) 146 | # => { :ok, %Paging{items: [%Artist{}, ...]} } 147 | """ 148 | def artists_I_follow(conn, params \\ []) do 149 | url = artists_I_follow_url(params) 150 | conn |> Client.get(url) |> handle_response 151 | end 152 | 153 | @doc """ 154 | Get the current user’s followed artists. 155 | 156 | iex> Spotify.Artist.artists_I_follow_url(limit: 5) 157 | "https://api.spotify.com/v1/me/following?type=artist&limit=5" 158 | """ 159 | def artists_I_follow_url(params) do 160 | "https://api.spotify.com/v1/me/following?type=artist&" <> URI.encode_query(params) 161 | end 162 | 163 | @doc """ 164 | Implements the hook expected by the Responder behaviour 165 | """ 166 | def build_response(body) do 167 | case body do 168 | %{"artists" => %{"items" => _items}} = response -> build_paged_artists(response) 169 | %{"artists" => artists} -> build_artists(artists) 170 | %{"tracks" => tracks} -> Track.build_tracks(tracks) 171 | %{"name" => _} -> to_struct(Artist, body) 172 | booleans_or_error -> booleans_or_error 173 | end 174 | end 175 | 176 | @doc false 177 | defp build_paged_artists(%{"artists" => response}) do 178 | %Paging{ 179 | items: build_artists(response["items"]), 180 | next: response["next"], 181 | total: response["total"], 182 | cursors: response["cursors"], 183 | limit: response["limit"], 184 | href: response["href"] 185 | } 186 | end 187 | 188 | @doc false 189 | def build_artists(artists) do 190 | Enum.map(artists, &to_struct(Artist, &1)) 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 3 | "earmark": {:hex, :earmark, "1.4.24", "1923e201c3742af421860b983560967cc3e3deacc59c12966bc991a5435565e6", [:mix], [{:earmark_parser, "~> 1.4.25", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "9724242f241f2ad634756d8f2bb57a3d0992cedd10c51842fa655703b4da7c67"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, 5 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, 6 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 7 | "httpoison": {:hex, :httpoison, "1.8.1", "df030d96de89dad2e9983f92b0c506a642d4b1f4a819c96ff77d12796189c63e", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "35156a6d678d6d516b9229e208942c405cf21232edd632327ecfaf4fd03e79e0"}, 8 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 9 | "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "7123ca0450686a61416a06cd38e26af18fd0f8c1cff5214770a957c6e0724338"}, 10 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 13 | "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 15 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 16 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 17 | "mock": {:hex, :mock, "0.1.3", "657937b03f88fce89b3f7d6becc9f1ec1ac19c71081aeb32117db9bc4d9b3980", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "bf7cf50d528394d870cdecac4920ab719cec0af98eff95759b57cab0e5ee143e"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 19 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 20 | "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, 21 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 22 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, 23 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 24 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 25 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 26 | } 27 | -------------------------------------------------------------------------------- /test/spotify/player_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spotify.PlayerTest do 2 | use ExUnit.Case 3 | 4 | alias Spotify.{ 5 | Context, 6 | CurrentlyPlaying, 7 | Device, 8 | Episode, 9 | History, 10 | Paging, 11 | Playback, 12 | Player, 13 | Track 14 | } 15 | 16 | describe "build_response/1 with devices response" do 17 | test "build list of device struct" do 18 | assert [%Device{}] = Player.build_response(devices_response()) 19 | end 20 | end 21 | 22 | describe "build_response/1 with playback response" do 23 | test "build item struct depending on currently_playing_type" do 24 | assert %Playback{item: %Track{}} = Player.build_response(playback_track_response()) 25 | assert %Playback{item: %Episode{}} = Player.build_response(playback_episode_response()) 26 | end 27 | 28 | test "build device struct" do 29 | assert %Playback{device: %Device{}} = Player.build_response(playback_track_response()) 30 | end 31 | 32 | test "build context struct" do 33 | assert %Playback{context: %Context{}} = Player.build_response(playback_track_response()) 34 | end 35 | end 36 | 37 | describe "build_response/1 with recently played response" do 38 | test "build list of history structs" do 39 | assert %Paging{items: [%History{}]} = Player.build_response(recently_played_response()) 40 | end 41 | end 42 | 43 | describe "build_response/1 with currently playing response" do 44 | test "build currently playing struct" do 45 | assert %CurrentlyPlaying{} = Player.build_response(currently_playing_response()) 46 | end 47 | end 48 | 49 | defp devices_response do 50 | %{ 51 | "devices" => [ 52 | %{ 53 | "id" => "DEVICE_ID", 54 | "is_active" => true, 55 | "is_private_session" => false, 56 | "is_restricted" => false, 57 | "name" => "Web Player (Chrome)", 58 | "type" => "Computer", 59 | "volume_percent" => 35 60 | } 61 | ] 62 | } 63 | end 64 | 65 | defp playback_track_response do 66 | %{ 67 | "actions" => %{"disallows" => %{"resuming" => true}}, 68 | "context" => %{ 69 | "external_urls" => %{ 70 | "spotify" => "https://open.spotify.com/album/ALBUM_ID" 71 | }, 72 | "href" => "https://api.spotify.com/v1/albums/ALBUM_ID", 73 | "type" => "album", 74 | "uri" => "spotify:album:ALBUM_ID" 75 | }, 76 | "currently_playing_type" => "track", 77 | "device" => %{ 78 | "id" => "DEVICE_ID", 79 | "is_active" => true, 80 | "is_private_session" => false, 81 | "is_restricted" => false, 82 | "name" => "Web Player (Chrome)", 83 | "type" => "Computer", 84 | "volume_percent" => 35 85 | }, 86 | "is_playing" => true, 87 | "item" => track(), 88 | "progress_ms" => 45646, 89 | "repeat_state" => "context", 90 | "shuffle_state" => false, 91 | "timestamp" => 1_607_682_100_329 92 | } 93 | end 94 | 95 | defp playback_episode_response do 96 | %{ 97 | "actions" => %{"disallows" => %{"resuming" => true}}, 98 | "context" => nil, 99 | "currently_playing_type" => "episode", 100 | "device" => %{ 101 | "id" => "DEVICE_ID", 102 | "is_active" => true, 103 | "is_private_session" => false, 104 | "is_restricted" => false, 105 | "name" => "Web Player (Chrome)", 106 | "type" => "Computer", 107 | "volume_percent" => 35 108 | }, 109 | "is_playing" => true, 110 | "item" => %{ 111 | "audio_preview_url" => "https://p.scdn.co/mp3-preview/...", 112 | "description" => "", 113 | "duration_ms" => 2_685_023, 114 | "explicit" => false, 115 | "external_urls" => %{ 116 | "spotify" => "https://open.spotify.com/episode/EPISODE_ID" 117 | }, 118 | "href" => "https://api.spotify.com/v1/episodes/EPISODE_ID", 119 | "id" => "EPISODE_ID", 120 | "images" => [ 121 | %{ 122 | "height" => 640, 123 | "url" => "https://i.scdn.co/image/...", 124 | "width" => 640 125 | }, 126 | %{ 127 | "height" => 300, 128 | "url" => "https://i.scdn.co/image/...", 129 | "width" => 300 130 | }, 131 | %{ 132 | "height" => 64, 133 | "url" => "https://i.scdn.co/image/...", 134 | "width" => 64 135 | } 136 | ], 137 | "is_externally_hosted" => false, 138 | "is_playable" => true, 139 | "language" => "sv", 140 | "languages" => ["sv"], 141 | "name" => "Episode Name", 142 | "release_date" => "2019-09-10", 143 | "release_date_precision" => "day", 144 | "resume_point" => %{ 145 | "fully_played" => false, 146 | "resume_position_ms" => 0 147 | }, 148 | "show" => %{ 149 | "available_markets" => ["US"], 150 | "copyrights" => [], 151 | "description" => "", 152 | "explicit" => false, 153 | "external_urls" => %{ 154 | "spotify" => "https://open.spotify.com/show/SHOW_ID" 155 | }, 156 | "href" => "https://api.spotify.com/v1/shows/SHOW_ID", 157 | "id" => "SHOW_ID", 158 | "images" => [ 159 | %{ 160 | "height" => 640, 161 | "url" => "https://i.scdn.co/image/...", 162 | "width" => 640 163 | }, 164 | %{ 165 | "height" => 300, 166 | "url" => "https://i.scdn.co/image/...", 167 | "width" => 300 168 | }, 169 | %{ 170 | "height" => 64, 171 | "url" => "https://i.scdn.co/image/...", 172 | "width" => 64 173 | } 174 | ], 175 | "is_externally_hosted" => false, 176 | "languages" => ["sv"], 177 | "media_type" => "audio", 178 | "name" => "Show Name", 179 | "publisher" => "Publisher", 180 | "total_episodes" => 500, 181 | "type" => "show", 182 | "uri" => "spotify:show:SHOW_ID" 183 | }, 184 | "type" => "episode", 185 | "uri" => "spotify:episode:EPISODE_ID" 186 | }, 187 | "progress_ms" => 1_479_096, 188 | "repeat_state" => "context", 189 | "shuffle_state" => false, 190 | "timestamp" => 1_607_685_202_846 191 | } 192 | end 193 | 194 | defp recently_played_response do 195 | %{ 196 | "cursors" => %{"after" => "1607703249293", "before" => "1607703043921"}, 197 | "href" => "https://api.spotify.com/v1/me/player/recently-played?limit=2", 198 | "items" => [ 199 | %{ 200 | "context" => %{ 201 | "external_urls" => %{ 202 | "spotify" => "https://open.spotify.com/playlist/PLAYLIST_ID" 203 | }, 204 | "href" => "https://api.spotify.com/v1/playlists/PLAYLIST_ID", 205 | "type" => "playlist", 206 | "uri" => "spotify:playlist:PLAYLIST_ID" 207 | }, 208 | "played_at" => "2020-12-11T16:14:09.293Z", 209 | "track" => track() 210 | } 211 | ], 212 | "limit" => 2, 213 | "next" => 214 | "https://api.spotify.com/v1/me/player/recently-played?before=1607703043921&limit=2" 215 | } 216 | end 217 | 218 | defp currently_playing_response do 219 | %{ 220 | "actions" => %{"disallows" => %{"pausing" => true}}, 221 | "context" => %{ 222 | "external_urls" => %{ 223 | "spotify" => "https://open.spotify.com/playlist/PLAYLIST_ID" 224 | }, 225 | "href" => "https://api.spotify.com/v1/playlists/PLAYLIST_ID", 226 | "type" => "playlist", 227 | "uri" => "spotify:playlist:PLAYLIST_ID" 228 | }, 229 | "currently_playing_type" => "track", 230 | "is_playing" => false, 231 | "item" => track(), 232 | "progress_ms" => 167_217, 233 | "timestamp" => 1_607_704_916_381 234 | } 235 | end 236 | 237 | defp track do 238 | %{ 239 | "album" => %{ 240 | "album_type" => "album", 241 | "artists" => [ 242 | %{ 243 | "external_urls" => %{ 244 | "spotify" => "https://open.spotify.com/artist/ARTIST_ID" 245 | }, 246 | "href" => "https://api.spotify.com/v1/artists/ARTIST_ID", 247 | "id" => "ARTIST_ID", 248 | "name" => "Artist Name", 249 | "type" => "artist", 250 | "uri" => "spotify:artist:ARTIST_ID" 251 | } 252 | ], 253 | "available_markets" => ["US"], 254 | "external_urls" => %{ 255 | "spotify" => "https://open.spotify.com/album/ALBUM_ID" 256 | }, 257 | "href" => "https://api.spotify.com/v1/albums/ALBUM_ID", 258 | "id" => "ALBUM_ID", 259 | "images" => [ 260 | %{ 261 | "height" => 640, 262 | "url" => "https://i.scdn.co/image/...", 263 | "width" => 640 264 | }, 265 | %{ 266 | "height" => 300, 267 | "url" => "https://i.scdn.co/image/...", 268 | "width" => 300 269 | }, 270 | %{ 271 | "height" => 64, 272 | "url" => "https://i.scdn.co/image/...", 273 | "width" => 64 274 | } 275 | ], 276 | "name" => "Artist Name", 277 | "release_date" => "2015-04-07", 278 | "release_date_precision" => "day", 279 | "total_tracks" => 23, 280 | "type" => "album", 281 | "uri" => "spotify:album:ALBUM_ID" 282 | }, 283 | "artists" => [ 284 | %{ 285 | "external_urls" => %{ 286 | "spotify" => "https://open.spotify.com/artist/ARTIST_ID" 287 | }, 288 | "href" => "https://api.spotify.com/v1/artists/ARTIST_ID", 289 | "id" => "ARTIST_ID", 290 | "name" => "Artist Name", 291 | "type" => "artist", 292 | "uri" => "spotify:artist:ARTIST_ID" 293 | } 294 | ], 295 | "available_markets" => ["US"], 296 | "disc_number" => 1, 297 | "duration_ms" => 196_013, 298 | "explicit" => false, 299 | "external_ids" => %{"isrc" => "ISRC"}, 300 | "external_urls" => %{ 301 | "spotify" => "https://open.spotify.com/track/TRACK_ID" 302 | }, 303 | "href" => "https://api.spotify.com/v1/tracks/TRACK_ID", 304 | "id" => "TRACK_ID", 305 | "is_local" => false, 306 | "name" => "Track Name", 307 | "popularity" => 37, 308 | "preview_url" => "https://p.scdn.co/mp3-preview/...", 309 | "track_number" => 1, 310 | "type" => "track", 311 | "uri" => "spotify:track:TRACK_ID" 312 | } 313 | end 314 | end 315 | -------------------------------------------------------------------------------- /lib/spotify/album.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Album do 2 | @moduledoc """ 3 | Functions for retrieving information about albums. 4 | 5 | Some endpoints return collections. Spotify wraps the collection in a paging object, 6 | this API does the same. A single piece of data will not be wrapped, however 7 | in some instances an objet key can contain a collection wrapped in a paging 8 | object, for example, requesting several albums will not give you a paging 9 | object for the albums, but the tracks will be wrapped in one. 10 | 11 | There are two functions for each endpoint, one that actually makes the request, 12 | and one that provides the endpoint: 13 | 14 | Spotify.Playist.create_playlist(conn, "foo", "bar") # makes the POST request. 15 | Spotify.Playist.create_playlist_url("foo", "bar") # provides the url for the request. 16 | 17 | https://developer.spotify.com/web-api/get-several-albums/ 18 | """ 19 | 20 | use Spotify.Responder 21 | import Spotify.Helpers 22 | 23 | alias Spotify.{ 24 | Album, 25 | Client, 26 | Paging, 27 | Track 28 | } 29 | 30 | defstruct ~w[ 31 | album_type 32 | artists 33 | available_markets 34 | copyrights 35 | external_ids 36 | external_urls 37 | genres 38 | href 39 | id 40 | images 41 | name 42 | popularity 43 | release_date 44 | release_date_precision 45 | tracks 46 | type 47 | label 48 | ]a 49 | 50 | @doc """ 51 | Get Spotify catalog information for a single album. 52 | [Spotify Documentation](https://developer.spotify.com/web-api/get-album/) 53 | 54 | **Method**: GET 55 | 56 | **Optional Params**: `market` 57 | 58 | Spotify.Album.get_album(conn, "4") 59 | # => { :ok, %Spotify.Album{...} } 60 | """ 61 | def get_album(conn, id, params \\ []) do 62 | url = get_album_url(id, params) 63 | conn |> Client.get(url) |> handle_response 64 | end 65 | 66 | @doc """ 67 | Get Spotify catalog information for a single album. 68 | 69 | iex> Spotify.Album.get_album_url("4") 70 | "https://api.spotify.com/v1/albums/4" 71 | """ 72 | def get_album_url(id, params \\ []) do 73 | "https://api.spotify.com/v1/albums/#{id}" <> query_string(params) 74 | end 75 | 76 | @doc """ 77 | Get Spotify catalog information for multiple albums identified by their Spotify IDs. 78 | [Spotify Documentation](https://developer.spotify.com/web-api/get-several-albums/) 79 | 80 | **Method**: GET 81 | 82 | **Optional Params**: `market` 83 | 84 | Spotify.Album.get_albums(conn, ids: "1,4") 85 | # => { :ok, %Spotify.Album{...} } 86 | """ 87 | def get_albums(conn, params) do 88 | url = get_albums_url(params) 89 | conn |> Client.get(url) |> handle_response 90 | end 91 | 92 | @doc """ 93 | Get Spotify catalog information for multiple albums identified by their Spotify IDs. 94 | 95 | iex> Spotify.Album.get_albums_url(ids: "1,3") 96 | "https://api.spotify.com/v1/albums?ids=1%2C3" 97 | """ 98 | def get_albums_url(params) do 99 | "https://api.spotify.com/v1/albums" <> query_string(params) 100 | end 101 | 102 | @doc """ 103 | Get Spotify catalog information about an album’s tracks. 104 | [Spotify Documentation](https://developer.spotify.com/web-api/get-albums-tracks/) 105 | 106 | **Method**: `GET` 107 | **Optional Params**: `limit`, `offset`, `market` 108 | 109 | Spotify.Album.get_album_tracks("1") 110 | # => { :ok, %Paging{items: [%Spotify.Tracks{}, ...] } } 111 | """ 112 | def get_album_tracks(conn, id, params \\ []) do 113 | url = get_album_tracks_url(id, params) 114 | conn |> Client.get(url) |> handle_response 115 | end 116 | 117 | @doc """ 118 | Get Spotify catalog information about an album’s tracks. 119 | 120 | iex> Spotify.Album.get_album_tracks_url("4") 121 | "https://api.spotify.com/v1/albums/4/tracks" 122 | """ 123 | def get_album_tracks_url(id, params \\ []) do 124 | "https://api.spotify.com/v1/albums/#{id}/tracks" <> query_string(params) 125 | end 126 | 127 | @doc """ 128 | Get Spotify catalog information about an artist’s albums. Optional parameters can be specified in the query string to filter and sort the response. 129 | 130 | [Spotify Documentation](https://developer.spotify.com/web-api/get-artists-albums/) 131 | 132 | **Method**: `GET` 133 | 134 | Spotify.Album.get_arists_albums(conn, "4") 135 | # => { :ok, %Paging{items: [%Album{}, ..]} } 136 | """ 137 | def get_artists_albums(conn, id) do 138 | url = get_artists_albums_url(id) 139 | conn |> Client.get(url) |> handle_response 140 | end 141 | 142 | @doc """ 143 | Get Spotify catalog information about an artist’s albums. Optional parameters can be specified in the query string to filter and sort the response. 144 | 145 | iex> Spotify.Album.get_artists_albums_url("4") 146 | "https://api.spotify.com/v1/artists/4/albums" 147 | """ 148 | def get_artists_albums_url(id) do 149 | "https://api.spotify.com/v1/artists/#{id}/albums" 150 | end 151 | 152 | @doc """ 153 | Get a list of new album releases featured in Spotify 154 | [Spotify Documentation](https://developer.spotify.com/web-api/get-list-new-releases/) 155 | 156 | **Method**: `GET` 157 | **Optional Params**: `country`, `limit`, `offset` 158 | 159 | Spotify.Album.new_releases(conn, country: "US") 160 | # => { :ok, %Paging{items: [%Album{}, ..]} } 161 | """ 162 | def new_releases(conn, params \\ []) do 163 | url = new_releases_url(params) 164 | conn |> Client.get(url) |> handle_response 165 | end 166 | 167 | @doc """ 168 | Get a list of new album releases featured in Spotify 169 | 170 | iex> Spotify.Album.new_releases_url(country: "US") 171 | "https://api.spotify.com/v1/browse/new-releases?country=US" 172 | """ 173 | def new_releases_url(params) do 174 | "https://api.spotify.com/v1/browse/new-releases" <> query_string(params) 175 | end 176 | 177 | @doc """ 178 | Save one or more albums to the current user’s “Your Music” library. 179 | [Spotify Documentation](https://developer.spotify.com/web-api/save-albums-user/) 180 | 181 | **Method**: `PUT` 182 | **Required Params**: `ids` 183 | 184 | Spotify.Album.save_albums(conn, ids: "1,4") 185 | # => :ok 186 | """ 187 | 188 | def save_albums(conn, params) do 189 | url = save_albums_url(params) 190 | conn |> Client.put(url) |> handle_response 191 | end 192 | 193 | @doc """ 194 | Save one or more albums to the current user’s “Your Music” library. 195 | [Spotify Documentation](https://developer.spotify.com/web-api/get-users-saved-albums/) 196 | 197 | iex> Spotify.Album.save_albums_url(ids: "1,4") 198 | "https://api.spotify.com/v1/me/albums?ids=1%2C4" 199 | """ 200 | def save_albums_url(params) do 201 | "https://api.spotify.com/v1/me/albums" <> query_string(params) 202 | end 203 | 204 | @doc """ 205 | Get a list of the albums saved in the current Spotify user’s “Your Music” library. 206 | [Spotify Documentation](https://developer.spotify.com/web-api/get-users-saved-albums/) 207 | 208 | **Method**: `GET` 209 | **Optional Params**: `limit`, `offset`, `market` 210 | 211 | Spotify.Album.my_albums(conn, limit: 5) 212 | # => { :ok, %Paging{items: [%Album{}, ...]} } 213 | """ 214 | def my_albums(conn, params) do 215 | url = my_albums_url(params) 216 | conn |> Client.get(url) |> handle_response 217 | end 218 | 219 | @doc """ 220 | Get a list of the albums saved in the current Spotify user’s “Your Music” library. 221 | 222 | iex> Spotify.Album.my_albums_url(limit: 5) 223 | "https://api.spotify.com/v1/me/albums?limit=5" 224 | """ 225 | def my_albums_url(params) do 226 | "https://api.spotify.com/v1/me/albums" <> query_string(params) 227 | end 228 | 229 | @doc """ 230 | Remove one or more albums from the current user’s “Your Music” library. 231 | [Spotify Documentation](https://developer.spotify.com/web-api/remove-albums-user/) 232 | 233 | **Method**: `DELETE` 234 | 235 | Spotify.Album.remove_albums(conn, ids: "1,4") 236 | # => :ok 237 | """ 238 | def remove_albums(conn, params) do 239 | url = remove_albums_url(params) 240 | conn |> Client.delete(url) |> handle_response 241 | end 242 | 243 | @doc """ 244 | Remove one or more albums from the current user’s “Your Music” library. 245 | 246 | iex> Spotify.Album.remove_albums_url(ids: "1,4") 247 | "https://api.spotify.com/v1/me/albums?ids=1%2C4" 248 | """ 249 | def remove_albums_url(params) do 250 | "https://api.spotify.com/v1/me/albums" <> query_string(params) 251 | end 252 | 253 | @doc """ 254 | Check if one or more albums is already saved in the current Spotify user’s “Your Music” library. 255 | [Spotify Documentation](https://developer.spotify.com/web-api/check-users-saved-albums/) 256 | 257 | **Method**: `GET` 258 | 259 | Spotify.Album.check_albums(ids: "1,4") 260 | # => [true, false] (Album 1 is in the user's library, 4 is not) 261 | """ 262 | def check_albums(conn, params) do 263 | url = check_albums_url(params) 264 | conn |> Client.get(url) |> handle_response 265 | end 266 | 267 | @doc """ 268 | Check if one or more albums is already saved in the current Spotify user’s “Your Music” library. 269 | 270 | iex> Spotify.Album.check_albums_url(ids: "1,4") 271 | "https://api.spotify.com/v1/me/albums/contains?ids=1%2C4" 272 | """ 273 | def check_albums_url(params) do 274 | "https://api.spotify.com/v1/me/albums/contains" <> query_string(params) 275 | end 276 | 277 | @doc """ 278 | Implement the callback required by the Responder behavior 279 | """ 280 | def build_response(body) do 281 | case body do 282 | %{"albums" => albums} -> build_albums(albums) 283 | %{"items" => items} -> infer_type_and_build(items) 284 | %{"album_type" => _} -> build_album(body) 285 | end 286 | end 287 | 288 | @doc false 289 | def infer_type_and_build(items) do 290 | case List.first(items) do 291 | %{"track_number" => _} -> build_tracks(items) 292 | %{"album_type" => _} -> build_albums(items) 293 | %{"album" => _} -> build_albums(items) 294 | end 295 | end 296 | 297 | @doc false 298 | def build_tracks(tracks) do 299 | paging = %Paging{items: tracks} 300 | tracks = Track.build_tracks(tracks) 301 | Map.put(paging, :items, tracks) 302 | end 303 | 304 | @doc false 305 | def build_album(album) do 306 | album = to_struct(Album, album) 307 | paging = to_struct(Paging, album.tracks) 308 | tracks = Enum.map(paging.items, &to_struct(Track, &1)) 309 | paging = Map.put(paging, :items, tracks) 310 | Map.put(album, :tracks, paging) 311 | end 312 | 313 | @doc false 314 | def build_albums(albums) when is_list(albums), do: Enum.map(albums, &build_album/1) 315 | 316 | def build_albums(albums) when is_map(albums) do 317 | paging = to_struct(Paging, albums) 318 | new_releases = Enum.map(paging.items, &to_struct(Album, &1)) 319 | Map.put(paging, :items, new_releases) 320 | end 321 | end 322 | -------------------------------------------------------------------------------- /lib/spotify/player.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Player do 2 | @moduledoc """ 3 | Provides functions for retrieving and manipulating user's current playback. 4 | 5 | https://developer.spotify.com/documentation/web-api/reference/player/ 6 | """ 7 | 8 | use Spotify.Responder 9 | import Spotify.Helpers 10 | 11 | alias Spotify.{ 12 | Client, 13 | Context, 14 | CurrentlyPlaying, 15 | Device, 16 | Episode, 17 | History, 18 | Paging, 19 | Playback, 20 | Track 21 | } 22 | 23 | @doc """ 24 | Add an item to the user's playback queue. 25 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/add-to-queue/) 26 | 27 | **Method**: `POST` 28 | 29 | **Optional Params**: `device_id` 30 | """ 31 | def enqueue(conn, uri, params \\ []) do 32 | url = params |> Keyword.put(:uri, uri) |> enqueue_url() 33 | conn |> Client.post(url) |> handle_response() 34 | end 35 | 36 | @doc """ 37 | iex> Spotify.Player.enqueue_url(device_id: "abc") 38 | "https://api.spotify.com/v1/me/player/queue?device_id=abc" 39 | """ 40 | def enqueue_url(params \\ []) do 41 | "https://api.spotify.com/v1/me/player/queue" <> query_string(params) 42 | end 43 | 44 | @doc """ 45 | Get the user's available devices. 46 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/get-a-users-available-devices/) 47 | 48 | **Method**: `GET` 49 | """ 50 | def get_devices(conn) do 51 | url = devices_url() 52 | conn |> Client.get(url) |> handle_response() 53 | end 54 | 55 | @doc """ 56 | iex> Spotify.Player.devices_url 57 | "https://api.spotify.com/v1/me/player/devices" 58 | """ 59 | def devices_url do 60 | "https://api.spotify.com/v1/me/player/devices" 61 | end 62 | 63 | @doc """ 64 | Get information about the user's playback currently playing context. 65 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/get-information-about-the-users-current-playback/) 66 | 67 | **Method**: `GET` 68 | 69 | **Optional Params**: `market`, `additional_types` 70 | """ 71 | def get_current_playback(conn, params \\ []) do 72 | url = player_url(params) 73 | conn |> Client.get(url) |> handle_response() 74 | end 75 | 76 | @doc """ 77 | iex> Spotify.Player.player_url(market: "US") 78 | "https://api.spotify.com/v1/me/player?market=US" 79 | """ 80 | def player_url(params \\ []) do 81 | "https://api.spotify.com/v1/me/player" <> query_string(params) 82 | end 83 | 84 | @doc """ 85 | Get the user's recently played tracks. 86 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/get-recently-played/) 87 | 88 | **Method**: `GET` 89 | 90 | **Optional Params**: `limit`, `after`, `before` 91 | """ 92 | def get_recently_played(conn, params \\ []) do 93 | url = recently_played_url(params) 94 | conn |> Client.get(url) |> handle_response() 95 | end 96 | 97 | @doc """ 98 | iex> Spotify.Player.recently_played_url(limit: 50) 99 | "https://api.spotify.com/v1/me/player/recently-played?limit=50" 100 | """ 101 | def recently_played_url(params \\ []) do 102 | "https://api.spotify.com/v1/me/player/recently-played" <> query_string(params) 103 | end 104 | 105 | @doc """ 106 | Get the user's currently playing tracks. 107 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/get-the-users-currently-playing-track/) 108 | 109 | **Method**: `GET` 110 | 111 | **Optional Params**: `market`, `additional_types` 112 | """ 113 | def get_currently_playing(conn, params \\ []) do 114 | url = currently_playing_url(params) 115 | conn |> Client.get(url) |> handle_response() 116 | end 117 | 118 | @doc """ 119 | iex> Spotify.Player.currently_playing_url(market: "US") 120 | "https://api.spotify.com/v1/me/player/currently-playing?market=US" 121 | """ 122 | def currently_playing_url(params \\ []) do 123 | "https://api.spotify.com/v1/me/player/currently-playing" <> query_string(params) 124 | end 125 | 126 | @doc """ 127 | Pause the user's playback. 128 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/pause-a-users-playback/) 129 | 130 | **Method**: `PUT` 131 | 132 | **Optional Params**: `device_id` 133 | """ 134 | def pause(conn, params \\ []) do 135 | url = pause_url(params) 136 | conn |> Client.put(url) |> handle_response() 137 | end 138 | 139 | @doc """ 140 | iex> Spotify.Player.pause_url(device_id: "abc") 141 | "https://api.spotify.com/v1/me/player/pause?device_id=abc" 142 | """ 143 | def pause_url(params \\ []) do 144 | "https://api.spotify.com/v1/me/player/pause" <> query_string(params) 145 | end 146 | 147 | @doc """ 148 | Seek to position in currently playing track. 149 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/seek-to-position-in-currently-playing-track/) 150 | 151 | **Method**: `PUT` 152 | 153 | **Optional Params**: `device_id` 154 | """ 155 | def seek(conn, position_ms, params \\ []) do 156 | url = params |> Keyword.put(:position_ms, position_ms) |> seek_url() 157 | conn |> Client.put(url) |> handle_response() 158 | end 159 | 160 | @doc """ 161 | iex> Spotify.Player.seek_url(device_id: "abc") 162 | "https://api.spotify.com/v1/me/player/seek?device_id=abc" 163 | """ 164 | def seek_url(params \\ []) do 165 | "https://api.spotify.com/v1/me/player/seek" <> query_string(params) 166 | end 167 | 168 | @doc """ 169 | Set repeat mode for the user's playback. 170 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/set-repeat-mode-on-users-playback/) 171 | 172 | **Method**: `PUT` 173 | 174 | **Optional Params**: `device_id` 175 | """ 176 | def set_repeat(conn, state, params \\ []) when state in [:track, :context, :off] do 177 | url = params |> Keyword.put(:state, state) |> repeat_url() 178 | conn |> Client.put(url) |> handle_response() 179 | end 180 | 181 | @doc """ 182 | iex> Spotify.Player.repeat_url(device_id: "abc") 183 | "https://api.spotify.com/v1/me/player/repeat?device_id=abc" 184 | """ 185 | def repeat_url(params \\ []) do 186 | "https://api.spotify.com/v1/me/player/repeat" <> query_string(params) 187 | end 188 | 189 | @doc """ 190 | Set volume for the user's playback. 191 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/set-volume-for-users-playback/) 192 | 193 | **Method**: `PUT` 194 | 195 | **Optional Params**: `device_id` 196 | """ 197 | def set_volume(conn, volume_percent, params \\ []) when volume_percent in 0..100 do 198 | url = params |> Keyword.put(:volume_percent, volume_percent) |> volume_url() 199 | conn |> Client.put(url) |> handle_response() 200 | end 201 | 202 | @doc """ 203 | iex> Spotify.Player.volume_url(device_id: "abc") 204 | "https://api.spotify.com/v1/me/player/volume?device_id=abc" 205 | """ 206 | def volume_url(params \\ []) do 207 | "https://api.spotify.com/v1/me/player/volume" <> query_string(params) 208 | end 209 | 210 | @doc """ 211 | Skip the user's playback to next track. 212 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/skip-users-playback-to-next-track/) 213 | 214 | **Method**: `POST` 215 | 216 | **Optional Params**: `device_id` 217 | """ 218 | def skip_to_next(conn, params \\ []) do 219 | url = next_url(params) 220 | conn |> Client.post(url) |> handle_response() 221 | end 222 | 223 | @doc """ 224 | iex> Spotify.Player.next_url(device_id: "abc") 225 | "https://api.spotify.com/v1/me/player/next?device_id=abc" 226 | """ 227 | def next_url(params \\ []) do 228 | "https://api.spotify.com/v1/me/player/next" <> query_string(params) 229 | end 230 | 231 | @doc """ 232 | Skip the user's playback to previous track. 233 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/skip-users-playback-to-previous-track/) 234 | 235 | **Method**: `POST` 236 | 237 | **Optional Params**: `device_id` 238 | """ 239 | def skip_to_previous(conn, params \\ []) do 240 | url = previous_url(params) 241 | conn |> Client.post(url) |> handle_response() 242 | end 243 | 244 | @doc """ 245 | iex> Spotify.Player.previous_url(device_id: "abc") 246 | "https://api.spotify.com/v1/me/player/previous?device_id=abc" 247 | """ 248 | def previous_url(params \\ []) do 249 | "https://api.spotify.com/v1/me/player/previous" <> query_string(params) 250 | end 251 | 252 | @doc """ 253 | Start/resume the user's playback. 254 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/start-a-users-playback/) 255 | 256 | **Method**: `PUT` 257 | 258 | **Optional Params**: `device_id`, `context_uri`, `uris`, `offset`, `position_ms` 259 | """ 260 | def play(conn, params \\ []) do 261 | {query_params, body_params} = Keyword.split(params, [:device_id]) 262 | 263 | url = play_url(query_params) 264 | body = body_params |> Enum.into(%{}) |> Poison.encode!() 265 | 266 | conn |> Client.put(url, body) |> handle_response() 267 | end 268 | 269 | @doc """ 270 | iex> Spotify.Player.play_url(device_id: "abc") 271 | "https://api.spotify.com/v1/me/player/play?device_id=abc" 272 | """ 273 | def play_url(params \\ []) do 274 | "https://api.spotify.com/v1/me/player/play" <> query_string(params) 275 | end 276 | 277 | @doc """ 278 | Toggle shuffle for the user's playback. 279 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/toggle-shuffle-for-users-playback/) 280 | 281 | **Method**: `PUT` 282 | 283 | **Optional Params**: `device_id` 284 | """ 285 | def set_shuffle(conn, state, params \\ []) when state |> is_boolean() do 286 | url = params |> Keyword.put(:state, state) |> shuffle_url() 287 | conn |> Client.put(url) |> handle_response() 288 | end 289 | 290 | @doc """ 291 | iex> Spotify.Player.shuffle_url(device_id: "abc") 292 | "https://api.spotify.com/v1/me/player/shuffle?device_id=abc" 293 | """ 294 | def shuffle_url(params \\ []) do 295 | "https://api.spotify.com/v1/me/player/shuffle" <> query_string(params) 296 | end 297 | 298 | @doc """ 299 | Transfer the user's playback. 300 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/player/transfer-a-users-playback/) 301 | 302 | **Method**: `PUT` 303 | 304 | **Optional Params**: `play` 305 | """ 306 | def transfer_playback(conn, device_ids, params \\ []) do 307 | url = player_url() 308 | body = params |> Keyword.put(:device_ids, device_ids) |> Enum.into(%{}) |> Poison.encode!() 309 | 310 | conn |> Client.put(url, body) |> handle_response() 311 | end 312 | 313 | def build_response(%{"devices" => devices}) do 314 | Enum.map(devices, &to_struct(Device, &1)) 315 | end 316 | 317 | def build_response(body = %{"items" => _}) do 318 | build_paged_histories(body) 319 | end 320 | 321 | def build_response(body = %{"device" => _}) do 322 | body = 323 | body 324 | |> build_item() 325 | |> build_device() 326 | |> build_context() 327 | 328 | to_struct(Playback, body) 329 | end 330 | 331 | def build_response(body) do 332 | body = 333 | body 334 | |> build_item() 335 | |> build_context() 336 | 337 | to_struct(CurrentlyPlaying, body) 338 | end 339 | 340 | defp build_paged_histories(body) do 341 | %Paging{ 342 | href: body["href"], 343 | items: body["items"] |> build_histories(), 344 | limit: body["limit"], 345 | next: body["next"], 346 | offset: body["offset"], 347 | previous: body["previous"], 348 | total: body["total"] 349 | } 350 | end 351 | 352 | defp build_histories(histories) do 353 | Enum.map(histories, fn history -> 354 | %History{ 355 | track: to_struct(Track, history["track"]), 356 | played_at: history["played_at"], 357 | context: to_struct(Context, history["context"]) 358 | } 359 | end) 360 | end 361 | 362 | defp build_item(body = %{"item" => nil}), do: body 363 | 364 | defp build_item(body = %{"currently_playing_type" => "track"}) do 365 | Map.update!(body, "item", &to_struct(Track, &1)) 366 | end 367 | 368 | defp build_item(body = %{"currently_playing_type" => "episode"}) do 369 | Map.update!(body, "item", &to_struct(Episode, &1)) 370 | end 371 | 372 | defp build_device(body = %{"device" => _}) do 373 | Map.update!(body, "device", &to_struct(Device, &1)) 374 | end 375 | 376 | defp build_device(body), do: body 377 | 378 | defp build_context(body = %{"context" => nil}), do: body 379 | 380 | defp build_context(body = %{"context" => _}) do 381 | Map.update!(body, "context", &to_struct(Context, &1)) 382 | end 383 | end 384 | -------------------------------------------------------------------------------- /lib/spotify/playlist.ex: -------------------------------------------------------------------------------- 1 | defmodule Spotify.Playlist do 2 | @moduledoc """ 3 | Functions for retrieving information about a user’s playlists and for 4 | managing a user’s playlists. 5 | 6 | Some endpoints return collections. Spotify wraps the collection in a paging object, 7 | this API does the same. A single piece of data will not be wrapped. 8 | 9 | There are two functions for each endpoint, one that actually makes the request, 10 | and one that provides the endpoint: 11 | 12 | Spotify.Playist.create_playlist(conn, "foo", "bar") # makes the POST request. 13 | Spotify.Playist.create_playlist_url("foo", "bar") # provides the url for the request. 14 | 15 | 16 | https://developer.spotify.com/web-api/playlist-endpoints/ 17 | """ 18 | 19 | import Spotify.Helpers 20 | use Spotify.Responder 21 | 22 | alias Spotify.{ 23 | Client, 24 | Paging 25 | } 26 | 27 | defstruct ~w[ 28 | collaborative 29 | description 30 | external_urls 31 | followers 32 | href 33 | id 34 | images 35 | name 36 | owner 37 | public 38 | snapshot_id 39 | tracks 40 | type 41 | uri 42 | ]a 43 | 44 | @doc """ 45 | Get a list of featured playlists. 46 | [Spotify Documenation](https://developer.spotify.com/web-api/get-list-featured-playlists/) 47 | 48 | **Valid params**: `locale`, `country`, `timestamp`, `limit`, `offset` 49 | 50 | **Method**: `GET` 51 | 52 | Spotify.Playlist.featured(country: "US") 53 | # => {:ok, %{ items: [%Spotify.Playlist{..} ...]}} 54 | """ 55 | def featured(conn, params \\ []) do 56 | url = featured_url(params) 57 | conn |> Client.get(url) |> handle_response 58 | end 59 | 60 | @doc """ 61 | Get a list of featured playlists. 62 | 63 | iex> Spotify.Playlist.featured_url(country: "US") 64 | "https://api.spotify.com/v1/browse/featured-playlists?country=US" 65 | """ 66 | def featured_url(params \\ []) do 67 | "https://api.spotify.com/v1/browse/featured-playlists" <> query_string(params) 68 | end 69 | 70 | @doc """ 71 | Get a category's playlists. 72 | [Spotify Documentation](https://developer.spotify.com/web-api/get-categorys-playlists/) 73 | 74 | **Valid params**: `country`, `limit`, `offset` 75 | 76 | **Method**: `GET` 77 | 78 | ## Example: 79 | Spotify.by_category(conn, "123") 80 | # => {:ok, %{ items: [%Spotify.Playlist{..} ...]}} 81 | """ 82 | def by_category(conn, id, params \\ []) do 83 | url = by_category_url(id, params) 84 | conn |> Client.get(url) |> handle_response 85 | end 86 | 87 | @doc """ 88 | Get a category's playlists. 89 | 90 | iex> Spotify.Playlist.by_category_url("123", [country: "US", limit: 5]) 91 | "https://api.spotify.com/v1/browse/categories/123/playlists?country=US&limit=5" 92 | """ 93 | def by_category_url(id, params \\ []) do 94 | "https://api.spotify.com/v1/browse/categories/#{id}/playlists" <> query_string(params) 95 | end 96 | 97 | @doc """ 98 | Add the current user as a follower of a playlist. 99 | [Spotify Documentation](https://developer.spotify.com/web-api/follow-playlist/) 100 | 101 | **Optional Body Params**: `public` 102 | 103 | **Method**: `PUT` 104 | Spotify.Playlist.follow_playlist(conn, "123", "456") 105 | # => :ok 106 | """ 107 | def follow_playlist(conn, owner_id, playlist_id, body \\ "") do 108 | url = follow_playlist_url(owner_id, playlist_id) 109 | conn |> Client.put(url, body) |> handle_response 110 | end 111 | 112 | @doc """ 113 | Add the current user as a follower of a playlist. 114 | 115 | iex> Spotify.Playlist.follow_playlist_url("123", "456") 116 | "https://api.spotify.com/v1/users/123/playlists/456/followers" 117 | """ 118 | def follow_playlist_url(owner_id, playlist_id) do 119 | get_playlist_url(owner_id, playlist_id) <> "/followers" 120 | end 121 | 122 | @doc """ 123 | Remove the current user as a follower of a playlist. 124 | [Spotify Documentation](https://developer.spotify.com/web-api/unfollow-playlist/) 125 | 126 | **Method**: `DELETE` 127 | 128 | Spotify.Playlist.unfollow_playlist(conn, "123", "456") 129 | # => :ok 130 | """ 131 | def unfollow_playlist(conn, owner_id, playlist_id) do 132 | url = unfollow_playlist_url(owner_id, playlist_id) 133 | conn |> Client.delete(url) |> handle_response 134 | end 135 | 136 | @doc """ 137 | Remove the current user as a follower of a playlist. 138 | 139 | iex> Spotify.Playlist.unfollow_playlist_url("123", "456") 140 | "https://api.spotify.com/v1/users/123/playlists/456/followers" 141 | """ 142 | def unfollow_playlist_url(owner_id, playlist_id) do 143 | follow_playlist_url(owner_id, playlist_id) 144 | end 145 | 146 | @doc """ 147 | Get a list of the playlists owned or followed by a Spotify user. 148 | [Spotify Documentation](https://developer.spotify.com/web-api/get-list-users-playlists/) 149 | 150 | **Method**: `GET` 151 | 152 | ** Optional Params: `limit`, `offset` 153 | 154 | Spotify.Playlist.get_users_playlists(conn, "123", q: "foo", limit: 5) 155 | # => {:ok, %{ 156 | # cursor: nil, 157 | # href: .., 158 | # items: [%Spotify.Playlist{..} ...], 159 | # limit: 20, 160 | # next: nil, 161 | # offset: 0, 162 | # previous: nil, 163 | # total: 20 164 | # } 165 | # } 166 | """ 167 | def get_users_playlists(conn, user_id, params \\ []) do 168 | url = get_users_playlists_url(user_id, params) 169 | conn |> Client.get(url) |> handle_response 170 | end 171 | 172 | @doc """ 173 | Get a list of the playlists owned or followed by a Spotify user. 174 | 175 | iex> Spotify.Playlist.get_users_playlists_url("123", limit: 5) 176 | "https://api.spotify.com/v1/users/123/playlists?limit=5" 177 | """ 178 | def get_users_playlists_url(user_id, params \\ []) do 179 | "https://api.spotify.com/v1/users/#{user_id}/playlists" <> query_string(params) 180 | end 181 | 182 | @doc """ 183 | Get a list of the playlists owned or followed by the current Spotify user. 184 | [Spotify Documentation](https://developer.spotify.com/documentation/web-api/reference/playlists/get-a-list-of-current-users-playlists/) 185 | 186 | **Method**: `GET` 187 | 188 | ** Optional Params: `limit`, `offset` 189 | 190 | Spotify.Playlist.get_users_playlists(conn, "123", q: "foo", limit: 5) 191 | # => {:ok, %{ 192 | # cursor: nil, 193 | # href: .., 194 | # items: [%Spotify.Playlist{..} ...], 195 | # limit: 20, 196 | # next: nil, 197 | # offset: 0, 198 | # previous: nil, 199 | # total: 20 200 | # } 201 | # } 202 | """ 203 | def get_current_user_playlists(conn, params \\ []) do 204 | url = get_current_user_playlists_url(params) 205 | conn |> Client.get(url) |> handle_response 206 | end 207 | 208 | @doc """ 209 | Get a list of the playlists owned or followed by a Spotify user. 210 | 211 | iex> Spotify.Playlist.get_current_user_playlists_url(limit: 5) 212 | "https://api.spotify.com/v1/me/playlists?limit=5" 213 | """ 214 | def get_current_user_playlists_url(params \\ []) do 215 | "https://api.spotify.com/v1/me/playlists" <> query_string(params) 216 | end 217 | 218 | @doc """ 219 | Get a playlist owned by a Spotify user. 220 | [Spotify Documentation](https://developer.spotify.com/web-api/get-playlist/) 221 | 222 | **Method**: `GET` 223 | 224 | **Optional Params `fields, market` 225 | 226 | Spotify.Playlist.get_playlist(conn, "123", "456") 227 | # => {:ok, %Spotify.Playlist{..}} 228 | """ 229 | def get_playlist(conn, user_id, playlist_id, params \\ []) do 230 | url = get_playlist_url(user_id, playlist_id, params) 231 | conn |> Client.get(url) |> handle_response 232 | end 233 | 234 | @doc """ 235 | Get a playlist owned by a Spotify user. 236 | 237 | iex> Spotify.Playlist.get_playlist_url("123", "456", market: "foo") 238 | "https://api.spotify.com/v1/users/123/playlists/456?market=foo" 239 | """ 240 | def get_playlist_url(user_id, playlist_id, params \\ []) do 241 | "https://api.spotify.com/v1/users/#{user_id}/playlists/#{playlist_id}" <> query_string(params) 242 | end 243 | 244 | @doc """ 245 | Get full details of the tracks of a playlist owned by a Spotify user. 246 | [Spotify Documentation](https://developer.spotify.com/web-api/get-playlists-tracks/) 247 | 248 | **Method**: `GET` 249 | 250 | **Optional Params `fields`, `market`, `limit`, `offset` 251 | Spotify.Playlist.get_playlist_tracks(conn, "123", "456") 252 | # => {:ok, %Spotify.Playlist{..}} 253 | """ 254 | def get_playlist_tracks(conn, user_id, playlist_id, params \\ []) do 255 | alias Spotify.Playlist.Track, as: Track 256 | 257 | url = get_playlist_tracks_url(user_id, playlist_id, params) 258 | conn |> Client.get(url) |> Track.handle_response() 259 | end 260 | 261 | @doc """ 262 | Get full details of the tracks of a playlist owned by a Spotify user. 263 | 264 | iex> Spotify.Playlist.get_playlist_tracks_url("123", "456", limit: 5, offset: 5) 265 | "https://api.spotify.com/v1/users/123/playlists/456/tracks?limit=5&offset=5" 266 | """ 267 | def get_playlist_tracks_url(user_id, playlist_id, params \\ []) do 268 | playlist_tracks_url(user_id, playlist_id) <> query_string(params) 269 | end 270 | 271 | @doc false 272 | def playlist_tracks_url(user_id, playlist_id) do 273 | get_playlist_url(user_id, playlist_id) <> "/tracks" 274 | end 275 | 276 | @doc """ 277 | Create a playlist for a Spotify user. (The playlist will be empty until you add tracks.) 278 | [Spotify Documentation](https://developer.spotify.com/web-api/create-playlist/) 279 | 280 | **Method**: `POST` 281 | 282 | **Body Params**: `name`, `public` 283 | 284 | body = "\{\"name\": \"foo\"}" 285 | Spotify.Playlist.create_playlist(conn, "123", body) 286 | # => %Spotify.Playlist{..} 287 | """ 288 | def create_playlist(conn, user_id, body) do 289 | url = create_playlist_url(user_id) 290 | conn |> Client.post(url, body) |> handle_response 291 | end 292 | 293 | @doc """ 294 | Create a playlist for a Spotify user. (The playlist will be empty until you add tracks.) 295 | 296 | iex> Spotify.Playlist.create_playlist_url("123") 297 | "https://api.spotify.com/v1/users/123/playlists" 298 | """ 299 | def create_playlist_url(user_id) do 300 | "https://api.spotify.com/v1/users/#{user_id}/playlists" 301 | end 302 | 303 | @doc """ 304 | Change a playlist’s name and public/private state. (The user must, of course, own the playlist.) 305 | [Spotify Documentation](https://developer.spotify.com/web-api/change-playlist-details/) 306 | 307 | **Method**: `PUT` 308 | 309 | **Request Data)**: `name`, `public` 310 | 311 | body = "{ \"name\": \"foo\", \"public\": true }" 312 | Spotify.Playlist.change_playlist(conn, "123", "456", body) 313 | # => :ok 314 | """ 315 | def change_playlist(conn, user_id, playlist_id, body) do 316 | url = change_playlist_url(user_id, playlist_id) 317 | conn |> Client.put(url, body) |> handle_response 318 | end 319 | 320 | @doc """ 321 | Change a playlist’s name and public/private state. (The user must, of course, own the playlist.) 322 | 323 | iex> Spotify.Playlist.change_playlist_url("123", "456") 324 | "https://api.spotify.com/v1/users/123/playlists/456" 325 | """ 326 | def change_playlist_url(user_id, playlist_id) do 327 | get_playlist_url(user_id, playlist_id) 328 | end 329 | 330 | @doc """ 331 | Add one or more tracks to a user’s playlist. 332 | [Spotify Documentation](https://developer.spotify.com/web-api/add-tracks-to-playlist/) 333 | 334 | **Method**: `POST` 335 | 336 | **Optional Params**: `uris`, `position` 337 | 338 | You can also pass the URI param in the request body. If you don't want to 339 | pass params, use an empty list. See Spotify docs and add_tracks/5. 340 | 341 | body = Poison.encode!(%{ uris: [ "spotify:track:755MBpLqJqCO87PkoyBBQC", "spotify:track:1hsWu8gT2We6OzjhYGAged" ]}) 342 | 343 | Spotify.Playlist.add_tracks("123", "456", body, []) 344 | # => {:ok, %{"snapshot_id" => "foo"}} 345 | """ 346 | def add_tracks(conn, user_id, playlist_id, body, params) do 347 | url = add_tracks_url(user_id, playlist_id, params) 348 | conn |> Client.post(url, body) |> handle_response 349 | end 350 | 351 | @doc """ 352 | Add one or more tracks to a user’s playlist. 353 | [Spotify Documentation](https://developer.spotify.com/web-api/add-tracks-to-playlist/) 354 | 355 | **Method**: `POST` 356 | 357 | **Optional Params**: `uris`, `position` 358 | 359 | You can also pass the URI param in the request body. See Spotify docs and add_tracks/4. 360 | Spotify.Playlist.add_tracks("123", "456", uris: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh") 361 | # => {:ok, %{"snapshot_id" => "foo"}} 362 | """ 363 | def add_tracks(conn, user_id, playlist_id, params) do 364 | url = add_tracks_url(user_id, playlist_id, params) 365 | conn |> Client.post(url) |> handle_response 366 | end 367 | 368 | @doc """ 369 | Add one or more tracks to a user’s playlist. 370 | 371 | iex> Spotify.Playlist.add_tracks_url("123", "456", uris: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh") 372 | "https://api.spotify.com/v1/users/123/playlists/456/tracks?uris=spotify%3Atrack%3A4iV5W9uYEdYUVa79Axb7Rh" 373 | """ 374 | def add_tracks_url(user_id, playlist_id, params \\ []) do 375 | playlist_tracks_url(user_id, playlist_id) <> query_string(params) 376 | end 377 | 378 | @doc """ 379 | Remove one or more tracks from a user’s playlist. 380 | [Spotify Documentation](https://developer.spotify.com/web-api/remove-tracks-playlist/) 381 | 382 | **Method**: `DELETE` 383 | 384 | **Request Data**: `tracks` 385 | 386 | Spotify.Playlist.remove_tracks(conn, "123", "456") 387 | # => {:ok, %{"snapshot_id": "foo"}} 388 | """ 389 | def remove_tracks(conn, user_id, playlist_id) do 390 | url = playlist_tracks_url(user_id, playlist_id) 391 | conn |> Client.delete(url) |> handle_response 392 | end 393 | 394 | @doc """ 395 | Remove one or more tracks from a user’s playlist. 396 | 397 | iex> Spotify.Playlist.remove_tracks_url("123", "456") 398 | "https://api.spotify.com/v1/users/123/playlists/456/tracks" 399 | """ 400 | def remove_tracks_url(user_id, playlist_id) do 401 | playlist_tracks_url(user_id, playlist_id) 402 | end 403 | 404 | @doc """ 405 | Reorder a track or a group of tracks in a playlist. 406 | [Spotify Documentation](https://developer.spotify.com/web-api/reorder-playlists-tracks/) 407 | 408 | **Method**: `PUT` 409 | 410 | **Required Request Body Data**: `range_start`, `insert_before` 411 | 412 | **Optional Request Body Data**: `range_length`, `snapshot_id` 413 | 414 | body = { \"range_start\": \"...\" } 415 | Spotify.Playlist.change_playlist(conn, "123", "456", body) 416 | # => {:ok, %{"snapshot_id" => "klq34klj..."} } 417 | """ 418 | def reorder_tracks(conn, user_id, playlist_id, body) do 419 | url = playlist_tracks_url(user_id, playlist_id) 420 | conn |> Client.put(url, body) |> handle_response 421 | end 422 | 423 | @doc """ 424 | Reorder a track or a group of tracks in a playlist. 425 | 426 | iex> Spotify.Playlist.reorder_tracks_url("123", "456") 427 | "https://api.spotify.com/v1/users/123/playlists/456/tracks" 428 | """ 429 | def reorder_tracks_url(user_id, playlist_id) do 430 | playlist_tracks_url(user_id, playlist_id) 431 | end 432 | 433 | @doc """ 434 | Replace all the tracks in a playlist, overwriting its existing tracks. This 435 | powerful request can be useful for replacing tracks, re-ordering existing 436 | tracks, or clearing the playlist. 437 | [Spotify Documentation](https://developer.spotify.com/web-api/replace-playlists-tracks/) 438 | 439 | **Method**: `PUT` 440 | 441 | **Optional Query Params**: `uris` 442 | 443 | You can also pass the URI param in the request body. Use `replace_tracks/2`. See Spotify docs. 444 | Spotify.Playlist.replace_tracks(conn, "123", "456", uris: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh,spotify:track:adkjaklsd94h") 445 | :ok 446 | """ 447 | def replace_tracks(conn, user_id, playlist_id, params) when is_list(params) do 448 | url = replace_tracks_url(user_id, playlist_id, params) 449 | conn |> Client.put(url) |> handle_response 450 | end 451 | 452 | def replace_tracks(conn, user_id, playlist_id, body) when is_binary(body) do 453 | url = replace_tracks_url(user_id, playlist_id) 454 | conn |> Client.put(url, body) |> handle_response 455 | end 456 | 457 | @doc """ 458 | Replace all the tracks in a playlist, overwriting its existing tracks. This 459 | 460 | iex> Spotify.Playlist.replace_tracks_url("123", "456", uris: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh,spotify:track:adkjaklsd94h") 461 | "https://api.spotify.com/v1/users/123/playlists/456/tracks?uris=spotify%3Atrack%3A4iV5W9uYEdYUVa79Axb7Rh%2Cspotify%3Atrack%3Aadkjaklsd94h" 462 | """ 463 | def replace_tracks_url(user_id, playlist_id, params \\ []) do 464 | playlist_tracks_url(user_id, playlist_id) <> query_string(params) 465 | end 466 | 467 | @doc """ 468 | Check to see if one or more Spotify users are following a specified playlist. 469 | [Spotify Documentation](https://developer.spotify.com/web-api/check-user-following-playlist/) 470 | 471 | **Method**: `GET` 472 | 473 | **Query Params: `ids` 474 | 475 | Spotify.Playlist.check_followers(conn, "123", "456", ids: "foo,bar") 476 | # => {:ok, boolean} 477 | """ 478 | def check_followers(conn, owner_id, playlist_id, params) do 479 | url = check_followers_url(owner_id, playlist_id, params) 480 | conn |> Client.get(url) |> handle_response 481 | end 482 | 483 | @doc """ 484 | Check to see if one or more Spotify users are following a specified playlist. 485 | 486 | iex> Spotify.Playlist.check_followers_url("123", "456", ids: "foo,bar") 487 | "https://api.spotify.com/v1/users/123/playlists/456/followers/contains?ids=foo%2Cbar" 488 | """ 489 | def check_followers_url(owner_id, playlist_id, params) do 490 | "https://api.spotify.com/v1/users/#{owner_id}/playlists/#{playlist_id}/followers/contains" <> 491 | query_string(params) 492 | end 493 | 494 | @doc """ 495 | Implements the hook expected by the Responder behaviour 496 | """ 497 | def build_response(body) do 498 | case body do 499 | %{"items" => _items} = response -> build_paged_response(response) 500 | %{"playlists" => playlists} -> build_paged_response(playlists) 501 | _ -> to_struct(__MODULE__, body) 502 | end 503 | end 504 | 505 | @doc false 506 | defp build_paged_response(response) do 507 | %Paging{ 508 | href: response["href"], 509 | items: build_playlists(response["items"]), 510 | limit: response["limit"], 511 | next: response["next"], 512 | offset: response["offset"], 513 | previous: response["previous"], 514 | total: response["total"] 515 | } 516 | end 517 | 518 | @doc false 519 | def build_playlists(playlists) do 520 | Enum.map(playlists, &to_struct(__MODULE__, &1)) 521 | end 522 | end 523 | --------------------------------------------------------------------------------